From ed5cfe08b860cd86c5ea29ea4705a5e20188a459 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 10:19:01 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=A2=9E=E5=8A=A0jenkins=E6=89=93=E5=8C=85?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 236 +++++++++++++++++++++++++++++++++ WonderQ-Admin-UI/.dockerignore | 10 ++ WonderQ-Admin-UI/Dockerfile | 29 ++++ WonderQ-Admin-UI/nginx.conf | 33 +++++ 4 files changed, 308 insertions(+) create mode 100644 Jenkinsfile create mode 100644 WonderQ-Admin-UI/.dockerignore create mode 100644 WonderQ-Admin-UI/Dockerfile create mode 100644 WonderQ-Admin-UI/nginx.conf diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..cb54035 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,236 @@ +pipeline { + agent any + + options { + disableConcurrentBuilds() + buildDiscarder(logRotator(numToKeepStr: '20')) + skipDefaultCheckout(true) + } + + parameters { + string( + name: 'ACR_REGISTRY', + defaultValue: '', + description: 'ACR 登录域名,不包含 https://,优先使用与 ACK 同 VPC 的内网域名' + ) + string(name: 'ACR_NAMESPACE', defaultValue: 'wonderq', description: 'ACR 命名空间') + string(name: 'API_REPOSITORY', defaultValue: 'wonderq-api', description: 'API 镜像仓库名') + string(name: 'ADMIN_REPOSITORY', defaultValue: 'wonderq-admin-ui', description: 'Admin UI 镜像仓库名') + string(name: 'ACK_NAMESPACE', defaultValue: 'wonderq-prod', description: 'ACK Kubernetes 命名空间') + string(name: 'API_DEPLOYMENT', defaultValue: 'wonderq-api', description: 'API Deployment 名称') + string(name: 'API_CONTAINER', defaultValue: 'api', description: 'API Deployment 中的容器名称') + string(name: 'ADMIN_DEPLOYMENT', defaultValue: 'wonderq-admin-ui', description: 'Admin UI Deployment 名称') + string(name: 'ADMIN_CONTAINER', defaultValue: 'admin-ui', description: 'Admin UI Deployment 中的容器名称') + string( + name: 'ADMIN_API_BASE_URL', + defaultValue: '', + description: 'Admin UI 构建时的 API 地址;ALB 使用同域名 /api 分流时保持为空' + ) + string( + name: 'ACR_CREDENTIALS_ID', + defaultValue: 'acr-push-credential', + description: 'Jenkins 中 ACR 用户名密码 Credential ID' + ) + string( + name: 'ACK_KUBECONFIG_CREDENTIALS_ID', + defaultValue: 'ack-prod-kubeconfig', + description: 'Jenkins 中 ACK kubeconfig Secret File Credential ID' + ) + booleanParam(name: 'DEPLOY_TO_ACK', defaultValue: true, description: '推送镜像后是否更新 ACK Deployment') + } + + environment { + DOCKER_BUILDKIT = '1' + } + + stages { + stage('Checkout') { + steps { + checkout scm + script { + def commit = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + env.IMAGE_TAG = "${commit}-${env.BUILD_NUMBER}" + env.API_IMAGE = "${params.ACR_REGISTRY}/${params.ACR_NAMESPACE}/${params.API_REPOSITORY}" + env.ADMIN_IMAGE = "${params.ACR_REGISTRY}/${params.ACR_NAMESPACE}/${params.ADMIN_REPOSITORY}" + } + } + } + + stage('Validate') { + steps { + sh ''' + set -eu + + if [ -z "$ACR_REGISTRY" ]; then + echo "ACR_REGISTRY 不能为空" >&2 + exit 1 + fi + + case "$ACR_REGISTRY" in + *://*) + echo "ACR_REGISTRY 只填写域名,不要包含协议" >&2 + exit 1 + ;; + esac + + command -v docker >/dev/null + docker version >/dev/null + + if [ "$DEPLOY_TO_ACK" = "true" ]; then + command -v kubectl >/dev/null + fi + ''' + } + } + + stage('Build API') { + steps { + sh ''' + set -eu + docker build \ + --tag "$API_IMAGE:$IMAGE_TAG" \ + WonderQ-Admin + ''' + } + } + + stage('Test API') { + steps { + sh ''' + set -eu + + test_container='' + cleanup_test_container() { + if [ -n "$test_container" ]; then + docker rm --force "$test_container" >/dev/null 2>&1 || true + fi + } + trap cleanup_test_container EXIT + + test_container="$(docker create \ + --entrypoint python \ + "$API_IMAGE:$IMAGE_TAG" \ + -m pytest)" + docker cp WonderQ-Admin/tests/. "$test_container:/app/tests" + docker start --attach "$test_container" + ''' + } + } + + stage('Build Admin UI') { + steps { + sh ''' + set -eu + docker build \ + --build-arg "VITE_API_BASE_URL=$ADMIN_API_BASE_URL" \ + --tag "$ADMIN_IMAGE:$IMAGE_TAG" \ + WonderQ-Admin-UI + + docker run --rm \ + --entrypoint nginx \ + "$ADMIN_IMAGE:$IMAGE_TAG" \ + -t + ''' + } + } + + stage('Push Images') { + steps { + withCredentials([ + usernamePassword( + credentialsId: params.ACR_CREDENTIALS_ID, + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + ) + ]) { + sh ''' + set -eu + set +x + + docker_config="$(mktemp -d)" + export DOCKER_CONFIG="$docker_config" + cleanup_docker_config() { + docker logout "$ACR_REGISTRY" >/dev/null 2>&1 || true + rm -rf -- "$docker_config" + } + trap cleanup_docker_config EXIT + + printf '%s' "$ACR_PASSWORD" | docker login \ + --username "$ACR_USERNAME" \ + --password-stdin \ + "$ACR_REGISTRY" + + docker push "$API_IMAGE:$IMAGE_TAG" + docker push "$ADMIN_IMAGE:$IMAGE_TAG" + ''' + } + } + } + + stage('Deploy ACK') { + when { + expression { params.DEPLOY_TO_ACK } + } + steps { + withCredentials([ + file( + credentialsId: params.ACK_KUBECONFIG_CREDENTIALS_ID, + variable: 'ACK_KUBECONFIG' + ) + ]) { + sh ''' + set -eu + + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + get deployment "$API_DEPLOYMENT" >/dev/null + + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + get deployment "$ADMIN_DEPLOYMENT" >/dev/null + + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + set image "deployment/$API_DEPLOYMENT" \ + "$API_CONTAINER=$API_IMAGE:$IMAGE_TAG" + + if ! kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + rollout status "deployment/$API_DEPLOYMENT" --timeout=300s; then + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + rollout undo "deployment/$API_DEPLOYMENT" || true + exit 1 + fi + + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + set image "deployment/$ADMIN_DEPLOYMENT" \ + "$ADMIN_CONTAINER=$ADMIN_IMAGE:$IMAGE_TAG" + + if ! kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + rollout status "deployment/$ADMIN_DEPLOYMENT" --timeout=300s; then + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + rollout undo "deployment/$ADMIN_DEPLOYMENT" || true + kubectl --kubeconfig "$ACK_KUBECONFIG" \ + --namespace "$ACK_NAMESPACE" \ + rollout undo "deployment/$API_DEPLOYMENT" || true + exit 1 + fi + ''' + } + } + } + } + + post { + success { + echo "部署完成,镜像标签:${env.IMAGE_TAG}" + } + failure { + echo '流水线失败,请查看对应阶段日志;数据库迁移不会随 Deployment 回滚自动撤销。' + } + } +} diff --git a/WonderQ-Admin-UI/.dockerignore b/WonderQ-Admin-UI/.dockerignore new file mode 100644 index 0000000..6f895df --- /dev/null +++ b/WonderQ-Admin-UI/.dockerignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.git/ +.idea/ +.env +.env.* +coverage/ +.vite/ +*.log + diff --git a/WonderQ-Admin-UI/Dockerfile b/WonderQ-Admin-UI/Dockerfile new file mode 100644 index 0000000..4278a6d --- /dev/null +++ b/WonderQ-Admin-UI/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS build + +WORKDIR /app + +COPY package.json yarn.lock ./ + +# The project keeps Windows native packages for local Yarn 1 installs. The lock +# file also contains the matching Linux packages used by this container build. +RUN yarn install --frozen-lockfile --non-interactive --ignore-platform + +COPY . . + +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL="${VITE_API_BASE_URL}" + +RUN yarn build + +FROM nginx:1.28-alpine AS runtime + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 + diff --git a/WonderQ-Admin-UI/nginx.conf b/WonderQ-Admin-UI/nginx.conf new file mode 100644 index 0000000..9c21d95 --- /dev/null +++ b/WonderQ-Admin-UI/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + root /usr/share/nginx/html; + index index.html; + charset utf-8; + server_tokens off; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json image/svg+xml; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /index.html { + expires -1; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} From c608e186d6c234193482fd4b58d1ec82f16eb1f5 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 10:53:19 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=89=93=E5=8C=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/jenkins-freestyle.sh | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100755 scripts/jenkins-freestyle.sh diff --git a/scripts/jenkins-freestyle.sh b/scripts/jenkins-freestyle.sh new file mode 100755 index 0000000..80b330b --- /dev/null +++ b/scripts/jenkins-freestyle.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +set +x + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname -- "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +# Jenkins 页面只需要配置 ACR_REGISTRY 和 DEPLOY_TO_ACK。 +# 其余默认值已与当前 ACK 资源对齐,必要时仍可通过同名环境变量覆盖。 +ACR_REGISTRY="${ACR_REGISTRY:-}" +ACR_NAMESPACE="${ACR_NAMESPACE:-one-feel}" +API_REPOSITORY="${API_REPOSITORY:-wanderq-api}" +ADMIN_REPOSITORY="${ADMIN_REPOSITORY:-wanderq-admin}" +ACK_NAMESPACE="${ACK_NAMESPACE:-wanqu}" +API_DEPLOYMENT="${API_DEPLOYMENT:-wanderq-api}" +API_CONTAINER="${API_CONTAINER:-}" +ADMIN_DEPLOYMENT="${ADMIN_DEPLOYMENT:-wanderq-admin}" +ADMIN_CONTAINER="${ADMIN_CONTAINER:-}" +ADMIN_API_BASE_URL="${ADMIN_API_BASE_URL:-}" +DEPLOY_TO_ACK="${DEPLOY_TO_ACK:-false}" +BUILD_NUMBER="${BUILD_NUMBER:-local}" +DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" +export DOCKER_BUILDKIT + +log() { + printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" +} + +fail() { + printf '错误:%s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "Jenkins Agent 缺少命令:$1" +} + +[[ -n "$ACR_REGISTRY" ]] || fail "ACR_REGISTRY 不能为空" +[[ "$ACR_REGISTRY" != *://* ]] || fail "ACR_REGISTRY 只填写域名,不要包含协议" +[[ -n "$ACR_NAMESPACE" ]] || fail "ACR_NAMESPACE 不能为空" +[[ -n "${ACR_USERNAME:-}" ]] || fail "未绑定 Jenkins ACR 用户名变量 ACR_USERNAME" +[[ -n "${ACR_PASSWORD:-}" ]] || fail "未绑定 Jenkins ACR 密码变量 ACR_PASSWORD" + +case "$DEPLOY_TO_ACK" in + true|false) ;; + *) fail "DEPLOY_TO_ACK 只能是 true 或 false" ;; +esac + +require_command git +require_command docker +docker version >/dev/null + +if [[ "$DEPLOY_TO_ACK" == "true" ]]; then + require_command kubectl + [[ -n "${ACK_KUBECONFIG:-}" ]] || fail "未绑定 Jenkins kubeconfig 文件变量 ACK_KUBECONFIG" + [[ -f "$ACK_KUBECONFIG" ]] || fail "ACK_KUBECONFIG 指向的文件不存在" +fi + +GIT_COMMIT="$(git rev-parse --short=12 HEAD)" +IMAGE_TAG="${IMAGE_TAG:-${GIT_COMMIT}-${BUILD_NUMBER}}" +API_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/${API_REPOSITORY}" +ADMIN_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/${ADMIN_REPOSITORY}" + +test_container="" +docker_config="" + +cleanup() { + if [[ -n "$test_container" ]]; then + docker rm --force "$test_container" >/dev/null 2>&1 || true + fi + if [[ -n "$docker_config" && -d "$docker_config" ]]; then + docker logout "$ACR_REGISTRY" >/dev/null 2>&1 || true + rm -rf -- "$docker_config" + fi +} +trap cleanup EXIT + +log "构建 API 镜像:$API_IMAGE:$IMAGE_TAG" +docker build \ + --tag "$API_IMAGE:$IMAGE_TAG" \ + WonderQ-Admin + +log "运行 API 测试" +test_container="$(docker create \ + --entrypoint python \ + "$API_IMAGE:$IMAGE_TAG" \ + -m pytest)" +docker cp WonderQ-Admin/tests/. "$test_container:/app/tests" +docker start --attach "$test_container" +docker rm "$test_container" >/dev/null +test_container="" + +log "构建 Admin UI 镜像:$ADMIN_IMAGE:$IMAGE_TAG" +docker build \ + --build-arg "VITE_API_BASE_URL=$ADMIN_API_BASE_URL" \ + --tag "$ADMIN_IMAGE:$IMAGE_TAG" \ + WonderQ-Admin-UI + +log "检查 Admin UI Nginx 配置" +docker run --rm \ + --entrypoint nginx \ + "$ADMIN_IMAGE:$IMAGE_TAG" \ + -t + +log "登录 ACR 并推送镜像" +docker_config="$(mktemp -d)" +export DOCKER_CONFIG="$docker_config" +printf '%s' "$ACR_PASSWORD" | docker login \ + --username "$ACR_USERNAME" \ + --password-stdin \ + "$ACR_REGISTRY" +docker push "$API_IMAGE:$IMAGE_TAG" +docker push "$ADMIN_IMAGE:$IMAGE_TAG" + +if [[ "$DEPLOY_TO_ACK" != "true" ]]; then + log "镜像已推送,DEPLOY_TO_ACK=false,本次不更新 ACK" + exit 0 +fi + +KUBECTL=( + kubectl + --kubeconfig "$ACK_KUBECONFIG" + --namespace "$ACK_NAMESPACE" +) + +log "检查 ACK Deployment" +"${KUBECTL[@]}" get deployment "$API_DEPLOYMENT" >/dev/null +"${KUBECTL[@]}" get deployment "$ADMIN_DEPLOYMENT" >/dev/null + +# 当前两个 Deployment 均为单容器;自动读取容器名,避免把 Pod 名称或历史拼写写进 Jenkins 参数。 +if [[ -z "$API_CONTAINER" ]]; then + API_CONTAINER="$("${KUBECTL[@]}" get deployment "$API_DEPLOYMENT" \ + -o jsonpath='{.spec.template.spec.containers[0].name}')" +fi +if [[ -z "$ADMIN_CONTAINER" ]]; then + ADMIN_CONTAINER="$("${KUBECTL[@]}" get deployment "$ADMIN_DEPLOYMENT" \ + -o jsonpath='{.spec.template.spec.containers[0].name}')" +fi +[[ -n "$API_CONTAINER" ]] || fail "无法读取 API Deployment 的容器名称" +[[ -n "$ADMIN_CONTAINER" ]] || fail "无法读取 Admin UI Deployment 的容器名称" + +log "更新 API Deployment:$API_DEPLOYMENT/$API_CONTAINER" +"${KUBECTL[@]}" set image \ + "deployment/$API_DEPLOYMENT" \ + "$API_CONTAINER=$API_IMAGE:$IMAGE_TAG" + +if ! "${KUBECTL[@]}" rollout status \ + "deployment/$API_DEPLOYMENT" \ + --timeout=300s; then + "${KUBECTL[@]}" rollout undo "deployment/$API_DEPLOYMENT" || true + fail "API 发布失败,已尝试回滚 Deployment;数据库迁移不会自动回滚" +fi + +log "更新 Admin UI Deployment:$ADMIN_DEPLOYMENT/$ADMIN_CONTAINER" +"${KUBECTL[@]}" set image \ + "deployment/$ADMIN_DEPLOYMENT" \ + "$ADMIN_CONTAINER=$ADMIN_IMAGE:$IMAGE_TAG" + +if ! "${KUBECTL[@]}" rollout status \ + "deployment/$ADMIN_DEPLOYMENT" \ + --timeout=300s; then + "${KUBECTL[@]}" rollout undo "deployment/$ADMIN_DEPLOYMENT" || true + "${KUBECTL[@]}" rollout undo "deployment/$API_DEPLOYMENT" || true + fail "Admin UI 发布失败,已尝试回滚两个 Deployment" +fi + +log "ACK 发布完成,镜像标签:$IMAGE_TAG" From 13e386c8dd25cf262047cfe618dc4c4ccba6d959 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 11:27:56 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/jenkins-freestyle.sh | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/scripts/jenkins-freestyle.sh b/scripts/jenkins-freestyle.sh index 80b330b..f595a1a 100755 --- a/scripts/jenkins-freestyle.sh +++ b/scripts/jenkins-freestyle.sh @@ -40,8 +40,6 @@ require_command() { [[ -n "$ACR_REGISTRY" ]] || fail "ACR_REGISTRY 不能为空" [[ "$ACR_REGISTRY" != *://* ]] || fail "ACR_REGISTRY 只填写域名,不要包含协议" [[ -n "$ACR_NAMESPACE" ]] || fail "ACR_NAMESPACE 不能为空" -[[ -n "${ACR_USERNAME:-}" ]] || fail "未绑定 Jenkins ACR 用户名变量 ACR_USERNAME" -[[ -n "${ACR_PASSWORD:-}" ]] || fail "未绑定 Jenkins ACR 密码变量 ACR_PASSWORD" case "$DEPLOY_TO_ACK" in true|false) ;; @@ -64,16 +62,11 @@ API_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/${API_REPOSITORY}" ADMIN_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/${ADMIN_REPOSITORY}" test_container="" -docker_config="" cleanup() { if [[ -n "$test_container" ]]; then docker rm --force "$test_container" >/dev/null 2>&1 || true fi - if [[ -n "$docker_config" && -d "$docker_config" ]]; then - docker logout "$ACR_REGISTRY" >/dev/null 2>&1 || true - rm -rf -- "$docker_config" - fi } trap cleanup EXIT @@ -104,13 +97,7 @@ docker run --rm \ "$ADMIN_IMAGE:$IMAGE_TAG" \ -t -log "登录 ACR 并推送镜像" -docker_config="$(mktemp -d)" -export DOCKER_CONFIG="$docker_config" -printf '%s' "$ACR_PASSWORD" | docker login \ - --username "$ACR_USERNAME" \ - --password-stdin \ - "$ACR_REGISTRY" +log "推送镜像" docker push "$API_IMAGE:$IMAGE_TAG" docker push "$ADMIN_IMAGE:$IMAGE_TAG" From 45c879328bbdbf04a7e8014b9fd6e459b7908295 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 11:32:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E9=83=A8=E7=BD=B2bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WonderQ-Admin/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/WonderQ-Admin/Dockerfile b/WonderQ-Admin/Dockerfile index 41ff1e9..3565911 100644 --- a/WonderQ-Admin/Dockerfile +++ b/WonderQ-Admin/Dockerfile @@ -15,7 +15,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY alembic.ini . COPY alembic ./alembic COPY app ./app -COPY data ./data EXPOSE 4000 From 202444353e62f64c30fb2b75e6ea145456f0b9de Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 11:46:52 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BBbug?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0009_remove_product_domain.py | 15 +++++ .../test_remove_product_domain_migration.py | 67 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 WonderQ-Admin/tests/test_remove_product_domain_migration.py diff --git a/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py b/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py index d470d9c..fad4cd9 100644 --- a/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py +++ b/WonderQ-Admin/alembic/versions/0009_remove_product_domain.py @@ -40,6 +40,20 @@ def _drop_column_with_foreign_keys(table_name: str, column_name: str) -> None: op.drop_column(table_name, column_name) +def _drop_referencing_foreign_keys(table_name: str) -> None: + bind = op.get_bind() + inspector = inspect(bind) + for current_table in inspector.get_table_names(): + if current_table == table_name: + continue + for foreign_key in inspector.get_foreign_keys(current_table): + if foreign_key.get("referred_table") != table_name: + continue + constraint_name = foreign_key.get("name") + if constraint_name: + op.drop_constraint(constraint_name, current_table, type_="foreignkey") + + def upgrade() -> None: bind = op.get_bind() existing_tables = set(inspect(bind).get_table_names()) @@ -49,6 +63,7 @@ def upgrade() -> None: for table_name in (*PRODUCT_TABLES[:3], "RouteSection", "DemandRecommendation", *PRODUCT_TABLES[3:]): if table_name in existing_tables: + _drop_referencing_foreign_keys(table_name) op.drop_table(table_name) diff --git a/WonderQ-Admin/tests/test_remove_product_domain_migration.py b/WonderQ-Admin/tests/test_remove_product_domain_migration.py new file mode 100644 index 0000000..9213ab1 --- /dev/null +++ b/WonderQ-Admin/tests/test_remove_product_domain_migration.py @@ -0,0 +1,67 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0009_remove_product_domain.py" +SPEC = spec_from_file_location("remove_product_domain_migration", MIGRATION_PATH) +assert SPEC and SPEC.loader +MIGRATION = module_from_spec(SPEC) +SPEC.loader.exec_module(MIGRATION) + + +class LegacySchemaInspector: + def get_table_names(self) -> list[str]: + return ["Product", "MiniProgramFavorite", "MiniProgramHistory"] + + def get_foreign_keys(self, table_name: str) -> list[dict[str, object]]: + foreign_keys = { + "MiniProgramFavorite": [ + { + "name": "MiniProgramFavorite_productId_fkey", + "constrained_columns": ["productId"], + "referred_table": "Product", + "referred_columns": ["id"], + } + ], + "MiniProgramHistory": [ + { + "name": "MiniProgramHistory_productId_fkey", + "constrained_columns": ["productId"], + "referred_table": "Product", + "referred_columns": ["id"], + } + ], + } + return foreign_keys.get(table_name, []) + + +def test_upgrade_drops_legacy_product_foreign_keys_before_product_table(monkeypatch): + events: list[tuple[str, ...]] = [] + inspector = LegacySchemaInspector() + + monkeypatch.setattr(MIGRATION, "inspect", lambda _bind: inspector) + monkeypatch.setattr(MIGRATION.op, "get_bind", lambda: object()) + monkeypatch.setattr( + MIGRATION.op, + "drop_constraint", + lambda name, table_name, type_: events.append(("drop_constraint", name, table_name, type_)), + ) + monkeypatch.setattr(MIGRATION.op, "drop_table", lambda table_name: events.append(("drop_table", table_name))) + + MIGRATION.upgrade() + + assert events == [ + ( + "drop_constraint", + "MiniProgramFavorite_productId_fkey", + "MiniProgramFavorite", + "foreignkey", + ), + ( + "drop_constraint", + "MiniProgramHistory_productId_fkey", + "MiniProgramHistory", + "foreignkey", + ), + ("drop_table", "Product"), + ] From a7255d8bb83916078225509fb7ce1387cecf28ef Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 12:06:42 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BBbug?= =?UTF-8?q?=E4=BF=AE=E5=A4=8Dv2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alembic/versions/0017_home_content.py | 36 ++- .../versions/0019_home_wild_archive_images.py | 28 ++- .../0020_home_team_building_details.py | 41 ++-- .../alembic/versions/0021_detail_records.py | 14 +- .../versions/0023_detail_concierge_advisor.py | 9 +- .../alembic/versions/0024_vehicle_demand.py | 126 ++++++++-- .../test_bootstrap_migration_compatibility.py | 229 ++++++++++++++++++ 7 files changed, 426 insertions(+), 57 deletions(-) create mode 100644 WonderQ-Admin/tests/test_bootstrap_migration_compatibility.py diff --git a/WonderQ-Admin/alembic/versions/0017_home_content.py b/WonderQ-Admin/alembic/versions/0017_home_content.py index 7ca0b22..8869895 100644 --- a/WonderQ-Admin/alembic/versions/0017_home_content.py +++ b/WonderQ-Admin/alembic/versions/0017_home_content.py @@ -50,6 +50,8 @@ SEED_TEAM_BUILDINGS = ( "description": "洞穴、瀑降与协作,适合 10-30 人。", "image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient", "demandKeyword": "户外团建", + "detailSubtitle": "洞穴、瀑降与协作,适合 10-30 人。", + "detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"], "isActive": True, "sortOrder": 0, }, @@ -60,6 +62,8 @@ SEED_TEAM_BUILDINGS = ( "description": "溯溪与分组协作,兼顾参与感与安全。", "image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", "demandKeyword": "峡谷团建", + "detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。", + "detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"], "isActive": True, "sortOrder": 1, }, @@ -70,6 +74,8 @@ SEED_TEAM_BUILDINGS = ( "description": "夜游、长桌宴与文化体验,适合团建收尾。", "image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg", "demandKeyword": "贵州团建", + "detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。", + "detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"], "isActive": True, "sortOrder": 2, }, @@ -80,6 +86,7 @@ SEED_WILD_ARCHIVES = ( "id": "hundred-meter-descent", "title": "百米自降", "image": "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg", + "images": ["https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg"], "demandKeyword": "悬崖瀑降", "isActive": True, "sortOrder": 0, @@ -88,6 +95,7 @@ SEED_WILD_ARCHIVES = ( "id": "shilong-cave", "title": "石龙洞", "image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg", + "images": ["https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg"], "demandKeyword": "地心探险", "isActive": True, "sortOrder": 1, @@ -96,6 +104,7 @@ SEED_WILD_ARCHIVES = ( "id": "cliff-current", "title": "绝壁迎流", "image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg", + "images": ["https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg"], "demandKeyword": "峡谷探险", "isActive": True, "sortOrder": 2, @@ -104,6 +113,7 @@ SEED_WILD_ARCHIVES = ( "id": "canyon-streaming", "title": "峡谷溯溪", "image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", + "images": ["https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient"], "demandKeyword": "峡谷溯溪", "isActive": True, "sortOrder": 3, @@ -120,12 +130,34 @@ def _create_table(table_name: str, columns: list[sa.Column]) -> None: op.create_index(f"ix_{table_name}_sortOrder", table_name, ["sortOrder"]) +def _prepare_seed_rows( + rows: tuple[dict, ...], + column_names: set[str], + timestamp: datetime, +) -> list[dict]: + return [ + { + key: value + for key, value in {**row, "createdAt": timestamp, "updatedAt": timestamp}.items() + if key in column_names + } + for row in rows + ] + + def _seed_table(table_name: str, columns: list[sa.Column], rows: tuple[dict, ...]) -> None: - table = sa.table(table_name, *[sa.column(column.name, column.type) for column in columns]) + if context.is_offline_mode(): + seed_columns = [sa.column(column.name, column.type) for column in columns] + else: + seed_columns = [ + sa.column(column["name"], column["type"]) + for column in inspect(op.get_bind()).get_columns(table_name) + ] + table = sa.table(table_name, *seed_columns) timestamp = datetime(2026, 1, 1) op.bulk_insert( table, - [{**row, "createdAt": timestamp, "updatedAt": timestamp} for row in rows], + _prepare_seed_rows(rows, {column.name for column in seed_columns}, timestamp), ) diff --git a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py index ff3ae28..584b7a9 100644 --- a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py +++ b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py @@ -6,6 +6,7 @@ Revises: 0018_home_wanfa_recommendations from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql @@ -16,18 +17,25 @@ depends_on = None def upgrade() -> None: - op.add_column( - "HomeWildArchive", - sa.Column( - "images", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - server_default=sa.text("'[]'::jsonb"), - ), - ) + bind = op.get_bind() + inspector = inspect(bind) + if "HomeWildArchive" not in set(inspector.get_table_names()): + return + column_names = {column["name"] for column in inspector.get_columns("HomeWildArchive")} + if "images" not in column_names: + op.add_column( + "HomeWildArchive", + sa.Column( + "images", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) op.execute( sa.text( - 'UPDATE "HomeWildArchive" SET "images" = jsonb_build_array("image") WHERE jsonb_array_length("images") = 0' + 'UPDATE "HomeWildArchive" SET "images" = jsonb_build_array("image") ' + 'WHERE "images" IS NULL OR jsonb_array_length("images") = 0' ) ) op.alter_column("HomeWildArchive", "images", server_default=None) diff --git a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py index f3ac483..bfcb456 100644 --- a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py +++ b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py @@ -6,6 +6,7 @@ Revises: 0019_home_wild_archive_images from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql @@ -16,30 +17,38 @@ depends_on = None def upgrade() -> None: - op.add_column( - "HomeTeamBuilding", - sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""), - ) - op.add_column( - "HomeTeamBuilding", - sa.Column( - "detailParagraphs", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - server_default=sa.text("'[]'::jsonb"), - ), - ) + bind = op.get_bind() + inspector = inspect(bind) + if "HomeTeamBuilding" not in set(inspector.get_table_names()): + return + column_names = {column["name"] for column in inspector.get_columns("HomeTeamBuilding")} + if "detailSubtitle" not in column_names: + op.add_column( + "HomeTeamBuilding", + sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""), + ) + if "detailParagraphs" not in column_names: + op.add_column( + "HomeTeamBuilding", + sa.Column( + "detailParagraphs", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) op.execute( sa.text( 'UPDATE "HomeTeamBuilding" ' 'SET "detailSubtitle" = CASE ' - 'WHEN "detailSubtitle" = \'\' THEN "description" ' + 'WHEN "detailSubtitle" IS NULL OR "detailSubtitle" = \'\' THEN "description" ' 'ELSE "detailSubtitle" END, ' '"detailParagraphs" = CASE ' - 'WHEN jsonb_array_length("detailParagraphs") = 0 ' + 'WHEN "detailParagraphs" IS NULL OR jsonb_array_length("detailParagraphs") = 0 ' 'THEN jsonb_build_array("description") ' 'ELSE "detailParagraphs" END ' - 'WHERE "detailSubtitle" = \'\' OR jsonb_array_length("detailParagraphs") = 0' + 'WHERE "detailSubtitle" IS NULL OR "detailSubtitle" = \'\' ' + 'OR "detailParagraphs" IS NULL OR jsonb_array_length("detailParagraphs") = 0' ) ) op.alter_column("HomeTeamBuilding", "detailSubtitle", server_default=None) diff --git a/WonderQ-Admin/alembic/versions/0021_detail_records.py b/WonderQ-Admin/alembic/versions/0021_detail_records.py index e9e5c22..c5c0336 100644 --- a/WonderQ-Admin/alembic/versions/0021_detail_records.py +++ b/WonderQ-Admin/alembic/versions/0021_detail_records.py @@ -8,6 +8,7 @@ from datetime import datetime from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql @@ -17,7 +18,7 @@ branch_labels = None depends_on = None -def upgrade() -> None: +def _create_detail_table() -> None: op.create_table( "DetailRecord", sa.Column("id", sa.String(), nullable=False), @@ -42,6 +43,12 @@ def upgrade() -> None: op.create_index("ix_DetailRecord_isActive", "DetailRecord", ["isActive"]) op.create_index("ix_DetailRecord_sortOrder", "DetailRecord", ["sortOrder"]) + +def upgrade() -> None: + bind = op.get_bind() + if "DetailRecord" not in set(inspect(bind).get_table_names()): + _create_detail_table() + detail_table = sa.table( "DetailRecord", sa.column("id", sa.String()), @@ -61,7 +68,10 @@ def upgrade() -> None: sa.column("createdAt", sa.DateTime()), sa.column("updatedAt", sa.DateTime()), ) - routes = op.get_bind().execute( + if bind.execute(sa.select(detail_table.c.id).limit(1)).first() is not None: + return + + routes = bind.execute( sa.text('SELECT "id", "title", "subtitle", "image", "sortOrder" FROM "WanfaRoute" ORDER BY "sortOrder" ASC') ).mappings().all() timestamp = datetime(2026, 1, 1) diff --git a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py index 80390c9..856b2b0 100644 --- a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py +++ b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py @@ -6,6 +6,7 @@ Revises: 0022_opaque_ids from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect revision = "0023_detail_concierge_advisor" @@ -15,7 +16,13 @@ depends_on = None def upgrade() -> None: - op.add_column("DetailRecord", sa.Column("conciergeAdvisorId", sa.String(), nullable=True)) + bind = op.get_bind() + inspector = inspect(bind) + if "DetailRecord" not in set(inspector.get_table_names()): + return + column_names = {column["name"] for column in inspector.get_columns("DetailRecord")} + if "conciergeAdvisorId" not in column_names: + op.add_column("DetailRecord", sa.Column("conciergeAdvisorId", sa.String(), nullable=True)) def downgrade() -> None: diff --git a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py index f15878b..a342ed6 100644 --- a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py +++ b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py @@ -2,6 +2,7 @@ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql @@ -11,34 +12,107 @@ branch_labels = None depends_on = None -def upgrade() -> None: - op.add_column("Lead", sa.Column("leadType", sa.String(), nullable=False, server_default="general")) - op.add_column("Lead", sa.Column("contactName", sa.String(), nullable=True)) - op.add_column("Lead", sa.Column("customerId", sa.String(), nullable=True)) - op.add_column("Lead", sa.Column("vehicleDemand", postgresql.JSONB(astext_type=sa.Text()), nullable=True)) - op.create_index("ix_Lead_leadType", "Lead", ["leadType"]) - op.create_index("ix_Lead_customerId", "Lead", ["customerId"]) - op.create_foreign_key("fk_Lead_customerId_Customer", "Lead", "Customer", ["customerId"], ["id"], ondelete="SET NULL") +def _ensure_lead_schema(bind) -> None: + inspector = inspect(bind) + table_names = set(inspector.get_table_names()) + if "Lead" not in table_names: + return + + column_names = {column["name"] for column in inspector.get_columns("Lead")} + missing_columns = { + "leadType": sa.Column("leadType", sa.String(), nullable=False, server_default="general"), + "contactName": sa.Column("contactName", sa.String(), nullable=True), + "customerId": sa.Column("customerId", sa.String(), nullable=True), + "vehicleDemand": sa.Column( + "vehicleDemand", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + } + for column_name, column in missing_columns.items(): + if column_name not in column_names: + op.add_column("Lead", column) + column_names.add(column_name) + + index_names = {index["name"] for index in inspector.get_indexes("Lead") if index.get("name")} + if "ix_Lead_leadType" not in index_names: + op.create_index("ix_Lead_leadType", "Lead", ["leadType"]) + if "ix_Lead_customerId" not in index_names: + op.create_index("ix_Lead_customerId", "Lead", ["customerId"]) + + customer_foreign_key_exists = any( + foreign_key.get("constrained_columns") == ["customerId"] + and foreign_key.get("referred_table") == "Customer" + and foreign_key.get("referred_columns") == ["id"] + for foreign_key in inspector.get_foreign_keys("Lead") + ) + if not customer_foreign_key_exists and "Customer" in table_names: + op.create_foreign_key( + "fk_Lead_customerId_Customer", + "Lead", + "Customer", + ["customerId"], + ["id"], + ondelete="SET NULL", + ) op.alter_column("Lead", "leadType", server_default=None) - op.create_table( - "VehicleServiceConfig", - sa.Column("id", sa.String(), nullable=False), - sa.Column("introTitle", sa.String(), nullable=False), - sa.Column("intro", sa.Text(), nullable=False), - sa.Column("serviceSections", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), - sa.Column("advantages", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), - sa.Column("processSteps", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), - sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")), - sa.Column("createdAt", sa.DateTime(), nullable=False), - sa.Column("updatedAt", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_VehicleServiceConfig_isActive", "VehicleServiceConfig", ["isActive"]) - op.alter_column("VehicleServiceConfig", "serviceSections", server_default=None) - op.alter_column("VehicleServiceConfig", "advantages", server_default=None) - op.alter_column("VehicleServiceConfig", "processSteps", server_default=None) - op.alter_column("VehicleServiceConfig", "isActive", server_default=None) + +def _ensure_vehicle_service_config(bind) -> None: + inspector = inspect(bind) + table_names = set(inspector.get_table_names()) + if "VehicleServiceConfig" not in table_names: + op.create_table( + "VehicleServiceConfig", + sa.Column("id", sa.String(), nullable=False), + sa.Column("introTitle", sa.String(), nullable=False), + sa.Column("intro", sa.Text(), nullable=False), + sa.Column( + "serviceSections", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "advantages", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "processSteps", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("updatedAt", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_VehicleServiceConfig_isActive", "VehicleServiceConfig", ["isActive"]) + column_names = {"advantages", "isActive", "processSteps", "serviceSections"} + else: + index_names = { + index["name"] + for index in inspector.get_indexes("VehicleServiceConfig") + if index.get("name") + } + if "ix_VehicleServiceConfig_isActive" not in index_names: + op.create_index("ix_VehicleServiceConfig_isActive", "VehicleServiceConfig", ["isActive"]) + column_names = { + column["name"] for column in inspector.get_columns("VehicleServiceConfig") + } + + for column_name in ("serviceSections", "advantages", "processSteps", "isActive"): + if column_name in column_names: + op.alter_column("VehicleServiceConfig", column_name, server_default=None) + + +def upgrade() -> None: + bind = op.get_bind() + _ensure_lead_schema(bind) + _ensure_vehicle_service_config(bind) def downgrade() -> None: diff --git a/WonderQ-Admin/tests/test_bootstrap_migration_compatibility.py b/WonderQ-Admin/tests/test_bootstrap_migration_compatibility.py new file mode 100644 index 0000000..3c41e11 --- /dev/null +++ b/WonderQ-Admin/tests/test_bootstrap_migration_compatibility.py @@ -0,0 +1,229 @@ +from datetime import datetime +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest +import sqlalchemy as sa + + +VERSIONS_DIR = Path(__file__).parents[1] / "alembic" / "versions" + + +def load_migration(filename: str): + path = VERSIONS_DIR / filename + spec = spec_from_file_location(f"bootstrap_compatibility_{path.stem}", path) + assert spec and spec.loader + migration = module_from_spec(spec) + spec.loader.exec_module(migration) + return migration + + +class CurrentSchemaInspector: + tables = { + "Customer", + "DetailRecord", + "HomeTeamBuilding", + "HomeWildArchive", + "Lead", + "VehicleServiceConfig", + } + columns = { + "DetailRecord": {"conciergeAdvisorId"}, + "HomeTeamBuilding": {"detailParagraphs", "detailSubtitle"}, + "HomeWildArchive": {"images"}, + "Lead": {"contactName", "customerId", "leadType", "vehicleDemand"}, + "VehicleServiceConfig": { + "advantages", + "intro", + "introTitle", + "isActive", + "processSteps", + "serviceSections", + }, + } + + def get_table_names(self) -> list[str]: + return sorted(self.tables) + + def get_columns(self, table_name: str) -> list[dict[str, object]]: + return [ + {"name": name, "type": sa.String()} + for name in sorted(self.columns.get(table_name, set())) + ] + + def get_indexes(self, table_name: str) -> list[dict[str, object]]: + indexes = { + "Lead": {"ix_Lead_customerId", "ix_Lead_leadType"}, + "VehicleServiceConfig": {"ix_VehicleServiceConfig_isActive"}, + } + return [{"name": name} for name in sorted(indexes.get(table_name, set()))] + + def get_foreign_keys(self, table_name: str) -> list[dict[str, object]]: + if table_name != "Lead": + return [] + return [ + { + "name": "Lead_customerId_fkey", + "constrained_columns": ["customerId"], + "referred_table": "Customer", + "referred_columns": ["id"], + } + ] + + +class ExistingRowsResult: + def first(self): + return ("existing",) + + def mappings(self): + return self + + def all(self) -> list[object]: + return [] + + +class ExistingRowsBind: + def execute(self, _statement) -> ExistingRowsResult: + return ExistingRowsResult() + + +def record_operation(monkeypatch, migration, operation: str, events: list[tuple[str, ...]]) -> None: + monkeypatch.setattr( + migration.op, + operation, + lambda *args, **_kwargs: events.append((operation, *(str(arg) for arg in args))), + ) + + +def test_home_content_seed_rows_cover_current_non_null_detail_columns(): + migration = load_migration("0017_home_content.py") + timestamp = datetime(2026, 1, 1) + + team_rows = migration._prepare_seed_rows( + migration.SEED_TEAM_BUILDINGS, + {"id", "description", "detailSubtitle", "detailParagraphs", "createdAt", "updatedAt"}, + timestamp, + ) + archive_rows = migration._prepare_seed_rows( + migration.SEED_WILD_ARCHIVES, + {"id", "image", "images", "createdAt", "updatedAt"}, + timestamp, + ) + + assert team_rows[0]["detailSubtitle"] == team_rows[0]["description"] + assert team_rows[0]["detailParagraphs"] == [team_rows[0]["description"]] + assert archive_rows[0]["images"] == [archive_rows[0]["image"]] + + +def test_home_content_online_seed_reflects_current_table_columns(monkeypatch): + migration = load_migration("0017_home_content.py") + inspector = CurrentSchemaInspector() + inspector.tables = {"HomeTeamBuilding"} + inspector.columns = { + "HomeTeamBuilding": { + "createdAt", + "demandKeyword", + "description", + "detailParagraphs", + "detailSubtitle", + "id", + "image", + "isActive", + "sortOrder", + "tag", + "title", + "updatedAt", + } + } + inserted: dict[str, object] = {} + + monkeypatch.setattr(migration.context, "is_offline_mode", lambda: False) + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: object()) + monkeypatch.setattr( + migration.op, + "bulk_insert", + lambda table, rows: inserted.update(table=table, rows=rows), + ) + + historical_columns = [ + sa.Column("id", sa.String()), + sa.Column("description", sa.Text()), + ] + migration._seed_table( + "HomeTeamBuilding", + historical_columns, + migration.SEED_TEAM_BUILDINGS, + ) + + inserted_table = inserted["table"] + inserted_rows = inserted["rows"] + assert set(inserted_table.c.keys()) == inspector.columns["HomeTeamBuilding"] + assert inserted_rows[0]["detailSubtitle"] == inserted_rows[0]["description"] + assert inserted_rows[0]["detailParagraphs"] == [inserted_rows[0]["description"]] + + +@pytest.mark.parametrize( + ("filename", "table_name", "column_names"), + [ + ("0019_home_wild_archive_images.py", "HomeWildArchive", {"images"}), + ( + "0020_home_team_building_details.py", + "HomeTeamBuilding", + {"detailParagraphs", "detailSubtitle"}, + ), + ("0023_detail_concierge_advisor.py", "DetailRecord", {"conciergeAdvisorId"}), + ], +) +def test_column_migrations_do_not_add_columns_already_in_current_schema( + monkeypatch, + filename: str, + table_name: str, + column_names: set[str], +): + migration = load_migration(filename) + inspector = CurrentSchemaInspector() + inspector.tables = {table_name} + inspector.columns = {table_name: column_names} + added_columns: list[tuple[str, ...]] = [] + + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: object()) + record_operation(monkeypatch, migration, "add_column", added_columns) + monkeypatch.setattr(migration.op, "execute", lambda *_args, **_kwargs: None) + monkeypatch.setattr(migration.op, "alter_column", lambda *_args, **_kwargs: None) + + migration.upgrade() + + assert added_columns == [] + + +def test_detail_record_migration_reuses_populated_current_table(monkeypatch): + migration = load_migration("0021_detail_records.py") + inspector = CurrentSchemaInspector() + events: list[tuple[str, ...]] = [] + + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: ExistingRowsBind()) + for operation in ("bulk_insert", "create_index", "create_table"): + record_operation(monkeypatch, migration, operation, events) + + migration.upgrade() + + assert events == [] + + +def test_vehicle_demand_migration_reuses_current_schema_objects(monkeypatch): + migration = load_migration("0024_vehicle_demand.py") + inspector = CurrentSchemaInspector() + events: list[tuple[str, ...]] = [] + + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: object()) + for operation in ("add_column", "create_foreign_key", "create_index", "create_table"): + record_operation(monkeypatch, migration, operation, events) + monkeypatch.setattr(migration.op, "alter_column", lambda *_args, **_kwargs: None) + + migration.upgrade() + + assert events == []