From 2f90c82d54f2d63e65882bae67026ef38d2be0ca Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 9 Aug 2026 00:10:24 +0800 Subject: [PATCH 1/3] ci: add PR quality and coverage gates --- .coveragerc | 16 +++++++++++++++ .gitea/workflows/ci.yml | 20 +++++++++++++++---- backend/accounts/tests.py | 1 - backend/accounts/urls.py | 1 - backend/common/admin.py | 1 - backend/common/logging.py | 1 - .../common/management/commands/check_mysql.py | 1 - backend/common/models.py | 1 - backend/common/tests.py | 1 - backend/config/asgi.py | 2 -- backend/config/settings.py | 1 - backend/config/urls.py | 1 - backend/content/services.py | 1 - backend/content/tests.py | 1 - backend/content/urls.py | 1 - backend/content/views.py | 1 - .../management/commands/seed_contests.py | 1 - backend/contest/routing.py | 1 - backend/contest/services.py | 1 + backend/contest/tests.py | 1 - backend/contest/urls.py | 1 - backend/engagement/admin.py | 1 - backend/engagement/tests.py | 1 - backend/engagement/views.py | 1 - backend/latex_lab/tests.py | 1 - backend/latex_lab/urls.py | 1 - .../commands/seed_initial_content.py | 1 - backend/math_life/tests.py | 1 - backend/math_life/urls.py | 1 - backend/progression/admin.py | 1 - backend/progression/tests.py | 1 - backend/progression/urls.py | 1 - requirements-dev.txt | 1 + ruff.toml | 13 ++++++++++++ 34 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 .coveragerc create mode 100644 ruff.toml diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..b41a20f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,16 @@ +[run] +source = backend +omit = + backend/*/migrations/* + backend/*/test_*.py + backend/*/tests.py + backend/manage.py + backend/config/wsgi.py + +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + if __name__ == .__main__.: +show_missing = true +skip_covered = true diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d4e5b4e..285dd68 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,13 +1,17 @@ name: CI on: - push: - branches: [main] pull_request: + branches: [main] + +concurrency: + group: ci-${{ gitea.event_name }}-${{ gitea.ref }} + cancel-in-progress: true jobs: test: runs-on: ubuntu-latest + timeout-minutes: 20 services: mysql: image: mysql:8.0.35 @@ -45,6 +49,8 @@ jobs: --timeout 120 \ --retries 5 \ -r requirements-dev.txt + - name: Ruff checks + run: .venv-ci/bin/ruff check backend scripts - name: Check migrations working-directory: backend run: ../.venv-ci/bin/python manage.py makemigrations --check --dry-run @@ -54,8 +60,14 @@ jobs: - name: ASGI import check working-directory: backend run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)" - - name: Unit tests - run: .venv-ci/bin/python -m pytest -q + - name: SQLite tests and coverage + run: | + .venv-ci/bin/python -m pytest -q \ + --cov=backend \ + --cov-config=.coveragerc \ + --cov-report=term \ + --cov-report=xml:coverage.xml \ + --cov-fail-under=75 - name: MySQL migrations and tests env: DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath diff --git a/backend/accounts/tests.py b/backend/accounts/tests.py index 7ce503c..4929020 100644 --- a/backend/accounts/tests.py +++ b/backend/accounts/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/accounts/urls.py b/backend/accounts/urls.py index 152a432..91dafa2 100644 --- a/backend/accounts/urls.py +++ b/backend/accounts/urls.py @@ -2,7 +2,6 @@ from django.urls import path from .views import LoginView, LogoutView, MeView, RegisterView, VisitorMigrationView - urlpatterns = [ path("register/", RegisterView.as_view(), name="register"), path("login/", LoginView.as_view(), name="login"), diff --git a/backend/common/admin.py b/backend/common/admin.py index 8c38f3f..b97a94f 100644 --- a/backend/common/admin.py +++ b/backend/common/admin.py @@ -1,3 +1,2 @@ -from django.contrib import admin # Register your models here. diff --git a/backend/common/logging.py b/backend/common/logging.py index fb53a2c..6f5e032 100644 --- a/backend/common/logging.py +++ b/backend/common/logging.py @@ -1,7 +1,6 @@ import contextvars import logging - request_id_context = contextvars.ContextVar("request_id", default="-") diff --git a/backend/common/management/commands/check_mysql.py b/backend/common/management/commands/check_mysql.py index 5db39cb..b197020 100644 --- a/backend/common/management/commands/check_mysql.py +++ b/backend/common/management/commands/check_mysql.py @@ -3,7 +3,6 @@ import re from django.core.management.base import BaseCommand, CommandError from django.db import connection - MINIMUM_VERSION = (8, 0, 35) diff --git a/backend/common/models.py b/backend/common/models.py index 71a8362..35e0d64 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -1,3 +1,2 @@ -from django.db import models # Create your models here. diff --git a/backend/common/tests.py b/backend/common/tests.py index 7ce503c..4929020 100644 --- a/backend/common/tests.py +++ b/backend/common/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/config/asgi.py b/backend/config/asgi.py index b04a7aa..f1784c0 100644 --- a/backend/config/asgi.py +++ b/backend/config/asgi.py @@ -7,13 +7,11 @@ from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application from django.urls import path - django_asgi_application = get_asgi_application() from common.consumers import HealthConsumer from contest.routing import websocket_urlpatterns - application = ProtocolTypeRouter( { "http": django_asgi_application, diff --git a/backend/config/settings.py b/backend/config/settings.py index 8a2caeb..509f7b3 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -4,7 +4,6 @@ from pathlib import Path import dj_database_url from django.core.exceptions import ImproperlyConfigured - BASE_DIR = Path(__file__).resolve().parent.parent PROJECT_ROOT = BASE_DIR.parent diff --git a/backend/config/urls.py b/backend/config/urls.py index 15fd2c0..3a6c2e1 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -3,7 +3,6 @@ from django.urls import include, path from common.views import health, home - admin.site.site_header = "葫芦数学运营后台" admin.site.site_title = "葫芦数学" admin.site.index_title = "内容与运营" diff --git a/backend/content/services.py b/backend/content/services.py index 7c090f0..a9a1028 100644 --- a/backend/content/services.py +++ b/backend/content/services.py @@ -6,7 +6,6 @@ from progression.models import RewardTransaction, UserAbility from .models import ContentInteraction, ContentItem, VideoProgress - ABILITY_TO_DIMENSION = { ContentItem.Ability.VISION: UserAbility.Dimension.VISION, ContentItem.Ability.HUMANITIES: UserAbility.Dimension.HUMANITIES, diff --git a/backend/content/tests.py b/backend/content/tests.py index 7ce503c..4929020 100644 --- a/backend/content/tests.py +++ b/backend/content/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/content/urls.py b/backend/content/urls.py index 8c41995..a46c961 100644 --- a/backend/content/urls.py +++ b/backend/content/urls.py @@ -8,7 +8,6 @@ from .views import ( VideoCompleteView, ) - urlpatterns = [ path("", ContentListView.as_view(), name="content-list"), path("videos/catalog/", VideoCatalogView.as_view(), name="video-catalog"), diff --git a/backend/content/views.py b/backend/content/views.py index af2cf62..9717a88 100644 --- a/backend/content/views.py +++ b/backend/content/views.py @@ -7,7 +7,6 @@ from rest_framework.views import APIView from .models import ContentInteraction, ContentItem, VideoProgress from .services import complete_video - ABILITY_META = { ContentItem.Ability.VISION: {"label": "数学眼光", "icon": "◉", "color": "#5d73e8"}, ContentItem.Ability.HUMANITIES: {"label": "数学人文", "icon": "▤", "color": "#b76d38"}, diff --git a/backend/contest/management/commands/seed_contests.py b/backend/contest/management/commands/seed_contests.py index a9c08a1..4fe746e 100644 --- a/backend/contest/management/commands/seed_contests.py +++ b/backend/contest/management/commands/seed_contests.py @@ -2,7 +2,6 @@ from django.core.management.base import BaseCommand from contest.models import Contest, ContestQuestion, Question, QuestionVersion - QUESTIONS = { Question.Track.BEGINNER: [ ("b-12-plus-19", "12 + 19", "31"), diff --git a/backend/contest/routing.py b/backend/contest/routing.py index 9b8677a..3bcf2d9 100644 --- a/backend/contest/routing.py +++ b/backend/contest/routing.py @@ -2,7 +2,6 @@ from django.urls import path from .consumers import MatchConsumer - websocket_urlpatterns = [ path("ws/v1/contest/matches//", MatchConsumer.as_asgi()), ] diff --git a/backend/contest/services.py b/backend/contest/services.py index 211cecc..02073fc 100644 --- a/backend/contest/services.py +++ b/backend/contest/services.py @@ -6,6 +6,7 @@ from django.utils import timezone from rest_framework.exceptions import ValidationError from accounts.models import User + from .models import ( CheatFlag, Contest, diff --git a/backend/contest/tests.py b/backend/contest/tests.py index 7ce503c..4929020 100644 --- a/backend/contest/tests.py +++ b/backend/contest/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/contest/urls.py b/backend/contest/urls.py index a0fcadb..dcf9ae5 100644 --- a/backend/contest/urls.py +++ b/backend/contest/urls.py @@ -9,7 +9,6 @@ from .views import ( MatchStateView, ) - urlpatterns = [ path("", ContestListView.as_view(), name="contest-list"), path("/start/", AttemptStartView.as_view(), name="attempt-start"), diff --git a/backend/engagement/admin.py b/backend/engagement/admin.py index 8c38f3f..b97a94f 100644 --- a/backend/engagement/admin.py +++ b/backend/engagement/admin.py @@ -1,3 +1,2 @@ -from django.contrib import admin # Register your models here. diff --git a/backend/engagement/tests.py b/backend/engagement/tests.py index 7ce503c..4929020 100644 --- a/backend/engagement/tests.py +++ b/backend/engagement/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/engagement/views.py b/backend/engagement/views.py index 91ea44a..b8e4ee0 100644 --- a/backend/engagement/views.py +++ b/backend/engagement/views.py @@ -1,3 +1,2 @@ -from django.shortcuts import render # Create your views here. diff --git a/backend/latex_lab/tests.py b/backend/latex_lab/tests.py index 7ce503c..4929020 100644 --- a/backend/latex_lab/tests.py +++ b/backend/latex_lab/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/latex_lab/urls.py b/backend/latex_lab/urls.py index 4703920..82d21fb 100644 --- a/backend/latex_lab/urls.py +++ b/backend/latex_lab/urls.py @@ -3,7 +3,6 @@ from rest_framework.routers import DefaultRouter from .views import CourseListView, ExerciseSubmitView, FormulaDocumentViewSet - router = DefaultRouter() router.register("documents", FormulaDocumentViewSet, basename="formula-document") diff --git a/backend/math_life/management/commands/seed_initial_content.py b/backend/math_life/management/commands/seed_initial_content.py index 73daf0f..71956ba 100644 --- a/backend/math_life/management/commands/seed_initial_content.py +++ b/backend/math_life/management/commands/seed_initial_content.py @@ -17,7 +17,6 @@ from math_life.models import ( ) from math_life.services import validate_story_content - DISCIPLINE_ICONS = { "人工智能": "🤖", "计算机": "💻", diff --git a/backend/math_life/tests.py b/backend/math_life/tests.py index 7ce503c..4929020 100644 --- a/backend/math_life/tests.py +++ b/backend/math_life/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/math_life/urls.py b/backend/math_life/urls.py index 98fe39f..dc997b2 100644 --- a/backend/math_life/urls.py +++ b/backend/math_life/urls.py @@ -9,7 +9,6 @@ from .views import ( StoryStartView, ) - urlpatterns = [ path("mathbti/", MathBTIAssessmentView.as_view(), name="mathbti-assessment"), path("mathbti/submit/", MathBTISubmitView.as_view(), name="mathbti-submit"), diff --git a/backend/progression/admin.py b/backend/progression/admin.py index 90a8d2b..a6bce6e 100644 --- a/backend/progression/admin.py +++ b/backend/progression/admin.py @@ -2,7 +2,6 @@ from django.contrib import admin from .models import Card, RewardTransaction, UserAbility, UserCard, UserPet - admin.site.register(UserPet) admin.site.register(UserAbility) admin.site.register(Card) diff --git a/backend/progression/tests.py b/backend/progression/tests.py index 7ce503c..4929020 100644 --- a/backend/progression/tests.py +++ b/backend/progression/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/backend/progression/urls.py b/backend/progression/urls.py index 96055a3..ed0424a 100644 --- a/backend/progression/urls.py +++ b/backend/progression/urls.py @@ -2,7 +2,6 @@ from django.urls import path from .views import ProgressionProfileView - urlpatterns = [ path("me/", ProgressionProfileView.as_view(), name="progression-profile"), ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 823d167..2e33cf1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ pytest==8.3.5 pytest-django==4.11.1 pytest-cov==6.2.1 +ruff==0.11.13 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..99d0675 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,13 @@ +target-version = "py311" +line-length = 100 +src = ["backend"] +exclude = [ + "backend/*/migrations", + "backend/staticfiles", +] + +[lint] +select = ["E4", "E7", "E9", "F", "I"] + +[lint.per-file-ignores] +"backend/config/asgi.py" = ["E402"] From 7c9e0303b930d3c099a019ed5b464ef330754ff9 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 9 Aug 2026 00:10:35 +0800 Subject: [PATCH 2/3] ci: add rollback-aware production smoke checks --- .gitea/workflows/deploy.yml | 46 ++++++++++++++++----------- docs/BAOTA_UBUNTU_FROM_ZERO.md | 22 ++++++++++--- docs/DEPLOYMENT.md | 30 +++++++++++++++--- scripts/deploy_production.sh | 13 ++++++++ scripts/smoke_production.py | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 scripts/smoke_production.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a248b57..b06933f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -12,9 +12,10 @@ concurrency: cancel-in-progress: false jobs: - test: + release-check: if: ${{ github.event.pull_request.merged == true }} runs-on: ubuntu-latest + timeout-minutes: 15 services: mysql: image: mysql:8.0.35 @@ -44,44 +45,41 @@ jobs: python3-dev \ python3-venv - - name: 安装测试依赖 + - name: 安装发布检查依赖 run: | - python3 -m venv .venv-ci - .venv-ci/bin/python -m pip install \ + python3 -m venv .venv-release + .venv-release/bin/python -m pip install \ --index-url https://mirrors.aliyun.com/pypi/simple \ --timeout 120 \ --retries 5 \ - -r requirements-dev.txt + -r requirements.txt - name: 检查迁移文件 working-directory: backend - run: ../.venv-ci/bin/python manage.py makemigrations --check --dry-run + run: ../.venv-release/bin/python manage.py makemigrations --check --dry-run - name: Django 系统检查 working-directory: backend - run: ../.venv-ci/bin/python manage.py check + run: ../.venv-release/bin/python manage.py check - name: ASGI 启动导入检查 working-directory: backend - run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)" + run: ../.venv-release/bin/python -c "from config.asgi import application; print(type(application).__name__)" - - name: 运行测试 - run: .venv-ci/bin/python -m pytest -q - - - name: MySQL 迁移与全量测试 + - name: MySQL 发布迁移检查 env: DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath run: | - .venv-ci/bin/python backend/manage.py check --database default - .venv-ci/bin/python backend/manage.py check_mysql - .venv-ci/bin/python backend/manage.py migrate --noinput - .venv-ci/bin/python -m pytest -q + .venv-release/bin/python backend/manage.py check --database default + .venv-release/bin/python backend/manage.py check_mysql + .venv-release/bin/python backend/manage.py migrate --noinput deploy: if: ${{ github.event.pull_request.merged == true }} needs: - - test + - release-check runs-on: ubuntu-latest + timeout-minutes: 25 steps: - name: 配置 SSH 环境 env: @@ -92,7 +90,7 @@ jobs: mkdir -p ~/.ssh printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + timeout 10 ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts - name: 远程执行 Django 部署 env: @@ -139,6 +137,8 @@ jobs: PROJECT_DIR="$PROJECT" \ PYTHON_BIN="$PYTHON_BIN" \ PREVIOUS_REVISION="$PREVIOUS_REVISION" \ + DEPLOY_SMOKE_BASE_URL="http://127.0.0.1:4321" \ + DEPLOY_SMOKE_WS_URL="ws://127.0.0.1:4321/ws/health/" \ bash scripts/deploy_production.sh echo "========================================" @@ -146,3 +146,13 @@ jobs: echo "========================================" EOF + + - name: 从 Runner 验证 Nginx 健康状态 + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + curl --fail --silent --show-error \ + --retry 5 \ + --retry-delay 2 \ + "http://$DEPLOY_HOST:4321/health/" \ + | grep -q '"status": "ok"' diff --git a/docs/BAOTA_UBUNTU_FROM_ZERO.md b/docs/BAOTA_UBUNTU_FROM_ZERO.md index ebf91dd..259cdbe 100644 --- a/docs/BAOTA_UBUNTU_FROM_ZERO.md +++ b/docs/BAOTA_UBUNTU_FROM_ZERO.md @@ -477,16 +477,28 @@ chmod 600 /root/.ssh/authorized_keys 确保有能匹配 `ubuntu-latest` 的在线 Runner。 -PR 合并到 `main` 后,workflow 会: +如果 Runner 和生产环境在同一 Docker 宿主机,且公网 IP 不支持 NAT 回环, +`DEPLOY_HOST` 可使用 Docker 网桥宿主机地址 `172.17.0.1`。 + +先在 Gitea 保护 `main` 分支,禁止直接推送,并要求 `CI / test` 通过后才能合并。 + +PR 阶段会执行: ```text -SQLite 快速测试 -→ 启动 MySQL 8.0.35 临时容器 -→ MySQL 全量迁移和测试 +Ruff 静态检查 +→ SQLite 测试和 75% 覆盖率门槛 +→ MySQL 8.0.35 全量迁移和测试 +``` + +PR 合并到 `main` 后会执行: + +```text +MySQL 8.0.35 发布迁移检查 → SSH 生产服务器 → MySQL 备份 → migrate -→ 重启和健康检查 +→ 重启 +→ 应用与 Nginx HTTP/WebSocket 冒烟检查 ``` ## 16. 备份与恢复 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 47ff915..66144c6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,17 +2,39 @@ Ubuntu 宝塔面板全新服务器请优先阅读 [Ubuntu + 宝塔面板从零部署](BAOTA_UBUNTU_FROM_ZERO.md)。本文只描述自动发布机制。 +PR 质量门禁由 `.gitea/workflows/ci.yml` 执行: + +1. Ruff 静态检查。 +2. 迁移文件、Django 和 ASGI 导入检查。 +3. SQLite 单元测试,覆盖率不得低于 75%。 +4. MySQL 8.0.35 全量迁移和测试。 + +同一 PR 推送新提交时,旧 CI 会自动取消。单次 CI 最多运行 20 分钟。 + 生产发布由 `.gitea/workflows/deploy.yml` 执行。目标为 `main` 的 PR 被真正合并后: -1. Runner 检出 `main` 并运行迁移检查、Django 检查和全部测试。 -2. 测试通过后 SSH 到生产服务器。 +1. Runner 检出 `main` 并在临时 MySQL 8.0.35 上执行发布迁移检查。 +2. 检查通过后 SSH 到生产服务器。 3. 服务器备份 MySQL,安装依赖,执行迁移并收集静态资源。 4. systemd 重启 Django ASGI 服务。 -5. 分别验证 HTTP、WebSocket 和 Redis Channel Layer。 -6. 失败时回退应用代码;数据库备份保留,不自动执行破坏性反向迁移。 +5. 验证应用 HTTP、Redis Channel Layer 和直连 WebSocket。 +6. 通过 Nginx 验证健康接口、首页、后台、视频目录和 WebSocket。 +7. Runner 从外部容器网络再次验证 Nginx 健康接口。 +8. 失败时回退应用代码;数据库备份保留,不自动执行破坏性反向迁移。 关闭但未合并的 PR 不会部署。同一时间只允许一个生产部署任务执行。 +## main 分支保护 + +仓库必须保护 `main` 分支: + +- 禁止直接推送,所有改动必须通过 PR。 +- 合并前必须通过状态检查 `CI / test`。 +- 禁止在检查未完成时合并。 +- 管理员也应遵守分支保护。 + +完整测试只在 PR 阶段执行。合并后发布流程不重复运行全量测试,因此分支保护是生产发布的必要条件。 + ## Gitea Secrets 在仓库 `Settings > Actions > Secrets` 配置: diff --git a/scripts/deploy_production.sh b/scripts/deploy_production.sh index 464bb27..d4fcbe3 100755 --- a/scripts/deploy_production.sh +++ b/scripts/deploy_production.sh @@ -13,6 +13,8 @@ MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-}" MYSQL_CONTAINER="${MYSQL_CONTAINER:-}" PREVIOUS_REVISION="${PREVIOUS_REVISION:-}" HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}" +SMOKE_BASE_URL="${DEPLOY_SMOKE_BASE_URL:-}" +SMOKE_WS_URL="${DEPLOY_SMOKE_WS_URL:-}" ROLLBACK_ENABLED=0 MYSQL_CREDENTIALS_FILE="" MYSQL_CONTAINER_CREDENTIALS_PATH="" @@ -291,6 +293,17 @@ async def check(): asyncio.run(check()) PY +if [ -n "$SMOKE_BASE_URL" ] || [ -n "$SMOKE_WS_URL" ]; then + [ -n "$SMOKE_BASE_URL" ] && [ -n "$SMOKE_WS_URL" ] \ + || fail "DEPLOY_SMOKE_BASE_URL 和 DEPLOY_SMOKE_WS_URL 必须同时设置" + [ -f "$PROJECT_DIR/scripts/smoke_production.py" ] \ + || fail "缺少生产冒烟脚本: scripts/smoke_production.py" + log "检查 Nginx、业务页面与公网 WebSocket" + "$VENV_DIR/bin/python" "$PROJECT_DIR/scripts/smoke_production.py" \ + --base-url "$SMOKE_BASE_URL" \ + --ws-url "$SMOKE_WS_URL" +fi + trap - ERR ROLLBACK_ENABLED=0 log "部署完成,当前版本: $(git rev-parse --short HEAD)" diff --git a/scripts/smoke_production.py b/scripts/smoke_production.py new file mode 100644 index 0000000..5f7dd4d --- /dev/null +++ b/scripts/smoke_production.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import json +from urllib.request import urlopen + +from websockets.asyncio.client import connect + + +def fetch(base_url, path): + url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" + with urlopen(url, timeout=10) as response: + if response.status != 200: + raise RuntimeError(f"{url} returned HTTP {response.status}") + return response.read() + + +async def check_websocket(url): + async with connect(url, open_timeout=10, close_timeout=5) as websocket: + payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=10)) + expected = {"status": "ok", "channel_layer": "ok"} + if payload != expected: + raise RuntimeError(f"{url} returned unexpected payload: {payload}") + + +def main(): + parser = argparse.ArgumentParser(description="Check the deployed Hulumath Nginx surface") + parser.add_argument("--base-url", required=True) + parser.add_argument("--ws-url", required=True) + parser.add_argument("--minimum-videos", type=int, default=54) + args = parser.parse_args() + + health = json.loads(fetch(args.base_url, "/health/")) + if health != {"status": "ok", "database": "ok"}: + raise RuntimeError(f"unexpected health payload: {health}") + + homepage = fetch(args.base_url, "/").decode("utf-8") + if "葫芦数学" not in homepage: + raise RuntimeError("homepage marker is missing") + + admin_login = fetch(args.base_url, "/admin/login/").decode("utf-8") + if "进入运营后台" not in admin_login: + raise RuntimeError("admin login marker is missing") + + catalog = json.loads(fetch(args.base_url, "/api/v1/content/videos/catalog/")) + if catalog.get("total", 0) < args.minimum_videos: + raise RuntimeError(f"video catalog is incomplete: {catalog.get('total', 0)}") + + asyncio.run(check_websocket(args.ws_url)) + print( + "Production smoke checks passed: " + f"health, homepage, admin, {catalog['total']} videos, websocket" + ) + + +if __name__ == "__main__": + main() From 8aad781581ef1d941f3bd9cd86d3ea477a412563 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 9 Aug 2026 00:20:48 +0800 Subject: [PATCH 3/3] fix: support idempotent actions over HTTP --- backend/common/test_frontend_assets.py | 22 +++++++++++++++++++ backend/static/css/app.css | 5 ++++- backend/static/js/app.js | 30 ++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 backend/common/test_frontend_assets.py diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py new file mode 100644 index 0000000..6e185e7 --- /dev/null +++ b/backend/common/test_frontend_assets.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from django.conf import settings + +STATIC_ROOT = Path(settings.BASE_DIR) / "static" + + +def test_app_js_幂等键兼容非安全上下文(): + source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8") + + assert "function createIdempotencyKey()" in source + assert "crypto.randomUUID()" not in source + assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2 + + +def test_app_css_答题提交按钮可见且长弹窗可滚动(): + source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") + + assert ".choice-list .primary-button" in source + assert "background: var(--green); color: white; text-align: center" in source + assert "max-height: calc(100dvh - 30px)" in source + assert "overflow-y: auto" in source diff --git a/backend/static/css/app.css b/backend/static/css/app.css index abbbd6a..a4108c7 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -198,7 +198,7 @@ button { color: inherit; } .video-complete { position: absolute; right: 12px; top: 12px; background: var(--green); color: white; border-radius: 99px; padding: 5px 8px; font-size: 9px; } .video-body { padding: 19px; }.ability-badge { display: inline-block; border: 1px solid var(--badge-color); color: var(--badge-color); border-radius: 8px; padding: 5px 8px; font-size: 10px; }.video-body h3 { min-height: 48px; margin: 14px 0 16px; font: 20px/1.3 Georgia, serif; }.video-metrics, .video-author { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; }.video-author { justify-content: flex-start; gap: 8px; align-items: center; margin-top: 14px; }.video-author i { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--badge-color); color: white; font-style: normal; } .video-dialog { width: min(760px, calc(100% - 30px)); }.video-player { min-height: 300px; border-radius: 17px; background: var(--ink); display: grid; place-items: center; color: white; text-align: center; padding: 30px; }.video-player span { display: block; color: var(--lime); font-size: 55px; }.video-player p { color: #aeb9b1; }.video-detail-meta { display: flex; gap: 12px; flex-wrap: wrap; color: var(--muted); font-size: 11px; margin: 18px 0; }.video-dialog h2 { font: 31px/1.2 Georgia, serif; }.video-dialog .video-description { color: var(--muted); line-height: 1.8; }.video-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; } -dialog { width: min(460px, calc(100% - 30px)); border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); } +dialog { width: min(460px, calc(100% - 30px)); max-height: calc(100vh - 30px); max-height: calc(100dvh - 30px); overflow-y: auto; border: 1px solid var(--line); border-radius: 20px; padding: 30px; color: var(--ink); background: #faf9f3; box-shadow: 0 30px 100px rgba(0,0,0,.22); } dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .dialog-close { position: absolute; right: 16px; top: 13px; border: 0; background: transparent; font-size: 25px; cursor: pointer; } .dialog-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 25px; } @@ -213,6 +213,9 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .choice-list { display: grid; gap: 9px; margin-top: 25px; } .choice-list button { text-align: left; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: white; cursor: pointer; } .choice-list button:hover { border-color: var(--green); } +.choice-list .primary-button { width: 100%; border-color: var(--green); background: var(--green); color: white; text-align: center; font-weight: 650; } +.choice-list .primary-button:hover { border-color: var(--green); background: var(--green); } +.choice-list button:disabled { cursor: wait; opacity: .65; } .quiz-progress { height: 4px; background: #e4e3da; margin: 15px 0 30px; }.quiz-progress i { display: block; height: 100%; background: var(--green); transition: width .2s; } .toast { position: fixed; right: 25px; bottom: 25px; background: var(--ink); color: white; border-radius: 12px; padding: 13px 17px; opacity: 0; transform: translateY(10px); pointer-events: none; transition: .2s; z-index: 20; } .toast.show { opacity: 1; transform: none; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d14f6c1..18c20a4 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -17,6 +17,32 @@ function csrfToken() { return match ? decodeURIComponent(match[1]) : ""; } +function createIdempotencyKey() { + const cryptoApi = globalThis.crypto; + if (typeof cryptoApi?.randomUUID === "function") { + return cryptoApi.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (typeof cryptoApi?.getRandomValues === "function") { + cryptoApi.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10).join(""), + ].join("-"); +} + async function api(path, options = {}) { const headers = { Accept: "application/json", ...(options.headers || {}) }; if (options.body && typeof options.body !== "string") { @@ -165,7 +191,7 @@ function renderStoryNode(run) { try { const next = await api(`math-life/runs/${run.run_id}/choice/`, { method: "POST", - headers: { "Idempotency-Key": crypto.randomUUID() }, + headers: { "Idempotency-Key": createIdempotencyKey() }, body: { choice_index: choice.index }, }); renderStoryNode(next); @@ -341,7 +367,7 @@ function renderAttempt(attempt) { try { const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, { method: "POST", - headers: { "Idempotency-Key": crypto.randomUUID() }, + headers: { "Idempotency-Key": createIdempotencyKey() }, body: { answers }, }); renderAttempt(result);