diff --git a/.gitignore b/.gitignore index 727b9fa..35ebb61 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ **/*.suo frontend/node_modules/ frontend/dist/ +frontend/dist-postgres/ +**/.dotnet-cli-home/ .codex-temp/ .tools/ build.log diff --git a/README.md b/README.md index 0525564..8c8bbad 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Live Recorder 是一个多平台直播录制系统,支持自动检测开播、实时录制、弹幕采集、通知推送与事件脚本编排。 -- 后端:`.NET 8 Web API + EF Core + SQLite` +- 后端:`.NET 8 Web API + EF Core + PostgreSQL` - 前端:`Vue 3 + TypeScript + Element Plus` 当前已适配 **抖音**(Douyin)与 **Bilibili**(部分),架构将平台特有逻辑隔离在 `Platforms` 目录下,新增虎牙、斗鱼、快手等平台无需改动应用层服务。 @@ -255,17 +255,34 @@ docker compose up -d ### fnOS 原生 FPK ```bash +./scripts/build-postgresql-fnos-package.sh ./scripts/build-fnos-package.sh -./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.0.1-x86_64.fpk +./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-fnos-package.sh \ + artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \ + artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-fnos-migration.sh \ + artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \ + artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk ``` - 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径 -- x86_64 原生包,不依赖 Docker;离线内置 .NET、PostgreSQL、官方 Node.js 22 与 curl +- 两个 FPK 都是 x86_64 原生应用,不依赖 Docker +- 先安装 `nxsir.postgresql`,再安装 Live Recorder;fnOS 会通过 `install_dep_apps` 检查依赖 +- PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433` +- 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector +- PostgreSQL 管理界面有独立管理员登录、客户端/数据库/角色/会话管理、只读 SQL 和手动备份恢复 +- Live Recorder 安装向导会要求设置自身 `admin` 密码,并填写 PostgreSQL 服务的应用接入令牌 +- 从旧 fnOS 包升级时会自动迁移原内置 PostgreSQL;只有自定义转储、SHA-256 和十张业务表行数校验全部成功后才切换 +- 迁移失败会继续使用旧数据库并在下次启动重试;旧数据库和迁移转储不会自动删除 +- Live Recorder 1.1.0 暂时仍携带旧 PG15 运行时,仅用于升级迁移和安全回退,不会在新安装上启动第二个数据库进程 - 依赖 fnOS 系统环境同时提供 `ffmpeg` 和 `ffprobe`,安装前请先确认二者可执行 -- 安装向导会要求设置 `admin` 管理员密码 - Web 管理界面默认使用端口 `18080` -- 数据库与日志保存在 fnOS 应用持久化目录 -- 录制文件保存在 fnOS 共享目录 `liverecorder/records` +- PostgreSQL 数据位于其应用持久化目录,手动备份位于共享目录 `postgresql/backups` +- 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改 + +更完整的安装、端口、凭据与迁移说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。 +其他 fnOS 应用接入共享数据库时,请直接参考 [docs/fnos-postgresql-client-integration.md](docs/fnos-postgresql-client-integration.md)。 ## 验证 diff --git a/docs/fnos-postgresql-client-integration.md b/docs/fnos-postgresql-client-integration.md new file mode 100644 index 0000000..b2a5817 --- /dev/null +++ b/docs/fnos-postgresql-client-integration.md @@ -0,0 +1,237 @@ +# fnOS PostgreSQL 共享服务接入指南 + +本文面向需要接入 `nxsir.postgresql` 的 fnOS 原生应用。共享服务不依赖 Docker,多个应用共用一个 PostgreSQL 15 进程,但每个应用获得独立数据库、独立登录角色和独立随机密码。 + +## 1. 服务约定 + +| 项目 | 默认值 | +|---|---| +| fnOS 应用名 | `nxsir.postgresql` | +| PostgreSQL 地址 | `127.0.0.1:15432` | +| 管理/API 地址 | `http://127.0.0.1:15433` | +| 传输加密 | 不启用 TLS,仅允许本机回环连接 | +| 密码认证 | PostgreSQL SCRAM-SHA-256 | +| 可申请扩展 | `vector`(pgvector) | + +不要连接 Unix Socket、不要使用服务管理员角色,也不要假设数据库名或角色名。客户端必须通过接入 API 获取完整凭据。 + +## 2. 声明 fnOS 依赖 + +在应用 FPK 的 `manifest` 中声明: + +```ini +install_dep_apps=nxsir.postgresql +``` + +用户应先安装并启动 PostgreSQL 共享服务,再安装你的应用。你的安装/升级向导需要提供一个密码字段,让用户填写安装共享服务时设置的“应用接入令牌”。令牌长度为 20~256 个字符,不允许换行。 + +接入令牌与 PostgreSQL 管理员密码是两套独立凭据: + +- 管理员密码只登录 PostgreSQL 管理面板。 +- 接入令牌只用于本机应用首次签发或重新签发数据库凭据。 + +## 3. 注册客户端 + +注册接口只接受来自回环地址的请求: + +```http +POST http://127.0.0.1:15433/internal/v1/enroll +Authorization: Bearer <应用接入令牌> +Content-Type: application/json +``` + +普通 PostgreSQL 客户端: + +```json +{ + "appId": "myapp", + "displayName": "My fnOS App", + "requestedExtensions": [] +} +``` + +需要 pgvector 的应用: + +```json +{ + "appId": "imagefind", + "displayName": "ImageFind", + "requestedExtensions": ["vector"] +} +``` + +字段限制: + +- `appId`:稳定且全局唯一,3~64 个字符;以小写字母开头,只允许小写字母、数字、点、下划线和连字符。发布后不要更改。 +- `displayName`:1~100 个字符,用于管理面板展示。 +- `requestedExtensions`:目前只能是空数组或包含 `vector`。 + +curl 示例: + +```bash +curl --fail --silent --show-error \ + -H "Authorization: Bearer $APP_ENROLLMENT_TOKEN" \ + -H 'Content-Type: application/json' \ + --data '{"appId":"myapp","displayName":"My fnOS App","requestedExtensions":[]}' \ + http://127.0.0.1:15433/internal/v1/enroll +``` + +成功响应: + +```json +{ + "host": "127.0.0.1", + "port": 15432, + "database": "appdb_myapp_...", + "username": "app_myapp_...", + "password": "一次性返回的随机密码", + "sslMode": "Disable", + "serviceVersion": "15" +} +``` + +每次对同一 `appId` 重新注册都会复用其数据库和角色,但会立即轮换密码,使旧密码失效。因此正常启动时应先读取本地凭据,只有首次安装、凭据丢失或明确执行密码轮换时才重新注册。 + +常见 HTTP 状态: + +| 状态 | 含义 | +|---:|---| +| `200` | 注册成功;响应中包含新密码 | +| `400` | `appId`、显示名或扩展参数无效 | +| `401` | 接入令牌错误或已被管理员轮换 | +| `403` | 请求不是从本机回环地址发起 | +| `500` | 共享服务内部错误;查看共享服务日志 | + +## 4. fnOS 生命周期脚本建议 + +安装回调只负责以 `0600` 保存令牌种子,不要在向导校验阶段依赖网络。应用启动时执行注册,并采用临时文件加原子重命名保存响应。 + +建议的持久化文件: + +```text +${TRIM_PKGVAR}/postgres-enrollment-token.seed # 首次注册前,0600 +${TRIM_PKGVAR}/postgres-client.conf # 注册成功后,0600 +``` + +推荐配置格式: + +```ini +host=127.0.0.1 +port=15432 +database=接口返回值 +username=接口返回值 +password=接口返回值 +``` + +注册成功并安全落盘后应删除令牌种子,避免长期保存高权限接入令牌。不要把密码写入日志、命令行参数、进程标题或 Web 前端。应用卸载时是否保留数据库由用户决定;不要自行执行 `DROP DATABASE`。 + +服务可能在 NAS 启动时稍晚就绪。建议: + +- 请求超时 10 秒左右。 +- 每 2 秒重试一次,最多等待 1~2 分钟。 +- 先探测 `GET /health/ready`,或直接重试注册。 +- 已有本地凭据时不要因为管理 API 暂时不可用而重新注册;直接尝试 PostgreSQL 连接。 + +## 5. 连接字符串 + +.NET / Npgsql: + +```text +Host=127.0.0.1;Port=15432;Database=;Username=;Password=;SSL Mode=Disable;Timeout=15;Command Timeout=120;Keepalive=30 +``` + +Python / psycopg: + +```python +import psycopg + +connection = psycopg.connect( + host="127.0.0.1", + port=15432, + dbname=database, + user=username, + password=password, + sslmode="disable", + connect_timeout=15, +) +``` + +JDBC: + +```text +jdbc:postgresql://127.0.0.1:15432/?sslmode=disable&connectTimeout=15 +``` + +应用角色是目标数据库及 `public` schema 的所有者,可以执行自身 migrations、建表和创建索引,但不能创建数据库、创建角色、成为超级用户或连接其他托管应用的数据库。 + +## 6. pgvector + +注册时申请 `"requestedExtensions":["vector"]` 后,共享服务会在应用数据库内执行: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; +``` + +应用可直接在 migration 中使用: + +```sql +CREATE TABLE embeddings ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + embedding vector(768) NOT NULL +); + +CREATE INDEX embeddings_cosine_idx + ON embeddings USING hnsw (embedding vector_cosine_ops); +``` + +不要由应用尝试安装系统扩展文件。未在允许列表中的扩展会在注册阶段被拒绝。 + +## 7. schema 与迁移 + +- 新数据库默认撤销 `PUBLIC` 的数据库连接权和 schema 权限。 +- 应用角色拥有自己的数据库和 `public` schema,可正常运行 Flyway、EF Core、Alembic、Liquibase 等迁移。 +- 用 custom-format `pg_dump` 迁移旧库时建议使用 `--no-owner --no-acl`。 +- 恢复前确认目标库为空;不要覆盖其他 `appId` 的数据库。 +- 切换前至少校验关键表行数,保留旧数据和带 SHA-256 的转储,确认稳定后再人工清理。 + +## 8. 密码轮换、吊销与恢复 + +管理员可以在 PostgreSQL 管理面板中: + +- 为某个客户端轮换密码:应用必须立即更新本地凭据。 +- 吊销客户端:对应角色变为 `NOLOGIN`,数据库不会被删除。 +- 轮换全局接入令牌:旧令牌立即失效,已经签发的数据库密码不受影响。 + +应用本地凭据丢失时,让用户在升级/修复向导重新输入当前接入令牌,再用相同 `appId` 注册。数据库内容会保留,但旧数据库密码会失效。 + +不要静默回退到陈旧数据库副本。若应用已经完成共享数据库迁移,但凭据不可用,应停止启动并提示修复凭据,避免产生两套分叉数据。 + +## 9. 健康检查与排错 + +```bash +curl -fsS http://127.0.0.1:15433/health +curl -fsS http://127.0.0.1:15433/health/ready +``` + +排查顺序: + +1. 确认 `nxsir.postgresql` 已安装且状态为运行中。 +2. 确认应用使用 `127.0.0.1`,而不是 NAS 局域网 IP。 +3. 检查接入 API 端口 `15433` 与数据库端口 `15432` 是否混用。 +4. 检查凭据文件权限是否为 `0600`,字段是否完整。 +5. `password authentication failed` 通常表示密码已轮换;用相同 `appId` 重新注册并原子更新凭据。 +6. `permission denied for database` 通常表示连接了其他应用的数据库;必须使用响应中的 database。 +7. 共享服务升级或重启不会要求客户端重新注册,应用应使用已有凭据自动重连。 + +## 10. 接入验收清单 + +- manifest 已声明 `install_dep_apps=nxsir.postgresql`。 +- 接入 API 只从回环地址调用。 +- `appId` 固定且不会随版本变化。 +- 令牌和数据库密码从不写日志。 +- 凭据以 `0600` 原子落盘,成功后删除令牌种子。 +- 普通启动不重复注册、不意外轮换密码。 +- 应用 migrations 能在自己的数据库执行。 +- 已验证无法连接另一个测试应用的数据库。 +- 共享服务重启后应用能用原凭据恢复。 +- 已准备凭据丢失、密码轮换和迁移失败的明确恢复流程。 diff --git a/docs/postgresql-migration.md b/docs/postgresql-migration.md index b369371..a243efc 100644 --- a/docs/postgresql-migration.md +++ b/docs/postgresql-migration.md @@ -1,4 +1,50 @@ -# PostgreSQL 切换与 SQLite 历史数据迁移 +# PostgreSQL 共享服务与历史数据迁移 + +## fnOS 原生部署 + +fnOS 方案由两个独立 FPK 组成: + +| 应用 | 默认端口 | 持久化内容 | +|---|---:|---| +| `nxsir.postgresql` | 管理界面 `15433`、数据库 `127.0.0.1:15432` | PostgreSQL 数据、凭据散列、审计日志 | +| `liverecorder` | Web 管理界面 `18080` | 应用日志、签发后的数据库客户端凭据、迁移回退数据 | + +安装顺序: + +1. 安装 PostgreSQL 共享服务,设置独立管理密码与长度至少 20 位的应用接入令牌。 +2. 安装 Live Recorder,设置应用管理员密码,并填写同一个接入令牌。 +3. Live Recorder 只通过 `127.0.0.1` 注册。共享服务为它创建独立数据库和 SCRAM 角色,随机密码只在注册响应中返回一次。 +4. 注册成功后,接入令牌会从 Live Recorder 持久化目录删除;签发凭据保存在权限为 `0600` 的 `postgres-client.conf`。 + +新安装不会启动 Live Recorder 包内的旧 PostgreSQL。录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套。 + +### 从旧 fnOS 版本自动迁移 + +升级包检测到旧 `PG_VERSION` 且尚无迁移标记时会: + +1. 启动旧的私有 PG15,只读导出 custom-format 转储并生成 SHA-256。 +2. 清空新签发的目标 schema,以 `--no-owner --no-acl` 恢复。 +3. 精确比较十张业务表在源库和目标库中的行数。 +4. 全部成功后写入 `shared-database.active` 标记并停止旧 PostgreSQL。 + +任一步失败都会继续使用旧数据库,下次启动再重试。系统不会自动删除旧数据、转储、校验文件;确认新版本稳定并另行备份后再手工清理。迁移标记一旦存在,凭据损坏时应用会拒绝回退到已经过期的旧库,防止录制数据分叉。 + +### 管理与备份 + +- PostgreSQL 管理面板使用独立 `admin` 会话,不复用 Live Recorder 登录。 +- SQL 工作台只接受单条 `SELECT`、`WITH`、`EXPLAIN`、`SHOW`、`VALUES` 或 `TABLE`,并在只读事务、30 秒超时和 1000 行上限下执行。 +- 自动签发的应用数据库与角色不能在普通数据库/角色页面直接删除,应从客户端页面吊销。 +- 备份仅手动触发,使用 custom-format `pg_dump` 并保存 SHA-256;恢复需要明确输入目标数据库名称确认。 + +### fnOS 验证 + +```bash +./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-fnos-migration.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +``` + +下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。 这份说明对应当前主线版本:应用正式运行数据库已经切换为 PostgreSQL,SQLite 仅用于一次性历史数据导入。 diff --git a/fnos-postgresql/app/ui/config b/fnos-postgresql/app/ui/config new file mode 100644 index 0000000..64638f6 --- /dev/null +++ b/fnos-postgresql/app/ui/config @@ -0,0 +1,13 @@ +{ + ".url": { + "nxsir.postgresql.Application": { + "title": "PostgreSQL 共享服务", + "icon": "images/icon_{0}.png", + "type": "url", + "protocol": "", + "port": "15433", + "url": "/", + "allUsers": false + } + } +} diff --git a/fnos-postgresql/cmd/config_callback b/fnos-postgresql/cmd/config_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/config_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/config_init b/fnos-postgresql/cmd/config_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/config_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/install_callback b/fnos-postgresql/cmd/install_callback new file mode 100644 index 0000000..4bef5ad --- /dev/null +++ b/fnos-postgresql/cmd/install_callback @@ -0,0 +1,35 @@ +#!/bin/bash +set -eu + +ADMIN_PASSWORD="${wizard_postgres_admin_password:-}" +ADMIN_PASSWORD_CONFIRM="${wizard_postgres_admin_password_confirm:-}" +ENROLLMENT_TOKEN="${wizard_postgres_enrollment_token:-}" +ENROLLMENT_TOKEN_CONFIRM="${wizard_postgres_enrollment_token_confirm:-}" +unset wizard_postgres_admin_password wizard_postgres_admin_password_confirm +unset wizard_postgres_enrollment_token wizard_postgres_enrollment_token_confirm + +fail() { + printf '%s\n' "$1" >&2 + if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then + printf '%s\n' "$1" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true + fi + exit 1 +} + +[ "$ADMIN_PASSWORD" = "$ADMIN_PASSWORD_CONFIRM" ] || fail "管理密码两次输入不一致。" +[ "${#ADMIN_PASSWORD}" -ge 12 ] || fail "管理密码至少需要 12 个字符。" +[ "${#ADMIN_PASSWORD}" -le 256 ] || fail "管理密码不能超过 256 个字符。" +[ "$ENROLLMENT_TOKEN" = "$ENROLLMENT_TOKEN_CONFIRM" ] || fail "接入令牌两次输入不一致。" +[ "${#ENROLLMENT_TOKEN}" -ge 20 ] || fail "接入令牌至少需要 20 个字符。" +[ "${#ENROLLMENT_TOKEN}" -le 256 ] || fail "接入令牌不能超过 256 个字符。" +case "$ADMIN_PASSWORD$ENROLLMENT_TOKEN" in + *$'\n'*|*$'\r'*) fail "密码和令牌不能包含换行符。" ;; +esac + +mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log" +chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true +umask 077 +printf '%s\n' "$ADMIN_PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed" +printf '%s\n' "$ENROLLMENT_TOKEN" >"${TRIM_PKGVAR}/enrollment-token.seed" +unset ADMIN_PASSWORD ADMIN_PASSWORD_CONFIRM ENROLLMENT_TOKEN ENROLLMENT_TOKEN_CONFIRM +exit 0 diff --git a/fnos-postgresql/cmd/install_init b/fnos-postgresql/cmd/install_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/install_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/main b/fnos-postgresql/cmd/main new file mode 100644 index 0000000..f72623c --- /dev/null +++ b/fnos-postgresql/cmd/main @@ -0,0 +1,175 @@ +#!/bin/bash +set -u + +PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}" +DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}" +VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}" +BACKUP_ROOT="${POSTGRES_SERVICE_BACKUP_ROOT:-$VOLUME_ROOT/@appshare/postgresql/backups}" +RUNTIME_ROOT="$APP_ROOT/runtime" +SERVER="$APP_ROOT/server/PostgresService.WebApi" +PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin" +PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15" +PG_LIB="$RUNTIME_ROOT/usr/lib/postgresql/15/lib" +PG_DATA="$DATA_ROOT/postgres" +RUN_ROOT="$DATA_ROOT/run" +LOG_ROOT="$DATA_ROOT/log" +APP_PID_FILE="$RUN_ROOT/postgres-service.pid" +APP_LOG="$LOG_ROOT/postgres-service.log" +PG_LOG="$LOG_ROOT/postgresql.log" +PG_PORT="${POSTGRES_SERVICE_PORT:-15432}" +SERVICE_PORT="${TRIM_SERVICE_PORT:-15433}" +RUNTIME_PATH="$PG_BIN:$RUNTIME_ROOT/usr/bin:${PATH:-/usr/local/bin:/usr/bin:/bin}" +RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu:$RUNTIME_ROOT/lib/x86_64-linux-gnu:$PG_LIB" + +log_message() { + mkdir -p "$LOG_ROOT" + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG" +} + +run_pg() { + env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" "$@" +} + +app_pid() { + if [ -f "$APP_PID_FILE" ]; then + pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]') + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + printf '%s' "$pid" + return 0 + fi + fi + return 1 +} + +postgres_running() { + [ -x "$PG_BIN/pg_ctl" ] || return 1 + [ -f "$PG_DATA/PG_VERSION" ] || return 1 + run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1 +} + +initialize_postgres() { + [ ! -f "$PG_DATA/PG_VERSION" ] || return 0 + mkdir -p "$PG_DATA" "$RUN_ROOT" "$LOG_ROOT" + chmod 0700 "$PG_DATA" "$RUN_ROOT" + run_pg "$PG_BIN/initdb" \ + -D "$PG_DATA" \ + -L "$PG_SHARE" \ + --username=postgres_service \ + --auth-local=trust \ + --auth-host=scram-sha-256 \ + --encoding=UTF8 \ + --no-locale >>"$PG_LOG" 2>&1 || return 1 + + { + printf "listen_addresses = '127.0.0.1'\n" + printf "port = %s\n" "$PG_PORT" + printf "unix_socket_directories = '%s'\n" "$RUN_ROOT" + printf "password_encryption = 'scram-sha-256'\n" + printf "max_connections = 100\n" + printf "shared_buffers = '128MB'\n" + printf "timezone = 'UTC'\n" + printf "log_timezone = 'UTC'\n" + printf "log_min_duration_statement = 5000\n" + } >>"$PG_DATA/postgresql.conf" + + { + printf 'local all postgres_service trust\n' + printf 'local all all reject\n' + printf 'host all all 127.0.0.1/32 scram-sha-256\n' + printf 'host all all ::1/128 scram-sha-256\n' + } >"$PG_DATA/pg_hba.conf" +} + +start_postgres() { + postgres_running && return 0 + initialize_postgres || { log_message "PostgreSQL 初始化失败。"; return 1; } + run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start || { + log_message "PostgreSQL 启动失败。" + return 1 + } +} + +stop_postgres() { + if postgres_running; then + run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -t 180 -w stop >>"$PG_LOG" 2>&1 || { + log_message "PostgreSQL 未能在 180 秒内安全停止。" + return 1 + } + fi +} + +start_app() { + mkdir -p "$DATA_ROOT" "$RUN_ROOT" "$LOG_ROOT" "$BACKUP_ROOT" "$DATA_ROOT/tmp" "$DATA_ROOT/cache" + chmod 0700 "$DATA_ROOT" "$RUN_ROOT" "$DATA_ROOT/tmp" "$DATA_ROOT/cache" 2>/dev/null || true + [ -x "$SERVER" ] || { log_message "管理服务不存在或不可执行:$SERVER"; return 1; } + [ -x "$PG_BIN/postgres" ] || { log_message "PostgreSQL 原生运行环境不完整。"; return 1; } + if app_pid >/dev/null; then return 0; fi + start_postgres || return 1 + + ( + export ASPNETCORE_ENVIRONMENT=Production + export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT" + export POSTGRES_SERVICE_DATA_ROOT="$DATA_ROOT" + export POSTGRES_SERVICE_SOCKET_ROOT="$RUN_ROOT" + export POSTGRES_SERVICE_PORT="$PG_PORT" + export POSTGRES_SERVICE_ADMIN_USER=postgres_service + export POSTGRES_SERVICE_PG_BIN="$PG_BIN" + export POSTGRES_SERVICE_BACKUP_ROOT="$BACKUP_ROOT" + export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 + export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$DATA_ROOT/dotnet-bundle" + export XDG_CACHE_HOME="$DATA_ROOT/cache" + export TMPDIR="$DATA_ROOT/tmp" + export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" + export PATH="$RUNTIME_PATH" + mkdir -p "$DOTNET_BUNDLE_EXTRACT_BASE_DIR" + cd "$APP_ROOT/server" || exit 1 + exec "$SERVER" + ) >>"$APP_LOG" 2>&1 & + pid=$! + printf '%s\n' "$pid" >"$APP_PID_FILE" + + attempt=0 + while [ "$attempt" -lt 90 ]; do + if ! kill -0 "$pid" 2>/dev/null; then + log_message "管理服务进程提前退出。" + stop_postgres + return 1 + fi + if (exec 3<>"/dev/tcp/127.0.0.1/$SERVICE_PORT") 2>/dev/null; then + exec 3>&- + log_message "PostgreSQL 共享服务启动成功,管理端口 $SERVICE_PORT,数据库端口 $PG_PORT。" + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + log_message "管理服务未能在 90 秒内就绪。" + stop_app + return 1 +} + +stop_app() { + if pid=$(app_pid); then + kill "$pid" 2>/dev/null || true + attempt=0 + while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do + sleep 1 + attempt=$((attempt + 1)) + done + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$APP_PID_FILE" + stop_postgres +} + +case "${1:-status}" in + start) start_app ;; + stop) stop_app ;; + restart) stop_app && start_app ;; + status) + if app_pid >/dev/null && postgres_running; then exit 0; fi + exit 3 + ;; + *) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;; +esac diff --git a/fnos-postgresql/cmd/uninstall_callback b/fnos-postgresql/cmd/uninstall_callback new file mode 100644 index 0000000..437d571 --- /dev/null +++ b/fnos-postgresql/cmd/uninstall_callback @@ -0,0 +1,3 @@ +#!/bin/bash +# 数据目录与手动备份默认保留,避免误删多个应用的共享数据。 +exit 0 diff --git a/fnos-postgresql/cmd/uninstall_init b/fnos-postgresql/cmd/uninstall_init new file mode 100644 index 0000000..61a7c23 --- /dev/null +++ b/fnos-postgresql/cmd/uninstall_init @@ -0,0 +1,4 @@ +#!/bin/bash +set -eu +"$(dirname -- "$0")/main" stop || true +exit 0 diff --git a/fnos-postgresql/cmd/upgrade_callback b/fnos-postgresql/cmd/upgrade_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos-postgresql/cmd/upgrade_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos-postgresql/cmd/upgrade_init b/fnos-postgresql/cmd/upgrade_init new file mode 100644 index 0000000..27b9a7d --- /dev/null +++ b/fnos-postgresql/cmd/upgrade_init @@ -0,0 +1,6 @@ +#!/bin/bash +set -eu + +# 当前服务固定在 PostgreSQL 15 主版本;数据目录与管理凭据位于 +# TRIM_PKGVAR,fnOS 替换不可变应用文件时无需复制。 +exit 0 diff --git a/fnos-postgresql/config/privilege b/fnos-postgresql/config/privilege new file mode 100644 index 0000000..dc2d99d --- /dev/null +++ b/fnos-postgresql/config/privilege @@ -0,0 +1,5 @@ +{ + "defaults": { + "run-as": "package" + } +} diff --git a/fnos-postgresql/config/resource b/fnos-postgresql/config/resource new file mode 100644 index 0000000..44392ea --- /dev/null +++ b/fnos-postgresql/config/resource @@ -0,0 +1,18 @@ +{ + "data-share": { + "shares": [ + { + "name": "postgresql", + "permission": { + "rw": ["nxsir.postgresql"] + } + }, + { + "name": "postgresql/backups", + "permission": { + "rw": ["nxsir.postgresql"] + } + } + ] + } +} diff --git a/fnos-postgresql/manifest b/fnos-postgresql/manifest new file mode 100644 index 0000000..5a4d973 --- /dev/null +++ b/fnos-postgresql/manifest @@ -0,0 +1,12 @@ +appname=nxsir.postgresql +version=15.1.0 +display_name=PostgreSQL 共享服务 +desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复 +platform=x86 +source=thirdparty +maintainer=Live Recorder Contributors +os_min_version=1.2.0 +desktop_uidir=ui +desktop_applaunchname=nxsir.postgresql.Application +checkport=true +ctl_stop=true diff --git a/fnos-postgresql/wizard/install b/fnos-postgresql/wizard/install new file mode 100644 index 0000000..0036aad --- /dev/null +++ b/fnos-postgresql/wizard/install @@ -0,0 +1,47 @@ +[ + { + "stepTitle": "设置 PostgreSQL 服务管理凭据", + "items": [ + { + "type": "tips", + "helpText": "管理密码用于登录数据库面板;接入令牌用于其他 fnOS 应用首次申请独立数据库。两者均不会设置默认值。" + }, + { + "type": "password", + "field": "wizard_postgres_admin_password", + "label": "管理面板密码", + "rules": [ + { "required": true, "message": "请输入管理面板密码" }, + { "min": 12, "message": "管理密码至少需要 12 个字符" }, + { "max": 256, "message": "管理密码不能超过 256 个字符" } + ] + }, + { + "type": "password", + "field": "wizard_postgres_admin_password_confirm", + "label": "再次输入管理密码", + "rules": [ + { "required": true, "message": "请再次输入管理密码" } + ] + }, + { + "type": "password", + "field": "wizard_postgres_enrollment_token", + "label": "应用接入令牌", + "rules": [ + { "required": true, "message": "请输入应用接入令牌" }, + { "min": 20, "message": "接入令牌至少需要 20 个字符" }, + { "max": 256, "message": "接入令牌不能超过 256 个字符" } + ] + }, + { + "type": "password", + "field": "wizard_postgres_enrollment_token_confirm", + "label": "再次输入接入令牌", + "rules": [ + { "required": true, "message": "请再次输入接入令牌" } + ] + } + ] + } +] diff --git a/fnos-postgresql/wizard/upgrade b/fnos-postgresql/wizard/upgrade new file mode 100644 index 0000000..e305f38 --- /dev/null +++ b/fnos-postgresql/wizard/upgrade @@ -0,0 +1,11 @@ +[ + { + "stepTitle": "升级 PostgreSQL 共享服务", + "items": [ + { + "type": "tips", + "helpText": "升级期间所有依赖应用会暂时失去数据库连接。当前版本仅执行 PostgreSQL 15 同主版本升级,并保留数据、凭据、审计记录和备份。" + } + ] + } +] diff --git a/fnos/cmd/install_callback b/fnos/cmd/install_callback index cc81af6..1bd08b8 100755 --- a/fnos/cmd/install_callback +++ b/fnos/cmd/install_callback @@ -3,7 +3,8 @@ set -eu PASSWORD="${wizard_admin_password:-}" PASSWORD_CONFIRM="${wizard_admin_password_confirm:-}" -unset wizard_admin_password wizard_admin_password_confirm +POSTGRES_ENROLLMENT_TOKEN="${wizard_postgres_enrollment_token:-}" +unset wizard_admin_password wizard_admin_password_confirm wizard_postgres_enrollment_token fail() { printf '%s\n' "$1" >&2 @@ -19,10 +20,16 @@ fail() { case "$PASSWORD" in *$'\n'*|*$'\r'*) fail "管理员密码不能包含换行符。" ;; esac +[ "${#POSTGRES_ENROLLMENT_TOKEN}" -ge 20 ] || fail "PostgreSQL 应用接入令牌至少需要 20 个字符。" +[ "${#POSTGRES_ENROLLMENT_TOKEN}" -le 256 ] || fail "PostgreSQL 应用接入令牌不能超过 256 个字符。" +case "$POSTGRES_ENROLLMENT_TOKEN" in + *$'\n'*|*$'\r'*) fail "PostgreSQL 应用接入令牌不能包含换行符。" ;; +esac mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log" chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true umask 077 printf '%s\n' "$PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed" -unset PASSWORD PASSWORD_CONFIRM +printf '%s\n' "$POSTGRES_ENROLLMENT_TOKEN" >"${TRIM_PKGVAR}/postgres-enrollment-token.seed" +unset PASSWORD PASSWORD_CONFIRM POSTGRES_ENROLLMENT_TOKEN exit 0 diff --git a/fnos/cmd/main b/fnos/cmd/main index 81c6633..c965c0a 100755 --- a/fnos/cmd/main +++ b/fnos/cmd/main @@ -21,11 +21,22 @@ APP_PID_FILE="$RUN_ROOT/liverecorder.pid" APP_LOG="$LOG_ROOT/liverecorder.log" PG_LOG="$LOG_ROOT/postgresql.log" ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed" +POSTGRES_ENROLLMENT_TOKEN_FILE="$DATA_ROOT/postgres-enrollment-token.seed" +POSTGRES_CREDENTIALS_FILE="$DATA_ROOT/postgres-client.conf" +POSTGRES_MIGRATION_ROOT="$DATA_ROOT/postgres-migration" +POSTGRES_MIGRATION_MARKER="$POSTGRES_MIGRATION_ROOT/shared-database.active" PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}" SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}" +POSTGRES_SERVICE_API="${POSTGRES_SERVICE_API:-http://127.0.0.1:15433}" SYSTEM_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}" RUNTIME_PATH="$RUNTIME_ROOT/bin:$SYSTEM_PATH" RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib:$PG_LIB" +DATABASE_CONNECTION_STRING="" +SHARED_DB_HOST="" +SHARED_DB_PORT="" +SHARED_DB_NAME="" +SHARED_DB_USER="" +SHARED_DB_PASSWORD="" log_message() { mkdir -p "$LOG_ROOT" @@ -52,6 +63,177 @@ run_native() { SSL_CERT_FILE="$CA_BUNDLE" CURL_CA_BUNDLE="$CA_BUNDLE" "$@" } +read_credential_field() { + key=$1 + sed -n "s/^${key}=//p" "$POSTGRES_CREDENTIALS_FILE" | sed -n '1p' +} + +configure_shared_credentials() { + [ -s "$POSTGRES_CREDENTIALS_FILE" ] || return 1 + SHARED_DB_HOST=$(read_credential_field host) + SHARED_DB_PORT=$(read_credential_field port) + SHARED_DB_NAME=$(read_credential_field database) + SHARED_DB_USER=$(read_credential_field username) + SHARED_DB_PASSWORD=$(read_credential_field password) + [ "$SHARED_DB_HOST" = "127.0.0.1" ] || return 1 + case "$SHARED_DB_PORT" in ''|*[!0-9]*) return 1 ;; esac + case "$SHARED_DB_NAME$SHARED_DB_USER" in *[!a-z0-9_]*) return 1 ;; esac + [ -n "$SHARED_DB_PASSWORD" ] || return 1 + DATABASE_CONNECTION_STRING="Host=$SHARED_DB_HOST;Port=$SHARED_DB_PORT;Database=$SHARED_DB_NAME;Username=$SHARED_DB_USER;Password=$SHARED_DB_PASSWORD;SSL Mode=Disable;Timeout=15;Command Timeout=120;Keepalive=30" +} + +enroll_shared_database() { + [ -s "$POSTGRES_ENROLLMENT_TOKEN_FILE" ] || { + log_message "缺少 PostgreSQL 共享服务接入令牌。请在升级或安装向导中重新填写。" + return 1 + } + token=$(sed -n '1p' "$POSTGRES_ENROLLMENT_TOKEN_FILE") + [ -n "$token" ] || return 1 + response_file="$DATA_ROOT/postgres-enrollment-response.tmp" + rm -f "$response_file" + attempt=0 + while [ "$attempt" -lt 60 ]; do + if run_native "$CURL_BIN" \ + --fail --silent --show-error \ + --connect-timeout 3 --max-time 10 \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + --data '{"appId":"liverecorder","displayName":"Live Recorder","requestedExtensions":[]}' \ + "$POSTGRES_SERVICE_API/internal/v1/enroll" >"$response_file" 2>>"$APP_LOG"; then + break + fi + attempt=$((attempt + 1)) + sleep 2 + done + unset token + [ -s "$response_file" ] || { + log_message "无法从 PostgreSQL 共享服务取得数据库凭据。" + rm -f "$response_file" + return 1 + } + + host=$(sed -n 's/.*"host":"\([^"]*\)".*/\1/p' "$response_file") + port=$(sed -n 's/.*"port":\([0-9][0-9]*\).*/\1/p' "$response_file") + database=$(sed -n 's/.*"database":"\([^"]*\)".*/\1/p' "$response_file") + username=$(sed -n 's/.*"username":"\([^"]*\)".*/\1/p' "$response_file") + password=$(sed -n 's/.*"password":"\([^"]*\)".*/\1/p' "$response_file") + [ "$host" = "127.0.0.1" ] && [ -n "$port" ] && [ -n "$database" ] && [ -n "$username" ] && [ -n "$password" ] || { + log_message "PostgreSQL 共享服务返回了无效凭据。" + rm -f "$response_file" + return 1 + } + umask 077 + { + printf 'host=%s\n' "$host" + printf 'port=%s\n' "$port" + printf 'database=%s\n' "$database" + printf 'username=%s\n' "$username" + printf 'password=%s\n' "$password" + } >"$POSTGRES_CREDENTIALS_FILE.tmp" + mv "$POSTGRES_CREDENTIALS_FILE.tmp" "$POSTGRES_CREDENTIALS_FILE" + chmod 0600 "$POSTGRES_CREDENTIALS_FILE" + rm -f "$POSTGRES_ENROLLMENT_TOKEN_FILE" "$response_file" + configure_shared_credentials +} + +ensure_shared_credentials() { + if configure_shared_credentials; then + return 0 + fi + rm -f "$POSTGRES_CREDENTIALS_FILE" + enroll_shared_database +} + +run_shared_pg() { + env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" \ + PGPASSWORD="$SHARED_DB_PASSWORD" "$@" +} + +collect_database_counts() { + mode=$1 + destination=$2 + : >"$destination" + for table_name in AppSettings CleanupOperations LiveRooms RecordResults RecordSessions RecordTasks RecordUploadJobs SystemLogEntries UserAccounts UserSessions; do + if [ "$mode" = "private" ]; then + count=$(run_pg "$PG_BIN/psql" -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder -Atqc \ + "SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1 + else + count=$(run_shared_pg "$PG_BIN/psql" -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" -Atqc \ + "SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1 + fi + printf '%s=%s\n' "$table_name" "$count" >>"$destination" + done +} + +migrate_private_postgres() { + mkdir -p "$POSTGRES_MIGRATION_ROOT" + chmod 0700 "$POSTGRES_MIGRATION_ROOT" + dump_file="$POSTGRES_MIGRATION_ROOT/private-postgres-15.dump" + source_counts="$POSTGRES_MIGRATION_ROOT/source-counts.txt" + target_counts="$POSTGRES_MIGRATION_ROOT/target-counts.txt" + start_postgres || return 1 + + log_message "开始导出原内置 PostgreSQL 数据库。" + run_pg "$PG_BIN/pg_dump" \ + -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder \ + --format=custom --no-owner --no-acl --file "$dump_file.tmp" >>"$PG_LOG" 2>&1 || return 1 + mv "$dump_file.tmp" "$dump_file" + sha256sum "$dump_file" >"$dump_file.sha256" + collect_database_counts private "$source_counts" || return 1 + + log_message "开始恢复数据到 PostgreSQL 共享服务。" + run_shared_pg "$PG_BIN/psql" \ + -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \ + -v ON_ERROR_STOP=1 -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION \"$SHARED_DB_USER\"" >>"$PG_LOG" 2>&1 || return 1 + run_shared_pg "$PG_BIN/pg_restore" \ + -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \ + --no-owner --no-acl --exit-on-error "$dump_file" >>"$PG_LOG" 2>&1 || return 1 + collect_database_counts shared "$target_counts" || return 1 + if ! cmp -s "$source_counts" "$target_counts"; then + log_message "共享数据库行数校验失败,继续使用原数据库。" + return 1 + fi + + umask 077 + { + printf 'migrated_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf 'database=%s\n' "$SHARED_DB_NAME" + printf 'dump=%s\n' "$dump_file" + } >"$POSTGRES_MIGRATION_MARKER" + stop_postgres || return 1 + log_message "内置 PostgreSQL 已迁移到共享服务,旧数据和迁移转储已保留。" +} + +select_database_connection() { + if ! ensure_shared_credentials; then + if [ -f "$POSTGRES_MIGRATION_MARKER" ]; then + log_message "已完成共享数据库迁移,但当前凭据不可用;为避免使用过期旧库,应用不会启动。" + return 1 + fi + if [ -f "$PG_DATA/PG_VERSION" ]; then + log_message "共享服务暂不可用,本次启动继续使用原内置 PostgreSQL。" + start_postgres || return 1 + DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30" + return 0 + fi + return 1 + fi + + if [ -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then + if ! migrate_private_postgres; then + log_message "共享数据库迁移失败,本次启动继续使用原内置 PostgreSQL;下次启动会重试迁移。" + start_postgres || return 1 + DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30" + return 0 + fi + elif [ ! -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then + mkdir -p "$POSTGRES_MIGRATION_ROOT" + umask 077 + printf 'fresh_install_at=%s\ndatabase=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$SHARED_DB_NAME" >"$POSTGRES_MIGRATION_MARKER" + fi + return 0 +} + system_media_tools_available() { missing_tools="" for tool_name in ffmpeg ffprobe; do @@ -142,7 +324,7 @@ launch_app_process() { ( export ASPNETCORE_ENVIRONMENT=Production export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT" - export ConnectionStrings__DefaultConnection="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30" + export ConnectionStrings__DefaultConnection="$DATABASE_CONNECTION_STRING" export LIVE_RECORDER_DEFAULT_OUTPUT_ROOT="$RECORD_ROOT" if [ -f "$ADMIN_PASSWORD_FILE" ]; then LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD=$(sed -n '1p' "$ADMIN_PASSWORD_FILE") @@ -183,7 +365,7 @@ start_app() { fi rm -f "$APP_PID_FILE" - start_postgres || return 1 + select_database_connection || return 1 launch_attempt=1 launch_app_process pid=$APP_PROCESS_PID @@ -235,7 +417,7 @@ case "${1:-status}" in stop) stop_app ;; restart) stop_app && start_app ;; status) - if app_pid >/dev/null && postgres_running; then + if app_pid >/dev/null; then exit 0 fi exit 3 diff --git a/fnos/cmd/upgrade_callback b/fnos/cmd/upgrade_callback index 06bd986..1601ab9 100755 --- a/fnos/cmd/upgrade_callback +++ b/fnos/cmd/upgrade_callback @@ -1,2 +1,15 @@ #!/bin/bash +set -eu + +TOKEN="${wizard_postgres_enrollment_token:-}" +unset wizard_postgres_enrollment_token +if [ -n "$TOKEN" ]; then + [ "${#TOKEN}" -ge 20 ] || { printf '%s\n' "PostgreSQL 应用接入令牌至少需要 20 个字符。" >&2; exit 1; } + case "$TOKEN" in + *$'\n'*|*$'\r'*) printf '%s\n' "PostgreSQL 应用接入令牌不能包含换行符。" >&2; exit 1 ;; + esac + umask 077 + printf '%s\n' "$TOKEN" >"${TRIM_PKGVAR}/postgres-enrollment-token.seed" +fi +unset TOKEN exit 0 diff --git a/fnos/manifest b/fnos/manifest index 0ece77d..e6f8bf8 100644 --- a/fnos/manifest +++ b/fnos/manifest @@ -1,7 +1,7 @@ appname=liverecorder -version=1.0.1 +version=1.1.0 display_name=Live Recorder -desc=原生直播录制系统,离线内置 PostgreSQL、Node.js 和 Web 管理界面,支持分片录制、弹幕采集与 OpenList 自动上传 +desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传 platform=x86 source=thirdparty maintainer=Live Recorder Contributors @@ -10,3 +10,4 @@ desktop_uidir=ui desktop_applaunchname=liverecorder.Application checkport=true ctl_stop=true +install_dep_apps=nxsir.postgresql diff --git a/fnos/wizard/install b/fnos/wizard/install index d87a34d..41a1580 100644 --- a/fnos/wizard/install +++ b/fnos/wizard/install @@ -43,6 +43,26 @@ "message": "管理员密码不能超过 256 个字符" } ] + }, + { + "type": "password", + "field": "wizard_postgres_enrollment_token", + "label": "PostgreSQL 应用接入令牌", + "helpText": "填写安装 PostgreSQL 共享服务时设置的应用接入令牌。令牌只用于首次签发独立数据库凭据。", + "rules": [ + { + "required": true, + "message": "请输入 PostgreSQL 应用接入令牌" + }, + { + "min": 20, + "message": "接入令牌至少需要 20 个字符" + }, + { + "max": 256, + "message": "接入令牌不能超过 256 个字符" + } + ] } ] } diff --git a/fnos/wizard/upgrade b/fnos/wizard/upgrade index f17a001..56e95b7 100644 --- a/fnos/wizard/upgrade +++ b/fnos/wizard/upgrade @@ -4,7 +4,19 @@ "items": [ { "type": "tips", - "helpText": "升级会保留 PostgreSQL 数据库、系统设置、上传任务和录制文件。" + "helpText": "本次升级会自动把原内置 PostgreSQL 数据迁移到共享服务。迁移成功前仍可回退到原数据库,旧数据不会自动删除。" + }, + { + "type": "password", + "field": "wizard_postgres_enrollment_token", + "label": "PostgreSQL 应用接入令牌", + "helpText": "首次迁移需要填写共享服务的应用接入令牌;已经完成迁移的后续升级可以留空。", + "rules": [ + { + "max": 256, + "message": "接入令牌不能超过 256 个字符" + } + ] } ] } diff --git a/frontend/package.json b/frontend/package.json index b9cfcc8..5ca071e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", + "build:postgres-admin": "vite build --config vite.postgres.config.ts", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/postgres-admin/App.vue b/frontend/postgres-admin/App.vue new file mode 100644 index 0000000..d1678a3 --- /dev/null +++ b/frontend/postgres-admin/App.vue @@ -0,0 +1,318 @@ + + + diff --git a/frontend/postgres-admin/index.html b/frontend/postgres-admin/index.html new file mode 100644 index 0000000..150a48f --- /dev/null +++ b/frontend/postgres-admin/index.html @@ -0,0 +1,13 @@ + + + + + + + PostgreSQL 服务 + + +
+ + + diff --git a/frontend/postgres-admin/main.ts b/frontend/postgres-admin/main.ts new file mode 100644 index 0000000..c75ba69 --- /dev/null +++ b/frontend/postgres-admin/main.ts @@ -0,0 +1,7 @@ +import { createApp } from "vue"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import App from "./App.vue"; +import "./style.css"; + +createApp(App).use(ElementPlus).mount("#app"); diff --git a/frontend/postgres-admin/style.css b/frontend/postgres-admin/style.css new file mode 100644 index 0000000..3feecd2 --- /dev/null +++ b/frontend/postgres-admin/style.css @@ -0,0 +1,26 @@ +:root { color-scheme: light; font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --bg:#eef2f6; --surface:#fff; --muted:#f4f6f9; --border:#dde3eb; --text:#172033; --secondary:#68758a; --accent:#2563eb; --side:#f7f8fa; } +:root[data-theme="dark"] { color-scheme: dark; --bg:#0b1220; --surface:#121c2d; --muted:#18253a; --border:#29384e; --text:#edf2fb; --secondary:#91a2bb; --accent:#60a5fa; --side:#0e1727; } +* { box-sizing:border-box; } +body { margin:0; min-width:320px; min-height:100vh; color:var(--text); background:var(--bg); } +button,input,textarea { font:inherit; } +.shell { min-height:100vh; } +.sidebar { position:fixed; inset:0 auto 0 0; width:220px; display:flex; flex-direction:column; padding:14px 12px; background:var(--side); border-right:1px solid var(--border); } +.brand { display:flex; align-items:center; gap:10px; height:52px; padding:0 8px 14px; border-bottom:1px solid var(--border); } +.brand-mark { display:grid; place-items:center; width:36px; height:36px; flex:0 0 auto; border-radius:9px; color:#fff; background:#2563eb; font-weight:800; } +.brand strong,.brand small { display:block; }.brand small { margin-top:2px; color:var(--secondary); font-size:11px; } +nav { display:grid; gap:3px; padding-top:12px; } +nav button { height:38px; padding:0 12px; border:0; border-radius:7px; color:var(--secondary); background:transparent; text-align:left; font-weight:650; cursor:pointer; } +nav button:hover,nav button.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); } +.sidebar-foot { margin-top:auto; padding:12px 8px; color:var(--secondary); font-size:11px; border-top:1px solid var(--border); }.health-dot { display:inline-block; width:7px; height:7px; margin-right:7px; border-radius:50%; background:#16a34a; } +.content { min-height:100vh; margin-left:220px; padding:0 22px 24px; } +.topbar { position:sticky; top:0; z-index:5; display:flex; align-items:center; justify-content:space-between; min-height:58px; margin:0 -22px 18px; padding:8px 22px; background:color-mix(in srgb,var(--surface) 88%,transparent); border-bottom:1px solid var(--border); backdrop-filter:blur(10px); } +.topbar h1 { margin:0; font-size:20px; }.topbar small { color:var(--secondary); }.top-actions { display:flex; gap:7px; } +.page-stack { display:grid; gap:14px; }.panel { padding:16px; border:1px solid var(--border); border-radius:10px; background:var(--surface); overflow:hidden; } +.panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:14px; }.panel h2 { margin:0; font-size:16px; }.panel p { margin:5px 0 0; color:var(--secondary); font-size:12px; } +.metric-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.metric-grid article { display:grid; gap:8px; padding:14px; border:1px solid var(--border); border-radius:9px; background:var(--surface); }.metric-grid span { color:var(--secondary); font-size:12px; }.metric-grid strong { font-size:22px; } +.block { display:block; margin-top:3px; color:var(--secondary); }.full { width:100%; }.database-select { width:220px; }.query-actions { display:flex; justify-content:flex-end; margin-top:12px; }.sql-editor :is(textarea) { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.65; }.table-scroll { overflow:auto; } +.login-screen { min-height:100vh; display:grid; place-items:center; padding:18px; }.login-card { width:min(100%,390px); padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); box-shadow:0 14px 40px #0002; }.login-card .brand-mark { margin-bottom:16px; }.login-card h1 { margin:0; font-size:24px; }.login-card p { margin:8px 0 20px; color:var(--secondary); font-size:13px; } +.mobile-section { display:none; margin-bottom:12px; } +.el-table { --el-table-bg-color:transparent; --el-table-tr-bg-color:transparent; --el-table-header-bg-color:var(--muted); --el-table-row-hover-bg-color:var(--muted); --el-table-border-color:var(--border); color:var(--text); }.el-message-box { max-width:calc(100vw - 24px); } +@media(max-width:960px){ .metric-grid{grid-template-columns:repeat(2,1fr)} } +@media(max-width:720px){ .sidebar{display:none}.content{margin-left:0;padding:0 12px 18px}.topbar{margin:0 -12px 12px;padding:8px 12px}.topbar h1{font-size:17px}.top-actions .el-button:first-child{display:none}.mobile-section{display:block}.panel{padding:12px}.panel-head{flex-direction:column}.metric-grid{grid-template-columns:repeat(2,1fr);gap:8px}.metric-grid strong{font-size:18px}.database-select{width:100%} } diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index d904456..bfe921a 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -28,7 +28,7 @@ const route = useRoute(); const authStore = useAuthStore(); const { isMobile } = useViewport(); const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendStatus(); -const { sidebarCollapsed, toggleSidebarCollapsed } = useUiPreferences(); +const { resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = useUiPreferences(); const mobileNavVisible = ref(false); @@ -78,14 +78,6 @@ const navigationGroups = [ const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员"); const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录"); -const isDark = ref(false); -try { isDark.value = localStorage.getItem("lr-theme") === "dark"; } catch { /* noop */ } -function toggleTheme() { - isDark.value = !isDark.value; - document.documentElement.dataset.theme = isDark.value ? "dark" : "light"; - try { localStorage.setItem("lr-theme", isDark.value ? "dark" : "light"); } catch { /* noop */ } -} - function isNavItemActive(index: string) { return route.path === index || route.path.startsWith(`${index}/`); } @@ -120,8 +112,6 @@ function closeMobileNav() { mobileNavVisible.value = false; } watch(() => route.fullPath, () => { mobileNavVisible.value = false; }); -// sync dark class on mount -watch(isDark, (v) => { document.documentElement.dataset.theme = v ? "dark" : "light"; }, { immediate: true });