This commit is contained in:
duanshuwen committed 2026-08-25 19:18:58 +08:00
commit 8f000811a8
15 files changed
+794 -1

No files matched your search

Vendored
+236
View File
@@ -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 回滚自动撤销。'
}
}
}
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.git/
.idea/
.env
.env.*
coverage/
.vite/
*.log
+29
View File
@@ -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
+33
View File
@@ -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;
}
}
-1
View File
@@ -15,7 +15,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini . COPY alembic.ini .
COPY alembic ./alembic COPY alembic ./alembic
COPY app ./app COPY app ./app
COPY data ./data
EXPOSE 4000 EXPOSE 4000
@@ -40,6 +40,20 @@ def _drop_column_with_foreign_keys(table_name: str, column_name: str) -> None:
op.drop_column(table_name, column_name) 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: def upgrade() -> None:
bind = op.get_bind() bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names()) 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:]): for table_name in (*PRODUCT_TABLES[:3], "RouteSection", "DemandRecommendation", *PRODUCT_TABLES[3:]):
if table_name in existing_tables: if table_name in existing_tables:
_drop_referencing_foreign_keys(table_name)
op.drop_table(table_name) op.drop_table(table_name)
@@ -53,6 +53,8 @@ SEED_TEAM_BUILDINGS = (
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"], "detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
"image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient", "image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "户外团建", "demandKeyword": "户外团建",
"detailSubtitle": "洞穴、瀑降与协作,适合 10-30 人。",
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
"isActive": True, "isActive": True,
"sortOrder": 0, "sortOrder": 0,
}, },
@@ -65,6 +67,8 @@ SEED_TEAM_BUILDINGS = (
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"], "detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", "image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "峡谷团建", "demandKeyword": "峡谷团建",
"detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。",
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
"isActive": True, "isActive": True,
"sortOrder": 1, "sortOrder": 1,
}, },
@@ -77,6 +81,8 @@ SEED_TEAM_BUILDINGS = (
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"], "detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
"image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg", "image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
"demandKeyword": "贵州团建", "demandKeyword": "贵州团建",
"detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。",
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
"isActive": True, "isActive": True,
"sortOrder": 2, "sortOrder": 2,
}, },
@@ -106,6 +112,7 @@ SEED_WILD_ARCHIVES = (
"title": "石龙洞", "title": "石龙洞",
"image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg", "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"],
"images": ["https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg"],
"demandKeyword": "地心探险", "demandKeyword": "地心探险",
"isActive": True, "isActive": True,
"sortOrder": 1, "sortOrder": 1,
@@ -115,6 +122,7 @@ SEED_WILD_ARCHIVES = (
"title": "绝壁迎流", "title": "绝壁迎流",
"image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg", "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"],
"images": ["https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg"],
"demandKeyword": "峡谷探险", "demandKeyword": "峡谷探险",
"isActive": True, "isActive": True,
"sortOrder": 2, "sortOrder": 2,
@@ -7,6 +7,7 @@ Revises: 0018_home_wanfa_recommendations
from alembic import context, op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -7,6 +7,7 @@ Revises: 0019_home_wild_archive_images
from alembic import context, op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -9,6 +9,7 @@ from datetime import datetime
from alembic import context, op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -93,6 +94,12 @@ def upgrade() -> None:
for index_name in missing_detail_record_indexes(existing_index_names): for index_name in missing_detail_record_indexes(existing_index_names):
op.create_index(index_name, "DetailRecord", DETAIL_RECORD_INDEXES[index_name]) 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( detail_table = sa.table(
"DetailRecord", "DetailRecord",
sa.column("id", sa.String()), sa.column("id", sa.String()),
@@ -7,6 +7,7 @@ Revises: 0022_opaque_ids
from alembic import context, op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import inspect
revision = "0023_detail_concierge_advisor" revision = "0023_detail_concierge_advisor"
@@ -3,6 +3,7 @@
from alembic import context, op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -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 == []
@@ -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"),
]
+156
View File
@@ -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"