feat(0.2.1): 单端口生产架构 —— /sync 同域反代 (去掉 13002 独立同步端口), 新增 deploy/prod 生产编排

This commit is contained in:
jif2.zhang
2026-10-26 21:57:40 +08:00
parent 4b19d1d929
commit 30bc489b5c
10 changed files with 301 additions and 0 deletions
+9
View File
@@ -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
+59
View File
@@ -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_PASSWORD>@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_PASSWORD>@pg:5432/iboard" \
-e SRC_DATABASE_URL="mysql://<user>:<pass>@<host>: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`
+77
View File
@@ -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}
@@ -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.'
+41
View File
@@ -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
+25
View File
@@ -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)
+11
View File
@@ -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
$$;