diff --git a/.gitignore b/.gitignore index e9a0913..9e35c58 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,8 @@ deploy/0.2.1/.env # Windows ´ò°ü²úÎï (±¾µØÉú³É, ²»Èë¿â) /artifacts/ + +# Éú²ú²¿ÊðÃÜԿÓëÊý¾ݾí +/deploy/prod/.env +/deploy/prod/data/ + diff --git a/deploy/prod/.env.example b/deploy/prod/.env.example new file mode 100644 index 0000000..10fd62f --- /dev/null +++ b/deploy/prod/.env.example @@ -0,0 +1,9 @@ +# 生产环境变量 (deploy/prod/.env, 权限 600, 绝不入库) +# POWERSYNC_JWT_KEY_B64 = base64url(POWERSYNC_JWT_SECRET 的 UTF-8 字节) +# printf %s "$POWERSYNC_JWT_SECRET" | openssl base64 -A | tr '+/' '-_' | tr -d '=' +PG_PASSWORD=change-me +PS_STORAGE_PASSWORD=change-me +JWT_SECRET=change-me +POWERSYNC_JWT_SECRET=change-me +POWERSYNC_JWT_KEY_B64=change-me +PUBLIC_URL=https://iboard.honor3.com diff --git a/deploy/prod/README.md b/deploy/prod/README.md new file mode 100644 index 0000000..83fdbe7 --- /dev/null +++ b/deploy/prod/README.md @@ -0,0 +1,59 @@ +# iBoard 0.2.1 生产部署 (单端口, 无独立同步端口) + +``` +pg (内网) ← app (3001 对外, /sync 反代) → powersync (内网) +``` + +对外只有 **3001** 一个端口,域名 `https://iboard.honor3.com` 由 NPM 反代到这里; +同步服务不再单独暴露端口(客户端拿到的 `POWERSYNC_URL` 是域名本身,请求经 app 的 +`/sync`、`/socket` 反代进入内网 powersync)。 + +## 首次部署顺序 (关键: publication 要在建表之后) + +```bash +# 0) 目录: /app/iboard-prod (源码已上传), 镜像 iboard:latest 已构建 +cd /app/iboard-prod/deploy/prod +cp .env.example .env && chmod 600 .env # 填真实密钥 +mkdir -p data/pg data/cache + +# 1) 先只起数据库 +docker compose --env-file .env up -d pg + +# 2) 跑迁移建表 (一次性) +docker run --rm --network iboard-prod_default \ + -e DATABASE_URL="postgresql://iboard:@pg:5432/iboard" \ + iboard:latest npx prisma migrate deploy + +# 3) 建逻辑复制 publication (表已存在) +docker compose --env-file .env exec -T pg \ + psql -U iboard -d iboard -f /dev/stdin < sql/publication.sql + +# 4) 起应用与同步服务 +docker compose --env-file .env up -d app powersync + +# 5) 验证 +curl -s http://127.0.0.1:3001/api/health +docker compose logs --tail 20 powersync # 应看到 replication 激活 +``` + +## 数据迁移 (从现网 MySQL 快照) + +```bash +# 只读源库; 目标必须是空库 (有数据会跳过, ETL_FORCE=1 强制) +docker run --rm --network iboard-prod_default \ + -e DATABASE_URL="postgresql://iboard:@pg:5432/iboard" \ + -e SRC_DATABASE_URL="mysql://:@:3306/iboard" \ + iboard:latest node scripts/etl-mariadb-to-pg.mjs +``` + +迁移后需要重启 powersync 让它重新快照: + +```bash +docker compose --env-file .env restart powersync +``` + +## 版本与回滚 + +- 新版本: 构建新镜像 → `docker compose up -d app` (只重建 app, 数据卷不动) +- 回滚: 用上一次的镜像标签重新 `up -d app` +- 数据: `data/pg` 持久化; 变更前建议 `docker compose exec -T pg pg_dump -U iboard iboard > backup.sql` diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml new file mode 100644 index 0000000..3bafd5f --- /dev/null +++ b/deploy/prod/docker-compose.yml @@ -0,0 +1,77 @@ +name: iboard-prod + +# iBoard 0.2.1 生产栈 (单端口对外) +# - app : 3001 对外 (唯一入口), 内部把 /sync 反代到 powersync +# - pg : PostgreSQL 16, 仅容器内网 (逻辑复制供同步服务消费) +# - powersync : 同步服务, 仅容器内网 (不对外暴露端口) +# +# 与旧的生产方式区别: 不再依赖宿主 MySQL, 数据库与同步服务都在栈内; +# 对外只有 3001 一个端口, 域名 https://iboard.honor3.com (NPM 反代) 即可。 +# +# 用法 (baihu:/app/iboard-prod): +# cp deploy/prod/.env.example deploy/prod/.env # 填密钥 +# docker compose --env-file deploy/prod/.env -f deploy/prod/docker-compose.yml up -d + +services: + pg: + image: postgres:16-alpine + restart: unless-stopped + command: ["postgres", "-c", "wal_level=logical"] + environment: + POSTGRES_USER: iboard + POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD missing} + POSTGRES_DB: iboard + PS_STORAGE_PASSWORD: ${PS_STORAGE_PASSWORD:?PS_STORAGE_PASSWORD missing} + volumes: + - ./data/pg:/var/lib/postgresql/data + - ./initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U iboard -d iboard"] + interval: 5s + timeout: 5s + retries: 12 + + app: + image: iboard:latest + restart: unless-stopped + depends_on: + pg: + condition: service_healthy + environment: + DATABASE_URL: postgresql://iboard:${PG_PASSWORD}@pg:5432/iboard + PORT: "3001" + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET missing} + POWERSYNC_JWT_SECRET: ${POWERSYNC_JWT_SECRET:?POWERSYNC_JWT_SECRET missing} + # 客户端拿到的同步地址 = 同域 (经由 app 的 /sync 反代) + POWERSYNC_URL: ${PUBLIC_URL:-https://iboard.honor3.com} + # app 内部转发同步请求到内网 powersync + POWERSYNC_UPSTREAM: http://powersync:8080 + OTA_PUBLIC_BASE: ${PUBLIC_URL:-https://iboard.honor3.com} + OTA_PACKAGE_DIR: /app/ota-package + MIHOME_CACHE_DIR: /app/cache + volumes: + - ./data/cache:/app/cache + ports: + - "3001:3001" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/api/health || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + powersync: + image: journeyapps/powersync-service:latest + restart: unless-stopped + depends_on: + pg: + condition: service_healthy + command: ["start", "-r", "unified"] + volumes: + - ./powersync/service.yaml:/config/service.yaml:ro + - ./powersync/sync-config.yaml:/config/sync-config.yaml:ro + environment: + POWERSYNC_CONFIG_PATH: /config/service.yaml + PS_SOURCE_URI: postgresql://iboard:${PG_PASSWORD}@pg:5432/iboard?sslmode=disable + PS_STORAGE_URI: postgresql://powersync_storage:${PS_STORAGE_PASSWORD}@pg:5432/iboard?sslmode=disable + PS_JWT_KEY_B64: ${POWERSYNC_JWT_KEY_B64:?POWERSYNC_JWT_KEY_B64 missing} diff --git a/deploy/prod/initdb/01-powersync-storage.sh b/deploy/prod/initdb/01-powersync-storage.sh new file mode 100644 index 0000000..6afb759 --- /dev/null +++ b/deploy/prod/initdb/01-powersync-storage.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 生产 PG 初始化: PowerSync bucket storage 专用用户 +set -e +: "${PS_STORAGE_PASSWORD:?PS_STORAGE_PASSWORD missing}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_storage') THEN + CREATE USER powersync_storage WITH PASSWORD '$PS_STORAGE_PASSWORD'; + END IF; + END + \$\$; + GRANT CREATE ON DATABASE $POSTGRES_DB TO powersync_storage; +EOSQL + +echo '[initdb] powersync_storage ready.' diff --git a/deploy/prod/powersync/service.yaml b/deploy/prod/powersync/service.yaml new file mode 100644 index 0000000..757674d --- /dev/null +++ b/deploy/prod/powersync/service.yaml @@ -0,0 +1,41 @@ +# PowerSync Service 主配置 (0.2.1 验证部署) +# 参考: https://docs.powersync.com/configuration/powersync-service/self-hosted-instances +# 注意: !env 是 YAML 整值标签, 只能整个值替换变量, 不能内嵌在 URI 字符串中; +# 完整连接串由 docker-compose 以 PS_* 环境变量注入。 + +replication: + connections: + - type: postgresql + # 源库: 本栈 pg 容器 (0.2.1 独立实例), 复制走 iboard021 账号 + uri: !env PS_SOURCE_URI + sslmode: disable + +# bucket storage: 同实例 PG (16 >= 14 允许同库不同 schema, service 自建 powersync schema) +storage: + type: postgresql + uri: !env PS_STORAGE_URI + sslmode: disable + +port: 8080 + +sync_config: + path: /config/sync-config.yaml + +# 客户端鉴权: app 后端 /api/powersync/token 签发 HS256 JWT (aud=powersync) +# oct 密钥 k = base64url(POWERSYNC_JWT_SECRET) +client_auth: + jwks: + keys: + - kty: oct + k: !env PS_JWT_KEY_B64 + alg: HS256 + audience: + - powersync + +telemetry: + disable_telemetry_sharing: true + +system: + logging: + level: info + format: text diff --git a/deploy/prod/powersync/sync-config.yaml b/deploy/prod/powersync/sync-config.yaml new file mode 100644 index 0000000..041834e --- /dev/null +++ b/deploy/prod/powersync/sync-config.yaml @@ -0,0 +1,25 @@ +# Sync Streams (edition 3): 任务模块 6 表 + User (展示用) +# Sequential ID Mapping (见 docs/V0.2/离线编辑模块/0.2.1实施方案.md §3): +# - 客户端主键 = uuid 列 (离线新建行同步回填后 id 稳定); int 自增 id 不下发 +# - 外键输出 uuid 镜像列, 别名回 projectId/columnId/parentId/taskId (客户端字段名不变) +# - 引号保留 PG PascalCase 标识; 可见性按 ProjectMember 过滤 + +config: + edition: 3 + +streams: + iboard_users: + auto_subscribe: true + query: SELECT CAST("id" AS TEXT) AS "id", "name", "email" FROM "User" + + iboard_task_data: + auto_subscribe: true + with: + my_projects: SELECT "projectId" FROM "ProjectMember" WHERE CAST("userId" AS TEXT) = auth.user_id() + queries: + - SELECT "uuid" AS "id", "uuid", "code", "name", CAST("ownerId" AS TEXT) AS "ownerId", "isPersonal", "archived", "notes", "taskKinds", "icon", "autoArchive", "createdAt", "updatedAt" FROM "Project" WHERE "id" IN my_projects + - SELECT "uuid" AS "id", "uuid", "projectUuid" AS "projectId", CAST("userId" AS TEXT) AS "userId", "permission", "createdAt" FROM "ProjectMember" WHERE "projectId" IN my_projects + - SELECT "uuid" AS "id", "uuid", "projectUuid" AS "projectId", "name", "sortOrder", "boundStatus", "retention", "createdAt", "updatedAt" FROM "BoardColumn" WHERE "projectId" IN my_projects + - SELECT "uuid" AS "id", "uuid", "projectUuid" AS "projectId", "columnUuid" AS "columnId", "parentUuid" AS "parentId", "title", "description", "kind", "markDate", "status", "doneAt", CAST("assigneeId" AS TEXT) AS "assigneeId", CAST("creatorId" AS TEXT) AS "creatorId", "extras", "sortOrder", "number", "archived", "archivedAt", "archiveSource", "createdAt", "updatedAt" FROM "Task" WHERE "projectId" IN my_projects + - SELECT "uuid" AS "id", "uuid", "taskUuid" AS "taskId", CAST("userId" AS TEXT) AS "userId" FROM "TaskMember" WHERE "taskId" IN (SELECT "id" FROM "Task" WHERE "projectId" IN my_projects) + - SELECT "uuid" AS "id", "uuid", "taskUuid" AS "taskId", "name" FROM "TaskTag" WHERE "taskId" IN (SELECT "id" FROM "Task" WHERE "projectId" IN my_projects) diff --git a/deploy/prod/sql/publication.sql b/deploy/prod/sql/publication.sql new file mode 100644 index 0000000..dcf3127 --- /dev/null +++ b/deploy/prod/sql/publication.sql @@ -0,0 +1,11 @@ +-- 生产 PG: PowerSync 逻辑复制 publication +-- 必须在 prisma migrate deploy 建表之后执行 (initdb 阶段表还不存在, 故不放 initdb 目录)。 +-- 幂等: 已存在则跳过。 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') THEN + CREATE PUBLICATION powersync FOR TABLE + "Project", "ProjectMember", "BoardColumn", "Task", "TaskMember", "TaskTag", "User"; + END IF; +END +$$; diff --git a/server/index.js b/server/index.js index 1d803de..2859086 100644 --- a/server/index.js +++ b/server/index.js @@ -17,12 +17,15 @@ import checklistsRouter from './routes/checklists.js' import projectsRouter, { columnRouter } from './routes/projects.js' import tasksRouter from './routes/tasks.js' import powersyncRouter from './routes/powersync.js' +import { powersyncProxy } from './powersync-proxy.js' const PORT = Number(process.env.PORT) || 3001 const DIST_DIR = path.join(process.cwd(), 'dist') const HAS_DIST = fs.existsSync(DIST_DIR) const app = express() +// 同步服务同域反代必须最先挂载 (流式长连接, 不能经过 json/cors 中间件) +app.use(powersyncProxy()) app.use(cors()) app.use(express.json()) diff --git a/server/powersync-proxy.js b/server/powersync-proxy.js new file mode 100644 index 0000000..ab3c0d2 --- /dev/null +++ b/server/powersync-proxy.js @@ -0,0 +1,54 @@ +// PowerSync 同域反代 (0.2.1): 把 /sync、/socket 转发到内网同步服务, +// 对外只暴露应用本身一个端口 (无需单独的 powersync 端口)。 +// - 未配置 POWERSYNC_UPSTREAM 时直接跳过 (本地开发/无同步服务时行为不变) +// - 必须挂在 express.json()/cors 之前: 同步走长连接流式响应, 不能被 body 解析打断 +import http from 'node:http' +import https from 'node:https' + +const PREFIXES = ['/sync', '/socket'] + +export function powersyncProxy() { + const upstream = process.env.POWERSYNC_UPSTREAM + if (!upstream) return (_req, _res, next) => next() + + let target + try { + target = new URL(upstream) + } catch { + console.error('[powersync-proxy] POWERSYNC_UPSTREAM 非法:', upstream) + return (_req, _res, next) => next() + } + const transport = target.protocol === 'https:' ? https : http + const basePort = Number(target.port) || (target.protocol === 'https:' ? 443 : 80) + console.log('[powersync-proxy] 同步请求转发到', target.origin) + + return (req, res, next) => { + if (!PREFIXES.some((p) => req.url === p || req.url.startsWith(p + '/') || req.url.startsWith(p + '?'))) { + return next() + } + const headers = { ...req.headers, host: target.host } + const upstreamReq = transport.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: basePort, + method: req.method, + path: req.url, + headers, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers) + upstreamRes.pipe(res) + }, + ) + upstreamReq.on('error', (e) => { + console.error('[powersync-proxy] 转发失败:', e.message) + if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' }) + res.end('powersync upstream error') + }) + // 长连接: 客户端断开时同步断开上游 + req.on('aborted', () => upstreamReq.destroy()) + res.on('close', () => upstreamReq.destroy()) + req.pipe(upstreamReq) + } +}