Merge pull request 'Ci/quality and smoke gates' (#3) from ci/quality-and-smoke-gates into main

Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-09 00:23:50 +08:00
42 changed files with 242 additions and 64 deletions
+16
View File
@@ -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
+16 -4
View File
@@ -1,13 +1,17 @@
name: CI name: CI
on: on:
push:
branches: [main]
pull_request: pull_request:
branches: [main]
concurrency:
group: ci-${{ gitea.event_name }}-${{ gitea.ref }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
services: services:
mysql: mysql:
image: mysql:8.0.35 image: mysql:8.0.35
@@ -45,6 +49,8 @@ jobs:
--timeout 120 \ --timeout 120 \
--retries 5 \ --retries 5 \
-r requirements-dev.txt -r requirements-dev.txt
- name: Ruff checks
run: .venv-ci/bin/ruff check backend scripts
- name: Check migrations - name: Check migrations
working-directory: backend working-directory: backend
run: ../.venv-ci/bin/python manage.py makemigrations --check --dry-run run: ../.venv-ci/bin/python manage.py makemigrations --check --dry-run
@@ -54,8 +60,14 @@ jobs:
- name: ASGI import check - name: ASGI import check
working-directory: backend working-directory: backend
run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)" run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)"
- name: Unit tests - name: SQLite tests and coverage
run: .venv-ci/bin/python -m pytest -q 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 - name: MySQL migrations and tests
env: env:
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
+28 -18
View File
@@ -12,9 +12,10 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
test: release-check:
if: ${{ github.event.pull_request.merged == true }} if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
services: services:
mysql: mysql:
image: mysql:8.0.35 image: mysql:8.0.35
@@ -44,44 +45,41 @@ jobs:
python3-dev \ python3-dev \
python3-venv python3-venv
- name: 安装测试依赖 - name: 安装发布检查依赖
run: | run: |
python3 -m venv .venv-ci python3 -m venv .venv-release
.venv-ci/bin/python -m pip install \ .venv-release/bin/python -m pip install \
--index-url https://mirrors.aliyun.com/pypi/simple \ --index-url https://mirrors.aliyun.com/pypi/simple \
--timeout 120 \ --timeout 120 \
--retries 5 \ --retries 5 \
-r requirements-dev.txt -r requirements.txt
- name: 检查迁移文件 - name: 检查迁移文件
working-directory: backend 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 系统检查 - name: Django 系统检查
working-directory: backend working-directory: backend
run: ../.venv-ci/bin/python manage.py check run: ../.venv-release/bin/python manage.py check
- name: ASGI 启动导入检查 - name: ASGI 启动导入检查
working-directory: backend 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: 运行测试 - name: MySQL 发布迁移检查
run: .venv-ci/bin/python -m pytest -q
- name: MySQL 迁移与全量测试
env: env:
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
run: | run: |
.venv-ci/bin/python backend/manage.py check --database default .venv-release/bin/python backend/manage.py check --database default
.venv-ci/bin/python backend/manage.py check_mysql .venv-release/bin/python backend/manage.py check_mysql
.venv-ci/bin/python backend/manage.py migrate --noinput .venv-release/bin/python backend/manage.py migrate --noinput
.venv-ci/bin/python -m pytest -q
deploy: deploy:
if: ${{ github.event.pull_request.merged == true }} if: ${{ github.event.pull_request.merged == true }}
needs: needs:
- test - release-check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25
steps: steps:
- name: 配置 SSH 环境 - name: 配置 SSH 环境
env: env:
@@ -92,7 +90,7 @@ jobs:
mkdir -p ~/.ssh mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.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 部署 - name: 远程执行 Django 部署
env: env:
@@ -139,6 +137,8 @@ jobs:
PROJECT_DIR="$PROJECT" \ PROJECT_DIR="$PROJECT" \
PYTHON_BIN="$PYTHON_BIN" \ PYTHON_BIN="$PYTHON_BIN" \
PREVIOUS_REVISION="$PREVIOUS_REVISION" \ 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 bash scripts/deploy_production.sh
echo "========================================" echo "========================================"
@@ -146,3 +146,13 @@ jobs:
echo "========================================" echo "========================================"
EOF 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"'
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -2,7 +2,6 @@ from django.urls import path
from .views import LoginView, LogoutView, MeView, RegisterView, VisitorMigrationView from .views import LoginView, LogoutView, MeView, RegisterView, VisitorMigrationView
urlpatterns = [ urlpatterns = [
path("register/", RegisterView.as_view(), name="register"), path("register/", RegisterView.as_view(), name="register"),
path("login/", LoginView.as_view(), name="login"), path("login/", LoginView.as_view(), name="login"),
-1
View File
@@ -1,3 +1,2 @@
from django.contrib import admin
# Register your models here. # Register your models here.
-1
View File
@@ -1,7 +1,6 @@
import contextvars import contextvars
import logging import logging
request_id_context = contextvars.ContextVar("request_id", default="-") request_id_context = contextvars.ContextVar("request_id", default="-")
@@ -3,7 +3,6 @@ import re
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import connection from django.db import connection
MINIMUM_VERSION = (8, 0, 35) MINIMUM_VERSION = (8, 0, 35)
-1
View File
@@ -1,3 +1,2 @@
from django.db import models
# Create your models here. # Create your models here.
+22
View File
@@ -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
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-2
View File
@@ -7,13 +7,11 @@ from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
from django.urls import path from django.urls import path
django_asgi_application = get_asgi_application() django_asgi_application = get_asgi_application()
from common.consumers import HealthConsumer from common.consumers import HealthConsumer
from contest.routing import websocket_urlpatterns from contest.routing import websocket_urlpatterns
application = ProtocolTypeRouter( application = ProtocolTypeRouter(
{ {
"http": django_asgi_application, "http": django_asgi_application,
-1
View File
@@ -4,7 +4,6 @@ from pathlib import Path
import dj_database_url import dj_database_url
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = BASE_DIR.parent PROJECT_ROOT = BASE_DIR.parent
-1
View File
@@ -3,7 +3,6 @@ from django.urls import include, path
from common.views import health, home from common.views import health, home
admin.site.site_header = "葫芦数学运营后台" admin.site.site_header = "葫芦数学运营后台"
admin.site.site_title = "葫芦数学" admin.site.site_title = "葫芦数学"
admin.site.index_title = "内容与运营" admin.site.index_title = "内容与运营"
-1
View File
@@ -6,7 +6,6 @@ from progression.models import RewardTransaction, UserAbility
from .models import ContentInteraction, ContentItem, VideoProgress from .models import ContentInteraction, ContentItem, VideoProgress
ABILITY_TO_DIMENSION = { ABILITY_TO_DIMENSION = {
ContentItem.Ability.VISION: UserAbility.Dimension.VISION, ContentItem.Ability.VISION: UserAbility.Dimension.VISION,
ContentItem.Ability.HUMANITIES: UserAbility.Dimension.HUMANITIES, ContentItem.Ability.HUMANITIES: UserAbility.Dimension.HUMANITIES,
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -8,7 +8,6 @@ from .views import (
VideoCompleteView, VideoCompleteView,
) )
urlpatterns = [ urlpatterns = [
path("", ContentListView.as_view(), name="content-list"), path("", ContentListView.as_view(), name="content-list"),
path("videos/catalog/", VideoCatalogView.as_view(), name="video-catalog"), path("videos/catalog/", VideoCatalogView.as_view(), name="video-catalog"),
-1
View File
@@ -7,7 +7,6 @@ from rest_framework.views import APIView
from .models import ContentInteraction, ContentItem, VideoProgress from .models import ContentInteraction, ContentItem, VideoProgress
from .services import complete_video from .services import complete_video
ABILITY_META = { ABILITY_META = {
ContentItem.Ability.VISION: {"label": "数学眼光", "icon": "", "color": "#5d73e8"}, ContentItem.Ability.VISION: {"label": "数学眼光", "icon": "", "color": "#5d73e8"},
ContentItem.Ability.HUMANITIES: {"label": "数学人文", "icon": "", "color": "#b76d38"}, ContentItem.Ability.HUMANITIES: {"label": "数学人文", "icon": "", "color": "#b76d38"},
@@ -2,7 +2,6 @@ from django.core.management.base import BaseCommand
from contest.models import Contest, ContestQuestion, Question, QuestionVersion from contest.models import Contest, ContestQuestion, Question, QuestionVersion
QUESTIONS = { QUESTIONS = {
Question.Track.BEGINNER: [ Question.Track.BEGINNER: [
("b-12-plus-19", "12 + 19", "31"), ("b-12-plus-19", "12 + 19", "31"),
-1
View File
@@ -2,7 +2,6 @@ from django.urls import path
from .consumers import MatchConsumer from .consumers import MatchConsumer
websocket_urlpatterns = [ websocket_urlpatterns = [
path("ws/v1/contest/matches/<uuid:match_id>/", MatchConsumer.as_asgi()), path("ws/v1/contest/matches/<uuid:match_id>/", MatchConsumer.as_asgi()),
] ]
+1
View File
@@ -6,6 +6,7 @@ from django.utils import timezone
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from accounts.models import User from accounts.models import User
from .models import ( from .models import (
CheatFlag, CheatFlag,
Contest, Contest,
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -9,7 +9,6 @@ from .views import (
MatchStateView, MatchStateView,
) )
urlpatterns = [ urlpatterns = [
path("", ContestListView.as_view(), name="contest-list"), path("", ContestListView.as_view(), name="contest-list"),
path("<slug:slug>/start/", AttemptStartView.as_view(), name="attempt-start"), path("<slug:slug>/start/", AttemptStartView.as_view(), name="attempt-start"),
-1
View File
@@ -1,3 +1,2 @@
from django.contrib import admin
# Register your models here. # Register your models here.
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -1,3 +1,2 @@
from django.shortcuts import render
# Create your views here. # Create your views here.
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -3,7 +3,6 @@ from rest_framework.routers import DefaultRouter
from .views import CourseListView, ExerciseSubmitView, FormulaDocumentViewSet from .views import CourseListView, ExerciseSubmitView, FormulaDocumentViewSet
router = DefaultRouter() router = DefaultRouter()
router.register("documents", FormulaDocumentViewSet, basename="formula-document") router.register("documents", FormulaDocumentViewSet, basename="formula-document")
@@ -17,7 +17,6 @@ from math_life.models import (
) )
from math_life.services import validate_story_content from math_life.services import validate_story_content
DISCIPLINE_ICONS = { DISCIPLINE_ICONS = {
"人工智能": "🤖", "人工智能": "🤖",
"计算机": "💻", "计算机": "💻",
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -9,7 +9,6 @@ from .views import (
StoryStartView, StoryStartView,
) )
urlpatterns = [ urlpatterns = [
path("mathbti/", MathBTIAssessmentView.as_view(), name="mathbti-assessment"), path("mathbti/", MathBTIAssessmentView.as_view(), name="mathbti-assessment"),
path("mathbti/submit/", MathBTISubmitView.as_view(), name="mathbti-submit"), path("mathbti/submit/", MathBTISubmitView.as_view(), name="mathbti-submit"),
-1
View File
@@ -2,7 +2,6 @@ from django.contrib import admin
from .models import Card, RewardTransaction, UserAbility, UserCard, UserPet from .models import Card, RewardTransaction, UserAbility, UserCard, UserPet
admin.site.register(UserPet) admin.site.register(UserPet)
admin.site.register(UserAbility) admin.site.register(UserAbility)
admin.site.register(Card) admin.site.register(Card)
-1
View File
@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-1
View File
@@ -2,7 +2,6 @@ from django.urls import path
from .views import ProgressionProfileView from .views import ProgressionProfileView
urlpatterns = [ urlpatterns = [
path("me/", ProgressionProfileView.as_view(), name="progression-profile"), path("me/", ProgressionProfileView.as_view(), name="progression-profile"),
] ]
+4 -1
View File
@@ -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-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-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; } .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::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-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; } .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 { 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 { 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 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; } .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 { 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; } .toast.show { opacity: 1; transform: none; }
+28 -2
View File
@@ -17,6 +17,32 @@ function csrfToken() {
return match ? decodeURIComponent(match[1]) : ""; 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 = {}) { async function api(path, options = {}) {
const headers = { Accept: "application/json", ...(options.headers || {}) }; const headers = { Accept: "application/json", ...(options.headers || {}) };
if (options.body && typeof options.body !== "string") { if (options.body && typeof options.body !== "string") {
@@ -165,7 +191,7 @@ function renderStoryNode(run) {
try { try {
const next = await api(`math-life/runs/${run.run_id}/choice/`, { const next = await api(`math-life/runs/${run.run_id}/choice/`, {
method: "POST", method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() }, headers: { "Idempotency-Key": createIdempotencyKey() },
body: { choice_index: choice.index }, body: { choice_index: choice.index },
}); });
renderStoryNode(next); renderStoryNode(next);
@@ -341,7 +367,7 @@ function renderAttempt(attempt) {
try { try {
const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, { const result = await api(`contests/attempts/${attempt.attempt_id}/submit/`, {
method: "POST", method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() }, headers: { "Idempotency-Key": createIdempotencyKey() },
body: { answers }, body: { answers },
}); });
renderAttempt(result); renderAttempt(result);
+17 -5
View File
@@ -477,16 +477,28 @@ chmod 600 /root/.ssh/authorized_keys
确保有能匹配 `ubuntu-latest` 的在线 Runner。 确保有能匹配 `ubuntu-latest` 的在线 Runner。
PR 合并到 `main` 后,workflow 会: 如果 Runner 和生产环境在同一 Docker 宿主机,且公网 IP 不支持 NAT 回环,
`DEPLOY_HOST` 可使用 Docker 网桥宿主机地址 `172.17.0.1`
先在 Gitea 保护 `main` 分支,禁止直接推送,并要求 `CI / test` 通过后才能合并。
PR 阶段会执行:
```text ```text
SQLite 快速测试 Ruff 静态检查
启动 MySQL 8.0.35 临时容器 SQLite 测试和 75% 覆盖率门槛
→ MySQL 全量迁移和测试 → MySQL 8.0.35 全量迁移和测试
```
PR 合并到 `main` 后会执行:
```text
MySQL 8.0.35 发布迁移检查
→ SSH 生产服务器 → SSH 生产服务器
→ MySQL 备份 → MySQL 备份
→ migrate → migrate
→ 重启和健康检查 → 重启
→ 应用与 Nginx HTTP/WebSocket 冒烟检查
``` ```
## 16. 备份与恢复 ## 16. 备份与恢复
+26 -4
View File
@@ -2,17 +2,39 @@
Ubuntu 宝塔面板全新服务器请优先阅读 [Ubuntu + 宝塔面板从零部署](BAOTA_UBUNTU_FROM_ZERO.md)。本文只描述自动发布机制。 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 被真正合并后: 生产发布由 `.gitea/workflows/deploy.yml` 执行。目标为 `main` 的 PR 被真正合并后:
1. Runner 检出 `main`运行迁移检查、Django 检查和全部测试 1. Runner 检出 `main`在临时 MySQL 8.0.35 上执行发布迁移检查
2. 测试通过后 SSH 到生产服务器。 2. 检查通过后 SSH 到生产服务器。
3. 服务器备份 MySQL,安装依赖,执行迁移并收集静态资源。 3. 服务器备份 MySQL,安装依赖,执行迁移并收集静态资源。
4. systemd 重启 Django ASGI 服务。 4. systemd 重启 Django ASGI 服务。
5. 分别验证 HTTP、WebSocket 和 Redis Channel Layer。 5. 验证应用 HTTP、Redis Channel Layer 和直连 WebSocket
6. 失败时回退应用代码;数据库备份保留,不自动执行破坏性反向迁移 6. 通过 Nginx 验证健康接口、首页、后台、视频目录和 WebSocket
7. Runner 从外部容器网络再次验证 Nginx 健康接口。
8. 失败时回退应用代码;数据库备份保留,不自动执行破坏性反向迁移。
关闭但未合并的 PR 不会部署。同一时间只允许一个生产部署任务执行。 关闭但未合并的 PR 不会部署。同一时间只允许一个生产部署任务执行。
## main 分支保护
仓库必须保护 `main` 分支:
- 禁止直接推送,所有改动必须通过 PR。
- 合并前必须通过状态检查 `CI / test`
- 禁止在检查未完成时合并。
- 管理员也应遵守分支保护。
完整测试只在 PR 阶段执行。合并后发布流程不重复运行全量测试,因此分支保护是生产发布的必要条件。
## Gitea Secrets ## Gitea Secrets
在仓库 `Settings > Actions > Secrets` 配置: 在仓库 `Settings > Actions > Secrets` 配置:
+1
View File
@@ -2,3 +2,4 @@
pytest==8.3.5 pytest==8.3.5
pytest-django==4.11.1 pytest-django==4.11.1
pytest-cov==6.2.1 pytest-cov==6.2.1
ruff==0.11.13
+13
View File
@@ -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"]
+13
View File
@@ -13,6 +13,8 @@ MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-}"
MYSQL_CONTAINER="${MYSQL_CONTAINER:-}" MYSQL_CONTAINER="${MYSQL_CONTAINER:-}"
PREVIOUS_REVISION="${PREVIOUS_REVISION:-}" PREVIOUS_REVISION="${PREVIOUS_REVISION:-}"
HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}" HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}"
SMOKE_BASE_URL="${DEPLOY_SMOKE_BASE_URL:-}"
SMOKE_WS_URL="${DEPLOY_SMOKE_WS_URL:-}"
ROLLBACK_ENABLED=0 ROLLBACK_ENABLED=0
MYSQL_CREDENTIALS_FILE="" MYSQL_CREDENTIALS_FILE=""
MYSQL_CONTAINER_CREDENTIALS_PATH="" MYSQL_CONTAINER_CREDENTIALS_PATH=""
@@ -291,6 +293,17 @@ async def check():
asyncio.run(check()) asyncio.run(check())
PY 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 trap - ERR
ROLLBACK_ENABLED=0 ROLLBACK_ENABLED=0
log "部署完成,当前版本: $(git rev-parse --short HEAD)" log "部署完成,当前版本: $(git rev-parse --short HEAD)"
+57
View File
@@ -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()