ソースを参照

feat: 系统设置增加云端数据库配置(PostgreSQL)

- 新增存储配置面板:SQLite / PostgreSQL 切换,主机、端口、库名、用户、密码、SSL
- 新增 GET/POST /api/settings/storage 与 /api/settings/storage/test(测试连接)
- 安装 pg 依赖;ops-store 支持「主机/库/用户/密码」分开填写,自动拼连接串
- 密码保存后只显示「已设置」,不回显;切换驱动提示重启生效
- 示例配置与 README 补充云端数据库说明与迁移步骤
liangzhao 1 週間 前
親
コミット
3f1b11e751
8 ファイル変更、414 行追加、2 行削除
  1. 15 0
      README.md
  2. 11 0
      config.example.json
  3. 10 2
      ops-store.js
  4. 150 0
      package-lock.json
  5. 1 0
      package.json
  6. 81 0
      public/app.js
  7. 47 0
      public/index.html
  8. 99 0
      server/routes/settings.js

+ 15 - 0
README.md

@@ -102,6 +102,21 @@
 
 5. 打开 `http://localhost:5177`。若修改了 `port`,请访问对应端口。
 
+## 云端数据库(PostgreSQL)
+
+默认用本地 SQLite(`data/ops.db`)。需要多台机器共用一份数据时,在「系统设置 → 5 云端数据库」里切到 PostgreSQL:
+
+1. 填主机 / 端口 / 数据库 / 用户 / 密码,点「测试连接」;
+2. 保存后**重启服务**生效(启动时才会建立连接池);
+3. 已有数据迁移:
+   ```bash
+   npm run db:export ops-export.json --with-files   # 从本地 SQLite 导出
+   # 切换到 PostgreSQL 并重启
+   npm run db:import ops-export.json                # 导入云端(目标库应为空)
+   ```
+
+安全建议:5432 不要对 0.0.0.0/0 开放,腾讯云安全组里只放行自己的出口 IP;密码用随机强口令(设置页只显示「已设置」)。
+
 ## 系统设置
 
 | 配置 | 说明 |

+ 11 - 0
config.example.json

@@ -16,6 +16,17 @@
     "driver": "sqlite",
     "sqlite": {
       "file": "data/ops.db"
+    },
+    "postgres": {
+      "host": "",
+      "port": 5432,
+      "database": "smart_ops",
+      "user": "smart_ops",
+      "password": "",
+      "ssl": false,
+      "schema": "public",
+      "max": 5,
+      "connectTimeoutMs": 10000
     }
   }
 }

+ 10 - 2
ops-store.js

@@ -28,9 +28,17 @@ function createOpsStore(config) {
   function build() {
     if (driver === 'postgres') {
       const pgCfg = storage.postgres || {};
-      const connectionString = process.env.OPS_DATABASE_URL || pgCfg.connectionString || '';
+      let connectionString = process.env.OPS_DATABASE_URL || pgCfg.connectionString || '';
+      // 也支持「主机/库/用户/密码」分开填(设置页用这种)
+      if (!String(connectionString).trim() && pgCfg.host && pgCfg.database) {
+        const user = encodeURIComponent(pgCfg.user || 'postgres');
+        const pass = pgCfg.password ? ':' + encodeURIComponent(pgCfg.password) : '';
+        const port = pgCfg.port || 5432;
+        connectionString = `postgres://${user}${pass}@${pgCfg.host}:${port}/${pgCfg.database}`;
+        if (pgCfg.ssl) connectionString += (connectionString.includes('?') ? '&' : '?') + 'sslmode=require';
+      }
       if (!String(connectionString).trim()) {
-        const err = new Error('未配置 PostgreSQL 连接串');
+        const err = new Error('未配置 PostgreSQL 连接信息');
         err.reason = 'storage-not-configured';
         throw err;
       }

+ 150 - 0
package-lock.json

@@ -9,7 +9,11 @@
       "version": "1.0.0",
       "dependencies": {
         "jpeg-js": "^0.4.4",
+        "pg": "^8.23.1",
         "pngjs": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=22.13.0"
       }
     },
     "node_modules/jpeg-js": {
@@ -18,6 +22,95 @@
       "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/pg": {
+      "version": "8.23.1",
+      "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.1.tgz",
+      "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.1",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.16.1",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.1"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+      "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.1",
+      "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+      "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.16.1.tgz",
+      "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
     "node_modules/pngjs": {
       "version": "7.0.0",
       "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-7.0.0.tgz",
@@ -26,6 +119,63 @@
       "engines": {
         "node": ">=14.19.0"
       }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
     }
   }
 }

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
   },
   "dependencies": {
     "jpeg-js": "^0.4.4",
+    "pg": "^8.23.1",
     "pngjs": "^7.0.0"
   },
   "engines": {

+ 81 - 0
public/app.js

@@ -102,6 +102,7 @@
     aitableSecretInput.value = '';
     aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
     await loadModels(settings.model);
+    await loadStorageSettings();
   }
 
   function setPanelStatus(badgeId, message, ok) {
@@ -292,6 +293,83 @@
   }
   window.shanAitableAction = aitableAction;
 
+  async function loadStorageSettings() {
+    try {
+      const response = await fetch('/api/settings/storage');
+      if (!response.ok) return;
+      const st = await response.json();
+      document.getElementById('settingsStorageDriver').value = st.driver || 'sqlite';
+      document.getElementById('settingsPgHost').value = st.postgres.host || '';
+      document.getElementById('settingsPgPort').value = st.postgres.port || 5432;
+      document.getElementById('settingsPgDatabase').value = st.postgres.database || '';
+      document.getElementById('settingsPgUser').value = st.postgres.user || '';
+      document.getElementById('settingsPgSsl').checked = !!st.postgres.ssl;
+      const pw = document.getElementById('settingsPgPassword');
+      pw.value = '';
+      pw.placeholder = st.postgres.passwordSet ? '已设置,留空保持不变' : '数据库密码';
+      toggleStorageFields();
+      if (!st.pgInstalled) setPanelStatus('storageTestStatus', '未安装 pg 驱动', false);
+    } catch { /* 读取失败不阻塞设置页 */ }
+  }
+
+  function toggleStorageFields() {
+    const isPg = document.getElementById('settingsStorageDriver').value === 'postgres';
+    document.getElementById('settingsPgFields').hidden = !isPg;
+  }
+
+  function storagePayload() {
+    return {
+      driver: document.getElementById('settingsStorageDriver').value,
+      pgHost: val('settingsPgHost'),
+      pgPort: Number(val('settingsPgPort')) || 5432,
+      pgDatabase: val('settingsPgDatabase'),
+      pgUser: val('settingsPgUser'),
+      pgPassword: document.getElementById('settingsPgPassword').value,
+      pgSsl: document.getElementById('settingsPgSsl').checked,
+    };
+  }
+
+  async function testStorage() {
+    const btn = document.getElementById('btnTestStorage');
+    btn.disabled = true;
+    setPanelStatus('storageTestStatus', '连接中…');
+    try {
+      const response = await fetch('/api/settings/storage/test', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(storagePayload()),
+      });
+      const data = await response.json();
+      if (data.ok) setPanelStatus('storageTestStatus', `连接成功 · ${data.version || ''}`, true);
+      else setPanelStatus('storageTestStatus', data.error || '连接失败', false);
+    } catch (error) {
+      setPanelStatus('storageTestStatus', error.message, false);
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
+  async function saveStorageSettings() {
+    const btn = document.getElementById('btnSaveStorage');
+    btn.disabled = true;
+    setPanelStatus('storageSaveStatus', '保存中…');
+    try {
+      const response = await fetch('/api/settings/storage', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(storagePayload()),
+      });
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
+      setPanelStatus('storageSaveStatus', data.restartRequired ? '已保存,重启服务后生效' : '已保存', true);
+      await loadStorageSettings();
+    } catch (error) {
+      setPanelStatus('storageSaveStatus', error.message, false);
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
   async function initSettings() {
     // 事件绑定与数据加载分离:加载失败重进设置页时不会重复 addEventListener
     if (!settingsBound) {
@@ -307,6 +385,9 @@
       document.getElementById('btnTestImage').addEventListener('click', testImageApi);
       document.getElementById('btnTestChat').addEventListener('click', testChatModel);
       document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
+      document.getElementById('btnSaveStorage').addEventListener('click', saveStorageSettings);
+      document.getElementById('btnTestStorage').addEventListener('click', testStorage);
+      document.getElementById('settingsStorageDriver').addEventListener('change', toggleStorageFields);
       const bindHelpPopover = (btnId, popId) => {
         const btn = document.getElementById(btnId);
         const pop = document.getElementById(popId);

+ 47 - 0
public/index.html

@@ -894,6 +894,53 @@
                 </div>
               </div>
             </div>
+
+            <div class="panel panel-narrow">
+              <div class="stage-marker"><span class="num">5</span><span>云端数据库</span></div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></svg></span>
+              <div class="panel-body">
+                <div class="field">
+                  <label>存储方式</label>
+                  <select id="settingsStorageDriver">
+                    <option value="sqlite">本地 SQLite(默认)</option>
+                    <option value="postgres">云端 PostgreSQL</option>
+                  </select>
+                  <div class="help">切换后需重启服务生效;已有数据可用 npm run db:export / db:import 迁移</div>
+                </div>
+                <div id="settingsPgFields" hidden>
+                  <div class="field">
+                    <label>主机 / 端口</label>
+                    <div style="display:flex;gap:8px;">
+                      <input type="text" id="settingsPgHost" placeholder="111.229.143.134" style="flex:1;">
+                      <input type="text" id="settingsPgPort" placeholder="5432" style="width:88px;">
+                    </div>
+                  </div>
+                  <div class="field">
+                    <label>数据库 / 用户</label>
+                    <input type="text" id="settingsPgDatabase" placeholder="smart_ops" autocomplete="off">
+                    <input type="text" id="settingsPgUser" placeholder="smart_ops" autocomplete="off" style="margin-top:6px;">
+                  </div>
+                  <div class="field">
+                    <label>密码</label>
+                    <input type="password" id="settingsPgPassword" placeholder="数据库密码" autocomplete="off">
+                  </div>
+                  <div class="field">
+                    <label style="display:flex;align-items:center;gap:6px;">
+                      <input type="checkbox" id="settingsPgSsl">
+                      <span>使用 SSL(云数据库要求时勾选)</span>
+                    </label>
+                  </div>
+                </div>
+                <div class="settings-links">
+                  <button type="button" class="settings-link settings-link-btn" id="btnTestStorage"><span>测试连接</span></button>
+                  <span id="storageTestStatus" class="badge"></span>
+                </div>
+                <div class="panel-save-row">
+                  <button class="btn btn-primary btn-small" id="btnSaveStorage">保存设置</button>
+                  <span id="storageSaveStatus" class="badge"></span>
+                </div>
+              </div>
+            </div>
           </div>
         </div>
       </main>

+ 99 - 0
server/routes/settings.js

@@ -193,6 +193,105 @@ function register(app, ctx) {
       sendJson(res, 502, { error: e.message });
     }
   });
+  // ---------- 云端数据库(PostgreSQL)配置 ----------
+
+  function pgInstalled() {
+    try { require.resolve('pg'); return true; } catch { return false; }
+  }
+
+  function storageFormFromBody(body) {
+    const current = (ctx.config.storage && ctx.config.storage.postgres) || {};
+    const driver = body.driver === 'postgres' ? 'postgres' : 'sqlite';
+    const pw = body.pgPassword;
+    return {
+      driver,
+      sqlite: {
+        file: String(body.sqliteFile
+          || (ctx.config.storage && ctx.config.storage.sqlite && ctx.config.storage.sqlite.file)
+          || 'data/ops.db'),
+      },
+      postgres: {
+        // 未提交的字段回落到已保存配置(测试连接时可直接复用)
+        host: String(body.pgHost !== undefined ? body.pgHost : (current.host || '')).trim(),
+        port: Number(body.pgPort) || current.port || 5432,
+        database: String(body.pgDatabase !== undefined ? body.pgDatabase : (current.database || '')).trim(),
+        user: String(body.pgUser !== undefined ? body.pgUser : (current.user || '')).trim(),
+        password: (pw !== undefined && pw !== null && !String(pw).includes('••')) ? String(pw) : (current.password || ''),
+        ssl: body.pgSsl !== undefined ? !!body.pgSsl : !!current.ssl,
+        schema: String(body.pgSchema || current.schema || 'public'),
+        max: current.max || 5,
+        connectTimeoutMs: 10000,
+      },
+    };
+  }
+
+  app.get('/api/settings/storage', async ({ res }) => {
+    const st = ctx.config.storage || {};
+    const pg = st.postgres || {};
+    sendJson(res, 200, {
+      driver: st.driver || 'sqlite',
+      sqliteFile: (st.sqlite && st.sqlite.file) || 'data/ops.db',
+      pgInstalled: pgInstalled(),
+      postgres: {
+        host: pg.host || '',
+        port: pg.port || 5432,
+        database: pg.database || '',
+        user: pg.user || '',
+        passwordSet: !!pg.password,
+        ssl: !!pg.ssl,
+        schema: pg.schema || 'public',
+      },
+    });
+  });
+
+  app.post('/api/settings/storage/test', async ({ req, res }) => {
+    const body = await readBody(req);
+    if (!pgInstalled()) return sendJson(res, 200, { ok: false, error: '未安装 pg 驱动(npm install pg)' });
+    const form = storageFormFromBody(body).postgres;
+    if (!form.host || !form.database || !form.user) {
+      return sendJson(res, 200, { ok: false, error: '请填写主机、数据库名和用户名' });
+    }
+    const { Client } = require('pg');
+    const client = new Client({
+      host: form.host,
+      port: form.port,
+      database: form.database,
+      user: form.user,
+      password: form.password,
+      ssl: form.ssl ? { rejectUnauthorized: false } : false,
+      connectionTimeoutMillis: 8000,
+    });
+    try {
+      await client.connect();
+      const r = await client.query('select version() as v, current_database() as db');
+      sendJson(res, 200, {
+        ok: true,
+        version: String(r.rows[0].v).split(' ').slice(0, 2).join(' '),
+        database: r.rows[0].db,
+      });
+    } catch (e) {
+      sendJson(res, 200, { ok: false, error: e.message || String(e) });
+    } finally {
+      await client.end().catch(() => {});
+    }
+  });
+
+  app.post('/api/settings/storage', async ({ req, res }) => {
+    const body = await readBody(req);
+    const next = storageFormFromBody(body);
+    if (next.driver === 'postgres' && (!next.postgres.host || !next.postgres.database || !next.postgres.user)) {
+      return sendJson(res, 400, { error: '启用云端数据库需要填写主机、数据库名和用户名' });
+    }
+    const previous = (ctx.config.storage && ctx.config.storage.driver) || 'sqlite';
+    ctx.config.storage = { ...(ctx.config.storage || {}), ...next };
+    ctx.saveConfig();
+    ctx.logs.pushSystemLog(
+      `存储驱动已配置为 ${next.driver}${next.driver === 'postgres' ? `(${next.postgres.host}:${next.postgres.port}/${next.postgres.database})` : ''}`,
+      'success',
+    );
+    sendJson(res, 200, { ok: true, restartRequired: previous !== next.driver });
+  });
+
 }
 
 module.exports = { register };