feat(project): init
This commit is contained in:
69
scripts/db/README.md
Normal file
69
scripts/db/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# cms-service 数据库脚本(migrate / verify / rollback)
|
||||
|
||||
本目录提供一套与 `iam-service/scripts/db` 类似的数据库操作脚本,适用于:
|
||||
|
||||
- 生产/预发:在启动 cms-service 前,显式执行迁移与校验
|
||||
- 开发/测试:快速初始化/回滚 schema
|
||||
|
||||
说明:
|
||||
|
||||
- cms-service 运行时也会自动执行 SQLx migrations(见 `src/infrastructure/db/mod.rs`)。如果你选择使用本目录脚本管理迁移,建议在部署流程中做到“先 migrate,再启动服务”,并在同一数据库上保持一致的迁移源(`cms-service/migrations/*.sql`)。
|
||||
- 本脚本会写入 SQLx 使用的 `_sqlx_migrations` 表,使得服务启动时不会重复执行已应用的迁移。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装 `psql`
|
||||
- `DATABASE_URL` 可用(可通过导出环境变量或在项目根目录 `.env` 中配置)
|
||||
- 校验/迁移 checksum 需要 `sha384sum` 或 `openssl` + `xxd`
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
export DATABASE_URL='postgres://...'
|
||||
|
||||
# 1) 应用迁移(写入 _sqlx_migrations)
|
||||
./scripts/db/migrate.sh
|
||||
|
||||
# 2) 校验 schema(包含:迁移校验 + 结构校验)
|
||||
./scripts/db/verify.sh
|
||||
|
||||
# 3) 回滚到指定版本(例如回滚到 0001)
|
||||
ROLLBACK_TO_VERSION=1 ./scripts/db/rollback.sh
|
||||
|
||||
# 4) 回滚所有迁移(仅回滚 cms-service 自己的对象;不会卸载 pgcrypto extension)
|
||||
ROLLBACK_TO_VERSION=0 ./scripts/db/rollback.sh
|
||||
```
|
||||
|
||||
## 版本号规则
|
||||
|
||||
- 迁移文件目录:`cms-service/migrations/*.sql`
|
||||
- 迁移版本号:取文件名前缀数字(例如 `0002_cms.sql` -> version=2)
|
||||
- 回滚脚本:`scripts/db/rollback/<version>.down.sql`(例如 `0002.down.sql`)
|
||||
- 校验脚本:`scripts/db/verify/<version>_*.sql`
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 启动时报 `failed to run migrations: VersionMismatch(<n>)`
|
||||
|
||||
含义:
|
||||
|
||||
- 数据库 `_sqlx_migrations` 表里记录的 `<n>` 号迁移 checksum,和当前仓库里对应 `migrations/<n>_*.sql` 的 checksum 不一致。
|
||||
- 常见原因是:迁移文件被改动过、或曾使用非 SQLx 算法写入了 `_sqlx_migrations.checksum`。
|
||||
|
||||
解决(开发环境推荐做法):
|
||||
|
||||
1) 停止 cms-service
|
||||
2) 回滚到 0(会删除 cms 表/类型,并清理 `_sqlx_migrations` 中的记录):
|
||||
|
||||
```bash
|
||||
ROLLBACK_TO_VERSION=0 ./scripts/db/rollback.sh
|
||||
```
|
||||
|
||||
3) 重新执行迁移与校验:
|
||||
|
||||
```bash
|
||||
./scripts/db/migrate.sh
|
||||
./scripts/db/verify.sh
|
||||
```
|
||||
|
||||
然后再启动 cms-service。
|
||||
95
scripts/db/migrate.sh
Executable file
95
scripts/db/migrate.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
MIGRATIONS_DIR="${ROOT_DIR}/migrations"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
checksum_hex_of_file() {
|
||||
local file="$1"
|
||||
if command -v sha384sum >/dev/null 2>&1; then
|
||||
sha384sum "${file}" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha384 -binary "${file}" | xxd -p -c 256
|
||||
return 0
|
||||
fi
|
||||
echo "sha384sum or openssl is required to compute sqlx checksum" >&2
|
||||
return 127
|
||||
}
|
||||
|
||||
TARGET_VERSION="${TARGET_VERSION:-}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS _sqlx_migrations (version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), success BOOLEAN NOT NULL, checksum BYTEA NOT NULL, execution_time BIGINT NOT NULL)"
|
||||
|
||||
shopt -s nullglob
|
||||
migrations=( "${MIGRATIONS_DIR}/"*.sql )
|
||||
if [[ ${#migrations[@]} -eq 0 ]]; then
|
||||
echo "No migration files found: ${MIGRATIONS_DIR}/*.sql"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${migrations[@]}"; do
|
||||
base="$(basename "${file}")"
|
||||
version_str="${base%%_*}"
|
||||
version_num="$((10#${version_str}))"
|
||||
description="${base#*_}"
|
||||
description="${description%.sql}"
|
||||
|
||||
if [[ -n "${TARGET_VERSION}" && "${version_num}" -gt "${TARGET_VERSION}" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
applied="$(psql "${DATABASE_URL}" -At -c "SELECT 1 FROM _sqlx_migrations WHERE version=${version_num} AND success=true LIMIT 1" || true)"
|
||||
if [[ "${applied}" == "1" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Applying ${version_str} (${base})"
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
checksum="$(checksum_hex_of_file "${file}")"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "INSERT INTO _sqlx_migrations(version, description, success, checksum, execution_time) VALUES (${version_num}, '${description}', true, decode('${checksum}','hex'), 0) ON CONFLICT (version) DO NOTHING"
|
||||
done
|
||||
|
||||
echo "Migrations completed"
|
||||
8
scripts/db/rebuild_cms_db.sh
Executable file
8
scripts/db/rebuild_cms_db.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
"${SCRIPT_DIR}/reset.sh"
|
||||
"${SCRIPT_DIR}/migrate.sh"
|
||||
"${SCRIPT_DIR}/verify.sh"
|
||||
6
scripts/db/reset.sh
Executable file
6
scripts/db/reset.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ROLLBACK_TO_VERSION=0 "${SCRIPT_DIR}/rollback.sh"
|
||||
79
scripts/db/rollback.sh
Executable file
79
scripts/db/rollback.sh
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
ROLLBACK_DIR="${SCRIPT_DIR}/rollback"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
ROLLBACK_TO_VERSION="${ROLLBACK_TO_VERSION:-}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS _sqlx_migrations (version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), success BOOLEAN NOT NULL, checksum BYTEA NOT NULL, execution_time BIGINT NOT NULL)" >/dev/null
|
||||
|
||||
if [[ -z "${ROLLBACK_TO_VERSION}" ]]; then
|
||||
echo "ROLLBACK_TO_VERSION is required (e.g. 1 or 0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while true; do
|
||||
current="$(psql "${DATABASE_URL}" -At -c "SELECT version FROM _sqlx_migrations WHERE success=true ORDER BY version DESC LIMIT 1" || true)"
|
||||
if [[ -z "${current}" ]]; then
|
||||
echo "No applied migrations found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${current}" -le "${ROLLBACK_TO_VERSION}" ]]; then
|
||||
echo "Rollback completed (current=${current}, target=${ROLLBACK_TO_VERSION})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
file="${ROLLBACK_DIR}/$(printf "%04d" "${current}").down.sql"
|
||||
if [[ ! -f "${file}" ]]; then
|
||||
echo "Rollback file not found for version ${current}: ${file}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Rolling back version ${current} using $(basename "${file}")"
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "SELECT ${current}" >/dev/null
|
||||
else
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "DELETE FROM _sqlx_migrations WHERE version=${current}"
|
||||
fi
|
||||
done
|
||||
|
||||
7
scripts/db/rollback/0001.down.sql
Normal file
7
scripts/db/rollback/0001.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
|
||||
-- cms-service 0001 仅创建 pgcrypto extension。为避免影响同库其他服务,这里不卸载 extension。
|
||||
SELECT 1;
|
||||
|
||||
COMMIT;
|
||||
|
||||
14
scripts/db/rollback/0002.down.sql
Normal file
14
scripts/db/rollback/0002.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS cms_article_versions;
|
||||
DROP TABLE IF EXISTS cms_article_tags;
|
||||
DROP TABLE IF EXISTS cms_articles;
|
||||
DROP TABLE IF EXISTS cms_media;
|
||||
DROP TABLE IF EXISTS cms_tags;
|
||||
DROP TABLE IF EXISTS cms_columns;
|
||||
|
||||
DROP TYPE IF EXISTS cms_article_status;
|
||||
DROP TYPE IF EXISTS cms_tag_kind;
|
||||
|
||||
COMMIT;
|
||||
|
||||
91
scripts/db/verify.sh
Executable file
91
scripts/db/verify.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
MIGRATIONS_DIR="${ROOT_DIR}/migrations"
|
||||
VERIFY_DIR="${SCRIPT_DIR}/verify"
|
||||
|
||||
load_database_url_from_env_file() {
|
||||
local env_file="$1"
|
||||
local line value
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[[ -z "${line}" || "${line}" == \#* ]] && continue
|
||||
line="${line#export }"
|
||||
if [[ "${line}" == DATABASE_URL=* ]]; then
|
||||
value="${line#DATABASE_URL=}"
|
||||
value="${value%$'\r'}"
|
||||
value="${value%\"}"
|
||||
value="${value#\"}"
|
||||
value="${value%\'}"
|
||||
value="${value#\'}"
|
||||
printf '%s' "${value}"
|
||||
return 0
|
||||
fi
|
||||
done < "${env_file}"
|
||||
return 1
|
||||
}
|
||||
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
if [[ -z "${DATABASE_URL}" && -f "${ROOT_DIR}/.env" ]]; then
|
||||
DATABASE_URL="$(load_database_url_from_env_file "${ROOT_DIR}/.env" || true)"
|
||||
fi
|
||||
if [[ -z "${DATABASE_URL}" ]]; then
|
||||
echo "DATABASE_URL is required (export it, or set it in ${ROOT_DIR}/.env)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found in PATH"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
checksum_hex_of_file() {
|
||||
local file="$1"
|
||||
if command -v sha384sum >/dev/null 2>&1; then
|
||||
sha384sum "${file}" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha384 -binary "${file}" | xxd -p -c 256
|
||||
return 0
|
||||
fi
|
||||
echo "sha384sum or openssl is required to compute sqlx checksum" >&2
|
||||
return 127
|
||||
}
|
||||
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS _sqlx_migrations (version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), success BOOLEAN NOT NULL, checksum BYTEA NOT NULL, execution_time BIGINT NOT NULL)"
|
||||
|
||||
echo "Checking migration checksums in _sqlx_migrations..."
|
||||
|
||||
shopt -s nullglob
|
||||
migrations=( "${MIGRATIONS_DIR}/"*.sql )
|
||||
for file in "${migrations[@]}"; do
|
||||
base="$(basename "${file}")"
|
||||
version_str="${base%%_*}"
|
||||
version_num="$((10#${version_str}))"
|
||||
expected_checksum="$(checksum_hex_of_file "${file}")"
|
||||
actual_checksum="$(psql "${DATABASE_URL}" -At -c "SELECT encode(checksum,'hex') FROM _sqlx_migrations WHERE version=${version_num} AND success=true LIMIT 1" || true)"
|
||||
|
||||
if [[ -n "${actual_checksum}" && "${actual_checksum}" != "${expected_checksum}" ]]; then
|
||||
echo "Checksum mismatch: version=${version_str} expected=${expected_checksum} actual=${actual_checksum}"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Running schema verify scripts..."
|
||||
|
||||
verify_files=( "${VERIFY_DIR}/"*.sql )
|
||||
if [[ ${#verify_files[@]} -eq 0 ]]; then
|
||||
echo "No verify files found: ${VERIFY_DIR}/*.sql"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${verify_files[@]}"; do
|
||||
base="$(basename "${file}")"
|
||||
echo "Verifying ${base}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${file}" >/dev/null
|
||||
done
|
||||
|
||||
echo "Verify completed"
|
||||
7
scripts/db/verify/0001_core.sql
Normal file
7
scripts/db/verify/0001_core.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
|
||||
RAISE EXCEPTION 'missing extension: pgcrypto';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
29
scripts/db/verify/0002_cms.sql
Normal file
29
scripts/db/verify/0002_cms.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cms_tag_kind') THEN
|
||||
RAISE EXCEPTION 'missing type: cms_tag_kind';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cms_article_status') THEN
|
||||
RAISE EXCEPTION 'missing type: cms_article_status';
|
||||
END IF;
|
||||
|
||||
IF to_regclass('public.cms_columns') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: cms_columns';
|
||||
END IF;
|
||||
IF to_regclass('public.cms_tags') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: cms_tags';
|
||||
END IF;
|
||||
IF to_regclass('public.cms_media') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: cms_media';
|
||||
END IF;
|
||||
IF to_regclass('public.cms_articles') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: cms_articles';
|
||||
END IF;
|
||||
IF to_regclass('public.cms_article_tags') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: cms_article_tags';
|
||||
END IF;
|
||||
IF to_regclass('public.cms_article_versions') IS NULL THEN
|
||||
RAISE EXCEPTION 'missing table: cms_article_versions';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
Reference in New Issue
Block a user