Merge branch 'main' of https://git.nianxx.cn/duanshuwen/WonderQ-Project
This commit is contained in:
236
Jenkinsfile
vendored
Normal file
236
Jenkinsfile
vendored
Normal 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
WonderQ-Admin-UI/.dockerignore
Normal file
10
WonderQ-Admin-UI/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.idea/
|
||||
.env
|
||||
.env.*
|
||||
coverage/
|
||||
.vite/
|
||||
*.log
|
||||
|
||||
29
WonderQ-Admin-UI/Dockerfile
Normal file
29
WonderQ-Admin-UI/Dockerfile
Normal 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
WonderQ-Admin-UI/nginx.conf
Normal file
33
WonderQ-Admin-UI/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
229
WonderQ-Admin/tests/test_bootstrap_migration_compatibility.py
Normal file
229
WonderQ-Admin/tests/test_bootstrap_migration_compatibility.py
Normal file
@@ -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 == []
|
||||
67
WonderQ-Admin/tests/test_remove_product_domain_migration.py
Normal file
67
WonderQ-Admin/tests/test_remove_product_domain_migration.py
Normal file
@@ -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
scripts/jenkins-freestyle.sh
Executable file
156
scripts/jenkins-freestyle.sh
Executable 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"
|
||||
Reference in New Issue
Block a user