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; + } +} 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 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/alembic/versions/0017_home_content.py b/WonderQ-Admin/alembic/versions/0017_home_content.py index 6775559..22b2993 100644 --- a/WonderQ-Admin/alembic/versions/0017_home_content.py +++ b/WonderQ-Admin/alembic/versions/0017_home_content.py @@ -53,6 +53,8 @@ SEED_TEAM_BUILDINGS = ( "detailParagraphs": ["洞穴、瀑降与协作,适合 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, }, @@ -65,6 +67,8 @@ SEED_TEAM_BUILDINGS = ( "detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"], "image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", "demandKeyword": "峡谷团建", + "detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。", + "detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"], "isActive": True, "sortOrder": 1, }, @@ -77,6 +81,8 @@ SEED_TEAM_BUILDINGS = ( "detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"], "image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg", "demandKeyword": "贵州团建", + "detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。", + "detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"], "isActive": True, "sortOrder": 2, }, @@ -106,6 +112,7 @@ SEED_WILD_ARCHIVES = ( "title": "石龙洞", "image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg", "images": ["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, @@ -115,6 +122,7 @@ SEED_WILD_ARCHIVES = ( "title": "绝壁迎流", "image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg", "images": ["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, 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 be437e9..5f8990c 100644 --- a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py +++ b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py @@ -7,6 +7,7 @@ Revises: 0018_home_wanfa_recommendations from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql 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 35ac7cb..f7fdad4 100644 --- a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py +++ b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py @@ -7,6 +7,7 @@ Revises: 0019_home_wild_archive_images from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql diff --git a/WonderQ-Admin/alembic/versions/0021_detail_records.py b/WonderQ-Admin/alembic/versions/0021_detail_records.py index 51abf0f..b53a0b8 100644 --- a/WonderQ-Admin/alembic/versions/0021_detail_records.py +++ b/WonderQ-Admin/alembic/versions/0021_detail_records.py @@ -9,6 +9,7 @@ from datetime import datetime from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql @@ -93,6 +94,12 @@ def upgrade() -> None: for index_name in missing_detail_record_indexes(existing_index_names): op.create_index(index_name, "DetailRecord", DETAIL_RECORD_INDEXES[index_name]) + +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()), diff --git a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py index 700547e..dda33fe 100644 --- a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py +++ b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py @@ -7,6 +7,7 @@ Revises: 0022_opaque_ids from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from sqlalchemy import inspect revision = "0023_detail_concierge_advisor" diff --git a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py index a40d199..517eee5 100644 --- a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py +++ b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py @@ -3,6 +3,7 @@ from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from sqlalchemy import inspect from sqlalchemy.dialects import postgresql 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 == [] 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"), + ] diff --git a/scripts/jenkins-freestyle.sh b/scripts/jenkins-freestyle.sh new file mode 100755 index 0000000..f595a1a --- /dev/null +++ b/scripts/jenkins-freestyle.sh @@ -0,0 +1,156 @@ +#!/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 不能为空" + +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="" + +cleanup() { + if [[ -n "$test_container" ]]; then + docker rm --force "$test_container" >/dev/null 2>&1 || true + 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 "推送镜像" +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"