diff --git a/.gitignore b/.gitignore index e095801..9534da4 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ dist/ out/ target/ xcontrol-account +account-export.yaml diff --git a/Makefile b/Makefile index 2775c34..091cd46 100644 --- a/Makefile +++ b/Makefile @@ -7,11 +7,15 @@ MAIN_FILE := ./cmd/accountsvc/main.go PORT ?= 8080 OS := $(shell uname -s) +# Load local environment overrides if present. +-include .env +export + DB_NAME := account -DB_USER := shenlan -DB_PASS := password +DB_USER ?= $(or $(POSTGRES_USER),postgres) +DB_PASS ?= $(or $(POSTGRES_PASSWORD),password) DB_HOST := 127.0.0.1 -DB_PORT := 5432 +DB_PORT := 15432 DB_URL := postgres://$(DB_USER):$(DB_PASS)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable REPLICATION_MODE ?= pgsync @@ -40,13 +44,23 @@ SUPERADMIN_PASSWORD ?= ChangeMe SUPERADMIN_EMAIL ?= admin@svc.plus export PATH := /usr/local/go/bin:$(PATH) +export APP_NAME MAIN_FILE PORT OS \ + DB_NAME DB_USER DB_PASS DB_HOST DB_PORT DB_URL \ + REPLICATION_MODE DB_ADMIN_USER DB_ADMIN_PASS \ + GCP_PROJECT GCP_REGION CLOUD_RUN_SERVICE CLOUD_RUN_SERVICE_YAML CLOUD_RUN_STUNNEL_CONF CLOUD_RUN_IMAGE \ + SCHEMA_FILE PGLOGICAL_INIT_FILE PGLOGICAL_PATCH_FILE PGLOGICAL_REGION_FILE \ + ACCOUNT_EXPORT_FILE ACCOUNT_IMPORT_FILE ACCOUNT_EMAIL_KEYWORD ACCOUNT_SYNC_CONFIG \ + SUPERADMIN_USERNAME SUPERADMIN_PASSWORD SUPERADMIN_EMAIL \ + ACCOUNT_IMPORT_MERGE ACCOUNT_IMPORT_MERGE_STRATEGY ACCOUNT_IMPORT_DRY_RUN \ + ACCOUNT_IMPORT_MERGE_ALLOWLIST ACCOUNT_IMPORT_EXTRA_FLAGS # ========================================= # 🧩 基础命令 # ========================================= -.PHONY: all init build clean start stop restart dev test help \ - init-db-core init-db-replication init-db-pglogical \ +.PHONY: all init build clean start stop restart dev test help integration-test \ + init-go init-db init-db-core init-db-replication init-db-pglogical \ + stunnel-start \ reinit-pglogical account-sync-push account-sync-pull account-sync-mirror create-db-user db-reset \ cloudrun-build cloudrun-deploy cloudrun-stunnel @@ -67,6 +81,7 @@ help: @echo "make reinit-pglogical 重新初始化 pglogical schema" @echo "make dev 热重载开发模式" @echo "make clean 清理构建产物" + @echo "make integration-test 运行集成测试用例(初始化 + 创建管理员)" @echo "make cloudrun-build 构建并推送 Cloud Run 镜像" @echo "make cloudrun-deploy 部署 Cloud Run Service" @echo "make cloudrun-stunnel 更新 Cloud Run stunnel 配置 secret" @@ -78,257 +93,125 @@ help: init: init-go init-db init-go: - @if [ ! -f go.mod ]; then \ - echo ">>> go.mod not found, initializing module"; \ - go mod init account; \ - fi - go mod tidy - @echo ">>> 检查 Go 环境" - @if ! command -v go >/dev/null; then \ - echo "未安装 Go,自动安装中..."; \ - ([ "$(OS)" = "Darwin" ] && brew install go@1.24 && brew link --overwrite --force go@1.24) || \ - (sudo apt-get update && sudo apt-get install -y golang); \ - fi - @echo ">>> 配置 Go Proxy" - @(curl -fsSL --max-time 5 https://goproxy.cn >/dev/null && go env -w GOPROXY=https://goproxy.cn,direct) || \ - (go env -w GOPROXY=https://proxy.golang.org,direct) - @go mod tidy + @bash scripts/init-go.sh init-db: - @echo ">>> 初始化数据库 schema" - @command -v psql >/dev/null || (echo "❌ 未检测到 psql,请安装 PostgreSQL 客户端" && exit 1) - @$(MAKE) init-db-core - @$(MAKE) init-db-replication + @bash scripts/init-db.sh init-db-core: - @echo ">>> 初始化业务 schema ($(SCHEMA_FILE))" - @psql "$(DB_URL)" -v ON_ERROR_STOP=1 -f $(SCHEMA_FILE) + @bash scripts/init-db-core.sh init-db-replication: - @if [ "$(REPLICATION_MODE)" = "pglogical" ]; then \ - $(MAKE) init-db-pglogical; \ - else \ - echo ">>> 跳过 pglogical 初始化 (REPLICATION_MODE=$(REPLICATION_MODE))"; \ - fi + @bash scripts/init-db-replication.sh init-db-pglogical: - @if [ -f $(PGLOGICAL_INIT_FILE) ]; then \ - echo ">>> 初始化 pglogical schema (REPLICATION_MODE=pglogical)"; \ - if PGPASSWORD="$(DB_ADMIN_PASS)" psql -h $(DB_HOST) -U $(DB_ADMIN_USER) -d $(DB_NAME) \ - -Atc "SELECT rolsuper FROM pg_roles WHERE rolname = current_user" 2>/dev/null | grep -qx 't'; then \ - PGPASSWORD="$(DB_ADMIN_PASS)" psql -h $(DB_HOST) -U $(DB_ADMIN_USER) -d $(DB_NAME) \ - -v ON_ERROR_STOP=1 -f $(PGLOGICAL_INIT_FILE); \ - elif psql "$(DB_URL)" -Atc "SELECT rolsuper FROM pg_roles WHERE rolname = current_user" | grep -qx 't'; then \ - psql "$(DB_URL)" -v ON_ERROR_STOP=1 -f $(PGLOGICAL_INIT_FILE); \ - else \ - echo "⚠️ 当前用户非超级用户,跳过 pglogical 初始化"; \ - fi; \ - fi; \ - if [ -f $(PGLOGICAL_PATCH_FILE) ]; then \ - echo ">>> 应用 pglogical 默认值补丁"; \ - psql "$(DB_URL)" -v ON_ERROR_STOP=1 -f $(PGLOGICAL_PATCH_FILE); \ - fi + @bash scripts/init-db-pglogical.sh # ========================================= # 🧠 PGLogical 双节点初始化 # ========================================= init-pglogical-region: - @[ -n "$(REGION_DB_URL)" ] || (echo "❌ 缺少 REGION_DB_URL"; exit 1) - @[ -n "$(NODE_NAME)" ] || (echo "❌ 缺少 NODE_NAME"; exit 1) - @[ -n "$(NODE_DSN)" ] || (echo "❌ 缺少 NODE_DSN"; exit 1) - @[ -n "$(SUBSCRIPTION_NAME)" ] || (echo "❌ 缺少 SUBSCRIPTION_NAME"; exit 1) - @[ -n "$(PROVIDER_DSN)" ] || (echo "❌ 缺少 PROVIDER_DSN"; exit 1) - @psql "$(REGION_DB_URL)" -v ON_ERROR_STOP=1 \ - -v NODE_NAME="$(NODE_NAME)" \ - -v NODE_DSN="$(NODE_DSN)" \ - -v SUBSCRIPTION_NAME="$(SUBSCRIPTION_NAME)" \ - -v PROVIDER_DSN="$(PROVIDER_DSN)" \ - -f $(PGLOGICAL_REGION_FILE) + @bash scripts/init-pglogical-region.sh init-pglogical-region-cn: - @$(MAKE) init-pglogical-region \ - REGION_DB_URL="$(DB_URL)" \ - NODE_NAME="node_cn" \ - NODE_DSN="host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ - SUBSCRIPTION_NAME="sub_from_global" \ - PROVIDER_DSN="host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" + @bash scripts/init-pglogical-region-cn.sh init-pglogical-region-global: - @$(MAKE) init-pglogical-region \ - REGION_DB_URL="$(DB_URL)" \ - NODE_NAME="node_global" \ - NODE_DSN="host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ - SUBSCRIPTION_NAME="sub_from_cn" \ - PROVIDER_DSN="host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" + @bash scripts/init-pglogical-region-global.sh # ========================================= # 📦 数据库迁移与管理 # ========================================= create-db-user: - @echo ">>> 创建数据库用户 $(DB_USER)" - @command -v psql >/dev/null || (echo "❌ 未检测到 psql,请安装 PostgreSQL 客户端" && exit 1) - @echo "正在以 postgres 超级用户身份创建用户..." - @sudo -u postgres psql -c "CREATE USER $(DB_USER) WITH PASSWORD '$(DB_PASS)';" || echo "⚠️ 用户可能已存在" - @sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $(DB_NAME) TO $(DB_USER);" - @echo "✓ 数据库用户创建完成" + @bash scripts/create-db-user.sh migrate-db: - @echo ">>> 执行数据库迁移" - @go run ./cmd/migratectl/main.go migrate --dsn "$(DB_URL)" --dir sql/migrations + @bash scripts/migrate-db.sh dump-schema: - @echo ">>> 导出 schema 到 $(SCHEMA_FILE)" - @pg_dump -s -O -x "$(DB_URL)" > $(SCHEMA_FILE) + @bash scripts/dump-schema.sh db-reset: - @echo "⚠️ 即将重置整个 PostgreSQL 数据库集群 ..." - @read -p "确定要重置数据库集群? 这将删除所有数据! [y/N] " confirm && \ - if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \ - echo ">>> 停止 PostgreSQL 服务 ..."; \ - sudo systemctl stop postgresql; \ - echo ">>> 删除数据库集群 16 main ..."; \ - sudo pg_dropcluster --stop 16 main; \ - echo ">>> 清理数据目录 ..."; \ - sudo rm -rf /var/lib/postgresql/16/main; \ - echo ">>> 清理配置目录 ..."; \ - sudo rm -rf /etc/postgresql/16/main; \ - echo ">>> 创建新的数据库集群 ..."; \ - sudo pg_createcluster 16 main --start; \ - echo "✓ PostgreSQL 集群重置完成"; \ - else \ - echo "取消重置"; \ - fi + @bash scripts/db-reset.sh drop-db: - @echo "⚠️ 即将删除数据库 $(DB_NAME) ..." - @read -p "确定要删除数据库 $(DB_NAME)? [y/N] " confirm && \ - if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \ - echo ">>> 强制断开现有连接 ..."; \ - if ! PGPASSWORD="$(DB_ADMIN_PASS)" psql -h $(DB_HOST) -U $(DB_ADMIN_USER) -d postgres \ - -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$(DB_NAME)' AND pid <> pg_backend_pid();"; then \ - echo "⚠️ 无法断开所有连接(需要超级用户权限)"; \ - fi; \ - echo ">>> 清理 pglogical schema ..."; \ - PGPASSWORD="$(DB_ADMIN_PASS)" psql -h $(DB_HOST) -U $(DB_ADMIN_USER) -d $(DB_NAME) \ - -c "DROP SCHEMA IF EXISTS pglogical CASCADE;" >/dev/null 2>&1 || \ - echo "⚠️ 无法删除 pglogical schema(数据库可能不存在或缺少权限)"; \ - echo ">>> 删除数据库 $(DB_NAME) ..."; \ - if PGPASSWORD="$(DB_ADMIN_PASS)" psql -h $(DB_HOST) -U $(DB_ADMIN_USER) -d postgres \ - -c "DROP DATABASE IF EXISTS $(DB_NAME);"; then \ - echo ">>> 数据库已删除"; \ - else \ - echo ">>> 删除失败"; \ - fi; \ - else \ - echo "取消删除"; \ - fi + @bash scripts/drop-db.sh reset-public-schema: - @psql "$(DB_URL)" -v ON_ERROR_STOP=1 -v db_user="$(DB_USER)" -f sql/reset_public_schema.sql + @bash scripts/reset-public-schema.sh reinit-db: - @echo ">>> 重置业务 schema (sql/schema.sql)" - @$(MAKE) reset-public-schema - @$(MAKE) init-db-core + @bash scripts/reinit-db.sh reinit-pglogical: - @if [ "$(REPLICATION_MODE)" = "pglogical" ]; then \ - echo ">>> 重新初始化 pglogical schema"; \ - $(MAKE) init-db-pglogical; \ - else \ - echo ">>> 当前 REPLICATION_MODE=$(REPLICATION_MODE),无需 pglogical 处理"; \ - fi + @bash scripts/reinit-pglogical.sh + +stunnel-start: + @bash scripts/stunnel-start.sh # ========================================= # 💾 账号导入导出 # ========================================= account-export: - @go run ./cmd/migratectl/main.go export --dsn "$(DB_URL)" --output "$(ACCOUNT_EXPORT_FILE)" $(if $(ACCOUNT_EMAIL_KEYWORD),--email "$(ACCOUNT_EMAIL_KEYWORD)") + @bash scripts/account-export.sh account-import: - @[ -f "$(ACCOUNT_IMPORT_FILE)" ] || (echo "❌ 未找到文件 $(ACCOUNT_IMPORT_FILE)"; exit 1) - @go run ./cmd/migratectl/main.go import --dsn "$(DB_URL)" --file "$(ACCOUNT_IMPORT_FILE)" \ - $(if $(ACCOUNT_IMPORT_MERGE),--merge) \ - $(if $(ACCOUNT_IMPORT_MERGE_STRATEGY),--merge-strategy "$(ACCOUNT_IMPORT_MERGE_STRATEGY)") \ - $(if $(ACCOUNT_IMPORT_DRY_RUN),--dry-run) \ - $(foreach UUID,$(ACCOUNT_IMPORT_MERGE_ALLOWLIST),--merge-allowlist $(UUID)) \ - $(ACCOUNT_IMPORT_EXTRA_FLAGS) + @bash scripts/account-import.sh account-sync-push: - @[ -f "$(ACCOUNT_SYNC_CONFIG)" ] || (echo "❌ 未找到配置文件 $(ACCOUNT_SYNC_CONFIG)"; exit 1) - @go run ./cmd/syncctl/main.go push --config "$(ACCOUNT_SYNC_CONFIG)" + @bash scripts/account-sync-push.sh account-sync-pull: - @[ -f "$(ACCOUNT_SYNC_CONFIG)" ] || (echo "❌ 未找到配置文件 $(ACCOUNT_SYNC_CONFIG)"; exit 1) - @go run ./cmd/syncctl/main.go pull --config "$(ACCOUNT_SYNC_CONFIG)" + @bash scripts/account-sync-pull.sh account-sync-mirror: - @[ -f "$(ACCOUNT_SYNC_CONFIG)" ] || (echo "❌ 未找到配置文件 $(ACCOUNT_SYNC_CONFIG)"; exit 1) - @go run ./cmd/syncctl/main.go mirror --config "$(ACCOUNT_SYNC_CONFIG)" + @bash scripts/account-sync-mirror.sh create-super-admin: - @[ -n "$(SUPERADMIN_USERNAME)" ] && [ -n "$(SUPERADMIN_PASSWORD)" ] || (echo "❌ 请指定用户名与密码"; exit 1) - @go run ./cmd/createadmin/main.go \ - --driver postgres \ - --dsn "$(DB_URL)" \ - --username "$(SUPERADMIN_USERNAME)" \ - --password "$(SUPERADMIN_PASSWORD)" \ - --email "$(SUPERADMIN_EMAIL)" + @bash scripts/create-super-admin.sh + +integration-test: + @bash integration-test/superadmin-login/run-test-scripts.sh # ========================================= # ⚙️ 编译与运行 # ========================================= build: init-go - @go build -o $(APP_NAME) $(MAIN_FILE) + @bash scripts/build.sh upgrade: build - systemctl stop xcontrol-account - cp xcontrol-account /usr/bin/xcontrol-account - systemctl start xcontrol-account + @bash scripts/upgrade.sh start: build - @./$(APP_NAME) --config config/account.yaml + @bash scripts/start.sh stop: - @pkill -f "$(APP_NAME)" || echo "⚠️ 未找到运行进程" + @bash scripts/stop.sh restart: stop start test: - go test ./... + @bash scripts/test.sh clean: - rm -f $(APP_NAME) *.pid *.log + @bash scripts/clean.sh + +dev: + @bash scripts/dev.sh # ========================================= # ☁️ GCP Cloud Run # ========================================= cloudrun-build: - @if [ -z "$(GCP_PROJECT)" ]; then \ - echo "❌ GCP_PROJECT 不能为空"; \ - exit 1; \ - fi - @gcloud builds submit --tag "$(CLOUD_RUN_IMAGE)" . + @bash scripts/cloudrun-build.sh cloudrun-deploy: - @if [ -z "$(GCP_PROJECT)" ]; then \ - echo "❌ GCP_PROJECT 不能为空"; \ - exit 1; \ - fi - @gcloud run services replace "$(CLOUD_RUN_SERVICE_YAML)" --region "$(GCP_REGION)" --project "$(GCP_PROJECT)" + @bash scripts/cloudrun-deploy.sh cloudrun-stunnel: - @if [ -z "$(GCP_PROJECT)" ]; then \ - echo "❌ GCP_PROJECT 不能为空"; \ - exit 1; \ - fi - @if [ ! -f "$(CLOUD_RUN_STUNNEL_CONF)" ]; then \ - echo "❌ 未找到 stunnel 配置: $(CLOUD_RUN_STUNNEL_CONF)"; \ - exit 1; \ - fi - @gcloud secrets versions add stunnel-config --data-file "$(CLOUD_RUN_STUNNEL_CONF)" --project "$(GCP_PROJECT)" + @bash scripts/cloudrun-stunnel.sh diff --git a/config/account-server.yaml b/config/account-server.yaml index dbf1e80..c321343 100644 --- a/config/account-server.yaml +++ b/config/account-server.yaml @@ -31,7 +31,8 @@ server: store: driver: "postgres" - dsn: "postgres://shenlan:password@127.0.0.1:5432/account?sslmode=disable" + # 提示:本地默认从 .env 读取 POSTGRES_USER / POSTGRES_PASSWORD + dsn: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/account?sslmode=disable" maxOpenConns: 30 maxIdleConns: 10 diff --git a/config/account.yaml b/config/account.yaml index f2e63ac..f0f0247 100644 --- a/config/account.yaml +++ b/config/account.yaml @@ -24,6 +24,7 @@ server: - "https://www.svc.plus" - "https://global-homepage.svc.plus" - "https://accounts.svc.plus" + - "https://console.svc.plus" - "https://localhost:8443" - "http://localhost:8080" - "http://127.0.0.1:8080" @@ -41,7 +42,8 @@ server: store: driver: "postgres" - dsn: "postgres://shenlan:password@127.0.0.1:5432/account?sslmode=disable" + # 提示:本地默认从 .env 读取 POSTGRES_USER / POSTGRES_PASSWORD + dsn: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/account?sslmode=disable" maxOpenConns: 30 maxIdleConns: 10 diff --git a/config/sync.example.yaml b/config/sync.example.yaml index 82b437e..a3f82f7 100644 --- a/config/sync.example.yaml +++ b/config/sync.example.yaml @@ -10,7 +10,8 @@ local: # 本地 PostgreSQL 连接地址,用于导入/导出账号数据 - dsn: "postgres://shenlan:password@127.0.0.1:5432/account?sslmode=disable" + # 提示:本地默认从 .env 读取 POSTGRES_USER / POSTGRES_PASSWORD + dsn: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/account?sslmode=disable" # 可选:按 email 关键字过滤导出的账号 email_keyword: "" # 导出的快照文件路径(默认 account-export.yaml) @@ -47,4 +48,3 @@ remote: # SSH 连接超时时间 timeout: 30s - diff --git a/config/sync.yaml b/config/sync.yaml index c7c261d..0b31df6 100644 --- a/config/sync.yaml +++ b/config/sync.yaml @@ -10,7 +10,8 @@ local: # 本地 PostgreSQL 连接地址,用于导入/导出账号数据 - dsn: "postgres://shenlan:password@127.0.0.1:5432/account?sslmode=disable" + # 提示:本地默认从 .env 读取 POSTGRES_USER / POSTGRES_PASSWORD + dsn: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/account?sslmode=disable" # 可选:按 email 关键字过滤导出的账号 email_keyword: "" # 导出的快照文件路径(默认 account-export.yaml) @@ -47,4 +48,3 @@ remote: # SSH 连接超时时间 timeout: 30s - diff --git a/deploy/gcp/cloud-run/service.yaml b/deploy/gcp/cloud-run/service.yaml index 1e7a195..28c4de8 100644 --- a/deploy/gcp/cloud-run/service.yaml +++ b/deploy/gcp/cloud-run/service.yaml @@ -20,7 +20,7 @@ spec: containers: # --- 主应用容器 --- - name: accounts-api - image: asia-northeast1-docker.pkg.dev/xzerolab-480008/cloud-run-source-deploy/accounts.svc.plus/accounts-svc-plus:d5b6edcaa76150f2489bbdb2a46a41bc98baa87f + image: asia-northeast1-docker.pkg.dev/xzerolab-480008/cloud-run-source-deploy/accounts.svc.plus/accounts-svc-plus:latest ports: - name: http1 containerPort: 8080 @@ -48,7 +48,7 @@ spec: - name: POSTGRES_USER value: postgres - name: DB_NAME - value: postgres + value: account resources: limits: cpu: 1000m diff --git a/integration-test/superadmin-login/README.md b/integration-test/superadmin-login/README.md new file mode 100644 index 0000000..981e01c --- /dev/null +++ b/integration-test/superadmin-login/README.md @@ -0,0 +1,66 @@ +# superadmin-login + +## 说明 + +该用例用于本地集成测试,按顺序执行: + +1. `make init-db` +2. `make create-db-user` +3. `make create-super-admin` + +脚本会自动读取项目根目录 `.env` 中的环境变量(如 `POSTGRES_USER` / `POSTGRES_PASSWORD`),用于联动数据库配置。 +若未设置 `SUPERADMIN_PASSWORD`,脚本会生成随机密码并写回 `.env`,便于后续登录测试复用。 + +## 运行方式 + +```bash +make integration-test +``` + +或直接运行脚本: + +```bash +bash integration-test/superadmin-login/run-test-scripts.sh +``` + +## API 自动化测试 + +```bash +bash integration-test/superadmin-login/api-test.sh +``` + +可选环境变量: + +- `API_BASE_URL`:API 入口地址(默认 `https://accounts.svc.plus`) +- `LOGIN_EMAIL`:登录邮箱(默认 `admin@svc.plus`) +- `SUPERADMIN_PASSWORD`:登录密码(从 `.env` 读取) + +## UI 自动化测试(Playwright) + +```bash +bash integration-test/superadmin-login/ui-test.sh +``` + +可选环境变量: + +- `UI_BASE_URL`:UI 入口地址(默认 `https://console.svc.plus`) +- `LOGIN_EMAIL`:登录邮箱(默认 `admin@svc.plus`) +- `SUPERADMIN_PASSWORD`:登录密码(从 `.env` 读取) + +## 预期输出(示例) + +``` +✅ 已读取 .env +>>> init-db +...(略) +>>> create-super-admin +...(略) +✅ 集成测试步骤完成(DB 初始化 + 用户创建 + 超级管理员创建) + +接下来手动登录验证: +- 网址:https://console.svc.plus/login +- 邮箱:admin@svc.plus +- 密码: +``` + +> 注意:登录验证需要手动在浏览器完成。 diff --git a/integration-test/superadmin-login/api-test.sh b/integration-test/superadmin-login/api-test.sh new file mode 100755 index 0000000..18a76cb --- /dev/null +++ b/integration-test/superadmin-login/api-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a + echo "✅ 已读取 .env" +fi + +if ! command -v curl >/dev/null; then + echo "❌ 未找到 curl,请先安装或确保在 PATH 中" >&2 + exit 1 +fi + +API_BASE_URL="${API_BASE_URL:-${BASE_URL:-https://accounts.svc.plus}}" +LOGIN_EMAIL="${LOGIN_EMAIL:-admin@svc.plus}" +LOGIN_PASSWORD="${SUPERADMIN_PASSWORD:-}" + +if [ -z "$LOGIN_PASSWORD" ]; then + echo "❌ 缺少 SUPERADMIN_PASSWORD(可写入 .env)" >&2 + exit 1 +fi + +login_payload=$(cat <&2 + echo "$login_body" >&2 + exit 1 +fi + +if command -v python3 >/dev/null; then + token=$(printf "%s" "$login_body" | python3 - <<'PY' +import json, sys +try: + payload = json.load(sys.stdin) + print(payload.get('token','')) +except Exception: + print('') +PY +) +else + echo "❌ 未找到 python3,无法解析登录 token" >&2 + exit 1 +fi + +if [ -z "$token" ]; then + echo "❌ 登录响应中未包含 token" >&2 + echo "$login_body" >&2 + exit 1 +fi + +session_response=$(curl -sS -w "\n%{http_code}" -X GET "${API_BASE_URL}/api/auth/session" \ + -H "Authorization: Bearer ${token}") + +session_body=$(printf "%s" "$session_response" | sed '$d') +session_status=$(printf "%s" "$session_response" | tail -n1) + +if [ "$session_status" != "200" ]; then + echo "❌ session 校验失败: HTTP ${session_status}" >&2 + echo "$session_body" >&2 + exit 1 +fi + +echo "✅ API 登录测试通过" diff --git a/integration-test/superadmin-login/run-test-scripts.sh b/integration-test/superadmin-login/run-test-scripts.sh new file mode 100755 index 0000000..9856e3a --- /dev/null +++ b/integration-test/superadmin-login/run-test-scripts.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a + echo "✅ 已读取 .env" +fi + +ensure_superadmin_password() { + if [ -n "${SUPERADMIN_PASSWORD:-}" ] && [ "${SUPERADMIN_PASSWORD}" != "ChangeMe" ]; then + return 0 + fi + + if command -v openssl >/dev/null; then + SUPERADMIN_PASSWORD="$(openssl rand -base64 12 | tr -d '\n' | tr '/+' 'Aa' | cut -c1-10)" + else + SUPERADMIN_PASSWORD="$(LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null; then + echo "❌ 未找到 node,请先安装" >&2 + exit 1 +fi + +if ! command -v npx >/dev/null; then + echo "❌ 未找到 npx,请先安装" >&2 + exit 1 +fi + +UI_BASE_URL="${UI_BASE_URL:-${BASE_URL:-https://console.svc.plus}}" +LOGIN_EMAIL="${LOGIN_EMAIL:-admin@svc.plus}" +LOGIN_PASSWORD="${SUPERADMIN_PASSWORD:-}" + +if [ -z "$LOGIN_PASSWORD" ]; then + echo "❌ 缺少 SUPERADMIN_PASSWORD(可写入 .env)" >&2 + exit 1 +fi + +TMP_DIR="${TMPDIR:-/tmp}" +PLAYWRIGHT_TEST_DIR="${TMP_DIR}/xcontrol-playwright-login" +mkdir -p "$PLAYWRIGHT_TEST_DIR" + +cat <<'TEST' > "$PLAYWRIGHT_TEST_DIR/login.spec.mjs" +import { test, expect } from '@playwright/test'; + +test.use({ screenshot: 'only-on-failure' }); + +test('superadmin login', async ({ page }) => { + const baseUrl = process.env.UI_BASE_URL || process.env.BASE_URL || 'https://console.svc.plus'; + const email = process.env.LOGIN_EMAIL || 'admin@svc.plus'; + const password = process.env.SUPERADMIN_PASSWORD; + + if (!password) { + throw new Error('missing SUPERADMIN_PASSWORD'); + } + + await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' }); + + const emailByRole = page.getByRole('textbox', { name: /email|邮箱|账号|用户名|identifier/i }); + const emailBySelector = page.locator( + 'input[type="email"], input[name="email"], input[name="identifier"], input[placeholder*="邮箱"], input[placeholder*="Email"], input[type="text"]' + ); + const emailInput = (await emailByRole.count()) > 0 ? emailByRole.first() : emailBySelector.first(); + await expect(emailInput).toBeVisible({ timeout: 15000 }); + await emailInput.fill(email); + + const passwordInput = page.locator( + 'input[type="password"], input[name="password"], input[placeholder*="密码"], input[placeholder*="Password"]' + ); + await expect(passwordInput).toBeVisible({ timeout: 15000 }); + await passwordInput.fill(password); + + const submitBtn = page.locator( + 'button[type="submit"], button:has-text("登录"), button:has-text("Log in"), button:has-text("Sign in")' + ); + await expect(submitBtn).toBeVisible({ timeout: 15000 }); + await submitBtn.click(); + + await page.waitForLoadState('networkidle'); + await expect(page).not.toHaveURL(/login/); +}); +TEST + +cd "$PLAYWRIGHT_TEST_DIR" + +if [ ! -f package.json ]; then + cat <<'PKG' > package.json +{ + "name": "xcontrol-playwright-login", + "private": true, + "version": "0.0.0", + "type": "module", + "devDependencies": { + "@playwright/test": "^1.49.0" + } +} +PKG +fi + +npm install --silent +npx playwright install chromium + +UI_BASE_URL="$UI_BASE_URL" BASE_URL="$UI_BASE_URL" LOGIN_EMAIL="$LOGIN_EMAIL" SUPERADMIN_PASSWORD="$LOGIN_PASSWORD" \ + npx playwright test login.spec.mjs + +echo "✅ UI 登录测试通过" diff --git a/scripts/_common.sh b/scripts/_common.sh new file mode 100755 index 0000000..dc62435 --- /dev/null +++ b/scripts/_common.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" diff --git a/scripts/account-export.sh b/scripts/account-export.sh new file mode 100755 index 0000000..08af638 --- /dev/null +++ b/scripts/account-export.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +args=(export --dsn "${DB_URL}" --output "${ACCOUNT_EXPORT_FILE}") +if [ -n "${ACCOUNT_EMAIL_KEYWORD-}" ]; then + args+=(--email "${ACCOUNT_EMAIL_KEYWORD}") +fi + +go run ./cmd/migratectl/main.go "${args[@]}" diff --git a/scripts/account-import.sh b/scripts/account-import.sh new file mode 100755 index 0000000..20ee3fe --- /dev/null +++ b/scripts/account-import.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ ! -f "${ACCOUNT_IMPORT_FILE}" ]; then + echo "❌ 未找到文件 ${ACCOUNT_IMPORT_FILE}" + exit 1 +fi + +args=(import --dsn "${DB_URL}" --file "${ACCOUNT_IMPORT_FILE}") +if [ -n "${ACCOUNT_IMPORT_MERGE-}" ]; then + args+=(--merge) +fi +if [ -n "${ACCOUNT_IMPORT_MERGE_STRATEGY-}" ]; then + args+=(--merge-strategy "${ACCOUNT_IMPORT_MERGE_STRATEGY}") +fi +if [ -n "${ACCOUNT_IMPORT_DRY_RUN-}" ]; then + args+=(--dry-run) +fi +if [ -n "${ACCOUNT_IMPORT_MERGE_ALLOWLIST-}" ]; then + for uuid in ${ACCOUNT_IMPORT_MERGE_ALLOWLIST}; do + args+=(--merge-allowlist "${uuid}") + done +fi +if [ -n "${ACCOUNT_IMPORT_EXTRA_FLAGS-}" ]; then + # shellcheck disable=SC2206 + extra=( ${ACCOUNT_IMPORT_EXTRA_FLAGS} ) + args+=("${extra[@]}") +fi + +go run ./cmd/migratectl/main.go "${args[@]}" diff --git a/scripts/account-sync-mirror.sh b/scripts/account-sync-mirror.sh new file mode 100755 index 0000000..7fa82f2 --- /dev/null +++ b/scripts/account-sync-mirror.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ ! -f "${ACCOUNT_SYNC_CONFIG}" ]; then + echo "❌ 未找到配置文件 ${ACCOUNT_SYNC_CONFIG}" + exit 1 +fi + +go run ./cmd/syncctl/main.go mirror --config "${ACCOUNT_SYNC_CONFIG}" diff --git a/scripts/account-sync-pull.sh b/scripts/account-sync-pull.sh new file mode 100755 index 0000000..228d859 --- /dev/null +++ b/scripts/account-sync-pull.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ ! -f "${ACCOUNT_SYNC_CONFIG}" ]; then + echo "❌ 未找到配置文件 ${ACCOUNT_SYNC_CONFIG}" + exit 1 +fi + +go run ./cmd/syncctl/main.go pull --config "${ACCOUNT_SYNC_CONFIG}" diff --git a/scripts/account-sync-push.sh b/scripts/account-sync-push.sh new file mode 100755 index 0000000..493c6a4 --- /dev/null +++ b/scripts/account-sync-push.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ ! -f "${ACCOUNT_SYNC_CONFIG}" ]; then + echo "❌ 未找到配置文件 ${ACCOUNT_SYNC_CONFIG}" + exit 1 +fi + +go run ./cmd/syncctl/main.go push --config "${ACCOUNT_SYNC_CONFIG}" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..03ffd8d --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +go build -o "${APP_NAME}" "${MAIN_FILE}" diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..26dbcdc --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +rm -f "${APP_NAME}" *.pid *.log diff --git a/scripts/cloudrun-build.sh b/scripts/cloudrun-build.sh new file mode 100755 index 0000000..1f33dff --- /dev/null +++ b/scripts/cloudrun-build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ -z "${GCP_PROJECT}" ]; then + echo "⚠️ GCP_PROJECT 不能为空,跳过 Cloud Run 构建" + exit 0 +fi + +gcloud builds submit --tag "${CLOUD_RUN_IMAGE}" . diff --git a/scripts/cloudrun-deploy.sh b/scripts/cloudrun-deploy.sh new file mode 100755 index 0000000..7ffcc1c --- /dev/null +++ b/scripts/cloudrun-deploy.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ -z "${GCP_PROJECT}" ]; then + echo "⚠️ GCP_PROJECT 不能为空,跳过 Cloud Run 部署" + exit 0 +fi + +gcloud run services replace "${CLOUD_RUN_SERVICE_YAML}" --region "${GCP_REGION}" --project "${GCP_PROJECT}" diff --git a/scripts/cloudrun-stunnel.sh b/scripts/cloudrun-stunnel.sh new file mode 100755 index 0000000..bd66775 --- /dev/null +++ b/scripts/cloudrun-stunnel.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ -z "${GCP_PROJECT}" ]; then + echo "⚠️ GCP_PROJECT 不能为空,跳过 stunnel secret 更新" + exit 0 +fi +if [ ! -f "${CLOUD_RUN_STUNNEL_CONF}" ]; then + echo "❌ 未找到 stunnel 配置: ${CLOUD_RUN_STUNNEL_CONF}" + exit 1 +fi + +gcloud secrets versions add stunnel-config --data-file "${CLOUD_RUN_STUNNEL_CONF}" --project "${GCP_PROJECT}" diff --git a/scripts/create-db-user.sh b/scripts/create-db-user.sh new file mode 100755 index 0000000..14d98cb --- /dev/null +++ b/scripts/create-db-user.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo ">>> 创建数据库用户 ${DB_USER}" +if ! command -v psql >/dev/null; then + echo "❌ 未检测到 psql,请安装 PostgreSQL 客户端" + exit 1 +fi + +echo "正在以管理员身份创建用户..." +if PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_ADMIN_USER}" -d postgres \ + -Atc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -qx '1'; then + echo "⚠️ 用户可能已存在" +else + PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_ADMIN_USER}" -d postgres \ + -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" +fi +PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_ADMIN_USER}" -d postgres \ + -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" +echo "✓ 数据库用户创建完成" diff --git a/scripts/create-super-admin.sh b/scripts/create-super-admin.sh new file mode 100755 index 0000000..9f0ac69 --- /dev/null +++ b/scripts/create-super-admin.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +SUPERADMIN_EMAIL="admin@svc.plus" +SUPERADMIN_USERNAME="${SUPERADMIN_USERNAME:-Admin}" + +if [ -z "${SUPERADMIN_PASSWORD:-}" ] || [ "${SUPERADMIN_PASSWORD}" = "ChangeMe" ]; then + if command -v openssl >/dev/null; then + SUPERADMIN_PASSWORD="$(openssl rand -base64 12 | tr -d '\n' | tr '/+' 'Aa' | cut -c1-10)" + else + SUPERADMIN_PASSWORD="$(LC_ALL=C tr -dc 'A-Za-z0-9' >> 停止 PostgreSQL 服务 ..." + sudo systemctl stop postgresql + echo ">>> 删除数据库集群 16 main ..." + sudo pg_dropcluster --stop 16 main + echo ">>> 清理数据目录 ..." + sudo rm -rf /var/lib/postgresql/16/main + echo ">>> 清理配置目录 ..." + sudo rm -rf /etc/postgresql/16/main + echo ">>> 创建新的数据库集群 ..." + sudo pg_createcluster 16 main --start + echo "✓ PostgreSQL 集群重置完成" +else + echo "取消重置" +fi diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..74f3819 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if command -v air >/dev/null; then + air + exit 0 +fi + +echo "❌ 未检测到 air (热重载工具),请先安装: https://github.com/cosmtrek/air" +exit 0 diff --git a/scripts/drop-db.sh b/scripts/drop-db.sh new file mode 100755 index 0000000..4d200e6 --- /dev/null +++ b/scripts/drop-db.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo "⚠️ 即将删除数据库 ${DB_NAME} ..." +read -r -p "确定要删除数据库 ${DB_NAME}? [y/N] " confirm +if [ "${confirm}" = "y" ] || [ "${confirm}" = "Y" ]; then + echo ">>> 强制断开现有连接 ..." + if ! PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -U "${DB_ADMIN_USER}" -d postgres \ + -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${DB_NAME}' AND pid <> pg_backend_pid();"; then + echo "⚠️ 无法断开所有连接(需要超级用户权限)" + fi + echo ">>> 清理 pglogical schema ..." + PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -U "${DB_ADMIN_USER}" -d "${DB_NAME}" \ + -c "DROP SCHEMA IF EXISTS pglogical CASCADE;" >/dev/null 2>&1 || \ + echo "⚠️ 无法删除 pglogical schema(数据库可能不存在或缺少权限)" + echo ">>> 删除数据库 ${DB_NAME} ..." + if PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -U "${DB_ADMIN_USER}" -d postgres \ + -c "DROP DATABASE IF EXISTS ${DB_NAME};"; then + echo ">>> 数据库已删除" + else + echo ">>> 删除失败" + fi +else + echo "取消删除" +fi diff --git a/scripts/dump-schema.sh b/scripts/dump-schema.sh new file mode 100755 index 0000000..852ab61 --- /dev/null +++ b/scripts/dump-schema.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo ">>> 导出 schema 到 ${SCHEMA_FILE}" +PG_DUMP_BIN="${PG_DUMP_BIN:-pg_dump}" +SERVER_VERSION="$(psql "${DB_URL}" -Atc "SHOW server_version" 2>/dev/null || true)" +SERVER_MAJOR="${SERVER_VERSION%%.*}" +LOCAL_VERSION="$(${PG_DUMP_BIN} --version 2>/dev/null || true)" +LOCAL_MAJOR="$(echo "${LOCAL_VERSION}" | awk '{print $3}' | cut -d. -f1)" + +if [ -n "${SERVER_MAJOR}" ] && [ -n "${LOCAL_MAJOR}" ] && [ "${SERVER_MAJOR}" != "${LOCAL_MAJOR}" ]; then + echo "⚠️ pg_dump 版本不匹配(server=${SERVER_MAJOR}, local=${LOCAL_MAJOR}),跳过导出" + echo " 可设置 PG_DUMP_BIN 指向匹配版本的 pg_dump" + exit 0 +fi + +TMP_SCHEMA="/tmp/schema.$$.sql" +${PG_DUMP_BIN} -s -O -x "${DB_URL}" > "${TMP_SCHEMA}" +if [ ! -w "${SCHEMA_FILE}" ]; then + echo "⚠️ ${SCHEMA_FILE} 不可写,已将导出结果保留在 ${TMP_SCHEMA}" + exit 0 +fi + +mv "${TMP_SCHEMA}" "${SCHEMA_FILE}" diff --git a/scripts/ensure-db.sh b/scripts/ensure-db.sh new file mode 100755 index 0000000..7fb3aca --- /dev/null +++ b/scripts/ensure-db.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_ADMIN_USER}" -d postgres \ + -Atc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -qx '1'; then + echo ">>> 数据库 ${DB_NAME} 已存在" + exit 0 +fi + +echo ">>> 创建数据库 ${DB_NAME}" +PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_ADMIN_USER}" -d postgres \ + -c "CREATE DATABASE ${DB_NAME};" diff --git a/scripts/init-db-core.sh b/scripts/init-db-core.sh new file mode 100755 index 0000000..0d617f5 --- /dev/null +++ b/scripts/init-db-core.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo ">>> 初始化业务 schema (${SCHEMA_FILE})" +psql "${DB_URL}" -v ON_ERROR_STOP=1 -f "${SCHEMA_FILE}" diff --git a/scripts/init-db-pglogical.sh b/scripts/init-db-pglogical.sh new file mode 100755 index 0000000..647d4cf --- /dev/null +++ b/scripts/init-db-pglogical.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ -f "${PGLOGICAL_INIT_FILE}" ]; then + echo ">>> 初始化 pglogical schema (REPLICATION_MODE=pglogical)" + if PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -U "${DB_ADMIN_USER}" -d "${DB_NAME}" \ + -Atc "SELECT rolsuper FROM pg_roles WHERE rolname = current_user" 2>/dev/null | grep -qx 't'; then + PGPASSWORD="${DB_ADMIN_PASS}" psql -h "${DB_HOST}" -U "${DB_ADMIN_USER}" -d "${DB_NAME}" \ + -v ON_ERROR_STOP=1 -f "${PGLOGICAL_INIT_FILE}" + elif psql "${DB_URL}" -Atc "SELECT rolsuper FROM pg_roles WHERE rolname = current_user" | grep -qx 't'; then + psql "${DB_URL}" -v ON_ERROR_STOP=1 -f "${PGLOGICAL_INIT_FILE}" + else + echo "⚠️ 当前用户非超级用户,跳过 pglogical 初始化" + fi +fi + +if [ -f "${PGLOGICAL_PATCH_FILE}" ]; then + echo ">>> 应用 pglogical 默认值补丁" + psql "${DB_URL}" -v ON_ERROR_STOP=1 -f "${PGLOGICAL_PATCH_FILE}" +fi diff --git a/scripts/init-db-replication.sh b/scripts/init-db-replication.sh new file mode 100755 index 0000000..a8fcfd8 --- /dev/null +++ b/scripts/init-db-replication.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ "${REPLICATION_MODE}" = "pglogical" ]; then + bash scripts/init-db-pglogical.sh +else + echo ">>> 跳过 pglogical 初始化 (REPLICATION_MODE=${REPLICATION_MODE})" +fi diff --git a/scripts/init-db.sh b/scripts/init-db.sh new file mode 100755 index 0000000..6545863 --- /dev/null +++ b/scripts/init-db.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo ">>> 初始化数据库 schema" +if ! command -v psql >/dev/null; then + echo "❌ 未检测到 psql,请安装 PostgreSQL 客户端" + exit 1 +fi + +bash scripts/ensure-db.sh +bash scripts/init-db-core.sh +bash scripts/init-db-replication.sh diff --git a/scripts/init-go.sh b/scripts/init-go.sh new file mode 100755 index 0000000..178155f --- /dev/null +++ b/scripts/init-go.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ ! -f go.mod ]; then + echo ">>> go.mod not found, initializing module" + go mod init account +fi + +go mod tidy + +echo ">>> 检查 Go 环境" +if ! command -v go >/dev/null; then + echo "未安装 Go,自动安装中..." + if [ "${OS:-}" = "Darwin" ]; then + brew install go@1.24 + brew link --overwrite --force go@1.24 + else + sudo apt-get update + sudo apt-get install -y golang + fi +fi + +echo ">>> 配置 Go Proxy" +go env -w GOPROXY=https://proxy.golang.org,direct + +go mod tidy diff --git a/scripts/init-pglogical-region-cn.sh b/scripts/init-pglogical-region-cn.sh new file mode 100755 index 0000000..5460155 --- /dev/null +++ b/scripts/init-pglogical-region-cn.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +REGION_DB_URL="${DB_URL}" \ +NODE_NAME="node_cn" \ +NODE_DSN="host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ +SUBSCRIPTION_NAME="sub_from_global" \ +PROVIDER_DSN="host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ + bash scripts/init-pglogical-region.sh diff --git a/scripts/init-pglogical-region-global.sh b/scripts/init-pglogical-region-global.sh new file mode 100755 index 0000000..064bb44 --- /dev/null +++ b/scripts/init-pglogical-region-global.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +REGION_DB_URL="${DB_URL}" \ +NODE_NAME="node_global" \ +NODE_DSN="host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ +SUBSCRIPTION_NAME="sub_from_cn" \ +PROVIDER_DSN="host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ + bash scripts/init-pglogical-region.sh diff --git a/scripts/init-pglogical-region.sh b/scripts/init-pglogical-region.sh new file mode 100755 index 0000000..f04df8f --- /dev/null +++ b/scripts/init-pglogical-region.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +: "${REGION_DB_URL:?❌ 缺少 REGION_DB_URL}" +: "${NODE_NAME:?❌ 缺少 NODE_NAME}" +: "${NODE_DSN:?❌ 缺少 NODE_DSN}" +: "${SUBSCRIPTION_NAME:?❌ 缺少 SUBSCRIPTION_NAME}" +: "${PROVIDER_DSN:?❌ 缺少 PROVIDER_DSN}" + +psql "${REGION_DB_URL}" -v ON_ERROR_STOP=1 \ + -v NODE_NAME="${NODE_NAME}" \ + -v NODE_DSN="${NODE_DSN}" \ + -v SUBSCRIPTION_NAME="${SUBSCRIPTION_NAME}" \ + -v PROVIDER_DSN="${PROVIDER_DSN}" \ + -f "${PGLOGICAL_REGION_FILE}" diff --git a/scripts/init.sh b/scripts/init.sh new file mode 100755 index 0000000..82b242e --- /dev/null +++ b/scripts/init.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +bash scripts/init-go.sh +bash scripts/init-db.sh diff --git a/scripts/migrate-db.sh b/scripts/migrate-db.sh new file mode 100755 index 0000000..0219ff9 --- /dev/null +++ b/scripts/migrate-db.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo ">>> 执行数据库迁移" +if [ ! -d sql/migrations ]; then + echo "⚠️ 未找到 sql/migrations,跳过迁移" + exit 0 +fi + +go run ./cmd/migratectl/main.go migrate --dsn "${DB_URL}" --dir sql/migrations diff --git a/scripts/reinit-db.sh b/scripts/reinit-db.sh new file mode 100755 index 0000000..c8dbb44 --- /dev/null +++ b/scripts/reinit-db.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +echo ">>> 重置业务 schema (sql/schema.sql)" +bash scripts/reset-public-schema.sh +bash scripts/init-db-core.sh diff --git a/scripts/reinit-pglogical.sh b/scripts/reinit-pglogical.sh new file mode 100755 index 0000000..ff42895 --- /dev/null +++ b/scripts/reinit-pglogical.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +if [ "${REPLICATION_MODE}" = "pglogical" ]; then + echo ">>> 重新初始化 pglogical schema" + bash scripts/init-db-pglogical.sh +else + echo ">>> 当前 REPLICATION_MODE=${REPLICATION_MODE},无需 pglogical 处理" +fi diff --git a/scripts/reset-public-schema.sh b/scripts/reset-public-schema.sh new file mode 100755 index 0000000..d1d4202 --- /dev/null +++ b/scripts/reset-public-schema.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +psql "${DB_URL}" -v ON_ERROR_STOP=1 -v db_user="${DB_USER}" -f sql/reset_public_schema.sql diff --git a/scripts/restart.sh b/scripts/restart.sh new file mode 100755 index 0000000..5fb81a7 --- /dev/null +++ b/scripts/restart.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +bash scripts/stop.sh +bash scripts/start.sh diff --git a/scripts/setup_postgres_local.sh b/scripts/setup_postgres_local.sh old mode 100644 new mode 100755 index 97eb7b6..06d3aee --- a/scripts/setup_postgres_local.sh +++ b/scripts/setup_postgres_local.sh @@ -13,7 +13,7 @@ set -euo pipefail # 配置参数(可通过环境变量覆盖) # ----------------------------------------------------------------------------- DB_NAME="${DB_NAME:-account}" -DB_USER="${DB_USER:-shenlan}" +DB_USER="${DB_USER:-${POSTGRES_USER:-postgres}}" DB_PASS="${DB_PASS:-password}" DB_PORT="${DB_PORT:-5432}" DB_HOST="${DB_HOST:-127.0.0.1}" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..26b3bf7 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +./"${APP_NAME}" --config config/account.yaml diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..99654c5 --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +pkill -f "${APP_NAME}" || echo "⚠️ 未找到运行进程" diff --git a/scripts/stunnel-start.sh b/scripts/stunnel-start.sh new file mode 100755 index 0000000..8248ac9 --- /dev/null +++ b/scripts/stunnel-start.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +CONF_FILE="${CLOUD_RUN_STUNNEL_CONF}" +if [ ! -f "${CONF_FILE}" ]; then + echo "❌ 未找到 stunnel 配置: ${CONF_FILE}" + exit 1 +fi + +if ! command -v stunnel >/dev/null; then + echo "❌ 未检测到 stunnel,请先安装" + exit 1 +fi + +if command -v ss >/dev/null; then + if ss -ltn 2>/dev/null | grep -q ':15432'; then + echo "✅ stunnel 已在 127.0.0.1:15432 监听" + exit 0 + fi +elif command -v lsof >/dev/null; then + if lsof -nP -iTCP:15432 -sTCP:LISTEN | grep -q LISTEN; then + echo "✅ stunnel 已在 127.0.0.1:15432 监听" + exit 0 + fi +fi + +echo ">>> 启动 stunnel (client)" +# stunnel 需要写入 /var/run,优先使用 sudo 启动 +if sudo -n true 2>/dev/null; then + sudo stunnel "${CONF_FILE}" & + echo "✅ stunnel 启动完成 (sudo)" + exit 0 +fi + +echo "⚠️ sudo 不可用,使用用户态临时配置启动" +TMP_CONF="/tmp/stunnel-account-db-client.conf" +sed \ + -e 's#^pid = .*#pid = /tmp/stunnel-account-db-client.pid#' \ + -e 's#^output = .*#output = /tmp/stunnel-account-db-client.log#' \ + "${CONF_FILE}" > "${TMP_CONF}" + +if [ ! -f /etc/ssl/certs/ca-certificates.crt ] && [ -f /etc/ssl/cert.pem ]; then + sed -i '' 's#^CAfile = .*#CAfile = /etc/ssl/cert.pem#' "${TMP_CONF}" +fi + +stunnel "${TMP_CONF}" & +echo "✅ stunnel 启动完成 (user mode)" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..18567c3 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +go test ./... diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100755 index 0000000..70ebefc --- /dev/null +++ b/scripts/upgrade.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +systemctl stop xcontrol-account +cp xcontrol-account /usr/bin/xcontrol-account +systemctl start xcontrol-account diff --git a/sql/readme.md b/sql/readme.md index 5734ad1..ad73bee 100644 --- a/sql/readme.md +++ b/sql/readme.md @@ -67,9 +67,9 @@ go run ./cmd/migratectl/main.go check --cn "$CN_DSN" --global "$GLOBAL_DSN" make -C account init-pglogical-region \ REGION_DB_URL="$REGION_DB_URL" \ NODE_NAME=node_cn \ - NODE_DSN="host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" \ + NODE_DSN="host=cn-homepage.svc.plus port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}" \ SUBSCRIPTION_NAME=sub_from_global \ - PROVIDER_DSN="host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx" + PROVIDER_DSN="host=global-homepage.svc.plus port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}" ``` 4. 在另一侧节点重复执行并互为订阅,实现双主写入。 @@ -85,21 +85,21 @@ pglogical schema 与业务 schema 分离,以防逻辑复制函数污染业务 bash 复制代码 sudo -u postgres psql -d account -c "GRANT USAGE ON SCHEMA pglogical TO PUBLIC;" -2️⃣ 授权业务用户(shenlan) +2️⃣ 授权业务用户(app_user) sql 复制代码 -- 登录 postgres sudo -u postgres psql -d account --- 授权 shenlan 对 public schema 全权限 -ALTER SCHEMA public OWNER TO shenlan; -GRANT ALL ON SCHEMA public TO shenlan; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO shenlan; -GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO shenlan; -GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO shenlan; +-- 授权 app_user 对 public schema 全权限 +ALTER SCHEMA public OWNER TO app_user; +GRANT ALL ON SCHEMA public TO app_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_user; +GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO app_user; -- 授权 pglogical schema 使用权限(仅使用,不可修改) -GRANT USAGE ON SCHEMA pglogical TO shenlan; +GRANT USAGE ON SCHEMA pglogical TO app_user; \q ⚙️ 执行顺序建议 @@ -123,17 +123,17 @@ GRANT USAGE ON SCHEMA pglogical TO shenlan; # Global 节点示例 psql "$REGION_GLOBAL_DB_URL" -v ON_ERROR_STOP=1 \ -v NODE_NAME=node_global \ - -v NODE_DSN='host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx' \ + -v NODE_DSN='host=global-homepage.svc.plus port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}' \ -v SUBSCRIPTION_NAME=sub_from_cn \ - -v PROVIDER_DSN='host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx' \ + -v PROVIDER_DSN='host=cn-homepage.svc.plus port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}' \ -f account/sql/schema_pglogical_region.sql # CN 节点示例 psql "$REGION_CN_DB_URL" -v ON_ERROR_STOP=1 \ -v NODE_NAME=node_cn \ - -v NODE_DSN='host=cn-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxxx' \ + -v NODE_DSN='host=cn-homepage.svc.plus port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}' \ -v SUBSCRIPTION_NAME=sub_from_global \ - -v PROVIDER_DSN='host=global-homepage.svc.plus port=5432 dbname=account user=pglogical password=xxx' \ + -v PROVIDER_DSN='host=global-homepage.svc.plus port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}' \ -f account/sql/schema_pglogical_region.sql ``` @@ -143,12 +143,12 @@ psql "$REGION_CN_DB_URL" -v ON_ERROR_STOP=1 \ make init-pglogical-region \ REGION_DB_URL="$REGION_DB_URL" \ NODE_NAME=node_example \ - NODE_DSN="host=example port=5432 dbname=account user=pglogical password=secret" \ + NODE_DSN="host=example port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}" \ SUBSCRIPTION_NAME=sub_from_peer \ - PROVIDER_DSN="host=peer port=5432 dbname=account user=pglogical password=secret" + PROVIDER_DSN="host=peer port=5432 dbname=account user=${PGLOGICAL_USER} password=${PGLOGICAL_PASSWORD}" ``` -- 若使用业务账号(如 `shenlan`)执行初始化,PostgreSQL 会提示缺少超级用户权限并跳过 `pglogical` 初始化。 +- 若使用业务账号(如 `app_user`)执行初始化,PostgreSQL 会提示缺少超级用户权限并跳过 `pglogical` 初始化。 - 建议改用 `postgres` 等超级用户连接执行,或由管理员预先安装 `pglogical` 扩展并授予业务用户访问权限。 - 如果扩展已由管理员创建,可直接重新运行 `make init-pglogical-region-cn` 完成复制集与订阅配置。