17 Commits
Author SHA1 Message Date
Jacky 6bcff55c4f Merge pull request 'fix: keep admin theme contrast consistent' (#5) from fix/admin-light-theme-contrast into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/5
2026-08-09 00:37:20 +08:00
Jacky c365454395 fix: keep admin theme contrast consistent
CI / test (pull_request) Canceled after 34s
PR合并自动部署 / release-check (pull_request) Successful in 1m35s
PR合并自动部署 / deploy (pull_request) Successful in 10s
2026-08-09 00:34:31 +08:00
Jacky ce07f6b3b7 Merge pull request 'ci: send production host in runner health check' (#4) from fix/runner-health-host into main
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/4
2026-08-09 00:29:14 +08:00
Jacky 54684fac7c ci: send production host in runner health check
PR合并自动部署 / release-check (pull_request) Canceled after 0s
PR合并自动部署 / deploy (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 14s
2026-08-09 00:28:05 +08:00
Jacky 160d58b2d2 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
2026-08-09 00:23:50 +08:00
Jacky 8aad781581 fix: support idempotent actions over HTTP
CI / test (pull_request) Canceled after 1m25s
PR合并自动部署 / release-check (pull_request) Successful in 1m44s
PR合并自动部署 / deploy (pull_request) Failing after 10s
2026-08-09 00:20:48 +08:00
Jacky 7c9e0303b9 ci: add rollback-aware production smoke checks
CI / test (pull_request) Successful in 3m27s
2026-08-09 00:10:35 +08:00
Jacky 2f90c82d54 ci: add PR quality and coverage gates 2026-08-09 00:10:24 +08:00
Jacky e50ac83988 Merge pull request 'ci: avoid GitHub action downloads' (#2) from fix/gitea-actions-mirror into main
CI / test (push) Successful in 2m18s
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/2
2026-08-08 23:52:13 +08:00
Jacky 8e0ab7dae7 ci: skip unused application image build
CI / test (pull_request) Successful in 3m17s
PR合并自动部署 / test (pull_request) Successful in 2m50s
PR合并自动部署 / deploy (pull_request) Successful in 10s
2026-08-08 23:36:27 +08:00
Jacky 4b59828544 ci: install Python development headers
CI / test (pull_request) Canceled after 15m21s
2026-08-08 23:26:52 +08:00
Jacky 1f800d713f ci: use domestic Python package mirror
CI / test (pull_request) Failing after 1m48s
2026-08-08 23:23:49 +08:00
Jacky 672c3cf98b ci: install dependencies in virtualenv
CI / test (pull_request) Failing after 12m57s
2026-08-08 23:08:47 +08:00
Jacky cfc9d99820 ci: checkout from local Gitea
CI / test (pull_request) Failing after 2m56s
2026-08-08 22:58:31 +08:00
Jacky cee657faac ci: avoid GitHub action downloads
CI / test (pull_request) Canceled after 4m58s
2026-08-08 22:53:09 +08:00
Jacky 8b8676f854 Merge pull request 'ci: isolate MySQL test services' (#1) from chore/ci-isolated-mysql into main
CI / test (push) Canceled after 4m9s
Reviewed-on: http://117.72.28.96:8765/Jacky/Hulumath-Web/pulls/1
2026-08-08 22:43:15 +08:00
Jacky 7511505aea ci: isolate MySQL test services
CI / test (pull_request) Canceled after 1m1s
PR合并自动部署 / deploy (pull_request) Canceled after 0s
PR合并自动部署 / test (pull_request) Canceled after 4m33s
2026-08-08 22:42:18 +08:00
44 changed files with 403 additions and 128 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
+55 -39
View File
@@ -1,62 +1,78 @@
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:
mysql:
image: mysql:8.0.35
env:
MYSQL_ROOT_PASSWORD: ci-root-password
MYSQL_DATABASE: hulumath
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
- uses: actions/setup-python@v5 env:
with: REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
python-version: "3.11" REVISION: ${{ gitea.sha }}
cache: pip run: |
git clone "$REPOSITORY_URL" .
git checkout --detach "$REVISION"
- name: Show Python version
run: python3 --version
- name: Install MySQL build dependencies - name: Install MySQL build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y default-libmysqlclient-dev pkg-config sudo apt-get install -y \
default-libmysqlclient-dev \
pkg-config \
python3-dev \
python3-venv
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements-dev.txt run: |
python3 -m venv .venv-ci
.venv-ci/bin/python -m pip install \
--index-url https://mirrors.aliyun.com/pypi/simple \
--timeout 120 \
--retries 5 \
-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: python manage.py makemigrations --check --dry-run run: ../.venv-ci/bin/python manage.py makemigrations --check --dry-run
- name: Django checks - name: Django checks
working-directory: backend working-directory: backend
run: python manage.py check run: ../.venv-ci/bin/python manage.py check
- name: ASGI import check - name: ASGI import check
working-directory: backend working-directory: backend
run: 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: pytest -q
- name: Start MySQL 8.0.35
run: | run: |
docker rm -f hulumath-ci-mysql 2>/dev/null || true .venv-ci/bin/python -m pytest -q \
docker run -d --name hulumath-ci-mysql \ --cov=backend \
-p 3307:3306 \ --cov-config=.coveragerc \
-e MYSQL_ROOT_PASSWORD=ci-root-password \ --cov-report=term \
-e MYSQL_DATABASE=hulumath \ --cov-report=xml:coverage.xml \
mysql:8.0.35 \ --cov-fail-under=75
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_0900_ai_ci
for i in $(seq 1 60); do
if docker exec hulumath-ci-mysql \
mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent; then
exit 0
fi
sleep 2
done
docker logs hulumath-ci-mysql
exit 1
- name: MySQL migrations and tests - name: MySQL migrations and tests
env: env:
DATABASE_URL: mysql://root:ci-root-password@127.0.0.1:3307/hulumath DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
run: | run: |
python backend/manage.py check --database default .venv-ci/bin/python backend/manage.py check --database default
python backend/manage.py check_mysql .venv-ci/bin/python backend/manage.py check_mysql
python backend/manage.py migrate --noinput .venv-ci/bin/python backend/manage.py migrate --noinput
pytest -q .venv-ci/bin/python -m pytest -q
- name: Build image
run: docker build -t hulumath:${{ gitea.sha }} .
+56 -46
View File
@@ -12,78 +12,74 @@ 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:
mysql:
image: mysql:8.0.35
env:
MYSQL_ROOT_PASSWORD: ci-root-password
MYSQL_DATABASE: hulumath
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps: steps:
- name: 检出 main - name: 检出 main
uses: actions/checkout@v4 env:
with: REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
ref: main run: git clone --branch main --single-branch "$REPOSITORY_URL" .
- name: 配置 Python - name: 显示 Python 版本
uses: actions/setup-python@v5 run: python3 --version
with:
python-version: "3.11"
cache: pip
- name: 安装 MySQL 编译依赖 - name: 安装 MySQL 编译依赖
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y default-libmysqlclient-dev pkg-config sudo apt-get install -y \
default-libmysqlclient-dev \
pkg-config \
python3-dev \
python3-venv
- name: 安装测试依赖 - name: 安装发布检查依赖
run: pip install -r requirements-dev.txt run: |
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.txt
- name: 检查迁移文件 - name: 检查迁移文件
working-directory: backend working-directory: backend
run: 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: python manage.py check run: ../.venv-release/bin/python manage.py check
- name: ASGI 启动导入检查 - name: ASGI 启动导入检查
working-directory: backend working-directory: backend
run: 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: pytest -q
- name: 启动 MySQL 8.0.35
run: |
docker rm -f hulumath-deploy-mysql 2>/dev/null || true
docker run -d --name hulumath-deploy-mysql \
-p 3307:3306 \
-e MYSQL_ROOT_PASSWORD=ci-root-password \
-e MYSQL_DATABASE=hulumath \
mysql:8.0.35 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_0900_ai_ci
for i in $(seq 1 60); do
if docker exec hulumath-deploy-mysql \
mysqladmin ping -h 127.0.0.1 -uroot -pci-root-password --silent; then
exit 0
fi
sleep 2
done
docker logs hulumath-deploy-mysql
exit 1
- name: MySQL 迁移与全量测试
env: env:
DATABASE_URL: mysql://root:ci-root-password@127.0.0.1:3307/hulumath DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
run: | run: |
python backend/manage.py check --database default .venv-release/bin/python backend/manage.py check --database default
python backend/manage.py check_mysql .venv-release/bin/python backend/manage.py check_mysql
python backend/manage.py migrate --noinput .venv-release/bin/python backend/manage.py migrate --noinput
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:
@@ -94,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:
@@ -141,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 "========================================"
@@ -148,3 +146,15 @@ jobs:
echo "========================================" echo "========================================"
EOF EOF
- name: 从 Runner 验证 Nginx 健康状态
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PUBLIC_HOST: 117.72.28.96
run: |
curl --fail --silent --show-error \
--retry 5 \
--retry-delay 2 \
--header "Host: $DEPLOY_PUBLIC_HOST" \
"http://$DEPLOY_HOST:4321/health/" \
| grep -q '"status": "ok"'
+5 -1
View File
@@ -1,8 +1,12 @@
FROM python:3.11-slim AS runtime FROM python:3.11-slim AS runtime
ARG PIP_INDEX_URL=https://pypi.org/simple
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_INDEX_URL=$PIP_INDEX_URL \
PIP_DEFAULT_TIMEOUT=120
WORKDIR /app WORKDIR /app
-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.
+36
View File
@@ -0,0 +1,36 @@
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
def test_admin_css_深色系统下仍保持完整浅色主题():
source = (STATIC_ROOT / "admin" / "css" / "hulumath_admin.css").read_text(
encoding="utf-8"
)
assert "color-scheme: light" in source
assert "--body-fg: #17211b" in source
assert "--body-bg: #f3f1e9" in source
assert "--darkened-bg: #ebeae3" in source
assert "background-color: #ffffff !important" in source
assert ".theme-toggle" in source
assert "display: none" 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"),
] ]
@@ -1,4 +1,5 @@
:root { :root {
color-scheme: light;
--hulu-ink: #17211b; --hulu-ink: #17211b;
--hulu-green: #196548; --hulu-green: #196548;
--hulu-green-dark: #124d37; --hulu-green-dark: #124d37;
@@ -8,18 +9,44 @@
--hulu-line: #dfe2da; --hulu-line: #dfe2da;
--hulu-muted: #6b756e; --hulu-muted: #6b756e;
--primary: #196548; --primary: #196548;
--primary-fg: #ffffff;
--secondary: #17211b; --secondary: #17211b;
--accent: #cce85b; --accent: #cce85b;
--body-fg: #17211b;
--body-bg: #f3f1e9;
--body-quiet-color: #6b756e;
--body-loud-color: #0d1510;
--header-bg: #17211b; --header-bg: #17211b;
--header-color: #ffffff; --header-color: #ffffff;
--header-branding-color: #ffffff;
--header-link-color: #ffffff;
--breadcrumbs-fg: #f3f1e9;
--breadcrumbs-bg: #235f47; --breadcrumbs-bg: #235f47;
--breadcrumbs-link-fg: #f3f1e9; --breadcrumbs-link-fg: #f3f1e9;
--link-fg: #196548; --link-fg: #196548;
--link-hover-color: #124d37; --link-hover-color: #124d37;
--link-selected-fg: #124d37;
--hairline-color: #e4e6df;
--border-color: #ccd2ca;
--error-fg: #b84136;
--message-success-bg: #dcefe5;
--message-warning-bg: #fff1c7;
--message-error-bg: #f8deda;
--darkened-bg: #ebeae3;
--selected-bg: #e2e9e2;
--selected-row: #dce9df;
--button-fg: #ffffff;
--button-bg: #196548; --button-bg: #196548;
--button-hover-bg: #124d37; --button-hover-bg: #124d37;
--default-button-bg: #17211b; --default-button-bg: #17211b;
--default-button-hover-bg: #2c3931; --default-button-hover-bg: #2c3931;
--close-button-bg: #667169;
--close-button-hover-bg: #3e4942;
--delete-button-bg: #b84136;
--delete-button-hover-bg: #913128;
--object-tools-fg: #ffffff;
--object-tools-bg: #196548;
--object-tools-hover-bg: #124d37;
} }
body { body {
@@ -27,6 +54,10 @@ body {
color: var(--hulu-ink); color: var(--hulu-ink);
} }
.theme-toggle {
display: none;
}
#header { #header {
min-height: 70px; min-height: 70px;
padding: 0 28px; padding: 0 28px;
@@ -125,6 +156,19 @@ div.breadcrumbs {
background: #ebeae3; background: #ebeae3;
} }
#nav-sidebar .module,
#nav-sidebar .module table,
#nav-sidebar .module th,
#nav-sidebar .module td {
background: var(--hulu-panel);
color: var(--hulu-ink);
}
#nav-sidebar .module td,
#nav-sidebar .module th {
border-color: var(--hulu-line);
}
#nav-sidebar .current-app .section, #nav-sidebar .current-app .section,
#nav-sidebar .current-model { #nav-sidebar .current-model {
background: rgba(25, 101, 72, .1); background: rgba(25, 101, 72, .1);
@@ -164,6 +208,14 @@ select,
border-radius: 8px !important; border-radius: 8px !important;
} }
input:not([type=submit]):not([type=button]):not([type=checkbox]):not([type=radio]),
textarea,
select,
.vTextField {
background-color: #ffffff !important;
color: var(--hulu-ink) !important;
}
input:focus, input:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
@@ -172,6 +224,29 @@ select:focus {
outline: none; outline: none;
} }
.form-row,
.submit-row {
border-color: var(--hulu-line);
background: var(--hulu-panel);
}
.form-row label,
.form-row p,
.form-row .help,
.form-row div.help,
.form-row div.help li {
color: var(--hulu-muted);
}
.form-row label {
color: #334039;
font-weight: 600;
}
.submit-row {
border: 1px solid var(--hulu-line);
}
.button, .button,
input[type=submit], input[type=submit],
input[type=button], input[type=button],
+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()