Compare commits
15
Commits
2b87b32f5b
...
v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587962f9c9 | ||
|
|
94188b5f0a | ||
|
|
46815c28f1 | ||
|
|
589adfa5e6 | ||
|
|
9f9aa2825a | ||
|
|
ee006fba18 | ||
|
|
ebd6070aae | ||
|
|
52816ce442 | ||
|
|
903ead60c9 | ||
|
|
3e47ba787d | ||
|
|
feb8845ec7 | ||
|
|
f177739824 | ||
|
|
aa3246a1cf | ||
|
|
ce7bed5f19 | ||
|
|
b1ac6cef86 |
@@ -60,6 +60,16 @@ jobs:
|
||||
- name: ASGI import check
|
||||
working-directory: backend
|
||||
run: ../.venv-ci/bin/python -c "from config.asgi import application; print(type(application).__name__)"
|
||||
- name: Production static assets check
|
||||
working-directory: backend
|
||||
env:
|
||||
DJANGO_DEBUG: "false"
|
||||
DJANGO_USE_HTTPS: "false"
|
||||
DJANGO_SECRET_KEY: ci-static-assets-check
|
||||
DJANGO_ALLOWED_HOSTS: localhost
|
||||
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
|
||||
REDIS_URL: redis://127.0.0.1:6379/0
|
||||
run: ../.venv-ci/bin/python manage.py collectstatic --noinput --clear
|
||||
- name: SQLite tests and coverage
|
||||
run: |
|
||||
.venv-ci/bin/python -m pytest -q \
|
||||
|
||||
+11
-51
@@ -15,64 +15,24 @@ jobs:
|
||||
release-check:
|
||||
if: ${{ github.event.pull_request.merged == true }}
|
||||
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
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: 检出 main
|
||||
env:
|
||||
REPOSITORY_URL: http://117.72.28.96:8765/Jacky/Hulumath-Web.git
|
||||
run: git clone --branch main --single-branch "$REPOSITORY_URL" .
|
||||
|
||||
- name: 显示 Python 版本
|
||||
run: python3 --version
|
||||
|
||||
- name: 安装 MySQL 编译依赖
|
||||
- name: 检查发布文件和脚本语法
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
python3-dev \
|
||||
python3-venv
|
||||
|
||||
- name: 安装发布检查依赖
|
||||
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: 检查迁移文件
|
||||
working-directory: backend
|
||||
run: ../.venv-release/bin/python manage.py makemigrations --check --dry-run
|
||||
|
||||
- name: Django 系统检查
|
||||
working-directory: backend
|
||||
run: ../.venv-release/bin/python manage.py check
|
||||
|
||||
- name: ASGI 启动导入检查
|
||||
working-directory: backend
|
||||
run: ../.venv-release/bin/python -c "from config.asgi import application; print(type(application).__name__)"
|
||||
|
||||
- name: MySQL 发布迁移检查
|
||||
env:
|
||||
DATABASE_URL: mysql://root:ci-root-password@mysql:3306/hulumath
|
||||
run: |
|
||||
.venv-release/bin/python backend/manage.py check --database default
|
||||
.venv-release/bin/python backend/manage.py check_mysql
|
||||
.venv-release/bin/python backend/manage.py migrate --noinput
|
||||
set -eu
|
||||
test -f requirements.txt
|
||||
test -f backend/manage.py
|
||||
test -f deploy/hulumath-web.service
|
||||
test -f scripts/deploy_production.sh
|
||||
test -f scripts/smoke_production.py
|
||||
bash -n scripts/deploy_production.sh
|
||||
python3 -m compileall -q backend scripts
|
||||
git diff-tree --check -m -r HEAD
|
||||
|
||||
deploy:
|
||||
if: ${{ github.event.pull_request.merged == true }}
|
||||
|
||||
+1
-1
@@ -428,7 +428,7 @@ PR 描述至少包含:
|
||||
2. 处理审查意见,不要无解释地关闭讨论。
|
||||
3. 请求 Jacky 审查。
|
||||
4. 只有 Jacky 明确批准后才可合并。
|
||||
5. 合并后观察 `PR合并自动部署` 的 `release-check` 和 `deploy`。
|
||||
5. 合并后观察 `PR合并自动部署`:`release-check` 做无依赖轻量检查,`deploy` 执行生产发布与冒烟。
|
||||
6. 部署失败时保留日志,先判断是代码、迁移、网络还是冒烟检查问题。
|
||||
|
||||
## 14. 安全与隐私
|
||||
|
||||
+52
-4
@@ -1,17 +1,65 @@
|
||||
import logging
|
||||
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _error_messages(details):
|
||||
messages = []
|
||||
|
||||
def collect(value):
|
||||
if isinstance(value, dict):
|
||||
for item in value.values():
|
||||
collect(item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
collect(item)
|
||||
elif value is not None:
|
||||
message = str(value).strip()
|
||||
if message and message not in messages:
|
||||
messages.append(message)
|
||||
|
||||
collect(details)
|
||||
return messages
|
||||
|
||||
|
||||
def _error_message(details):
|
||||
messages = _error_messages(details)
|
||||
if not messages:
|
||||
return "请求未能完成"
|
||||
return ";".join(messages)[:300]
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
response = drf_exception_handler(exc, context)
|
||||
request = context.get("request")
|
||||
if response is None:
|
||||
logger.exception(
|
||||
"api_unhandled_error method=%s path=%s",
|
||||
getattr(request, "method", "-"),
|
||||
getattr(request, "path", "-"),
|
||||
)
|
||||
return response
|
||||
|
||||
request = context.get("request")
|
||||
details = response.data
|
||||
code = getattr(exc, "default_code", "request_error")
|
||||
message = _error_message(details)
|
||||
fields = ",".join(details.keys()) if isinstance(details, dict) else "-"
|
||||
logger.info(
|
||||
"api_request_error method=%s path=%s status=%s code=%s fields=%s message=%s",
|
||||
getattr(request, "method", "-"),
|
||||
getattr(request, "path", "-"),
|
||||
response.status_code,
|
||||
code,
|
||||
fields,
|
||||
message,
|
||||
)
|
||||
response.data = {
|
||||
"error": {
|
||||
"code": getattr(exc, "default_code", "request_error"),
|
||||
"message": "请求未能完成",
|
||||
"details": response.data,
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": details,
|
||||
"request_id": getattr(request, "request_id", None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.test import override_settings
|
||||
|
||||
STATIC_ROOT = Path(settings.BASE_DIR) / "static"
|
||||
|
||||
@@ -13,6 +16,18 @@ def test_app_js_幂等键兼容非安全上下文():
|
||||
assert source.count('"Idempotency-Key": createIdempotencyKey()') == 2
|
||||
|
||||
|
||||
def test_app_js_api_错误优先显示详情并输出安全诊断日志():
|
||||
source = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "function collectApiErrorMessages(" in source
|
||||
assert "const baseMessage = detailMessages.length" in source
|
||||
assert 'console.error("[Hulumath API]", {' in source
|
||||
assert "requestId: error.requestId" in source
|
||||
assert "error.details = details || null" in source
|
||||
assert "payload.error?.message || payload.detail ||" in source
|
||||
assert "payload.error?.message || payload.detail ||\n (details ?" not in source
|
||||
|
||||
|
||||
def test_app_css_答题提交按钮可见且长弹窗可滚动():
|
||||
source = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
@@ -51,12 +66,49 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在():
|
||||
assert "pointerdown" in toolbox
|
||||
assert "window.HuluGames" in games
|
||||
assert "sudoku-board" in games
|
||||
assert 'errorMessage.className = "form-error game-form-error"' in games
|
||||
assert "errorMessage.textContent = error.message" in games
|
||||
assert "touch-action: none" in styles
|
||||
assert ".calculator-controls [hidden]" in styles
|
||||
assert ".sudoku-board" in styles
|
||||
assert "@media (max-width: 700px)" in styles
|
||||
|
||||
|
||||
def test_latex_默认源码可渲染且_katex_静态资源完整():
|
||||
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||
app = (STATIC_ROOT / "js" / "app.js").read_text(encoding="utf-8")
|
||||
katex_root = STATIC_ROOT / "vendor" / "katex"
|
||||
katex_css = (katex_root / "katex.min.css").read_text(encoding="utf-8")
|
||||
font_paths = set(re.findall(r"url\(fonts/([^)]+)\)", katex_css))
|
||||
|
||||
assert (
|
||||
'<textarea id="latex-source" spellcheck="false">'
|
||||
r"\int_{0}^{1} x^2\,dx = \frac{1}{3}</textarea>"
|
||||
) in template
|
||||
assert r"\\int_{0}^{1}" not in template
|
||||
assert "function normalizeLatexSource(source)" in app
|
||||
assert "globalThis.katex.render(normalized, target" in app
|
||||
assert "throwOnError: true" in app
|
||||
assert len(font_paths) == 60
|
||||
assert all((katex_root / "fonts" / path).is_file() for path in font_paths)
|
||||
assert (katex_root / "LICENSE.txt").is_file()
|
||||
|
||||
|
||||
def test_production_manifest_可处理全部静态资源(tmp_path):
|
||||
storage = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
with override_settings(
|
||||
STATIC_ROOT=tmp_path,
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {"BACKEND": storage},
|
||||
},
|
||||
):
|
||||
call_command("collectstatic", interactive=False, clear=True, verbosity=0)
|
||||
|
||||
assert (tmp_path / "staticfiles.json").is_file()
|
||||
assert (tmp_path / "vendor" / "katex" / "katex.min.css").is_file()
|
||||
|
||||
|
||||
def test_realtime_match_联机码与_websocket_前端资源存在():
|
||||
template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8")
|
||||
realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ast
|
||||
import secrets
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
from fractions import Fraction
|
||||
|
||||
@@ -36,6 +37,18 @@ TWENTY_FOUR_PUZZLES = {
|
||||
MathGameAttempt.Difficulty.HARD: [(1, 5, 5, 5), (3, 3, 7, 7), (5, 5, 7, 11)],
|
||||
}
|
||||
|
||||
TWENTY_FOUR_SYMBOLS = str.maketrans(
|
||||
{
|
||||
"×": "*",
|
||||
"·": "*",
|
||||
"∙": "*",
|
||||
"÷": "/",
|
||||
"−": "-",
|
||||
"–": "-",
|
||||
"—": "-",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _grid_from_text(value):
|
||||
return [[int(value[row * 9 + column]) for column in range(9)] for row in range(9)]
|
||||
@@ -80,7 +93,10 @@ def start_game(user, kind, difficulty):
|
||||
|
||||
|
||||
def _validate_twenty_four_expression(source, numbers):
|
||||
source = str(source or "").strip()
|
||||
source = unicodedata.normalize("NFKC", str(source or "")).translate(
|
||||
TWENTY_FOUR_SYMBOLS
|
||||
)
|
||||
source = source.strip()
|
||||
if not source or len(source) > 120:
|
||||
raise ValidationError({"expression": "请输入不超过 120 个字符的表达式"})
|
||||
try:
|
||||
@@ -90,7 +106,11 @@ def _validate_twenty_four_expression(source, numbers):
|
||||
used = []
|
||||
|
||||
def evaluate(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, int):
|
||||
if (
|
||||
isinstance(node, ast.Constant)
|
||||
and isinstance(node.value, int)
|
||||
and not isinstance(node.value, bool)
|
||||
):
|
||||
used.append(node.value)
|
||||
return Fraction(node.value)
|
||||
if isinstance(node, ast.BinOp) and isinstance(
|
||||
|
||||
@@ -2,33 +2,133 @@ from django.core.management.base import BaseCommand
|
||||
|
||||
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
|
||||
|
||||
import random
|
||||
|
||||
|
||||
def _generate_beginner():
|
||||
"""入门:100以内加减乘除,约 400 题。"""
|
||||
questions = []
|
||||
ops = [
|
||||
("+", lambda a, b: a + b),
|
||||
("-", lambda a, b: a - b),
|
||||
("×", lambda a, b: a * b),
|
||||
("÷", lambda a, b: a // b if b != 0 and a % b == 0 else None),
|
||||
]
|
||||
idx = 0
|
||||
for a in range(2, 100):
|
||||
for b in range(2, min(a + 1, 100)):
|
||||
for sym, fn in ops:
|
||||
if idx >= 400:
|
||||
return questions
|
||||
if sym == "÷" and (b == 0 or a % b != 0):
|
||||
continue
|
||||
if sym == "-" and a < b:
|
||||
continue
|
||||
result = fn(a, b)
|
||||
if result is None or result < 0 or result > 10000:
|
||||
continue
|
||||
slug = f"b-auto-{idx:04d}"
|
||||
prompt = f"{a} {sym} {b}"
|
||||
answer = str(result)
|
||||
if (slug, prompt, answer) not in questions:
|
||||
questions.append((slug, prompt, answer))
|
||||
idx += 1
|
||||
return questions[:400]
|
||||
|
||||
|
||||
def _generate_standard():
|
||||
"""标准:两位数乘除、大数减法、平方,约 400 题。"""
|
||||
questions = []
|
||||
idx = 0
|
||||
# 两位数乘法
|
||||
for a in range(11, 100):
|
||||
for b in range(11, min(a + 1, 100)):
|
||||
if idx >= 150:
|
||||
break
|
||||
questions.append((f"s-auto-mul-{idx:04d}", f"{a} × {b}", str(a * b)))
|
||||
idx += 1
|
||||
# 三位数除法
|
||||
for a in range(100, 1000, 7):
|
||||
for b in range(11, 50):
|
||||
if idx >= 300:
|
||||
break
|
||||
if a % b == 0:
|
||||
questions.append((f"s-auto-div-{idx:04d}", f"{a} ÷ {b}", str(a // b)))
|
||||
idx += 1
|
||||
# 大数减法
|
||||
for a in range(1000, 10000, 137):
|
||||
b = random.randint(100, a - 1)
|
||||
if idx >= 350:
|
||||
break
|
||||
questions.append((f"s-auto-sub-{idx:04d}", f"{a} - {b}", str(a - b)))
|
||||
idx += 1
|
||||
# 平方
|
||||
for a in range(11, 100):
|
||||
if idx >= 400:
|
||||
break
|
||||
questions.append((f"s-auto-sq-{idx:04d}", f"{a}²", str(a * a)))
|
||||
idx += 1
|
||||
return questions[:400]
|
||||
|
||||
|
||||
def _generate_advanced():
|
||||
"""进阶:模运算、组合数、高次幂、开方、等差数列求和,约 200 题。"""
|
||||
questions = []
|
||||
idx = 0
|
||||
# 模运算
|
||||
for base in range(2, 20):
|
||||
for exp in range(2, 13):
|
||||
for mod in range(3, 20):
|
||||
if idx >= 50:
|
||||
break
|
||||
result = pow(base, exp, mod)
|
||||
questions.append((f"a-auto-mod-{idx:04d}", f"{base}^{exp} ÷ {mod} 的余数", str(result)))
|
||||
idx += 1
|
||||
# 组合数 C(n,k)
|
||||
for n in range(5, 31):
|
||||
for k in range(2, min(n // 2 + 1, 8)):
|
||||
if idx >= 100:
|
||||
break
|
||||
from math import comb
|
||||
questions.append((f"a-auto-comb-{idx:04d}", f"C({n},{k})", str(comb(n, k))))
|
||||
idx += 1
|
||||
# 高次幂
|
||||
for base in range(2, 10):
|
||||
for exp in range(3, 10):
|
||||
if idx >= 130:
|
||||
break
|
||||
result = base ** exp
|
||||
if result > 10**8:
|
||||
continue
|
||||
questions.append((f"a-auto-pow-{idx:04d}", f"{base}^{exp}", str(result)))
|
||||
idx += 1
|
||||
# 开方(完全平方数)
|
||||
for n in range(10, 200):
|
||||
if idx >= 160:
|
||||
break
|
||||
r = int(n**0.5)
|
||||
if r * r == n:
|
||||
questions.append((f"a-auto-sqrt-{idx:04d}", f"√{n}", str(r)))
|
||||
idx += 1
|
||||
# 等差数列求和 1..n
|
||||
for n in range(10, 201, 5):
|
||||
if idx >= 200:
|
||||
break
|
||||
result = n * (n + 1) // 2
|
||||
questions.append((f"a-auto-sum-{idx:04d}", f"1 到 {n} 的整数和", str(result)))
|
||||
idx += 1
|
||||
return questions[:200]
|
||||
|
||||
|
||||
QUESTIONS = {
|
||||
Question.Track.BEGINNER: [
|
||||
("b-12-plus-19", "12 + 19", "31"),
|
||||
("b-8-times-7", "8 × 7", "56"),
|
||||
("b-90-minus-37", "90 - 37", "53"),
|
||||
("b-144-div-12", "144 ÷ 12", "12"),
|
||||
("b-25-times-4", "25 × 4", "100"),
|
||||
],
|
||||
Question.Track.STANDARD: [
|
||||
("s-17-times-23", "17 × 23", "391"),
|
||||
("s-625-div-25", "625 ÷ 25", "25"),
|
||||
("s-48-times-15", "48 × 15", "720"),
|
||||
("s-1000-minus-387", "1000 - 387", "613"),
|
||||
("s-35-squared", "35²", "1225"),
|
||||
],
|
||||
Question.Track.ADVANCED: [
|
||||
("a-mod-2pow10", "2¹⁰ 除以 7 的余数", "2"),
|
||||
("a-sum-1-50", "1 到 50 的整数和", "1275"),
|
||||
("a-15-choose-2", "C(15,2)", "105"),
|
||||
("a-sqrt-2025", "√2025", "45"),
|
||||
("a-3pow6", "3⁶", "729"),
|
||||
],
|
||||
Question.Track.BEGINNER: _generate_beginner(),
|
||||
Question.Track.STANDARD: _generate_standard(),
|
||||
Question.Track.ADVANCED: _generate_advanced(),
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "创建首批分层题目、实时 1v1、每日赛和单人练习"
|
||||
help = "创建分层题库(入门/标准/进阶)和实时 1v1、每日赛、单人练习"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
versions = {}
|
||||
@@ -93,6 +193,9 @@ class Command(BaseCommand):
|
||||
},
|
||||
)
|
||||
ContestQuestion.objects.filter(contest=contest).delete()
|
||||
pool = list(versions[track])
|
||||
random.shuffle(pool)
|
||||
selected = pool[:8]
|
||||
ContestQuestion.objects.bulk_create(
|
||||
[
|
||||
ContestQuestion(
|
||||
@@ -101,11 +204,14 @@ class Command(BaseCommand):
|
||||
order=index,
|
||||
points=100,
|
||||
)
|
||||
for index, version in enumerate(versions[track], start=1)
|
||||
for index, version in enumerate(selected, start=1)
|
||||
]
|
||||
)
|
||||
total = sum(map(len, QUESTIONS.values()))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"已创建 {sum(map(len, QUESTIONS.values()))} 道题和 {len(contest_specs)} 场比赛"
|
||||
f"已创建 {total} 道题(入门{len(QUESTIONS[Question.Track.BEGINNER])}、"
|
||||
f"标准{len(QUESTIONS[Question.Track.STANDARD])}、"
|
||||
f"进阶{len(QUESTIONS[Question.Track.ADVANCED])})和 {len(contest_specs)} 场比赛"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -435,6 +435,11 @@ def match_payload(match, user):
|
||||
if reveal_results and opponent_attempt
|
||||
else None
|
||||
),
|
||||
"duration_ms": (
|
||||
opponent_attempt.duration_ms
|
||||
if reveal_results and opponent_attempt
|
||||
else None
|
||||
),
|
||||
}
|
||||
if opponent
|
||||
else None
|
||||
@@ -481,12 +486,13 @@ def refresh_match_state(match_id):
|
||||
elif match.status == RealtimeMatch.Status.ACTIVE and match.started_at:
|
||||
deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds)
|
||||
if timezone.now() >= deadline:
|
||||
elapsed_ms = match.contest.duration_seconds * 1000
|
||||
match.attempts.filter(status=ContestAttempt.Status.ACTIVE).update(
|
||||
status=ContestAttempt.Status.EXPIRED,
|
||||
duration_ms=elapsed_ms,
|
||||
submitted_at=timezone.now(),
|
||||
)
|
||||
now = timezone.now()
|
||||
for attempt in match.attempts.filter(status=ContestAttempt.Status.ACTIVE):
|
||||
elapsed_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
|
||||
attempt.status = ContestAttempt.Status.EXPIRED
|
||||
attempt.duration_ms = elapsed_ms
|
||||
attempt.submitted_at = now
|
||||
attempt.save(update_fields=["status", "duration_ms", "submitted_at"])
|
||||
match = finalize_match(match.id)
|
||||
return match
|
||||
|
||||
@@ -516,7 +522,15 @@ def finalize_match(match_id):
|
||||
first_result, second_result = 0.0, 1.0
|
||||
match.winner_id = second.user_id
|
||||
else:
|
||||
first_result = second_result = 0.5
|
||||
diff = first.duration_ms - second.duration_ms
|
||||
if abs(diff) <= 100:
|
||||
first_result = second_result = 0.5
|
||||
elif diff < 0:
|
||||
first_result, second_result = 1.0, 0.0
|
||||
match.winner_id = first.user_id
|
||||
else:
|
||||
first_result, second_result = 0.0, 1.0
|
||||
match.winner_id = second.user_id
|
||||
|
||||
users = {
|
||||
user.id: user
|
||||
|
||||
@@ -47,6 +47,55 @@ def test_twenty_four_服务端校验数字使用与幂等提交(game_user):
|
||||
assert replay["score"] == result["score"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_twenty_four_api_兼容常见数学符号并完成计分(client, game_user):
|
||||
attempt = MathGameAttempt.objects.create(
|
||||
user=game_user,
|
||||
kind=MathGameAttempt.Kind.TWENTY_FOUR,
|
||||
puzzle={"numbers": [1, 3, 4, 6]},
|
||||
solution={"target": 24},
|
||||
)
|
||||
client.force_login(game_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
|
||||
{"expression": "6÷(1−3÷4)"},
|
||||
content_type="application/json",
|
||||
HTTP_IDEMPOTENCY_KEY="unicode-game-submit",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == MathGameAttempt.Status.COMPLETED
|
||||
assert response.json()["score"] >= 100
|
||||
attempt.refresh_from_db()
|
||||
assert attempt.submission == {"expression": "6/(1-3/4)"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_twenty_four_api_答案错误时返回具体原因和请求编号(client, game_user):
|
||||
attempt = MathGameAttempt.objects.create(
|
||||
user=game_user,
|
||||
kind=MathGameAttempt.Kind.TWENTY_FOUR,
|
||||
puzzle={"numbers": [1, 3, 4, 6]},
|
||||
solution={"target": 24},
|
||||
)
|
||||
client.force_login(game_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/contests/games/attempts/{attempt.id}/submit/",
|
||||
{"expression": "1 + 3 + 4 + 6"},
|
||||
content_type="application/json",
|
||||
HTTP_IDEMPOTENCY_KEY="incorrect-game-submit",
|
||||
HTTP_X_REQUEST_ID="twenty-four-invalid-test",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["message"] == "当前结果是 14,还没有得到 24"
|
||||
assert response.json()["error"]["request_id"] == "twenty-four-invalid-test"
|
||||
attempt.refresh_from_db()
|
||||
assert attempt.status == MathGameAttempt.Status.ACTIVE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
|
||||
attempt = MathGameAttempt.objects.create(
|
||||
@@ -70,6 +119,13 @@ def test_twenty_four_拒绝额外数字和非四则表达式(game_user):
|
||||
{"expression": "pow(2, 3) * 3"},
|
||||
"invalid-function",
|
||||
)
|
||||
with pytest.raises(ValidationError, match="只允许"):
|
||||
submit_game(
|
||||
game_user,
|
||||
attempt.id,
|
||||
{"expression": "6 / (True - 3 / 4)"},
|
||||
"invalid-boolean",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import User
|
||||
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
|
||||
from contest.models import (
|
||||
Contest,
|
||||
ContestQuestion,
|
||||
Question,
|
||||
QuestionVersion,
|
||||
RealtimeMatch,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -94,3 +103,103 @@ def test_challenge_api_创建加入状态提交形成完整闭环(realtime_api_s
|
||||
final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final_state["result"]["winner"] == "self"
|
||||
assert final_state["attempt"]["questions"][0]["correct_answer"] == "42"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_challenge_api_无效联机码返回具体原因_request_id_和诊断日志(
|
||||
realtime_api_setup,
|
||||
caplog,
|
||||
):
|
||||
_, _, second_client = realtime_api_setup
|
||||
caplog.set_level("INFO", logger="common.api")
|
||||
|
||||
response = second_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": "ABC234"},
|
||||
content_type="application/json",
|
||||
HTTP_X_REQUEST_ID="challenge-invalid-test",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response["X-Request-ID"] == "challenge-invalid-test"
|
||||
assert response.json()["error"] == {
|
||||
"code": "invalid",
|
||||
"message": "联机码不存在",
|
||||
"details": {"challenge_code": "联机码不存在"},
|
||||
"request_id": "challenge-invalid-test",
|
||||
}
|
||||
assert "api_request_error method=POST" in caplog.text
|
||||
assert "path=/api/v1/contests/challenges/join/" in caplog.text
|
||||
assert "fields=challenge_code message=联机码不存在" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_challenge_api_创建者不能加入自己的联机码(realtime_api_setup):
|
||||
contest, first_client, _ = realtime_api_setup
|
||||
created = first_client.post(
|
||||
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = first_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": created.json()["challenge_code"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["message"] == "不能加入自己创建的约战"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_challenge_api_过期联机码返回具体原因(realtime_api_setup):
|
||||
contest, first_client, second_client = realtime_api_setup
|
||||
created = first_client.post(
|
||||
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
RealtimeMatch.objects.filter(id=created.json()["match_id"]).update(
|
||||
expires_at=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
|
||||
response = second_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": created.json()["challenge_code"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_challenge_api_已使用联机码返回具体原因(realtime_api_setup):
|
||||
contest, first_client, second_client = realtime_api_setup
|
||||
created = first_client.post(
|
||||
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
second_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": created.json()["challenge_code"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
third = User.objects.create_user(
|
||||
username="api_player_three",
|
||||
password="StrongPass_2026",
|
||||
nickname="API 玩家三",
|
||||
)
|
||||
third_client = Client()
|
||||
third_client.force_login(third)
|
||||
|
||||
response = third_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": created.json()["challenge_code"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["message"] == "联机码已失效或已被使用"
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""端到端模拟两人实时竞赛,覆盖同分不同时长的平局判定场景。"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import User
|
||||
from contest.models import (
|
||||
Contest,
|
||||
ContestQuestion,
|
||||
Question,
|
||||
QuestionVersion,
|
||||
RealtimeMatch,
|
||||
)
|
||||
|
||||
|
||||
def _setup_realtime_contest(db, slug="tiebreak-contest", duration=60):
|
||||
question = Question.objects.create(
|
||||
slug=f"{slug}-q",
|
||||
track=Question.Track.STANDARD,
|
||||
)
|
||||
version = QuestionVersion.objects.create(
|
||||
question=question,
|
||||
version=1,
|
||||
prompt="18 + 24",
|
||||
answer="42",
|
||||
explanation="18 + 24 = 42",
|
||||
)
|
||||
contest = Contest.objects.create(
|
||||
slug=slug,
|
||||
title="同分决胜测试赛",
|
||||
kind=Contest.Kind.REALTIME,
|
||||
track=Question.Track.STANDARD,
|
||||
status=Contest.Status.PUBLISHED,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
ContestQuestion.objects.create(
|
||||
contest=contest,
|
||||
question_version=version,
|
||||
order=1,
|
||||
points=100,
|
||||
)
|
||||
return contest
|
||||
|
||||
|
||||
def _create_user(username, nickname):
|
||||
return User.objects.create_user(
|
||||
username=username,
|
||||
password="StrongPass_2026",
|
||||
nickname=nickname,
|
||||
)
|
||||
|
||||
|
||||
def _client(user):
|
||||
c = Client()
|
||||
c.force_login(user)
|
||||
return c
|
||||
|
||||
|
||||
def _create_and_join(first_client, second_client, contest):
|
||||
created = first_client.post(
|
||||
f"/api/v1/contests/{contest.slug}/challenges/",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert created.status_code == 201
|
||||
code = created.json()["challenge_code"]
|
||||
|
||||
joined = second_client.post(
|
||||
"/api/v1/contests/challenges/join/",
|
||||
{"challenge_code": code},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert joined.status_code == 200
|
||||
assert joined.json()["status"] == "active"
|
||||
match_id = joined.json()["match_id"]
|
||||
|
||||
first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
second_state = second_client.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
return match_id, first_state, second_state
|
||||
|
||||
|
||||
def _set_start(match, user, seconds_ago):
|
||||
"""统一设置 match 和 attempt 的 started_at。"""
|
||||
t = timezone.now() - timedelta(seconds=seconds_ago)
|
||||
match.started_at = t
|
||||
match.save(update_fields=["started_at"])
|
||||
match.attempts.filter(user=user).update(started_at=t)
|
||||
|
||||
|
||||
def _submit(client, attempt_id, answer, key):
|
||||
return client.post(
|
||||
f"/api/v1/contests/attempts/{attempt_id}/submit/",
|
||||
{"answers": [{"order": 1, "answer": answer}]},
|
||||
content_type="application/json",
|
||||
HTTP_IDEMPOTENCY_KEY=key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_同分但一方更快则快者胜(db):
|
||||
"""两人答对同一题,但提交时间不同,用时短者获胜。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-fast")
|
||||
first = _create_user("tie_p1", "快方")
|
||||
second = _create_user("tie_p2", "慢方")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, fs, ss = _create_and_join(fc, sc, contest)
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 5)
|
||||
r1 = _submit(fc, fa, "42", "tie-fast-one")
|
||||
assert r1.status_code == 200
|
||||
|
||||
_set_start(match, second, 15)
|
||||
r2 = _submit(sc, sa, "42", "tie-fast-two")
|
||||
assert r2.status_code == 200
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["result"]["winner"] == "self"
|
||||
assert final["attempt"]["score"] == 100
|
||||
assert final["opponent"]["score"] == 100
|
||||
assert final["attempt"]["duration_ms"] < final["opponent"]["duration_ms"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_同分且同时长则平局(db):
|
||||
"""两人答对同一题且用时完全相同,判平局。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-draw")
|
||||
first = _create_user("draw_p1", "甲")
|
||||
second = _create_user("draw_p2", "乙")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, fs, ss = _create_and_join(fc, sc, contest)
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 5)
|
||||
_submit(fc, fa, "42", "draw-one")
|
||||
|
||||
_set_start(match, second, 5)
|
||||
_submit(sc, sa, "42", "draw-two")
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["result"]["winner"] == "draw"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_一方答对一方答错则答对者胜(db):
|
||||
"""一人答对一人答错,答对者胜(不受时间影响)。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-correct")
|
||||
first = _create_user("corr_p1", "答对方")
|
||||
second = _create_user("corr_p2", "答错方")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, fs, ss = _create_and_join(fc, sc, contest)
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 3)
|
||||
_submit(fc, fa, "42", "corr-one")
|
||||
|
||||
_set_start(match, second, 10)
|
||||
_submit(sc, sa, "0", "corr-two")
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["result"]["winner"] == "self"
|
||||
assert final["attempt"]["score"] == 100
|
||||
assert final["opponent"]["score"] == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_双方都答错则快者胜(db):
|
||||
"""两人都答错,分数相同(0 分),比较用时,快者胜。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-both-wrong")
|
||||
first = _create_user("wrong_p1", "快错")
|
||||
second = _create_user("wrong_p2", "慢错")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, fs, ss = _create_and_join(fc, sc, contest)
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 5)
|
||||
_submit(fc, fa, "0", "wrong-one")
|
||||
|
||||
_set_start(match, second, 12)
|
||||
_submit(sc, sa, "0", "wrong-two")
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["result"]["winner"] == "self"
|
||||
assert final["attempt"]["score"] == 0
|
||||
assert final["opponent"]["score"] == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_超时提交导致分数为零_同分时比较用时(db):
|
||||
"""一方超时提交(分数为 0),另一方正常提交也得 0 分,同分快者胜。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-timeout", duration=10)
|
||||
first = _create_user("to_p1", "超时方")
|
||||
second = _create_user("to_p2", "正常零分方")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, fs, ss = _create_and_join(fc, sc, contest)
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 20)
|
||||
r1 = _submit(fc, fa, "42", "to-one")
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["attempt"]["status"] == "expired"
|
||||
|
||||
_set_start(match, second, 3)
|
||||
_submit(sc, sa, "0", "to-two")
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["attempt"]["score"] == 0
|
||||
assert final["opponent"]["score"] == 0
|
||||
assert final["result"]["winner"] == "opponent"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_随机匹配同分快者胜(db):
|
||||
"""通过随机匹配(非联机码)也能正确触发同分快者胜。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-random")
|
||||
first = _create_user("rnd_p1", "随机快")
|
||||
second = _create_user("rnd_p2", "随机慢")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
r1 = fc.post(
|
||||
f"/api/v1/contests/{contest.slug}/matchmaking/",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
r2 = sc.post(
|
||||
f"/api/v1/contests/{contest.slug}/matchmaking/",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r1.json()["status"] == "active" or r2.json()["status"] == "active"
|
||||
|
||||
match_id = r1.json().get("match_id") or r2.json()["match_id"]
|
||||
fs = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
ss = sc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 4)
|
||||
_submit(fc, fa, "42", "rnd-one")
|
||||
|
||||
_set_start(match, second, 8)
|
||||
_submit(sc, sa, "42", "rnd-two")
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["result"]["winner"] == "self"
|
||||
assert final["attempt"]["score"] == 100
|
||||
assert final["opponent"]["score"] == 100
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rating_变化在同分快者胜时正确(db):
|
||||
"""同分快者胜时,Elo rating 按正常胜负变化(不平局)。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-rating")
|
||||
first = _create_user("rate_p1", "Rating快")
|
||||
second = _create_user("rate_p2", "Rating慢")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, fs, ss = _create_and_join(fc, sc, contest)
|
||||
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
|
||||
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
_set_start(match, first, 5)
|
||||
_submit(fc, fa, "42", "rate-one")
|
||||
|
||||
_set_start(match, second, 12)
|
||||
_submit(sc, sa, "42", "rate-two")
|
||||
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
assert first.rating > 1000
|
||||
assert second.rating < 1000
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_双方都超时则由_refresh_自动结算判平(db):
|
||||
"""双方都超时(分数都为 0),refresh_match_state 自动结算,duration 统一为时限,判平。"""
|
||||
contest = _setup_realtime_contest(db, "tiebreak-both-timeout", duration=10)
|
||||
first = _create_user("both_to_p1", "快超时")
|
||||
second = _create_user("both_to_p2", "慢超时")
|
||||
fc, sc = _client(first), _client(second)
|
||||
|
||||
match_id, _, _ = _create_and_join(fc, sc, contest)
|
||||
|
||||
# 两个人都不提交,直接让 match 超时
|
||||
match = RealtimeMatch.objects.get(id=match_id)
|
||||
match.started_at = timezone.now() - timedelta(seconds=15)
|
||||
match.save(update_fields=["started_at"])
|
||||
match.attempts.update(started_at=match.started_at)
|
||||
|
||||
from contest.services import refresh_match_state
|
||||
refresh_match_state(match.id)
|
||||
|
||||
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
|
||||
assert final["status"] == "completed"
|
||||
assert final["attempt"]["score"] == 0
|
||||
assert final["opponent"]["score"] == 0
|
||||
# 双方都超时,duration_ms 相同(都约 15000ms),判平
|
||||
assert final["result"]["winner"] == "draw"
|
||||
@@ -139,6 +139,8 @@ button { color: inherit; }
|
||||
.realtime-progress-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }.realtime-progress-panel > div { padding: 13px 15px; border-radius: 12px; background: #eef1eb; }.realtime-progress-panel span, .realtime-progress-panel b { display: block; }.realtime-progress-panel span { color: var(--muted); font-size: 10px; }.realtime-progress-panel b { margin-top: 5px; color: var(--green); }
|
||||
.realtime-answer-form input { margin-top: 7px; width: 100%; padding: 12px; border: 1px solid #d6d8d1; border-radius: 9px; }.realtime-submitted { margin-top: 20px; padding: 30px; border-radius: 16px; background: var(--ink); color: white; text-align: center; }.realtime-submitted strong { color: var(--lime); font: 27px Georgia, serif; }.realtime-submitted p { margin-bottom: 0; color: #b9c2bc; }
|
||||
.realtime-result { margin: 20px 0; padding: 28px; border-radius: 17px; background: var(--ink); color: white; text-align: center; }.realtime-result > strong { color: var(--lime); font: 38px Georgia, serif; }.realtime-result p { color: #c5cec8; }.realtime-result > b { display: inline-block; padding: 6px 10px; border-radius: 99px; background: rgba(204,232,91,.13); color: var(--lime); }.result-opponent > strong { color: #ef947c; }
|
||||
.realtime-time-line { margin: 10px 0 6px; font-size: 13px; color: #9ba89d; }
|
||||
.realtime-tiebreak { display: block; margin-top: 6px; font-size: 11px; color: var(--lime); font-weight: 700; }
|
||||
.realtime-review { display: grid; gap: 9px; }.realtime-review article { padding: 15px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 11px; background: white; }.realtime-review article.incorrect { border-left-color: #c05245; }.realtime-review p { margin: 7px 0; color: #3e4942; }.realtime-review small { color: var(--muted); }
|
||||
.math-games-section { margin-top: 60px; }
|
||||
.game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
@@ -161,7 +163,9 @@ button { color: inherit; }
|
||||
.editor-pane label, .preview-pane > span { display: block; color: #9eaaa2; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; }
|
||||
.editor-pane textarea { width: 100%; height: 330px; margin-top: 22px; resize: none; border: 0; outline: 0; background: transparent; color: #dcefa1; font: 16px/1.8 "SFMono-Regular", Consolas, monospace; }
|
||||
.preview-pane { display: flex; flex-direction: column; }
|
||||
#latex-preview { margin: auto; max-width: 100%; overflow-wrap: anywhere; font: 28px/1.6 Georgia, serif; }
|
||||
#latex-preview { margin: auto; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 18px 4px; font-size: 28px; line-height: 1.6; }
|
||||
#latex-preview .katex-display { margin: 0; overflow-x: auto; overflow-y: hidden; }
|
||||
#latex-preview.latex-preview-error { color: #b84136; font: 14px/1.7 system-ui, sans-serif; white-space: normal; }
|
||||
.preview-pane button { align-self: flex-end; }
|
||||
.profile-panel { padding: 35px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||
@@ -219,10 +223,16 @@ button { color: inherit; }
|
||||
.pet-node { position: absolute; left: calc(50% - 49px); top: 145px; width: 98px; height: 98px; border: 2px solid #b49d8b; border-radius: 50%; background: white; display: grid; place-items: center; box-shadow: 0 13px 25px rgba(38,48,40,.12); }
|
||||
.pet-node b { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #778990; color: white; font: 22px Georgia, serif; }.pet-node span { position: absolute; bottom: 8px; font-size: 10px; color: var(--muted); }
|
||||
.map-hint { margin: 0; text-align: center; color: #a0a49e; font-size: 11px; }
|
||||
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 11px; }
|
||||
.filter-chip { border: 1px solid var(--line); border-radius: 99px; padding: 9px 14px; background: rgba(255,255,252,.7); color: var(--muted); cursor: pointer; }
|
||||
.filter-chip.active { background: var(--ink); border-color: var(--ink); color: white; }
|
||||
.discipline-filters .filter-chip { border-radius: 9px; }
|
||||
.video-browser { margin-top: 28px; }.filter-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 11px; }
|
||||
.filter-dropdown { position: relative; }
|
||||
.filter-dropdown-toggle { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 12px; padding: 10px 16px; background: rgba(255,255,252,.82); color: var(--ink); cursor: pointer; font-size: 14px; min-width: 140px; justify-content: space-between; }
|
||||
.filter-dropdown-toggle b { font-size: 10px; color: var(--muted); transition: transform .2s; }
|
||||
.filter-dropdown-toggle[aria-expanded="true"] b { transform: rotate(180deg); }
|
||||
.filter-dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 200px; max-height: 320px; overflow-y: auto; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,252,.96); backdrop-filter: blur(16px); box-shadow: 0 12px 40px rgba(38,48,40,.12); padding: 6px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.filter-dropdown-panel.hidden { display: none; }
|
||||
.filter-option { border: 0; border-radius: 10px; padding: 10px 14px; background: transparent; color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; transition: background .15s; }
|
||||
.filter-option:hover { background: #eef1eb; }
|
||||
.filter-option.active { background: var(--ink); color: white; }
|
||||
.video-section-heading { margin: 35px 0 16px; display: flex; justify-content: space-between; align-items: end; }.video-section-heading h2 { margin: 8px 0 0; font: 30px Georgia, serif; }.video-section-heading > span { color: var(--muted); font-size: 12px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }.video-grid.loading { display: block; color: var(--muted); padding: 35px 0; }
|
||||
.video-card { border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,252,.82); overflow: hidden; cursor: pointer; transition: .2s ease; }
|
||||
|
||||
+128
-24
@@ -70,22 +70,72 @@ function createIdempotencyKey() {
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function collectApiErrorMessages(value, messages = []) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => collectApiErrorMessages(item, messages));
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach((item) => collectApiErrorMessages(item, messages));
|
||||
} else if (value !== undefined && value !== null) {
|
||||
const message = String(value).trim();
|
||||
if (message && !messages.includes(message)) messages.push(message);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function logApiError(error) {
|
||||
if (error.status === 401 || error.status === 403) return;
|
||||
console.error("[Hulumath API]", {
|
||||
method: error.method,
|
||||
path: error.path,
|
||||
status: error.status,
|
||||
code: error.code,
|
||||
requestId: error.requestId,
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const requestPath = `/api/v1/${path}`;
|
||||
const headers = { Accept: "application/json", ...(options.headers || {}) };
|
||||
if (options.body && typeof options.body !== "string") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(options.body);
|
||||
}
|
||||
if (!["GET", "HEAD"].includes(options.method || "GET")) headers["X-CSRFToken"] = csrfToken();
|
||||
const response = await fetch(`/api/v1/${path}`, { credentials: "same-origin", ...options, headers });
|
||||
if (!["GET", "HEAD"].includes(method)) headers["X-CSRFToken"] = csrfToken();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(requestPath, { credentials: "same-origin", ...options, headers });
|
||||
} catch (cause) {
|
||||
const error = new Error("网络连接失败,请检查连接后重试");
|
||||
error.status = null;
|
||||
error.code = "network_error";
|
||||
error.requestId = null;
|
||||
error.details = null;
|
||||
error.path = requestPath;
|
||||
error.method = method;
|
||||
error.cause = cause;
|
||||
logApiError(error);
|
||||
throw error;
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const details = payload.error?.details;
|
||||
const message = payload.error?.message || payload.detail ||
|
||||
(details ? Object.values(details).flat().join(" ") : "请求失败");
|
||||
const detailMessages = collectApiErrorMessages(details);
|
||||
const requestId = payload.error?.request_id || response.headers.get("X-Request-ID");
|
||||
const baseMessage = detailMessages.length
|
||||
? detailMessages.join(";")
|
||||
: payload.error?.message || payload.detail || `请求失败(HTTP ${response.status})`;
|
||||
const message = requestId ? `${baseMessage}(请求编号:${requestId})` : baseMessage;
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.code = payload.error?.code || "request_error";
|
||||
error.requestId = requestId;
|
||||
error.details = details || null;
|
||||
error.path = requestPath;
|
||||
error.method = method;
|
||||
logApiError(error);
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
@@ -477,27 +527,46 @@ function renderAttempt(attempt) {
|
||||
root.append(label, title, form);
|
||||
}
|
||||
|
||||
function filterChip(label, value, type, active) {
|
||||
function filterOption(label, value, type, active) {
|
||||
const button = document.createElement("button");
|
||||
button.className = `filter-chip${active ? " active" : ""}`;
|
||||
button.className = `filter-option${active ? " active" : ""}`;
|
||||
button.textContent = label;
|
||||
button.dataset[type] = value;
|
||||
button.setAttribute("role", "option");
|
||||
button.addEventListener("click", () => {
|
||||
if (type === "ability") state.videoAbility = value;
|
||||
if (type === "discipline") state.videoDiscipline = value;
|
||||
renderVideoFilters();
|
||||
renderVideos();
|
||||
closeFilterDropdowns();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function closeFilterDropdowns() {
|
||||
$$(".filter-dropdown-panel").forEach((panel) => panel.classList.add("hidden"));
|
||||
$$(".filter-dropdown-toggle").forEach((toggle) => toggle.setAttribute("aria-expanded", "false"));
|
||||
}
|
||||
|
||||
function toggleFilterDropdown(type) {
|
||||
const panel = $(`#${type}-panel`);
|
||||
const toggle = $(`#${type}-toggle`);
|
||||
const isOpen = !panel.classList.contains("hidden");
|
||||
closeFilterDropdowns();
|
||||
if (!isOpen) {
|
||||
panel.classList.remove("hidden");
|
||||
toggle.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
}
|
||||
|
||||
function renderVideoFilters() {
|
||||
if (!state.videoCatalog) return;
|
||||
const abilityRoot = $("#ability-filters");
|
||||
abilityRoot.replaceChildren(
|
||||
filterChip(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
|
||||
|
||||
const abilityPanel = $("#ability-panel");
|
||||
abilityPanel.replaceChildren(
|
||||
filterOption(`全部 ${state.videoCatalog.total}`, "", "ability", !state.videoAbility),
|
||||
...state.videoCatalog.abilities.map((ability) =>
|
||||
filterChip(
|
||||
filterOption(
|
||||
`${ability.icon} ${ability.label} ${ability.count}`,
|
||||
ability.id,
|
||||
"ability",
|
||||
@@ -505,11 +574,14 @@ function renderVideoFilters() {
|
||||
)
|
||||
),
|
||||
);
|
||||
const disciplineRoot = $("#discipline-filters");
|
||||
disciplineRoot.replaceChildren(
|
||||
filterChip("全部专业", "", "discipline", !state.videoDiscipline),
|
||||
const abilityLabel = state.videoCatalog.abilities.find((a) => a.id === state.videoAbility);
|
||||
$("#ability-label").textContent = abilityLabel ? `${abilityLabel.icon} ${abilityLabel.label}` : "全部维度";
|
||||
|
||||
const disciplinePanel = $("#discipline-panel");
|
||||
disciplinePanel.replaceChildren(
|
||||
filterOption("全部专业", "", "discipline", !state.videoDiscipline),
|
||||
...state.videoCatalog.disciplines.map((discipline) =>
|
||||
filterChip(
|
||||
filterOption(
|
||||
`${discipline.icon || "∑"} ${discipline.name}`,
|
||||
discipline.name,
|
||||
"discipline",
|
||||
@@ -517,11 +589,10 @@ function renderVideoFilters() {
|
||||
)
|
||||
),
|
||||
);
|
||||
const disciplineLabel = state.videoCatalog.disciplines.find((d) => d.name === state.videoDiscipline);
|
||||
$("#discipline-label").textContent = disciplineLabel ? `${disciplineLabel.icon || "∑"} ${disciplineLabel.name}` : "全部专业";
|
||||
|
||||
const selected = state.videoCatalog.abilities.find(
|
||||
(item) => item.id === state.videoAbility
|
||||
);
|
||||
$("#video-stream-title").textContent = selected ? `${selected.label}视频流` : "全部视频";
|
||||
$("#video-stream-title").textContent = abilityLabel ? `${abilityLabel.label}视频流` : "全部视频";
|
||||
$$(".ability-node").forEach((node) => {
|
||||
node.classList.toggle("active", node.dataset.ability === state.videoAbility);
|
||||
});
|
||||
@@ -1011,15 +1082,40 @@ function switchTool(tool) {
|
||||
window.HuluToolbox?.activate(tool);
|
||||
}
|
||||
|
||||
function normalizeLatexSource(source) {
|
||||
let normalized = String(source || "").trim();
|
||||
if (normalized.startsWith("$$") && normalized.endsWith("$$")) {
|
||||
normalized = normalized.slice(2, -2).trim();
|
||||
} else if (normalized.startsWith("\\[") && normalized.endsWith("\\]")) {
|
||||
normalized = normalized.slice(2, -2).trim();
|
||||
} else if (normalized.startsWith("$") && normalized.endsWith("$")) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
return normalized.replace(/\\\\(?=[A-Za-z,;:!])/g, "\\");
|
||||
}
|
||||
|
||||
function renderLatexPreview(source) {
|
||||
const target = $("#latex-preview");
|
||||
target.replaceChildren();
|
||||
if (!source.trim()) return;
|
||||
if (typeof katex === "undefined") { target.textContent = source; return; }
|
||||
target.classList.remove("latex-preview-error");
|
||||
const normalized = normalizeLatexSource(source);
|
||||
if (!normalized) return;
|
||||
if (typeof globalThis.katex?.render !== "function") {
|
||||
target.classList.add("latex-preview-error");
|
||||
target.textContent = "公式渲染组件加载失败,请刷新页面后重试。";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
katex.render(source, target, { throwOnError: false, displayMode: true });
|
||||
} catch {
|
||||
target.textContent = source;
|
||||
globalThis.katex.render(normalized, target, {
|
||||
displayMode: true,
|
||||
throwOnError: true,
|
||||
trust: false,
|
||||
maxExpand: 500,
|
||||
maxSize: 20,
|
||||
});
|
||||
} catch (error) {
|
||||
target.classList.add("latex-preview-error");
|
||||
target.textContent = `公式语法错误:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,7 +1124,10 @@ async function saveFormula() {
|
||||
try {
|
||||
await api("latex/documents/", {
|
||||
method: "POST",
|
||||
body: { title: `公式 ${new Date().toLocaleString()}`, source: $("#latex-source").value },
|
||||
body: {
|
||||
title: `公式 ${new Date().toLocaleString()}`,
|
||||
source: normalizeLatexSource($("#latex-source").value),
|
||||
},
|
||||
});
|
||||
showToast("公式及首个版本已保存");
|
||||
} catch (error) {
|
||||
@@ -1051,6 +1150,11 @@ function bindUI() {
|
||||
});
|
||||
$("#enter-alumni").addEventListener("click", showLifeSkills);
|
||||
$("#back-to-hub").addEventListener("click", showLifeHub);
|
||||
$("#ability-toggle").addEventListener("click", () => toggleFilterDropdown("ability"));
|
||||
$("#discipline-toggle").addEventListener("click", () => toggleFilterDropdown("discipline"));
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".filter-dropdown")) closeFilterDropdowns();
|
||||
});
|
||||
$$(".route-card").forEach((card) => {
|
||||
card.addEventListener("click", (event) => {
|
||||
if (event.target.closest(".route-enter")) return;
|
||||
|
||||
@@ -143,6 +143,13 @@
|
||||
input.placeholder = "例如:6 / (1 - 3 / 4)";
|
||||
input.autocomplete = "off";
|
||||
input.inputMode = "text";
|
||||
input.spellcheck = false;
|
||||
const errorMessage = document.createElement("p");
|
||||
errorMessage.className = "form-error game-form-error";
|
||||
errorMessage.setAttribute("role", "alert");
|
||||
input.addEventListener("input", () => {
|
||||
errorMessage.textContent = "";
|
||||
});
|
||||
const keypad = document.createElement("div");
|
||||
keypad.className = "game-keypad";
|
||||
["+", "-", "*", "/", "(", ")"].forEach((operator) => {
|
||||
@@ -159,10 +166,11 @@
|
||||
submit.className = "primary-button";
|
||||
submit.type = "submit";
|
||||
submit.textContent = "验证并计分";
|
||||
form.append(input, keypad, submit);
|
||||
form.append(input, keypad, errorMessage, submit);
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
errorMessage.textContent = "";
|
||||
try {
|
||||
const result = await api(
|
||||
`contests/games/attempts/${attempt.attempt_id}/submit/`,
|
||||
@@ -175,6 +183,7 @@
|
||||
renderTwentyFour(result);
|
||||
showToast("得到 24,成绩已记录");
|
||||
} catch (error) {
|
||||
errorMessage.textContent = error.message;
|
||||
showToast(error.message);
|
||||
submit.disabled = false;
|
||||
}
|
||||
|
||||
@@ -329,13 +329,30 @@
|
||||
const score = document.createElement("p");
|
||||
score.textContent =
|
||||
`你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}`;
|
||||
const timeLine = document.createElement("p");
|
||||
timeLine.className = "realtime-time-line";
|
||||
const selfMs = match.attempt.duration_ms || 0;
|
||||
const opponentMs = match.opponent.duration_ms || 0;
|
||||
const selfSec = (selfMs / 1000).toFixed(1);
|
||||
const opponentSec = (opponentMs / 1000).toFixed(1);
|
||||
timeLine.textContent = `你 ${selfSec}s · ${match.opponent.nickname} ${opponentSec}s`;
|
||||
if (match.attempt.score === match.opponent.score && match.result.winner !== "draw") {
|
||||
const faster = selfMs < opponentMs ? "你" : match.opponent.nickname;
|
||||
const tiebreak = document.createElement("small");
|
||||
tiebreak.className = "realtime-tiebreak";
|
||||
tiebreak.textContent = `同分,${faster}更快完成,快者胜`;
|
||||
timeLine.append(tiebreak);
|
||||
}
|
||||
const rating = document.createElement("b");
|
||||
const sign = match.result.rating_delta > 0 ? "+" : "";
|
||||
rating.textContent =
|
||||
`Rating ${sign}${match.result.rating_delta} → ${match.result.rating_after}`;
|
||||
result.append(outcome, score, rating);
|
||||
result.append(outcome, score, timeLine, rating);
|
||||
root.append(result);
|
||||
|
||||
// 刷新右上角用户 Rating 显示
|
||||
if (typeof loadUser === "function") loadUser();
|
||||
|
||||
const review = document.createElement("div");
|
||||
review.className = "realtime-review";
|
||||
match.attempt.questions.forEach((question) => {
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -328,8 +328,8 @@
|
||||
<div class="tool-workspace" id="tool-latex">
|
||||
<div class="workspace-heading"><div><span class="kicker">LATEX LAB</span><h2>数学表达实验室</h2></div><p>源码、实时预览与版本保存。</p></div>
|
||||
<div class="editor-shell">
|
||||
<div class="editor-pane"><label for="latex-source">LaTeX 源码</label><textarea id="latex-source" spellcheck="false">\\int_{0}^{1} x^2\\,dx = \\frac{1}{3}</textarea></div>
|
||||
<div class="preview-pane"><span>实时预览</span><div id="latex-preview">\int_{0}^{1} x^2\,dx = \frac{1}{3}</div><button id="save-formula" class="primary-button">保存到我的公式</button></div>
|
||||
<div class="editor-pane"><label for="latex-source">LaTeX 源码</label><textarea id="latex-source" spellcheck="false">\int_{0}^{1} x^2\,dx = \frac{1}{3}</textarea></div>
|
||||
<div class="preview-pane"><span>实时预览</span><div id="latex-preview" role="status" aria-live="polite"></div><button id="save-formula" class="primary-button">保存到我的公式</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -353,8 +353,20 @@
|
||||
<p class="map-hint">点击地图节点,进入对应维度的视频流</p>
|
||||
</section>
|
||||
<section class="video-browser">
|
||||
<div class="filter-row ability-filters" id="ability-filters"></div>
|
||||
<div class="filter-row discipline-filters" id="discipline-filters"></div>
|
||||
<div class="filter-row">
|
||||
<div class="filter-dropdown" id="ability-filter">
|
||||
<button class="filter-dropdown-toggle" id="ability-toggle" aria-expanded="false" aria-haspopup="listbox">
|
||||
<span id="ability-label">全部维度</span><b>▾</b>
|
||||
</button>
|
||||
<div class="filter-dropdown-panel hidden" id="ability-panel" role="listbox"></div>
|
||||
</div>
|
||||
<div class="filter-dropdown" id="discipline-filter">
|
||||
<button class="filter-dropdown-toggle" id="discipline-toggle" aria-expanded="false" aria-haspopup="listbox">
|
||||
<span id="discipline-label">全部专业</span><b>▾</b>
|
||||
</button>
|
||||
<div class="filter-dropdown-panel hidden" id="discipline-panel" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-section-heading"><div><span class="kicker">VIDEO STREAM</span><h2 id="video-stream-title">全部视频</h2></div><span id="video-result-count">0 条内容</span></div>
|
||||
<div id="video-list" class="video-grid loading">正在读取视频流…</div>
|
||||
</section>
|
||||
|
||||
@@ -493,14 +493,18 @@ Ruff 静态检查
|
||||
PR 合并到 `main` 后会执行:
|
||||
|
||||
```text
|
||||
MySQL 8.0.35 发布迁移检查
|
||||
最终 main 发布文件与脚本语法检查
|
||||
→ SSH 生产服务器
|
||||
→ MySQL 备份
|
||||
→ collectstatic
|
||||
→ migrate
|
||||
→ 重启
|
||||
→ 应用与 Nginx HTTP/WebSocket 冒烟检查
|
||||
```
|
||||
|
||||
合并后的轻量发布检查不再重复创建虚拟环境、启动临时 MySQL 或下载依赖;
|
||||
完整 Django、MySQL 和静态资源检查由合并前必须通过的 `CI / test` 负责。
|
||||
|
||||
## 16. 备份与恢复
|
||||
|
||||
自动备份目录:
|
||||
|
||||
+6
-4
@@ -13,9 +13,9 @@ PR 质量门禁由 `.gitea/workflows/ci.yml` 执行:
|
||||
|
||||
生产发布由 `.gitea/workflows/deploy.yml` 执行。目标为 `main` 的 PR 被真正合并后:
|
||||
|
||||
1. Runner 检出 `main` 并在临时 MySQL 8.0.35 上执行发布迁移检查。
|
||||
2. 检查通过后 SSH 到生产服务器。
|
||||
3. 服务器备份 MySQL,安装依赖,执行迁移并收集静态资源。
|
||||
1. Runner 检出最终 `main`,检查必要发布文件、Python 语法和部署脚本语法。
|
||||
2. 轻量检查通过后 SSH 到生产服务器;该阶段不重复安装 Python/MySQL 依赖。
|
||||
3. 服务器备份 MySQL,增量安装依赖,收集静态资源并执行迁移。
|
||||
4. systemd 重启 Django ASGI 服务。
|
||||
5. 验证应用 HTTP、Redis Channel Layer 和直连 WebSocket。
|
||||
6. 通过 Nginx 验证健康接口、首页、后台、视频目录和 WebSocket。
|
||||
@@ -33,7 +33,9 @@ PR 质量门禁由 `.gitea/workflows/ci.yml` 执行:
|
||||
- 禁止在检查未完成时合并。
|
||||
- 管理员也应遵守分支保护。
|
||||
|
||||
完整测试只在 PR 阶段执行。合并后发布流程不重复运行全量测试,因此分支保护是生产发布的必要条件。
|
||||
完整测试、MySQL 迁移验证和生产静态资源检查只在 PR 阶段执行。合并后的
|
||||
`release-check` 不创建虚拟环境或临时 MySQL,避免在一次变更中重复下载依赖;
|
||||
因此分支保护和 `CI / test` 是生产发布的必要条件。
|
||||
|
||||
## Gitea Secrets
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ test -s "$BACKUP_FILE" || fail "数据库备份为空: $BACKUP_FILE"
|
||||
find "$BACKUP_DIR" -type f -name 'hulumath_*.sql.gz' -mtime +14 -delete
|
||||
log "数据库备份完成: $BACKUP_FILE"
|
||||
|
||||
log "执行 Django 发布检查、迁移和静态资源收集"
|
||||
log "执行 Django 发布检查、静态资源收集和迁移"
|
||||
if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
|
||||
$SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles"
|
||||
fi
|
||||
@@ -240,8 +240,8 @@ else
|
||||
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level ERROR --database default
|
||||
fi
|
||||
"$VENV_DIR/bin/python" manage.py check_mysql
|
||||
"$VENV_DIR/bin/python" manage.py migrate --noinput
|
||||
"$VENV_DIR/bin/python" manage.py collectstatic --noinput
|
||||
"$VENV_DIR/bin/python" manage.py migrate --noinput
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
log "安装或更新 systemd 服务"
|
||||
|
||||
Reference in New Issue
Block a user