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:
+16
@@ -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
@@ -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
@@ -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,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "内容与运营"
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,3 +1,2 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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` 配置:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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)"
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user