diff --git a/backend/app/api/endpoints/admin_catalogue.py b/backend/app/api/endpoints/admin_catalogue.py
index d8f223ab..ab80617b 100644
--- a/backend/app/api/endpoints/admin_catalogue.py
+++ b/backend/app/api/endpoints/admin_catalogue.py
@@ -1,42 +1,12 @@
from fastapi import APIRouter, Depends
from app.api.deps.auth import get_current_user
-from app.models import User
-from app.services.admin_common import require_admin
-from app.services.learning_resources import _builtin_descriptors, spaces_manifest
+from app.services import admin_catalogue as service
router = APIRouter()
@router.get("/catalogue/preview")
-def preview_catalogue(current_user: User = Depends(get_current_user)) -> dict:
- require_admin(current_user)
- latest: dict[str, dict] = {}
- for descriptor in _builtin_descriptors():
- if descriptor.get("kind") != "activity":
- continue
- key = str(descriptor["key"])
- version = int(descriptor.get("version_number", 1))
- previous = latest.get(key)
- if previous is None or version > int(previous.get("version_number", 1)):
- latest[key] = descriptor
- items = []
- for index, descriptor in enumerate(sorted(latest.values(), key=lambda item: (item["space"], item["key"])), 1):
- items.append(
- {
- "id": index,
- "resource_key": descriptor["key"],
- "space_key": descriptor["space"],
- "subject_key": descriptor["subject"],
- "kind": "activity",
- "version_number": int(descriptor.get("version_number", 1)),
- "title": descriptor["title"],
- "renderer": descriptor["renderer"],
- "definition": descriptor["definition"],
- "capabilities": descriptor["capabilities"],
- "provenance": descriptor["provenance"],
- "content_sha256": "catalogue-preview",
- }
- )
- return {"spaces": spaces_manifest()["spaces"], "items": items, "total": len(items)}
+def preview_catalogue(actor=Depends(get_current_user)) -> dict:
+ return service.preview_catalogue(actor=actor)
diff --git a/backend/app/api/endpoints/ai_tutor.py b/backend/app/api/endpoints/ai_tutor.py
index 45698a8c..c04d8fc2 100644
--- a/backend/app/api/endpoints/ai_tutor.py
+++ b/backend/app/api/endpoints/ai_tutor.py
@@ -1,59 +1,18 @@
-from urllib.parse import urljoin
-
-import httpx
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends
from app.api.deps.auth import get_current_user
-from app.core.config import get_settings
-from app.models import User
from app.schemas.ai_tutor import AiTutorChatCreate, AiTutorChatRead, AiTutorConfigRead
+from app.services import ai_tutor as service
router = APIRouter()
-def _config() -> tuple[bool, str, str, str | None]:
- settings = get_settings()
- key = settings.ai_tutor_api_key.get_secret_value() if settings.ai_tutor_api_key else None
- enabled = bool(settings.ai_tutor_enabled and key and settings.ai_tutor_provider == "deepseek")
- return enabled, settings.ai_tutor_provider, settings.ai_tutor_model, key
-
-
@router.get("/config", response_model=AiTutorConfigRead)
-def get_config(current_user: User = Depends(get_current_user)) -> AiTutorConfigRead:
- del current_user
- enabled, provider, model, _ = _config()
- return AiTutorConfigRead(enabled=enabled, provider=provider, model=model if enabled else None)
+def get_config(_actor=Depends(get_current_user)) -> AiTutorConfigRead:
+ return service.get_config()
@router.post("/chat", response_model=AiTutorChatRead)
-def chat(payload: AiTutorChatCreate, current_user: User = Depends(get_current_user)) -> AiTutorChatRead:
- enabled, provider, model, api_key = _config()
- if not enabled or not api_key:
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="学校尚未启用 AI 助教")
- context = payload.context or {}
- context_line = ";".join(f"{key}={value}" for key, value in context.items() if value)
- system = (
- "你是星序学习平台的 AI 助教。使用简洁、可验证的中文回答,优先引导学生观察、提出假设和检查证据,"
- "不要替代教师评分,不要声称学生已经掌握,也不要编造当前实验中不存在的参数。"
- f"当前学习身份:{current_user.role}。当前页面上下文:{context_line or '未提供'}。"
- )
- endpoint = urljoin(get_settings().ai_tutor_base_url.rstrip("/") + "/", "chat/completions")
- request_body = {
- "model": model,
- "messages": [{"role": "system", "content": system}, {"role": "user", "content": payload.message}],
- "temperature": 0.35,
- "stream": False,
- }
- try:
- with httpx.Client(timeout=get_settings().ai_tutor_timeout_seconds) as client:
- response = client.post(endpoint, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=request_body)
- response.raise_for_status()
- data = response.json()
- except (httpx.HTTPError, ValueError) as exc:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="AI 助教暂时无法连接模型服务") from exc
- choices = data.get("choices") if isinstance(data, dict) else None
- answer = choices[0].get("message", {}).get("content") if choices and isinstance(choices[0], dict) else None
- if not isinstance(answer, str) or not answer.strip():
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型没有返回可显示的回答")
- return AiTutorChatRead(answer=answer.strip(), provider=provider, model=model)
+def chat(payload: AiTutorChatCreate, actor=Depends(get_current_user)) -> AiTutorChatRead:
+ return service.chat(payload, actor=actor)
diff --git a/backend/app/api/endpoints/content_platform.py b/backend/app/api/endpoints/content_platform.py
index e91c0002..d8935cf4 100644
--- a/backend/app/api/endpoints/content_platform.py
+++ b/backend/app/api/endpoints/content_platform.py
@@ -1,12 +1,12 @@
"""HTTP adapter for V8.4 shared course drafts and releases."""
-from collections.abc import Callable
from typing import Any
-from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.orm import Session
from app.api.deps.auth import get_current_user
+from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.content_platform import (
CheckpointAttemptCreate,
@@ -24,11 +24,6 @@
router = APIRouter()
-def _service_call(db: Session, operation: Callable[..., Any], **kwargs: Any) -> Any:
- from app.api.endpoints.course_workflow import service_call
- return service_call(db, operation, **kwargs)
-
-
@router.post(
"/courses/{course_id}/units/{unit_id}/checkpoints/{checkpoint_key}/attempts",
response_model=CheckpointAttemptRead,
@@ -43,7 +38,7 @@ def submit_checkpoint_attempt(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return _service_call(
+ return service_call(
db,
completion_service.submit_checkpoint_attempt,
actor=current_user,
@@ -61,7 +56,7 @@ def get_course_draft(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return _service_call(
+ return service_call(
db,
platform_service.get_course_draft,
actor=current_user,
@@ -77,7 +72,7 @@ def replace_course_draft(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return _service_call(
+ return service_call(
db,
platform_service.replace_course_draft,
actor=current_user,
@@ -93,7 +88,7 @@ def list_course_releases(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
- return _service_call(
+ return service_call(
db,
platform_service.list_course_releases,
actor=current_user,
@@ -113,7 +108,7 @@ def create_course_release(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return _service_call(
+ return service_call(
db,
platform_service.create_course_release,
actor=current_user,
@@ -132,7 +127,7 @@ def get_current_course_release(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return _service_call(
+ return service_call(
db,
platform_service.get_current_course_release,
actor=current_user,
@@ -150,7 +145,7 @@ def get_course_release(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return _service_call(
+ return service_call(
db,
platform_service.get_course_release,
actor=current_user,
diff --git a/backend/app/api/endpoints/course_media.py b/backend/app/api/endpoints/course_media.py
index 88528f6c..e84b9166 100644
--- a/backend/app/api/endpoints/course_media.py
+++ b/backend/app/api/endpoints/course_media.py
@@ -7,7 +7,7 @@
from starlette.concurrency import run_in_threadpool
from app.api.deps.auth import get_current_user
-from app.api.endpoints.course_workflow import service_call
+from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.course_media import CourseMediaPage, CourseMediaRead
from app.services import course_media as media
diff --git a/backend/app/api/endpoints/course_workflow.py b/backend/app/api/endpoints/course_workflow.py
index 122af853..e1a22867 100644
--- a/backend/app/api/endpoints/course_workflow.py
+++ b/backend/app/api/endpoints/course_workflow.py
@@ -1,19 +1,14 @@
"""v2 course workflow transport. Services own authorization and one commit per command."""
-from collections.abc import Callable
-from typing import Any
-from fastapi import APIRouter, Depends, HTTPException, Query, Request
-from sqlalchemy.exc import IntegrityError, OperationalError
+from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.deps.auth import get_current_user
+from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.course_workflow import CourseCreateCommand, CourseDraftCommand, CourseDraftReadV2, CourseForkCommand, CourseForkRead, CourseRevisionRead, CourseWorkflowRead, WorkflowReceiptRead
from app.services import course_drafts_v2 as drafts
from app.services.course_workflow_support import read_operation
-from app.services.content_platform import ContentPlatformError
-from app.services.course_completion import CourseCompletionError
-from app.core.learning_evidence_contract import LearningEvidenceError
from app.schemas import course_workflow as dto
from app.services import course_candidates as candidates, course_reviews_v2 as reviews, course_publications_v2 as publications
@@ -25,28 +20,6 @@ def operation_receipt(client_request_id: str, actor=Depends(get_current_user), d
return service_call(db, read_operation, actor=actor, client_request_id=client_request_id)
-def service_call(db: Session, operation: Callable[..., Any], **kwargs):
- try:
- return operation(db, **kwargs)
- except (ContentPlatformError, CourseCompletionError) as error:
- db.rollback()
- raise HTTPException(status_code=error.status_code, detail={"code": error.code, "message": error.message}) from error
- except LearningEvidenceError as error:
- db.rollback()
- raise HTTPException(status_code=error.status_code, detail={"code": error.code, "message": error.detail}) from error
- except IntegrityError as error:
- db.rollback()
- raise HTTPException(status_code=409, detail={"code": "workflow_write_conflict", "message": "数据已变化或操作已提交,请重新读取并保留原请求编号"}) from error
- except OperationalError as error:
- db.rollback()
- if "database is locked" in str(error.orig).lower() or "database table is locked" in str(error.orig).lower():
- raise HTTPException(status_code=409, detail={"code": "workflow_write_conflict", "message": "另一项写入正在提交,请保留原请求编号后重试"}) from error
- raise
- except HTTPException:
- db.rollback()
- raise
-
-
@router.get("/courses", response_model=list[CourseWorkflowRead])
def list_courses(school_id: int | None = None, actor=Depends(get_current_user), db: Session = Depends(get_db)):
return service_call(db, drafts.list_courses, actor=actor, school_id=school_id)
diff --git a/backend/app/api/endpoints/learning_history.py b/backend/app/api/endpoints/learning_history.py
index ef2c438c..f412514f 100644
--- a/backend/app/api/endpoints/learning_history.py
+++ b/backend/app/api/endpoints/learning_history.py
@@ -2,7 +2,7 @@
from sqlalchemy.orm import Session
from app.api.deps.auth import get_current_user
-from app.api.endpoints.course_workflow import service_call
+from app.api.service_calls import service_call
from app.db.session import get_db
from app.schemas.learning_history import ContextCheckpointAnswer, ContextCheckpointRead, LearningContextRead, LearningContextStart
from app.services import learning_assessments, learning_contexts
diff --git a/backend/app/api/endpoints/submissions.py b/backend/app/api/endpoints/submissions.py
index 211428e8..e1c5b4e5 100644
--- a/backend/app/api/endpoints/submissions.py
+++ b/backend/app/api/endpoints/submissions.py
@@ -1,36 +1,21 @@
-from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
-from sqlalchemy import and_, func, or_, select
+"""HTTP adapters for assignment queries and versioned submission commands."""
+
+from fastapi import APIRouter, Depends, Query, Request, status
from sqlalchemy.orm import Session
from app.api.deps.auth import get_current_user
+from app.api.service_calls import service_call
from app.db.session import get_db
-from app.models import Assignment, AssignmentClassPolicy, ClassGroup, ClassMembership, Course, CourseClass, CourseUnit, CourseUnitClassPlan, School, Submission, User
from app.schemas.course import (
- AssignmentRead,
AssignmentReviewRead,
AssignmentSubmissionPage,
- CourseRead,
- CourseUnitRead,
- StudentAssignmentCenterItem,
StudentAssignmentCenterPage,
StudentAssignmentFilter,
SubmissionCreate,
SubmissionGrade,
SubmissionRead,
)
-from app.schemas.school import ClassRead
-from app.services.assignment_policies import (
- build_effective_assignment_policy,
- effective_assignment_payload,
- resolve_assignment_class_policy,
-)
-from app.services.access_control import course_attached_to_class, get_class, require_class_teacher_or_admin, require_course_editor_or_admin, require_course_scope, require_course_visible, require_school_role, require_student_unit_published, teacher_class_ids
-from app.services.learning_evidence_access import (
- authoritative_prerequisite_unit_ids_by_scope,
-)
-from app.services.pagination import list_legacy_scalars, paged_endpoint_url
-from app.services.course_release_plans import effective_unit_access, get_course_class_or_404, get_plan_for_unit
-
+from app.services import assignment_history, submissions_queries
router = APIRouter()
@@ -42,175 +27,17 @@ def list_my_assignments(
filter_by: StudentAssignmentFilter = Query(default="all", alias="filter"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
- current_user: User = Depends(get_current_user),
+ current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> StudentAssignmentCenterPage:
- if current_user.role != "student":
- raise HTTPException(status_code=403, detail="Only students can view their assignment center")
-
- class_group: ClassGroup | None = None
- if class_id is not None:
- class_group = _require_active_user_class(db, current_user.id, class_id)
- if course_id is not None:
- if class_group is not None:
- require_course_scope(db, current_user, class_group, course_id)
- else:
- require_course_visible(db, current_user, course_id)
-
- active_class_ids = select(ClassMembership.class_id).where(
- ClassMembership.user_id == current_user.id,
- ClassMembership.role == "student",
- ClassMembership.status == "active",
- )
- statement = (
- select(
- ClassGroup,
- Course,
- CourseUnit,
- Assignment,
- Submission,
- AssignmentClassPolicy,
- CourseUnitClassPlan,
- )
- .select_from(ClassGroup)
- .join(CourseClass, CourseClass.class_id == ClassGroup.id)
- .join(Course, Course.id == CourseClass.course_id)
- .join(CourseUnit, CourseUnit.course_id == Course.id)
- .join(
- CourseUnitClassPlan,
- and_(
- CourseUnitClassPlan.course_class_id == CourseClass.id,
- CourseUnitClassPlan.course_unit_id == CourseUnit.id,
- ),
- )
- .join(Assignment, Assignment.unit_id == CourseUnit.id)
- .outerjoin(
- AssignmentClassPolicy,
- and_(
- AssignmentClassPolicy.assignment_id == Assignment.id,
- AssignmentClassPolicy.class_id == ClassGroup.id,
- ),
- )
- .outerjoin(
- Submission,
- and_(
- Submission.assignment_id == Assignment.id,
- Submission.student_id == current_user.id,
- Submission.class_id == ClassGroup.id,
- ),
- )
- .where(
- ClassGroup.id.in_(active_class_ids),
- CourseClass.status == "active",
- Course.status == "published",
- CourseUnit.status == "published",
- CourseUnitClassPlan.release_mode != "hidden",
- or_(
- and_(
- Assignment.audience_mode == "selected_classes",
- AssignmentClassPolicy.id.is_not(None),
- AssignmentClassPolicy.assigned.is_(True),
- ),
- and_(
- Assignment.audience_mode == "all_attached_classes",
- or_(
- AssignmentClassPolicy.id.is_(None),
- AssignmentClassPolicy.assigned.is_(True),
- ),
- ),
- ),
- )
- )
- effective_status = func.coalesce(AssignmentClassPolicy.status_override, Assignment.status)
- if class_id is not None:
- statement = statement.where(ClassGroup.id == class_id)
- if course_id is not None:
- statement = statement.where(Course.id == course_id)
- if filter_by == "active":
- statement = statement.where(
- effective_status == "active",
- ClassGroup.status == "active",
- )
- elif filter_by == "feedback":
- statement = statement.where(Submission.status.in_(["graded", "returned"]))
- elif filter_by == "history":
- statement = statement.where(
- or_(
- effective_status.in_(["closed", "archived"]),
- ClassGroup.status != "active",
- )
- )
-
- statement = statement.order_by(ClassGroup.id, Course.id, CourseUnit.position, Assignment.id)
- total = int(db.scalar(select(func.count()).select_from(statement.order_by(None).subquery())) or 0)
- rows = db.execute(statement.offset(offset).limit(limit)).all()
- school_by_id = {
- school.id: school
- for school in db.scalars(select(School).where(School.id.in_({row[1].school_id for row in rows}))).all()
- } if rows else {}
- prerequisite_ids = {row[6].prerequisite_unit_id for row in rows if row[6].prerequisite_unit_id is not None}
- completed_by_scope = authoritative_prerequisite_unit_ids_by_scope(
+ return submissions_queries.list_my_assignments(
db,
- subject_user_id=current_user.id,
- scopes={(row[0].id, row[1].id) for row in rows} if prerequisite_ids else set(),
- )
- visible_rows = []
- for row in rows:
- row_class, course, unit, assignment, submission, policy, plan = row
- access = effective_unit_access(
- db,
- course=course,
- class_group=row_class,
- unit=unit,
- plan=plan,
- student_id=current_user.id,
- completed_unit_ids=completed_by_scope.get((row_class.id, course.id), set()),
- school_active=school_by_id.get(course.school_id) is not None and school_by_id[course.school_id].status == "active",
- )
- visible_rows.append((*row, access))
- rows = visible_rows
- items: list[StudentAssignmentCenterItem] = []
- for row_class, course, unit, assignment, submission, policy, plan, access in rows:
- effective = build_effective_assignment_policy(assignment, row_class.id, policy)
- submit_block_reason = _assignment_submit_block_reason(
- effective.status,
- submission,
- row_class.status,
- )
- if submit_block_reason is None and access.state != "open":
- submit_block_reason = "unit_locked"
- assignment_payload = effective_assignment_payload(assignment, effective)
- assignment_payload["unit_release_state"] = access.state
- assignment_payload["unit_lock_reasons"] = list(access.lock_reasons)
- items.append(
- StudentAssignmentCenterItem(
- class_=ClassRead.model_validate(row_class),
- course=CourseRead.model_validate(course),
- unit=CourseUnitRead(
- id=unit.id,
- course_id=unit.course_id,
- activity_key=unit.activity_key,
- title=unit.title,
- position=unit.position,
- content_slug=unit.content_slug,
- status=unit.status,
- effective_release_state=access.state,
- lock_reasons=list(access.lock_reasons),
- ),
- assignment=AssignmentRead.model_validate(assignment_payload),
- submission=SubmissionRead.model_validate(submission) if submission is not None else None,
- can_submit=submit_block_reason is None,
- read_only=submit_block_reason is not None,
- submit_block_reason=submit_block_reason,
- )
- )
- next_offset = offset + len(items)
- return StudentAssignmentCenterPage(
- items=items,
- total=total,
+ current_user=current_user,
+ class_id=class_id,
+ course_id=course_id,
+ filter_by=filter_by,
limit=limit,
offset=offset,
- next_offset=next_offset if next_offset < total else None,
)
@@ -223,90 +50,31 @@ def create_submission(
assignment_id: int,
payload: SubmissionCreate,
request: Request,
- current_user: User = Depends(get_current_user),
+ current_user=Depends(get_current_user),
db: Session = Depends(get_db),
-) -> Submission:
- from app.api.endpoints.course_workflow import service_call
- from app.services.assignment_history import legacy_submit
- return service_call(db, legacy_submit, actor=current_user, assignment_id=assignment_id, payload=payload, request=request)
+) -> SubmissionRead:
+ return service_call(
+ db,
+ assignment_history.legacy_submit,
+ actor=current_user,
+ assignment_id=assignment_id,
+ payload=payload,
+ request=request,
+ )
@router.get("/assignments/{assignment_id}/review", response_model=AssignmentReviewRead)
def read_assignment_review(
assignment_id: int,
class_id: int | None = Query(default=None),
- current_user: User = Depends(get_current_user),
+ current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> AssignmentReviewRead:
- if current_user.role != "student":
- raise HTTPException(status_code=403, detail="Only students can review their assignment history")
- assignment, unit, course = _resolve_assignment(db, assignment_id)
- require_course_visible(db, current_user, course.id)
- require_student_unit_published(current_user, unit)
- statement = (
- select(Submission)
- .where(
- Submission.assignment_id == assignment.id,
- Submission.student_id == current_user.id,
- )
- .order_by(Submission.submitted_at.desc(), Submission.id.desc())
- )
- if class_id is not None:
- class_group = _require_active_user_class(db, current_user.id, class_id)
- if class_group.school_id != course.school_id:
- raise HTTPException(status_code=422, detail="Class does not belong to assignment school")
- if not course_attached_to_class(db, course.id, class_group.id):
- raise HTTPException(status_code=403, detail="Course is not attached to this class")
- effective = resolve_assignment_class_policy(db, assignment, class_group.id)
- if not effective.assigned:
- raise HTTPException(status_code=403, detail="Assignment is not assigned to this class")
- statement = statement.where(Submission.class_id == class_group.id)
- else:
- eligible_class_ids = _active_user_course_class_ids(db, current_user.id, course.id)
- eligible_class_ids = [
- eligible_class_id
- for eligible_class_id in eligible_class_ids
- if resolve_assignment_class_policy(db, assignment, eligible_class_id).assigned
- ]
- if len(eligible_class_ids) > 1:
- raise HTTPException(
- status_code=422,
- detail="class_id is required when assignment is available in multiple classes",
- )
- if not eligible_class_ids:
- raise HTTPException(status_code=403, detail="Assignment is outside current student class scope")
- effective = resolve_assignment_class_policy(db, assignment, eligible_class_ids[0])
- statement = statement.where(Submission.class_id == effective.class_id)
- class_group = get_class(db, effective.class_id)
- submission = db.scalars(statement.limit(1)).first()
- submit_block_reason = _assignment_submit_block_reason(
- effective.status,
- submission,
- class_group.status,
- )
- course_class = get_course_class_or_404(db, course.id, class_group.id)
- plan = get_plan_for_unit(db, course_class, unit.id)
- access = effective_unit_access(
+ return submissions_queries.read_assignment_review(
db,
- course=course,
- class_group=class_group,
- unit=unit,
- plan=plan,
- student_id=current_user.id,
- )
- if access.state == "hidden":
- raise HTTPException(status_code=403, detail="Course unit is not visible in this class")
- if submit_block_reason is None and access.state != "open":
- submit_block_reason = "unit_locked"
- can_submit = submit_block_reason is None
- return AssignmentReviewRead(
- course_id=course.id,
- unit_id=unit.id,
- assignment=AssignmentRead.model_validate(effective_assignment_payload(assignment, effective)),
- submission=SubmissionRead.model_validate(submission) if submission is not None else None,
- can_submit=can_submit,
- read_only=not can_submit,
- submit_block_reason=submit_block_reason,
+ current_user=current_user,
+ assignment_id=assignment_id,
+ class_id=class_id,
)
@@ -314,24 +82,14 @@ def read_assignment_review(
def list_assignment_submissions(
assignment_id: int,
class_id: int | None = Query(default=None),
- current_user: User = Depends(get_current_user),
+ current_user=Depends(get_current_user),
db: Session = Depends(get_db),
-) -> list[Submission]:
- statement = _assignment_submission_history_statement(
+) -> list[SubmissionRead]:
+ return submissions_queries.list_assignment_submissions(
db,
+ current_user=current_user,
assignment_id=assignment_id,
class_id=class_id,
- current_user=current_user,
- )
- return list_legacy_scalars(
- db,
- statement,
- paged_endpoint=paged_endpoint_url(
- f"/api/assignments/{assignment_id}/submissions/page",
- class_id=class_id,
- limit=200,
- offset=0,
- ),
)
@@ -341,193 +99,32 @@ def list_assignment_submissions_page(
class_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
- current_user: User = Depends(get_current_user),
+ current_user=Depends(get_current_user),
db: Session = Depends(get_db),
) -> AssignmentSubmissionPage:
- statement = _assignment_submission_history_statement(
+ return submissions_queries.list_assignment_submissions_page(
db,
+ current_user=current_user,
assignment_id=assignment_id,
class_id=class_id,
- current_user=current_user,
- )
- total = int(db.scalar(select(func.count()).select_from(statement.order_by(None).subquery())) or 0)
- items = list(db.scalars(statement.offset(offset).limit(limit)).all())
- next_offset = offset + len(items)
- return AssignmentSubmissionPage(
- items=[SubmissionRead.model_validate(item) for item in items],
- total=total,
limit=limit,
offset=offset,
- next_offset=next_offset if next_offset < total else None,
)
-def _assignment_submission_history_statement(
- db: Session,
- *,
- assignment_id: int,
- class_id: int | None,
- current_user: User,
-):
- assignment, unit, course = _resolve_assignment(db, assignment_id)
- statement = select(Submission).where(Submission.assignment_id == assignment.id).order_by(Submission.id)
-
- if current_user.role == "student":
- require_course_visible(db, current_user, course.id)
- require_student_unit_published(current_user, unit)
- if class_id is None:
- eligible_class_ids = _active_user_course_class_ids(db, current_user.id, course.id)
- if len(eligible_class_ids) != 1:
- raise HTTPException(
- status_code=422,
- detail="class_id is required for student assignment submission history scope",
- )
- class_id = eligible_class_ids[0]
- statement = statement.where(Submission.student_id == current_user.id)
- _require_active_user_class(db, current_user.id, class_id)
- if not course_attached_to_class(db, course.id, class_id):
- raise HTTPException(status_code=403, detail="Course is not attached to this class")
- if not resolve_assignment_class_policy(db, assignment, class_id).assigned:
- raise HTTPException(status_code=403, detail="Assignment is not assigned to this class")
- course_class = get_course_class_or_404(db, course.id, class_id)
- plan = get_plan_for_unit(db, course_class, unit.id)
- access = effective_unit_access(
- db,
- course=course,
- class_group=get_class(db, class_id),
- unit=unit,
- plan=plan,
- student_id=current_user.id,
- )
- if access.state == "hidden":
- raise HTTPException(status_code=403, detail="Course unit is not visible in this class")
- statement = statement.where(Submission.class_id == class_id)
- return statement
-
- require_school_role(db, current_user, course.school_id, {"admin", "teacher"})
- if class_id is not None:
- class_group = get_class(db, class_id)
- if class_group.school_id != course.school_id:
- raise HTTPException(status_code=422, detail="Class does not belong to assignment school")
- if not course_attached_to_class(db, course.id, class_group.id):
- raise HTTPException(status_code=403, detail="Course is not attached to this class")
- if class_group.kind == "course_cohort":
- require_course_editor_or_admin(
- db,
- current_user,
- course,
- detail="Course submissions require an active course teacher",
- )
- else:
- require_class_teacher_or_admin(
- db,
- current_user,
- class_group,
- detail="Assignment submissions require class teacher scope",
- )
- statement = statement.where(Submission.class_id == class_id)
- elif current_user.role != "admin":
- class_ids = set(teacher_class_ids(db, current_user.id))
- try:
- require_course_editor_or_admin(
- db,
- current_user,
- course,
- detail="Course submissions require an active course teacher",
- )
- except HTTPException:
- pass
- else:
- class_ids.update(
- db.scalars(
- select(ClassGroup.id)
- .join(CourseClass, CourseClass.class_id == ClassGroup.id)
- .where(
- CourseClass.course_id == course.id,
- CourseClass.status == "active",
- ClassGroup.kind == "course_cohort",
- ClassGroup.status == "active",
- )
- ).all()
- )
- if not class_ids:
- return statement.where(Submission.id.is_(None))
- statement = statement.where(Submission.class_id.in_(class_ids))
- return statement
-
-
@router.patch("/submissions/{submission_id}/grade", response_model=SubmissionRead)
def grade_submission(
submission_id: int,
payload: SubmissionGrade,
request: Request,
- current_user: User = Depends(get_current_user),
+ current_user=Depends(get_current_user),
db: Session = Depends(get_db),
-) -> Submission:
- from app.api.endpoints.course_workflow import service_call
- from app.services.assignment_history import legacy_grade
- return service_call(db, legacy_grade, actor=current_user, submission_id=submission_id, payload=payload, request=request)
-
-
-def _require_active_user_class(db: Session, user_id: int, class_id: int) -> ClassGroup:
- class_group = get_class(db, class_id)
- membership = db.scalar(
- select(ClassMembership.id).where(
- ClassMembership.class_id == class_id,
- ClassMembership.user_id == user_id,
- ClassMembership.role == "student",
- ClassMembership.status == "active",
- )
- )
- if membership is None:
- raise HTTPException(status_code=403, detail="Class is outside current student scope")
- return class_group
-
-
-def _active_user_course_class_ids(db: Session, user_id: int, course_id: int) -> list[int]:
- return list(
- db.scalars(
- select(ClassMembership.class_id)
- .join(CourseClass, CourseClass.class_id == ClassMembership.class_id)
- .where(
- ClassMembership.user_id == user_id,
- ClassMembership.role == "student",
- ClassMembership.status == "active",
- CourseClass.course_id == course_id,
- CourseClass.status == "active",
- )
- .distinct()
- .order_by(ClassMembership.class_id)
- ).all()
+) -> SubmissionRead:
+ return service_call(
+ db,
+ assignment_history.legacy_grade,
+ actor=current_user,
+ submission_id=submission_id,
+ payload=payload,
+ request=request,
)
-
-
-def _assignment_submit_block_reason(
- assignment_status: str,
- submission: Submission | None,
- class_status: str,
-) -> str | None:
- if class_status != "active":
- return "class_archived"
- if assignment_status == "closed":
- return "assignment_closed"
- if assignment_status == "archived":
- return "assignment_archived"
- if assignment_status != "active":
- return "assignment_not_active"
- if submission is not None and submission.status != "returned":
- return "already_submitted"
- return None
-
-
-def _resolve_assignment(db: Session, assignment_id: int) -> tuple[Assignment, CourseUnit, Course]:
- assignment = db.get(Assignment, assignment_id)
- if assignment is None:
- raise HTTPException(status_code=404, detail="Assignment not found")
- unit = db.get(CourseUnit, assignment.unit_id)
- if unit is None:
- raise HTTPException(status_code=404, detail="Course unit not found")
- course = db.get(Course, unit.course_id)
- if course is None:
- raise HTTPException(status_code=404, detail="Course not found")
- return assignment, unit, course
diff --git a/backend/app/api/service_calls.py b/backend/app/api/service_calls.py
new file mode 100644
index 00000000..33dd8084
--- /dev/null
+++ b/backend/app/api/service_calls.py
@@ -0,0 +1,33 @@
+"""Translate service failures at the HTTP boundary and roll back failed commands."""
+from collections.abc import Callable
+from typing import Any
+
+from fastapi import HTTPException
+from sqlalchemy.exc import IntegrityError, OperationalError
+from sqlalchemy.orm import Session
+
+from app.core.learning_evidence_contract import LearningEvidenceError
+from app.services.content_platform import ContentPlatformError
+from app.services.course_completion import CourseCompletionError
+
+
+def service_call(db: Session, operation: Callable[..., Any], **kwargs):
+ try:
+ return operation(db, **kwargs)
+ except (ContentPlatformError, CourseCompletionError) as error:
+ db.rollback()
+ raise HTTPException(status_code=error.status_code, detail={"code": error.code, "message": error.message}) from error
+ except LearningEvidenceError as error:
+ db.rollback()
+ raise HTTPException(status_code=error.status_code, detail={"code": error.code, "message": error.detail}) from error
+ except IntegrityError as error:
+ db.rollback()
+ raise HTTPException(status_code=409, detail={"code": "workflow_write_conflict", "message": "数据已变化或操作已提交,请重新读取并保留原请求编号"}) from error
+ except OperationalError as error:
+ db.rollback()
+ if "database is locked" in str(error.orig).lower() or "database table is locked" in str(error.orig).lower():
+ raise HTTPException(status_code=409, detail={"code": "workflow_write_conflict", "message": "另一项写入正在提交,请保留原请求编号后重试"}) from error
+ raise
+ except HTTPException:
+ db.rollback()
+ raise
diff --git a/backend/app/services/admin_catalogue.py b/backend/app/services/admin_catalogue.py
new file mode 100644
index 00000000..a0edb2c9
--- /dev/null
+++ b/backend/app/services/admin_catalogue.py
@@ -0,0 +1,37 @@
+"""Project the latest built-in activities for the administrator preview."""
+
+from app.models import User
+from app.services.admin_common import require_admin
+from app.services.learning_resources import _builtin_descriptors, spaces_manifest
+
+
+def preview_catalogue(*, actor: User) -> dict:
+ require_admin(actor)
+ latest: dict[str, dict] = {}
+ for descriptor in _builtin_descriptors():
+ if descriptor.get("kind") != "activity":
+ continue
+ key = str(descriptor["key"])
+ version = int(descriptor.get("version_number", 1))
+ previous = latest.get(key)
+ if previous is None or version > int(previous.get("version_number", 1)):
+ latest[key] = descriptor
+ items = []
+ for index, descriptor in enumerate(sorted(latest.values(), key=lambda item: (item["space"], item["key"])), 1):
+ items.append(
+ {
+ "id": index,
+ "resource_key": descriptor["key"],
+ "space_key": descriptor["space"],
+ "subject_key": descriptor["subject"],
+ "kind": "activity",
+ "version_number": int(descriptor.get("version_number", 1)),
+ "title": descriptor["title"],
+ "renderer": descriptor["renderer"],
+ "definition": descriptor["definition"],
+ "capabilities": descriptor["capabilities"],
+ "provenance": descriptor["provenance"],
+ "content_sha256": "catalogue-preview",
+ }
+ )
+ return {"spaces": spaces_manifest()["spaces"], "items": items, "total": len(items)}
diff --git a/backend/app/services/ai_tutor.py b/backend/app/services/ai_tutor.py
new file mode 100644
index 00000000..41803e21
--- /dev/null
+++ b/backend/app/services/ai_tutor.py
@@ -0,0 +1,54 @@
+"""School-configured tutor availability and bounded upstream chat requests."""
+
+from urllib.parse import urljoin
+
+import httpx
+from fastapi import HTTPException, status
+
+from app.core.config import get_settings
+from app.models import User
+from app.schemas.ai_tutor import AiTutorChatCreate, AiTutorChatRead, AiTutorConfigRead
+
+
+def _config() -> tuple[bool, str, str, str | None]:
+ settings = get_settings()
+ key = settings.ai_tutor_api_key.get_secret_value() if settings.ai_tutor_api_key else None
+ enabled = bool(settings.ai_tutor_enabled and key and settings.ai_tutor_provider == "deepseek")
+ return enabled, settings.ai_tutor_provider, settings.ai_tutor_model, key
+
+
+def get_config() -> AiTutorConfigRead:
+ enabled, provider, model, _ = _config()
+ return AiTutorConfigRead(enabled=enabled, provider=provider, model=model if enabled else None)
+
+
+def chat(payload: AiTutorChatCreate, *, actor: User) -> AiTutorChatRead:
+ enabled, provider, model, api_key = _config()
+ if not enabled or not api_key:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="学校尚未启用 AI 助教")
+ context = payload.context or {}
+ context_line = ";".join(f"{key}={value}" for key, value in context.items() if value)
+ system = (
+ "你是星序学习平台的 AI 助教。使用简洁、可验证的中文回答,优先引导学生观察、提出假设和检查证据,"
+ "不要替代教师评分,不要声称学生已经掌握,也不要编造当前实验中不存在的参数。"
+ f"当前学习身份:{actor.role}。当前页面上下文:{context_line or '未提供'}。"
+ )
+ endpoint = urljoin(get_settings().ai_tutor_base_url.rstrip("/") + "/", "chat/completions")
+ request_body = {
+ "model": model,
+ "messages": [{"role": "system", "content": system}, {"role": "user", "content": payload.message}],
+ "temperature": 0.35,
+ "stream": False,
+ }
+ try:
+ with httpx.Client(timeout=get_settings().ai_tutor_timeout_seconds) as client:
+ response = client.post(endpoint, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=request_body)
+ response.raise_for_status()
+ data = response.json()
+ except (httpx.HTTPError, ValueError) as exc:
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="AI 助教暂时无法连接模型服务") from exc
+ choices = data.get("choices") if isinstance(data, dict) else None
+ answer = choices[0].get("message", {}).get("content") if choices and isinstance(choices[0], dict) else None
+ if not isinstance(answer, str) or not answer.strip():
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型没有返回可显示的回答")
+ return AiTutorChatRead(answer=answer.strip(), provider=provider, model=model)
diff --git a/backend/app/services/assignment_history.py b/backend/app/services/assignment_history.py
index 6b4c1d02..bf730b18 100644
--- a/backend/app/services/assignment_history.py
+++ b/backend/app/services/assignment_history.py
@@ -69,7 +69,7 @@ def teacher_scope(db: Session, *, actor: User, submission: Submission, write: bo
if group.kind == "course_cohort":
require_course_editor_or_admin(db, actor, course)
else:
- require_class_teacher_or_admin(db, actor, group)
+ require_class_teacher_or_admin(db, actor, group, detail="Submission grading requires class teacher scope" if write else "Submission history requires class teacher scope")
return assignment, unit, course, group
diff --git a/backend/app/services/submissions_queries.py b/backend/app/services/submissions_queries.py
new file mode 100644
index 00000000..fe86cda7
--- /dev/null
+++ b/backend/app/services/submissions_queries.py
@@ -0,0 +1,498 @@
+"""Read-side assignment scope, availability, and legacy submission queries."""
+
+from fastapi import HTTPException
+from sqlalchemy import and_, func, or_, select
+from sqlalchemy.orm import Session
+
+from app.models import Assignment, AssignmentClassPolicy, ClassGroup, ClassMembership, Course, CourseClass, CourseUnit, CourseUnitClassPlan, School, Submission, User
+from app.schemas.course import (
+ AssignmentRead,
+ AssignmentReviewRead,
+ AssignmentSubmissionPage,
+ CourseRead,
+ CourseUnitRead,
+ StudentAssignmentCenterItem,
+ StudentAssignmentCenterPage,
+ StudentAssignmentFilter,
+ SubmissionRead,
+)
+from app.schemas.school import ClassRead
+from app.services.assignment_policies import (
+ build_effective_assignment_policy,
+ effective_assignment_payload,
+ resolve_assignment_class_policy,
+)
+from app.services.access_control import course_attached_to_class, get_class, require_class_teacher_or_admin, require_course_editor_or_admin, require_course_scope, require_course_visible, require_school_role, require_student_unit_published, teacher_class_ids
+from app.services.learning_evidence_access import (
+ authoritative_prerequisite_unit_ids_by_scope,
+)
+from app.services.pagination import list_legacy_scalars, paged_endpoint_url
+from app.services.course_release_plans import effective_unit_access, get_course_class_or_404, get_plan_for_unit
+
+
+def list_my_assignments(
+ db: Session,
+ *,
+ current_user: User,
+ class_id: int | None = None,
+ course_id: int | None = None,
+ filter_by: StudentAssignmentFilter = "all",
+ limit: int = 50,
+ offset: int = 0,
+) -> StudentAssignmentCenterPage:
+ if current_user.role != "student":
+ raise HTTPException(status_code=403, detail="Only students can view their assignment center")
+
+ class_group: ClassGroup | None = None
+ if class_id is not None:
+ class_group = _require_active_user_class(db, current_user.id, class_id)
+ if course_id is not None:
+ if class_group is not None:
+ require_course_scope(db, current_user, class_group, course_id)
+ else:
+ require_course_visible(db, current_user, course_id)
+
+ active_class_ids = select(ClassMembership.class_id).where(
+ ClassMembership.user_id == current_user.id,
+ ClassMembership.role == "student",
+ ClassMembership.status == "active",
+ )
+ statement = (
+ select(
+ ClassGroup,
+ Course,
+ CourseUnit,
+ Assignment,
+ Submission,
+ AssignmentClassPolicy,
+ CourseUnitClassPlan,
+ )
+ .select_from(ClassGroup)
+ .join(CourseClass, CourseClass.class_id == ClassGroup.id)
+ .join(Course, Course.id == CourseClass.course_id)
+ .join(CourseUnit, CourseUnit.course_id == Course.id)
+ .join(
+ CourseUnitClassPlan,
+ and_(
+ CourseUnitClassPlan.course_class_id == CourseClass.id,
+ CourseUnitClassPlan.course_unit_id == CourseUnit.id,
+ ),
+ )
+ .join(Assignment, Assignment.unit_id == CourseUnit.id)
+ .outerjoin(
+ AssignmentClassPolicy,
+ and_(
+ AssignmentClassPolicy.assignment_id == Assignment.id,
+ AssignmentClassPolicy.class_id == ClassGroup.id,
+ ),
+ )
+ .outerjoin(
+ Submission,
+ and_(
+ Submission.assignment_id == Assignment.id,
+ Submission.student_id == current_user.id,
+ Submission.class_id == ClassGroup.id,
+ ),
+ )
+ .where(
+ ClassGroup.id.in_(active_class_ids),
+ CourseClass.status == "active",
+ Course.status == "published",
+ CourseUnit.status == "published",
+ CourseUnitClassPlan.release_mode != "hidden",
+ or_(
+ and_(
+ Assignment.audience_mode == "selected_classes",
+ AssignmentClassPolicy.id.is_not(None),
+ AssignmentClassPolicy.assigned.is_(True),
+ ),
+ and_(
+ Assignment.audience_mode == "all_attached_classes",
+ or_(
+ AssignmentClassPolicy.id.is_(None),
+ AssignmentClassPolicy.assigned.is_(True),
+ ),
+ ),
+ ),
+ )
+ )
+ effective_status = func.coalesce(AssignmentClassPolicy.status_override, Assignment.status)
+ if class_id is not None:
+ statement = statement.where(ClassGroup.id == class_id)
+ if course_id is not None:
+ statement = statement.where(Course.id == course_id)
+ if filter_by == "active":
+ statement = statement.where(
+ effective_status == "active",
+ ClassGroup.status == "active",
+ )
+ elif filter_by == "feedback":
+ statement = statement.where(Submission.status.in_(["graded", "returned"]))
+ elif filter_by == "history":
+ statement = statement.where(
+ or_(
+ effective_status.in_(["closed", "archived"]),
+ ClassGroup.status != "active",
+ )
+ )
+
+ statement = statement.order_by(ClassGroup.id, Course.id, CourseUnit.position, Assignment.id)
+ total = int(db.scalar(select(func.count()).select_from(statement.order_by(None).subquery())) or 0)
+ rows = db.execute(statement.offset(offset).limit(limit)).all()
+ school_by_id = {
+ school.id: school
+ for school in db.scalars(select(School).where(School.id.in_({row[1].school_id for row in rows}))).all()
+ } if rows else {}
+ prerequisite_ids = {row[6].prerequisite_unit_id for row in rows if row[6].prerequisite_unit_id is not None}
+ completed_by_scope = authoritative_prerequisite_unit_ids_by_scope(
+ db,
+ subject_user_id=current_user.id,
+ scopes={(row[0].id, row[1].id) for row in rows} if prerequisite_ids else set(),
+ )
+ visible_rows = []
+ for row in rows:
+ row_class, course, unit, assignment, submission, policy, plan = row
+ access = effective_unit_access(
+ db,
+ course=course,
+ class_group=row_class,
+ unit=unit,
+ plan=plan,
+ student_id=current_user.id,
+ completed_unit_ids=completed_by_scope.get((row_class.id, course.id), set()),
+ school_active=school_by_id.get(course.school_id) is not None and school_by_id[course.school_id].status == "active",
+ )
+ visible_rows.append((*row, access))
+ rows = visible_rows
+ items: list[StudentAssignmentCenterItem] = []
+ for row_class, course, unit, assignment, submission, policy, plan, access in rows:
+ effective = build_effective_assignment_policy(assignment, row_class.id, policy)
+ submit_block_reason = _assignment_submit_block_reason(
+ effective.status,
+ submission,
+ row_class.status,
+ )
+ if submit_block_reason is None and access.state != "open":
+ submit_block_reason = "unit_locked"
+ assignment_payload = effective_assignment_payload(assignment, effective)
+ assignment_payload["unit_release_state"] = access.state
+ assignment_payload["unit_lock_reasons"] = list(access.lock_reasons)
+ items.append(
+ StudentAssignmentCenterItem(
+ class_=ClassRead.model_validate(row_class),
+ course=CourseRead.model_validate(course),
+ unit=CourseUnitRead(
+ id=unit.id,
+ course_id=unit.course_id,
+ activity_key=unit.activity_key,
+ title=unit.title,
+ position=unit.position,
+ content_slug=unit.content_slug,
+ status=unit.status,
+ effective_release_state=access.state,
+ lock_reasons=list(access.lock_reasons),
+ ),
+ assignment=AssignmentRead.model_validate(assignment_payload),
+ submission=SubmissionRead.model_validate(submission) if submission is not None else None,
+ can_submit=submit_block_reason is None,
+ read_only=submit_block_reason is not None,
+ submit_block_reason=submit_block_reason,
+ )
+ )
+ next_offset = offset + len(items)
+ return StudentAssignmentCenterPage(
+ items=items,
+ total=total,
+ limit=limit,
+ offset=offset,
+ next_offset=next_offset if next_offset < total else None,
+ )
+
+
+def read_assignment_review(
+ db: Session,
+ *,
+ assignment_id: int,
+ current_user: User,
+ class_id: int | None = None,
+) -> AssignmentReviewRead:
+ if current_user.role != "student":
+ raise HTTPException(status_code=403, detail="Only students can review their assignment history")
+ assignment, unit, course = _resolve_assignment(db, assignment_id)
+ require_course_visible(db, current_user, course.id)
+ require_student_unit_published(current_user, unit)
+ statement = (
+ select(Submission)
+ .where(
+ Submission.assignment_id == assignment.id,
+ Submission.student_id == current_user.id,
+ )
+ .order_by(Submission.submitted_at.desc(), Submission.id.desc())
+ )
+ if class_id is not None:
+ class_group = _require_active_user_class(db, current_user.id, class_id)
+ if class_group.school_id != course.school_id:
+ raise HTTPException(status_code=422, detail="Class does not belong to assignment school")
+ if not course_attached_to_class(db, course.id, class_group.id):
+ raise HTTPException(status_code=403, detail="Course is not attached to this class")
+ effective = resolve_assignment_class_policy(db, assignment, class_group.id)
+ if not effective.assigned:
+ raise HTTPException(status_code=403, detail="Assignment is not assigned to this class")
+ statement = statement.where(Submission.class_id == class_group.id)
+ else:
+ eligible_class_ids = _active_user_course_class_ids(db, current_user.id, course.id)
+ eligible_class_ids = [
+ eligible_class_id
+ for eligible_class_id in eligible_class_ids
+ if resolve_assignment_class_policy(db, assignment, eligible_class_id).assigned
+ ]
+ if len(eligible_class_ids) > 1:
+ raise HTTPException(
+ status_code=422,
+ detail="class_id is required when assignment is available in multiple classes",
+ )
+ if not eligible_class_ids:
+ raise HTTPException(status_code=403, detail="Assignment is outside current student class scope")
+ effective = resolve_assignment_class_policy(db, assignment, eligible_class_ids[0])
+ statement = statement.where(Submission.class_id == effective.class_id)
+ class_group = get_class(db, effective.class_id)
+ submission = db.scalars(statement.limit(1)).first()
+ submit_block_reason = _assignment_submit_block_reason(
+ effective.status,
+ submission,
+ class_group.status,
+ )
+ course_class = get_course_class_or_404(db, course.id, class_group.id)
+ plan = get_plan_for_unit(db, course_class, unit.id)
+ access = effective_unit_access(
+ db,
+ course=course,
+ class_group=class_group,
+ unit=unit,
+ plan=plan,
+ student_id=current_user.id,
+ )
+ if access.state == "hidden":
+ raise HTTPException(status_code=403, detail="Course unit is not visible in this class")
+ if submit_block_reason is None and access.state != "open":
+ submit_block_reason = "unit_locked"
+ can_submit = submit_block_reason is None
+ return AssignmentReviewRead(
+ course_id=course.id,
+ unit_id=unit.id,
+ assignment=AssignmentRead.model_validate(effective_assignment_payload(assignment, effective)),
+ submission=SubmissionRead.model_validate(submission) if submission is not None else None,
+ can_submit=can_submit,
+ read_only=not can_submit,
+ submit_block_reason=submit_block_reason,
+ )
+
+
+def list_assignment_submissions(
+ db: Session,
+ *,
+ assignment_id: int,
+ current_user: User,
+ class_id: int | None = None,
+) -> list[Submission]:
+ statement = _assignment_submission_history_statement(
+ db,
+ assignment_id=assignment_id,
+ class_id=class_id,
+ current_user=current_user,
+ )
+ return list_legacy_scalars(
+ db,
+ statement,
+ paged_endpoint=paged_endpoint_url(
+ f"/api/assignments/{assignment_id}/submissions/page",
+ class_id=class_id,
+ limit=200,
+ offset=0,
+ ),
+ )
+
+
+def list_assignment_submissions_page(
+ db: Session,
+ *,
+ assignment_id: int,
+ current_user: User,
+ class_id: int | None = None,
+ limit: int = 50,
+ offset: int = 0,
+) -> AssignmentSubmissionPage:
+ statement = _assignment_submission_history_statement(
+ db,
+ assignment_id=assignment_id,
+ class_id=class_id,
+ current_user=current_user,
+ )
+ total = int(db.scalar(select(func.count()).select_from(statement.order_by(None).subquery())) or 0)
+ items = list(db.scalars(statement.offset(offset).limit(limit)).all())
+ next_offset = offset + len(items)
+ return AssignmentSubmissionPage(
+ items=[SubmissionRead.model_validate(item) for item in items],
+ total=total,
+ limit=limit,
+ offset=offset,
+ next_offset=next_offset if next_offset < total else None,
+ )
+
+
+def _assignment_submission_history_statement(
+ db: Session,
+ *,
+ assignment_id: int,
+ class_id: int | None,
+ current_user: User,
+):
+ assignment, unit, course = _resolve_assignment(db, assignment_id)
+ statement = select(Submission).where(Submission.assignment_id == assignment.id).order_by(Submission.id)
+
+ if current_user.role == "student":
+ require_course_visible(db, current_user, course.id)
+ require_student_unit_published(current_user, unit)
+ if class_id is None:
+ eligible_class_ids = _active_user_course_class_ids(db, current_user.id, course.id)
+ if len(eligible_class_ids) != 1:
+ raise HTTPException(
+ status_code=422,
+ detail="class_id is required for student assignment submission history scope",
+ )
+ class_id = eligible_class_ids[0]
+ statement = statement.where(Submission.student_id == current_user.id)
+ _require_active_user_class(db, current_user.id, class_id)
+ if not course_attached_to_class(db, course.id, class_id):
+ raise HTTPException(status_code=403, detail="Course is not attached to this class")
+ if not resolve_assignment_class_policy(db, assignment, class_id).assigned:
+ raise HTTPException(status_code=403, detail="Assignment is not assigned to this class")
+ course_class = get_course_class_or_404(db, course.id, class_id)
+ plan = get_plan_for_unit(db, course_class, unit.id)
+ access = effective_unit_access(
+ db,
+ course=course,
+ class_group=get_class(db, class_id),
+ unit=unit,
+ plan=plan,
+ student_id=current_user.id,
+ )
+ if access.state == "hidden":
+ raise HTTPException(status_code=403, detail="Course unit is not visible in this class")
+ statement = statement.where(Submission.class_id == class_id)
+ return statement
+
+ require_school_role(db, current_user, course.school_id, {"admin", "teacher"})
+ if class_id is not None:
+ class_group = get_class(db, class_id)
+ if class_group.school_id != course.school_id:
+ raise HTTPException(status_code=422, detail="Class does not belong to assignment school")
+ if not course_attached_to_class(db, course.id, class_group.id):
+ raise HTTPException(status_code=403, detail="Course is not attached to this class")
+ if class_group.kind == "course_cohort":
+ require_course_editor_or_admin(
+ db,
+ current_user,
+ course,
+ detail="Course submissions require an active course teacher",
+ )
+ else:
+ require_class_teacher_or_admin(
+ db,
+ current_user,
+ class_group,
+ detail="Assignment submissions require class teacher scope",
+ )
+ statement = statement.where(Submission.class_id == class_id)
+ elif current_user.role != "admin":
+ class_ids = set(teacher_class_ids(db, current_user.id))
+ try:
+ require_course_editor_or_admin(
+ db,
+ current_user,
+ course,
+ detail="Course submissions require an active course teacher",
+ )
+ except HTTPException:
+ pass
+ else:
+ class_ids.update(
+ db.scalars(
+ select(ClassGroup.id)
+ .join(CourseClass, CourseClass.class_id == ClassGroup.id)
+ .where(
+ CourseClass.course_id == course.id,
+ CourseClass.status == "active",
+ ClassGroup.kind == "course_cohort",
+ ClassGroup.status == "active",
+ )
+ ).all()
+ )
+ if not class_ids:
+ return statement.where(Submission.id.is_(None))
+ statement = statement.where(Submission.class_id.in_(class_ids))
+ return statement
+
+
+def _require_active_user_class(db: Session, user_id: int, class_id: int) -> ClassGroup:
+ class_group = get_class(db, class_id)
+ membership = db.scalar(
+ select(ClassMembership.id).where(
+ ClassMembership.class_id == class_id,
+ ClassMembership.user_id == user_id,
+ ClassMembership.role == "student",
+ ClassMembership.status == "active",
+ )
+ )
+ if membership is None:
+ raise HTTPException(status_code=403, detail="Class is outside current student scope")
+ return class_group
+
+
+def _active_user_course_class_ids(db: Session, user_id: int, course_id: int) -> list[int]:
+ return list(
+ db.scalars(
+ select(ClassMembership.class_id)
+ .join(CourseClass, CourseClass.class_id == ClassMembership.class_id)
+ .where(
+ ClassMembership.user_id == user_id,
+ ClassMembership.role == "student",
+ ClassMembership.status == "active",
+ CourseClass.course_id == course_id,
+ CourseClass.status == "active",
+ )
+ .distinct()
+ .order_by(ClassMembership.class_id)
+ ).all()
+ )
+
+
+def _assignment_submit_block_reason(
+ assignment_status: str,
+ submission: Submission | None,
+ class_status: str,
+) -> str | None:
+ if class_status != "active":
+ return "class_archived"
+ if assignment_status == "closed":
+ return "assignment_closed"
+ if assignment_status == "archived":
+ return "assignment_archived"
+ if assignment_status != "active":
+ return "assignment_not_active"
+ if submission is not None and submission.status != "returned":
+ return "already_submitted"
+ return None
+
+
+def _resolve_assignment(db: Session, assignment_id: int) -> tuple[Assignment, CourseUnit, Course]:
+ assignment = db.get(Assignment, assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ unit = db.get(CourseUnit, assignment.unit_id)
+ if unit is None:
+ raise HTTPException(status_code=404, detail="Course unit not found")
+ course = db.get(Course, unit.course_id)
+ if course is None:
+ raise HTTPException(status_code=404, detail="Course not found")
+ return assignment, unit, course
diff --git a/backend/scripts/initialize_demo_data.py b/backend/scripts/initialize_demo_data.py
index 26d07b9c..1ffef9b0 100644
--- a/backend/scripts/initialize_demo_data.py
+++ b/backend/scripts/initialize_demo_data.py
@@ -2270,20 +2270,144 @@ def _validate_v84_release(
raise DemoInitializationError(f"V8.4 {version_marker} completion preset drifted")
+async def _demo_workflow_receipt(
+ api: DemoApi, actor: Actor, request_id: str, operation_kind: str,
+) -> dict[str, Any] | None:
+ response = await api.get(f"/api/v2/operations/{request_id}", actor)
+ if response.status_code == 404:
+ return None
+ receipt = _require_status(response, 200, "read demo course workflow receipt")
+ if receipt.get("client_request_id") != request_id or receipt.get("operation_kind") != operation_kind:
+ raise DemoInitializationError("demo course workflow receipt drifted")
+ return receipt["response"]
+
+
+async def _publish_demo_course_candidate(
+ api: DemoApi,
+ *,
+ course_id: int,
+ teacher: Actor,
+ admin: Actor,
+ expected_revision: int,
+ release_number: int,
+ note: str,
+) -> None:
+ # A committed step may outlive this process. Reuse its public receipt rather
+ # than generating another candidate or assuming an unrelated review is ours.
+ request_prefix = f"astra-demo-course-{course_id}-release-{release_number}"
+ draft = _require_status(
+ await api.get(f"/api/v2/courses/{course_id}/draft", teacher),
+ 200, "read demo candidate source draft",
+ )
+ if draft["revision"] != expected_revision:
+ raise DemoInitializationError("demo candidate source revision drifted")
+ submitted = await _demo_workflow_receipt(api, teacher, f"{request_prefix}-submit", "course.submit")
+ if submitted is None:
+ source = {"source_revision": draft["revision"], "source_state_token": draft["state_token"]}
+ preview = _require_status(
+ await api.post(f"/api/v2/courses/{course_id}/submission-preview", teacher, source),
+ 200, "preview demo course candidate",
+ )
+ submitted = _require_status(
+ await api.post(f"/api/v2/courses/{course_id}/submissions", teacher, {
+ **source, "preview_token": preview["preview_token"],
+ "client_request_id": f"{request_prefix}-submit", "note": note,
+ }),
+ 201, "submit demo course candidate",
+ )
+ items = submitted.get("items", [])
+ if len(items) != 1 or items[0].get("course_id") != course_id or submitted.get("skipped"):
+ raise DemoInitializationError("demo candidate submission scope drifted")
+ candidate = _require_status(
+ await api.get(f"/api/v2/candidates/{items[0]['candidate_id']}", teacher),
+ 200, "read frozen demo course candidate",
+ )
+ snapshot_sha256 = sha256(json.dumps(
+ candidate["snapshot"], ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False,
+ ).encode("utf-8")).hexdigest()
+ if (
+ candidate["course_id"] != course_id
+ or candidate["submitted_by_user_id"] != teacher.user_id
+ or candidate["note"] != note
+ or candidate["stale"]
+ or candidate["published_release_id"] is not None
+ or snapshot_sha256 != draft["state_token"]
+ ):
+ raise DemoInitializationError("demo candidate snapshot or publication baseline drifted")
+
+ review_note = f"{note}:已核对合成教学内容、完成规则与发布影响。"
+ reviewed = await _demo_workflow_receipt(api, admin, f"{request_prefix}-review", "course.review")
+ if candidate["status"] == "submitted" and reviewed is None:
+ reviewed = _require_status(
+ await api.post("/api/v2/candidate-reviews", admin, {
+ "client_request_id": f"{request_prefix}-review",
+ "items": [{"review_item_id": candidate["review_item_id"], "expected_version": candidate["review_version"]}],
+ "decision": "approved", "note": review_note,
+ }),
+ 200, "review demo course candidate",
+ )
+ elif (
+ candidate["status"] != "approved"
+ or reviewed is None
+ or candidate["reviewed_by_user_id"] != admin.user_id
+ or candidate["review_note"] != review_note
+ ):
+ raise DemoInitializationError("demo candidate review state drifted")
+ reviewed_items = reviewed.get("items", [])
+ if (
+ len(reviewed_items) != 1
+ or reviewed_items[0].get("candidate_id") != candidate["candidate_id"]
+ or reviewed_items[0].get("status") != "approved"
+ ):
+ raise DemoInitializationError("demo candidate review receipt drifted")
+ _require_status(
+ await api.post("/api/v2/publications", teacher, {
+ "client_request_id": f"{request_prefix}-publish",
+ "items": [{
+ "candidate_id": candidate["candidate_id"],
+ "expected_review_version": reviewed_items[0]["review_version"],
+ }],
+ "note": note,
+ }),
+ 201, "publish reviewed demo course candidate",
+ )
+
+
+async def _assert_demo_draft_has_no_unpublished_candidate(api: DemoApi, teacher: Actor, course_id: int) -> None:
+ page = _require_status(
+ await api.get(f"/api/v2/candidates?course_id={course_id}&limit=100", teacher),
+ 200, "check candidate history before updating demo draft",
+ )
+ # This also protects candidates submitted by another request/teacher: they
+ # are never ours to rewrite, approve, withdraw or replace while resuming.
+ if page.get("next_offset") is not None or any(item["published_release_id"] is None for item in page["items"]):
+ raise DemoInitializationError("demo candidate draft drifted; unpublished candidate must be preserved")
+
+
async def _ensure_v84_course_content(
api: DemoApi,
*,
course: dict[str, Any],
owner: Actor,
peer_teacher: Actor,
+ admin: Actor,
) -> dict[str, Any]:
course_id = int(course["id"])
+ releases = _require_status(
+ await api.get(f"/api/v1/courses/{course_id}/releases", owner),
+ 200, "list V8.4 demo releases",
+ )
+ if len(releases) > 2:
+ raise DemoInitializationError("V8.4 demo course has more than two releases")
draft = _require_status(
await api.get(f"/api/v1/courses/{course_id}/draft", owner),
200,
"read V8.4 shared course draft",
)
if not draft.get("units"):
+ if releases:
+ raise DemoInitializationError("V8.4 shared draft drifted after publication")
+ await _assert_demo_draft_has_no_unpublished_candidate(api, owner, course_id)
draft = await _replace_v84_draft(
api,
actor=owner,
@@ -2303,19 +2427,13 @@ async def _ensure_v84_course_content(
)
graded_assignment = assignments[DEMO_V84_ASSIGNMENTS[0]["title"]]
- releases = _require_status(
- await api.get(f"/api/v1/courses/{course_id}/releases", owner),
- 200,
- "list V8.4 demo releases",
- )
- if len(releases) > 2:
- raise DemoInitializationError("V8.4 demo course has more than two releases")
if not releases:
if not _validate_v84_draft(
draft,
version_marker="版本一",
assignment_id=int(graded_assignment["id"]),
):
+ await _assert_demo_draft_has_no_unpublished_candidate(api, owner, course_id)
draft = await _replace_v84_draft(
api,
actor=peer_teacher,
@@ -2324,14 +2442,10 @@ async def _ensure_v84_course_content(
version_marker="版本一",
assignment_id=int(graded_assignment["id"]),
)
- _require_status(
- await api.post(
- f"/api/v1/courses/{course_id}/releases",
- owner,
- {"expected_revision": draft["revision"], "note": "课程闭环演示版本一"},
- ),
- 201,
- "publish V8.4 demo release one",
+ await _publish_demo_course_candidate(
+ api, course_id=course_id, teacher=owner, admin=admin,
+ expected_revision=draft["revision"], release_number=1,
+ note="课程闭环演示版本一",
)
releases = _require_status(
await api.get(f"/api/v1/courses/{course_id}/releases", owner),
@@ -2355,6 +2469,7 @@ async def _ensure_v84_course_content(
version_marker="版本二",
assignment_id=int(graded_assignment["id"]),
):
+ await _assert_demo_draft_has_no_unpublished_candidate(api, peer_teacher, course_id)
draft = await _replace_v84_draft(
api,
actor=peer_teacher,
@@ -2363,14 +2478,10 @@ async def _ensure_v84_course_content(
version_marker="版本二",
assignment_id=int(graded_assignment["id"]),
)
- _require_status(
- await api.post(
- f"/api/v1/courses/{course_id}/releases",
- peer_teacher,
- {"expected_revision": draft["revision"], "note": "课程闭环演示版本二"},
- ),
- 201,
- "publish V8.4 demo release two",
+ await _publish_demo_course_candidate(
+ api, course_id=course_id, teacher=peer_teacher, admin=admin,
+ expected_revision=draft["revision"], release_number=2,
+ note="课程闭环演示版本二",
)
releases = _require_status(
await api.get(f"/api/v1/courses/{course_id}/releases", owner),
@@ -2405,51 +2516,76 @@ async def _ensure_v84_submissions(
*,
assignments: dict[str, dict[str, Any]],
internal_class_id: int,
+ release_id: int,
student: Actor,
peer_teacher: Actor,
) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for spec in DEMO_V84_ASSIGNMENTS:
assignment = assignments[spec["title"]]
- review = _require_status(
- await api.get(
- f"/api/assignments/{assignment['id']}/review?class_id={internal_class_id}",
- student,
- ),
- 200,
- f"read V8.4 assignment review {spec['title']}",
- )
- submission = review.get("submission")
- if submission is None:
- submission = _require_status(
+ request_prefix = f"astra-demo-assignment-{assignment['id']}"
+ open_payload = {"client_request_id": f"{request_prefix}-open", "class_id": internal_class_id}
+ workspace = _require_status(
+ await api.post(f"/api/v2/assignments/{assignment['id']}/open", student, open_payload),
+ 200, f"open V8.4 demo assignment {spec['title']}",
+ )
+ context = workspace.get("context") or {}
+ if context.get("course_release_id") != release_id or context.get("class_id") != internal_class_id:
+ raise DemoInitializationError("V8.4 demo assignment learning context drifted")
+ content = {
+ "report": "实验观察显示动能与势能相互转化,总机械能保持稳定。",
+ "fixture": "synthetic-local-demo",
+ }
+ if workspace.get("attempt") is None:
+ if workspace.get("submission_id") is not None or not workspace["can_submit"]:
+ raise DemoInitializationError("V8.4 demo assignment submission history drifted")
+ _require_status(
await api.post(
- f"/api/assignments/{assignment['id']}/submissions",
+ f"/api/v2/assignments/{assignment['id']}/attempts",
student,
{
+ "client_request_id": f"{request_prefix}-submit",
+ "context_key": context["context_key"],
"class_id": internal_class_id,
- "content": {
- "report": "实验观察显示动能与势能相互转化,总机械能保持稳定。",
- "fixture": "synthetic-local-demo",
- },
+ "expected_submission_revision": workspace["submission_revision"],
+ "expected_assignment_sha256": workspace["assignment_sha256"],
+ "content": content,
},
),
201,
f"submit V8.4 demo assignment {spec['title']}",
)
- if spec["desired_status"] == "graded" and submission.get("status") == "submitted":
- submission = _require_status(
- await api.patch(
- f"/api/submissions/{submission['id']}/grade",
+ workspace = _require_status(
+ await api.post(f"/api/v2/assignments/{assignment['id']}/open", student, open_payload),
+ 200, "reread V8.4 assignment attempt",
+ )
+ attempt = workspace["attempt"]
+ if attempt["content"] != content or attempt["attempt_number"] != 1 or attempt["context_key"] != context["context_key"]:
+ raise DemoInitializationError("V8.4 demo assignment attempt drifted")
+ if spec["desired_status"] == "graded" and workspace.get("grade") is None:
+ _require_status(
+ await api.post(
+ f"/api/v2/assignment-attempts/{attempt['id']}/grades",
peer_teacher,
{
+ "client_request_id": f"{request_prefix}-grade",
+ "expected_submission_revision": workspace["submission_revision"],
+ "expected_grade_revision": 0,
"score": 92,
"feedback": "证据完整,守恒关系表达清楚。",
"status": "graded",
},
),
- 200,
+ 201,
"grade V8.4 demo assignment",
)
+ review = _require_status(
+ await api.get(f"/api/assignments/{assignment['id']}/review?class_id={internal_class_id}", student),
+ 200, f"read V8.4 assignment review {spec['title']}",
+ )
+ submission = review.get("submission")
+ if submission is None or submission.get("current_attempt_id") != attempt["id"]:
+ raise DemoInitializationError("V8.4 demo assignment current attempt drifted")
if spec["desired_status"] == "graded":
if (
submission.get("status") != "graded"
@@ -2476,6 +2612,7 @@ async def _ensure_v84_learning_completion(
internal_class_id: int,
student: Actor,
owner: Actor,
+ admin: Actor,
) -> dict[str, Any]:
releases = content_state["releases"]
current_release = releases[0]
@@ -2490,63 +2627,68 @@ async def _ensure_v84_learning_completion(
if len(matching_rules) != 1 or matching_rules[0].get("status") != "active":
raise DemoInitializationError("V8.4 active completion rule drifted")
rule = matching_rules[0]
- mechanics_unit = units["physics.mechanics"]
- runtime_payload = {
- "schema_version": "astra-learning-activity-evidence-sidecar-v1",
- "command": {
- "schema_version": "astra-learning-activity-event-v1",
- "scope": {
- "class_id": internal_class_id,
- "course_id": course["id"],
- "course_unit_id": mechanics_unit["source_course_unit_id"],
- "activity_key": mechanics_unit["activity_key"],
- },
- "run": {
- "run_id": "v84-demo-run-0001",
- "group_id": "v84-demo-group-0001",
- "sequence": 1,
+ _require_status(
+ await api.post("/api/v2/resources/install-system", admin, {}),
+ 200, "install registered demo learning resources",
+ )
+ contexts = {}
+ for activity_key in ("physics.mechanics", "physics.energy-checkpoint"):
+ contexts[activity_key] = _require_status(
+ await api.post("/api/v2/learning-contexts", student, {
+ "client_request_id": f"astra-demo-{course['id']}-{activity_key}-start",
+ "mode": "formal", "course_id": course["id"],
+ "course_unit_id": units[activity_key]["source_course_unit_id"],
+ "expected_release_id": release_id,
+ }),
+ 201, "open version-pinned demo learning context",
+ )
+ runtime_path = f"/api/v2/learning-contexts/{contexts['physics.mechanics']['context_key']}/activity-runtime"
+ runtime = _require_status(
+ await api.get(runtime_path, student), 200, "read pinned demo runtime configuration",
+ )
+ if not runtime["blocks"] or not runtime["blocks"][0]["controls"]:
+ raise DemoInitializationError("demo experiment has no registered observation control")
+ block = runtime["blocks"][0]
+ control = block["controls"][0]
+ observation = {
+ "block_id": block["block_id"], "parameter": control["key"],
+ "value": control["maximum"] if control["value"] != control["maximum"] else control["minimum"],
+ }
+ for sequence, event_type in ((1, "started"), (2, "attempted")):
+ runtime_payload = {
+ "schema_version": "astra-learning-activity-evidence-sidecar-v1",
+ "command": {
+ "schema_version": "astra-learning-activity-event-v1",
+ "scope": runtime["scope"],
+ "run": {"run_id": "v84-demo-run-0001", "group_id": "v84-demo-group-0001", "sequence": sequence},
+ "versions": {key: runtime[key] for key in (
+ "manifest_version", "content_version", "event_schema_version", "rule_version", "generation",
+ )},
+ "client_event_id": f"v84-demo:physics.mechanics:{event_type}:{sequence}",
+ "event_type": event_type,
+ "evidence": {} if event_type == "started" else {"operation": "parameter-change", "cursor": observation},
+ "occurred_at": f"2026-08-01T08:00:0{sequence}.000Z",
},
- "versions": {
- "manifest_version": "v84-demo-manifest",
- "content_version": "v84-demo-release-2",
- "event_schema_version": 1,
- "rule_version": rule["version_number"],
- "generation": "v84-demo-generation",
+ "snapshot": {
+ "state_schema_version": runtime["state_schema_version"],
+ "applied_through_learner_sequence": sequence,
+ "data": {"last_observation": None if event_type == "started" else observation},
},
- "client_event_id": "v84-demo:physics.mechanics:attempted:1",
- "event_type": "attempted",
- "evidence": {"operation": "mechanics-controlled-run"},
- "occurred_at": "2026-08-01T08:00:00.000Z",
- },
- "snapshot": {
- "state_schema_version": "v84-demo-state-v1",
- "applied_through_learner_sequence": 1,
- "data": {"stage": "attempted"},
- },
- }
- runtime_response = await api.post(
- "/api/learning-evidence/activity-runtime/events",
- student,
- runtime_payload,
- )
- if runtime_response.status_code not in {200, 201}:
- raise DemoInitializationError(
- f"append V8.4 experiment evidence failed with HTTP {runtime_response.status_code}: "
- f"{_response_detail(runtime_response)}"
+ }
+ runtime_receipt = _require_status(
+ await api.post(f"{runtime_path}/events", student, runtime_payload),
+ (200, 201), "append version-pinned demo experiment evidence",
)
- runtime_receipt = runtime_response.json()
- if runtime_receipt.get("status") not in {"confirmed", "reconciled"}:
- raise DemoInitializationError("V8.4 experiment evidence receipt drifted")
+ if runtime_receipt.get("status") not in {"confirmed", "reconciled"}:
+ raise DemoInitializationError("V8.4 experiment evidence receipt drifted")
- checkpoint_unit = units["physics.energy-checkpoint"]
checkpoint = _require_status(
await api.post(
- f"/api/v1/courses/{course['id']}/units/{checkpoint_unit['source_course_unit_id']}"
- "/checkpoints/energy-conservation-check/attempts",
+ f"/api/v2/learning-contexts/{contexts['physics.energy-checkpoint']['context_key']}"
+ "/checkpoints/energy-conservation-check",
student,
{
"client_attempt_id": "v84-demo-checkpoint-correct-1",
- "course_release_id": release_id,
"selected_choice_ids": ["mechanical"],
},
),
@@ -2746,11 +2888,13 @@ async def initialize_demo_data(
course=open_course,
owner=teacher,
peer_teacher=peer_teacher,
+ admin=admin,
)
v84_submissions = await _ensure_v84_submissions(
api,
assignments=v84_content["assignments"],
internal_class_id=int(open_course["_internal_class_id"]),
+ release_id=int(v84_content["releases"][0]["id"]),
student=open_student,
peer_teacher=peer_teacher,
)
@@ -2761,6 +2905,7 @@ async def initialize_demo_data(
internal_class_id=int(open_course["_internal_class_id"]),
student=open_student,
owner=teacher,
+ admin=admin,
)
return {
diff --git a/backend/tests/test_access_control.py b/backend/tests/test_access_control.py
index 9101b883..641f86bc 100644
--- a/backend/tests/test_access_control.py
+++ b/backend/tests/test_access_control.py
@@ -9,7 +9,9 @@
Course,
CourseCollaborator,
CourseUnit,
+ PointLedger,
SchoolMembership,
+ Submission,
User,
UserKnowledgeSnapshot,
)
@@ -389,6 +391,16 @@ def test_school_teacher_without_class_scope_cannot_manage_class_assignments(clie
)
assert peer_grade.status_code == 403
assert peer_grade.json()["detail"] == "Submission grading requires class teacher scope"
+ untouched_history = client.get(
+ f"/api/v2/submissions/{submission_id}/history", headers=student_headers,
+ )
+ assert untouched_history.status_code == 200
+ assert untouched_history.json()["revision"] == 1
+ assert untouched_history.json()["total"] == 1
+ assert untouched_history.json()["grades"] == []
+ untouched_points = client.get("/api/points/ledger", headers=student_headers)
+ assert untouched_points.status_code == 200
+ assert untouched_points.json() == []
peer_unscoped_events = client.get("/api/learning-events", headers=peer_headers)
assert peer_unscoped_events.status_code == 200
@@ -1396,7 +1408,9 @@ def test_students_only_see_published_course_content_and_active_assignments(clien
json={"class_id": class_id, "content": {"answer": "closed"}},
)
assert closed_submission.status_code == 409
- assert closed_submission.json()["detail"] == "Assignment is not active"
+ with get_session_factory(get_settings().database_url)() as db:
+ assert db.scalar(select(Submission).where(Submission.assignment_id == closed_assignment_id)) is None
+ assert db.scalar(select(PointLedger).where(PointLedger.assignment_id == closed_assignment_id)) is None
draft_course_submission = client.post(
f"/api/assignments/{draft_course_assignment_id}/submissions",
headers=student_headers,
diff --git a/backend/tests/test_admin_organization_governance.py b/backend/tests/test_admin_organization_governance.py
index 1927b718..ee205ab7 100644
--- a/backend/tests/test_admin_organization_governance.py
+++ b/backend/tests/test_admin_organization_governance.py
@@ -13,11 +13,20 @@
learning_events,
points,
schools,
- submissions,
)
from app.core.config import get_settings
from app.db.session import get_session_factory
-from app.models import ClassMembership, SchoolMembership
+from app.models import (
+ AssignmentAttempt,
+ AssignmentGrade,
+ AuditLog,
+ ClassMembership,
+ LearningEvent,
+ LearningResult,
+ PointLedger,
+ SchoolMembership,
+ Submission,
+)
from app.services import class_join_requests, course_release_write_gate
from app.services.knowledge_snapshot_runs import rebuild_periodic_knowledge_snapshots
@@ -858,8 +867,6 @@ def test_admin_authority_changes_cannot_orphan_organization_responsibility(clien
(assignment_policies.put_assignment_class_policy, ("lock_active_class_for_write",)),
(assignment_policies.delete_assignment_class_policy, ("lock_active_class_for_write",)),
(points.update_assignment_point_rule, ("lock_active_school_for_write",)),
- (submissions.create_submission, ("require_student_unit_open_for_write",)),
- (submissions.grade_submission, ("lock_active_class_for_write",)),
(
learning_events.create_learning_event,
("lock_active_class_for_write", "lock_active_school_for_write"),
@@ -881,6 +888,100 @@ def test_scoped_domain_write_handlers_keep_active_organization_gate(handler, req
assert required_token in source
+def _assignment_write_state() -> dict:
+ """Capture persisted facts, including the mutable compatibility head."""
+ with get_session_factory(get_settings().database_url)() as db:
+ state = {}
+ for model in (Submission, AssignmentAttempt, AssignmentGrade, PointLedger, LearningResult, LearningEvent):
+ table = model.__table__
+ state[table.name] = [tuple(row) for row in db.execute(select(table).order_by(table.c.id))]
+ state["successful_submission_audits"] = [
+ tuple(row)
+ for row in db.execute(
+ select(AuditLog.__table__)
+ .where(AuditLog.action.like("submission.%"), AuditLog.event_result == "success")
+ .order_by(AuditLog.id)
+ )
+ ]
+ return state
+
+
+@pytest.mark.parametrize("operation", ["submit", "grade"])
+@pytest.mark.parametrize("revocation", ["school", "class", "class_membership"])
+def test_assignment_http_writes_reject_revoked_scope_without_changing_history(client, operation, revocation):
+ admin = _bootstrap_admin(client)
+ teacher = _register_and_login(client, "revoked_assignment_teacher", "teacher")
+ student = _register_and_login(client, "revoked_assignment_student", "student")
+ school, class_group = _create_organization(client, teacher, "Revoked assignment")
+ _, assignment_id = _create_learning_scope(client, teacher, student, school["id"], class_group["id"])
+ rule = client.patch(
+ f"/api/points/assignments/{assignment_id}/rule",
+ headers=_auth_header(teacher["token"]),
+ json={"enabled": True, "points_per_score": 2, "max_points": 40},
+ )
+ assert rule.status_code == 200, rule.json()
+
+ if operation == "grade":
+ submitted = client.post(
+ f"/api/assignments/{assignment_id}/submissions",
+ headers=_auth_header(student["token"]),
+ json={"class_id": class_group["id"], "content": {"answer": "preserve this attempt"}},
+ )
+ assert submitted.status_code == 201, submitted.json()
+ write_url = f"/api/submissions/{submitted.json()['id']}/grade"
+ write_payload = {"status": "graded", "score": 10, "feedback": "must not persist"}
+ actor = teacher
+ else:
+ # This assignment has no previous attempt: rejection cannot be caused by
+ # the separate duplicate-submission rule.
+ write_url = f"/api/assignments/{assignment_id}/submissions"
+ write_payload = {"class_id": class_group["id"], "content": {"answer": "must not persist"}}
+ actor = student
+
+ if revocation == "class_membership":
+ with get_session_factory(get_settings().database_url)() as db:
+ membership = db.scalar(
+ select(ClassMembership).where(
+ ClassMembership.class_id == class_group["id"],
+ ClassMembership.user_id == actor["id"],
+ )
+ )
+ assert membership is not None
+ membership.status = "inactive"
+ db.commit()
+ expected_status = 403
+ expected_detail = "Course is outside current user scope" if operation == "submit" else "Submission grading requires class teacher scope"
+ else:
+ if revocation == "school":
+ archived_class = _patch_organization(
+ client,
+ admin,
+ "classes",
+ class_group["id"],
+ {"expected_version": 1, "reason": "archive child before parent", "status": "archived"},
+ )
+ assert archived_class.status_code == 200, archived_class.json()
+ resource = "schools" if revocation == "school" else "classes"
+ resource_id = school["id"] if revocation == "school" else class_group["id"]
+ archived = _patch_organization(
+ client,
+ admin,
+ resource,
+ resource_id,
+ {"expected_version": 1, "reason": "verify assignment write revocation", "status": "archived"},
+ )
+ assert archived.status_code == 200, archived.json()
+ expected_status = 409
+ expected_detail = "School is not active" if revocation == "school" else "Class is not active"
+
+ before = _assignment_write_state()
+ write = client.post if operation == "submit" else client.patch
+ response = write(write_url, headers=_auth_header(actor["token"]), json=write_payload)
+ assert response.status_code == expected_status, response.json()
+ assert response.json()["detail"] == expected_detail
+ assert _assignment_write_state() == before
+
+
def test_student_release_write_gate_keeps_active_class_lock():
source = getsource(
course_release_write_gate.require_student_unit_open_for_write
diff --git a/backend/tests/test_ai_tutor.py b/backend/tests/test_ai_tutor.py
new file mode 100644
index 00000000..0b417276
--- /dev/null
+++ b/backend/tests/test_ai_tutor.py
@@ -0,0 +1,105 @@
+import json
+
+import httpx
+import pytest
+
+from app.core.config import get_settings
+from test_content_platform_api import _auth, _register
+
+
+@pytest.fixture()
+def tutor_actor(client, monkeypatch):
+ monkeypatch.setenv("ASTRA_AI_TUTOR_ENABLED", "true")
+ monkeypatch.setenv("ASTRA_AI_TUTOR_API_KEY", "local-test-secret")
+ monkeypatch.setenv("ASTRA_AI_TUTOR_BASE_URL", "https://tutor.example.test/v1")
+ monkeypatch.setenv("ASTRA_AI_TUTOR_MODEL", "test-tutor-model")
+ monkeypatch.setenv("ASTRA_AI_TUTOR_TIMEOUT_SECONDS", "17")
+ get_settings.cache_clear()
+ return _auth(_register(client, "tutor_student", "student")["token"])
+
+
+def _capture_upstream(monkeypatch, handler):
+ client_type = httpx.Client
+ requests = []
+
+ def respond(request):
+ requests.append(request)
+ return handler(request)
+
+ def create_client(*, timeout):
+ assert timeout == 17
+ return client_type(timeout=timeout, transport=httpx.MockTransport(respond))
+
+ monkeypatch.setattr(httpx, "Client", create_client)
+ return requests
+
+
+def test_tutor_requires_login_before_config_or_upstream_chat(client, tutor_actor, monkeypatch):
+ requests = _capture_upstream(monkeypatch, lambda request: httpx.Response(200, json={}))
+ unauthenticated = {"Cookie": ""}
+ assert client.get("/api/ai-tutor/config", headers=unauthenticated).status_code == 401
+ assert client.post("/api/ai-tutor/chat", headers=unauthenticated, json={"message": "如何观察?"}).status_code == 401
+ assert requests == []
+
+
+@pytest.mark.parametrize("setting,value", [("ASTRA_AI_TUTOR_ENABLED", "false"), ("ASTRA_AI_TUTOR_API_KEY", "")])
+def test_disabled_tutor_hides_model_and_does_not_call_upstream(client, tutor_actor, monkeypatch, setting, value):
+ monkeypatch.setenv(setting, value)
+ get_settings.cache_clear()
+ requests = _capture_upstream(monkeypatch, lambda request: httpx.Response(200, json={}))
+ response = client.get("/api/ai-tutor/config", headers=tutor_actor)
+ assert response.status_code == 200
+ assert response.json() == {"enabled": False, "provider": "deepseek", "model": None}
+ response = client.post("/api/ai-tutor/chat", headers=tutor_actor, json={"message": "如何观察?"})
+ assert response.status_code == 503
+ assert response.json() == {"detail": "学校尚未启用 AI 助教"}
+ assert requests == []
+
+
+def test_tutor_sends_actor_context_and_returns_only_the_answer(client, tutor_actor, monkeypatch):
+ requests = _capture_upstream(monkeypatch, lambda request: httpx.Response(200, json={
+ "choices": [{"message": {"content": " 先比较两次摆动的周期。 "}}],
+ "usage": {"total_tokens": 100},
+ }))
+ config = client.get("/api/ai-tutor/config", headers=tutor_actor)
+ assert config.status_code == 200
+ assert config.json() == {"enabled": True, "provider": "deepseek", "model": "test-tutor-model"}
+ assert requests == []
+ response = client.post("/api/ai-tutor/chat", headers=tutor_actor, json={
+ "message": " 如何比较? ",
+ "context": {"space": "englab", "page_title": "摆的运动", "route": ""},
+ })
+ assert response.status_code == 200
+ assert response.json() == {"answer": "先比较两次摆动的周期。", "provider": "deepseek", "model": "test-tutor-model"}
+ assert "local-test-secret" not in config.text + response.text
+ assert len(requests) == 1
+ request = requests[0]
+ assert str(request.url) == "https://tutor.example.test/v1/chat/completions"
+ assert request.headers["Authorization"] == "Bearer local-test-secret"
+ body = json.loads(request.content)
+ assert body["model"] == "test-tutor-model"
+ assert body["temperature"] == 0.35
+ assert body["stream"] is False
+ assert len(body["messages"]) == 2
+ assert body["messages"][0]["role"] == "system"
+ assert "当前学习身份:student" in body["messages"][0]["content"]
+ assert "当前页面上下文:space=englab;page_title=摆的运动。" in body["messages"][0]["content"]
+ assert body["messages"][1] == {"role": "user", "content": "如何比较?"}
+
+
+@pytest.mark.parametrize("failure", ["timeout", "status", "json", "empty_answer"])
+def test_tutor_upstream_failures_return_safe_gateway_errors(client, tutor_actor, monkeypatch, failure):
+ def respond(request):
+ if failure == "timeout":
+ raise httpx.ReadTimeout("upstream secret must stay private", request=request)
+ if failure == "status":
+ return httpx.Response(429, json={"error": "upstream secret must stay private"})
+ if failure == "json":
+ return httpx.Response(200, text="upstream secret must stay private")
+ return httpx.Response(200, json={"choices": [{"message": {"content": " "}}]})
+
+ requests = _capture_upstream(monkeypatch, respond)
+ response = client.post("/api/ai-tutor/chat", headers=tutor_actor, json={"message": "如何观察?"})
+ assert response.status_code == 502
+ assert response.json() == {"detail": "模型没有返回可显示的回答" if failure == "empty_answer" else "AI 助教暂时无法连接模型服务"}
+ assert len(requests) == 1
diff --git a/backend/tests/test_course_learning_loop.py b/backend/tests/test_course_learning_loop.py
index 9479d5d9..bd97e90b 100644
--- a/backend/tests/test_course_learning_loop.py
+++ b/backend/tests/test_course_learning_loop.py
@@ -11,6 +11,7 @@
ClassKnowledgeSnapshot,
Course,
KnowledgeSnapshotRun,
+ PointLedger,
SchoolMembership,
Submission,
UserKnowledgeSnapshot,
@@ -60,6 +61,14 @@ def _bootstrap_admin(client, username: str) -> str:
return login.json()["access_token"]
+def _submission_history(client, token: str, submission_id: int) -> dict:
+ response = client.get(
+ f"/api/v2/submissions/{submission_id}/history", headers=_auth_header(token),
+ )
+ assert response.status_code == 200, response.text
+ return response.json()
+
+
def test_knowledge_snapshot_period_windows_align_day_and_week():
day_start, day_end = knowledge_snapshot_runs.snapshot_window("day", date(2026, 7, 3))
assert day_start.isoformat() == "2026-07-03T00:00:00"
@@ -375,6 +384,9 @@ def test_teacher_course_assignment_and_student_learning_event_loop(client, monke
assert grade.status_code == 200
assert grade.json()["status"] == "graded"
assert grade.json()["score"] == 18
+ graded_history = _submission_history(client, student_token, submission_id)
+ graded_ledger = client.get("/api/points/ledger", headers=_auth_header(student_token))
+ assert graded_ledger.status_code == 200
active_review = client.get(
f"/api/assignments/{assignment_id}/review",
@@ -443,7 +455,8 @@ def test_teacher_course_assignment_and_student_learning_event_loop(client, monke
json={"class_id": class_id, "content": {"answer": "late retry"}},
)
assert closed_resubmit.status_code == 409
- assert closed_resubmit.json()["detail"] == "Assignment is not active"
+ assert closed_resubmit.json()["detail"] == "作业当前不能提交"
+ assert _submission_history(client, student_token, submission_id) == graded_history
with get_session_factory(get_settings().database_url)() as db:
stored_assignment = db.get(Assignment, assignment_id)
@@ -469,7 +482,11 @@ def test_teacher_course_assignment_and_student_learning_event_loop(client, monke
json={"class_id": class_id, "content": {"answer": "archived retry"}},
)
assert archived_resubmit.status_code == 409
- assert archived_resubmit.json()["detail"] == "Assignment is not active"
+ assert archived_resubmit.json()["detail"] == "作业当前不能提交"
+ assert _submission_history(client, student_token, submission_id) == graded_history
+ unchanged_ledger = client.get("/api/points/ledger", headers=_auth_header(student_token))
+ assert unchanged_ledger.status_code == 200
+ assert unchanged_ledger.json() == graded_ledger.json()
with get_session_factory(get_settings().database_url)() as db:
stored_assignment = db.get(Assignment, assignment_id)
@@ -528,7 +545,9 @@ def test_teacher_course_assignment_and_student_learning_event_loop(client, monke
grade_snapshot = grade_audit_items[0]["snapshot_json"]
assert grade_snapshot["before"]["score"] is None
assert grade_snapshot["after"]["score"] == 18
- assert grade_snapshot["after"]["score_delta"] == 18
+ assert grade_snapshot["after"]["point_delta"] == 18
+ assert grade_snapshot["after"]["attempt_id"] == graded_history["attempts"][0]["id"]
+ assert grade_snapshot["after"]["grade_id"] == graded_history["grades"][0]["id"]
course_audit = client.get(
f"/api/admin/audit-logs?action=course.create&resource_id={course_id}",
@@ -581,14 +600,21 @@ def test_teacher_course_assignment_and_student_learning_event_loop(client, monke
assert class_join_audit_items[0]["snapshot_json"]["after"]["role"] == "student"
submission_create_audit = client.get(
- f"/api/admin/audit-logs?action=submission.create&resource_id={submission_id}",
+ (
+ "/api/admin/audit-logs?action=submission.attempt.create"
+ f"&resource_id={graded_history['attempts'][0]['id']}"
+ ),
headers=_auth_header(admin_token),
)
assert submission_create_audit.status_code == 200
submission_create_audit_items = submission_create_audit.json()["items"]
assert submission_create_audit.json()["total"] == 1
assert len(submission_create_audit_items) == 1
- assert submission_create_audit_items[0]["snapshot_json"]["after"]["content_keys"] == ["answer"]
+ assert submission_create_audit_items[0]["resource_type"] == "assignment_attempt"
+ attempt_snapshot = submission_create_audit_items[0]["snapshot_json"]
+ assert attempt_snapshot["submission_id"] == submission_id
+ assert attempt_snapshot["attempt_number"] == 1
+ assert attempt_snapshot["content_keys"] == ["answer"]
student_points = client.get("/api/points/ledger", headers=_auth_header(student_token))
assert student_points.status_code == 200
@@ -1026,7 +1052,7 @@ def test_same_assignment_can_be_submitted_once_per_class(client):
assert second_review.json()["submission"]["id"] == second_submission.json()["id"]
-def test_assignment_point_rule_controls_grading_points(client):
+def test_assignment_point_rule_is_pinned_per_attempt_and_return_adjusts_points_once(client):
teacher_token = _register_and_login(client, "teacher_point_rule", "teacher")
peer_teacher_token = _register_and_login(client, "peer_point_rule", "teacher")
student_token = _register_and_login(client, "student_point_rule", "student")
@@ -1148,6 +1174,11 @@ def test_assignment_point_rule_controls_grading_points(client):
)
assert submission.status_code == 201
submission_id = submission.json()["id"]
+ first_history = _submission_history(client, student_token, submission_id)
+ first_attempt = first_history["attempts"][0]
+ assert first_attempt["assignment_snapshot"]["point_rule"] == {
+ "enabled": True, "points_per_score": 2, "max_points": 25,
+ }
grade = client.patch(
f"/api/submissions/{submission_id}/grade",
@@ -1188,10 +1219,62 @@ def test_assignment_point_rule_controls_grading_points(client):
assert disabled_regrade.status_code == 200
ledger_after_disable = client.get(f"/api/points/ledger?class_id={class_id}", headers=_auth_header(teacher_token))
assert ledger_after_disable.status_code == 200
- assert [item["delta"] for item in ledger_after_disable.json()] == [24, 1, -25]
+ # A changed live rule applies to later attempts, not an existing answer's frozen rule.
+ assert [item["delta"] for item in ledger_after_disable.json()] == [24, 1]
progress_after_disable = client.get(f"/api/progress/me?class_id={class_id}", headers=_auth_header(student_token))
assert progress_after_disable.status_code == 200
- assert progress_after_disable.json()["total_points"] == 0
+ assert progress_after_disable.json()["total_points"] == 25
+ regraded_history = _submission_history(client, student_token, submission_id)
+ assert regraded_history["attempts"] == first_history["attempts"]
+ assert [item["score"] for item in regraded_history["grades"]] == [16, 15, 12]
+ assert [item["point_delta"] for item in regraded_history["grades"]] == [0, 1, 24]
+
+ return_payload = {
+ "client_request_id": "return-after-point-rule-change",
+ "expected_submission_revision": regraded_history["revision"],
+ "expected_grade_revision": regraded_history["grades"][0]["revision"],
+ "status": "returned", "score": 0, "feedback": "请补充依据后重新提交",
+ }
+ returned = client.patch(
+ f"/api/submissions/{submission_id}/grade", headers=_auth_header(teacher_token), json=return_payload,
+ )
+ assert returned.status_code == 200, returned.text
+ returned_history = _submission_history(client, student_token, submission_id)
+ assert returned_history["grades"][0]["point_delta"] == -25
+ assert returned_history["grades"][1:] == regraded_history["grades"]
+ repeated_return = client.patch(
+ f"/api/submissions/{submission_id}/grade", headers=_auth_header(teacher_token), json=return_payload,
+ )
+ assert repeated_return.status_code == 200
+ assert _submission_history(client, student_token, submission_id) == returned_history
+
+ resubmission = client.post(
+ f"/api/assignments/{assignment_id}/submissions", headers=_auth_header(student_token),
+ json={"class_id": class_id, "content": {"answer": "revised under disabled rule"}},
+ )
+ assert resubmission.status_code == 201
+ assert resubmission.json()["id"] == submission_id
+ resubmitted_history = _submission_history(client, student_token, submission_id)
+ assert resubmitted_history["total"] == 2
+ assert resubmitted_history["attempts"][1] == first_attempt
+ assert resubmitted_history["attempts"][0]["assignment_snapshot"]["point_rule"] == {
+ "enabled": False, "points_per_score": 1, "max_points": None,
+ }
+ assert resubmitted_history["grades"] == returned_history["grades"]
+ new_grade = client.patch(
+ f"/api/submissions/{submission_id}/grade", headers=_auth_header(teacher_token),
+ json={"score": 18, "feedback": "新版规则不计积分"},
+ )
+ assert new_grade.status_code == 200
+ final_history = _submission_history(client, student_token, submission_id)
+ assert final_history["grades"][0]["point_delta"] == 0
+ assert final_history["grades"][1:] == returned_history["grades"]
+ final_ledger = client.get(f"/api/points/ledger?class_id={class_id}", headers=_auth_header(teacher_token))
+ assert final_ledger.status_code == 200
+ assert [item["delta"] for item in final_ledger.json()] == [24, 1, -25]
+ final_progress = client.get(f"/api/progress/me?class_id={class_id}", headers=_auth_header(student_token))
+ assert final_progress.status_code == 200
+ assert final_progress.json()["total_points"] == 0
def test_assignment_class_policy_controls_audience_status_events_and_point_override(client):
@@ -1406,7 +1489,14 @@ def test_assignment_class_policy_controls_audience_status_events_and_point_overr
json={"class_id": class_two_id, "content": {"answer": "not assigned"}},
)
assert class_two_submission.status_code == 403
- assert class_two_submission.json()["detail"] == "Assignment is not assigned to this class"
+ assert class_two_submission.json()["detail"] == "作业不在该班级范围"
+ with get_session_factory(get_settings().database_url)() as db:
+ assert db.scalar(select(Submission).where(
+ Submission.assignment_id == assignment_id, Submission.class_id == class_two_id,
+ )) is None
+ assert db.scalar(select(PointLedger).where(
+ PointLedger.assignment_id == assignment_id, PointLedger.class_id == class_two_id,
+ )) is None
class_two_event = client.post(
"/api/learning-events",
headers=_auth_header(student_two_token),
diff --git a/backend/tests/test_course_release_plans.py b/backend/tests/test_course_release_plans.py
index 6a941511..6bdb0ffd 100644
--- a/backend/tests/test_course_release_plans.py
+++ b/backend/tests/test_course_release_plans.py
@@ -10,7 +10,7 @@
from sqlalchemy.dialects import mysql
from sqlalchemy.schema import CreateTable
-from app.api.endpoints import code_judge, learning_events, submissions
+from app.api.endpoints import code_judge, learning_events
from app.core.config import get_settings
from app.db.session import get_session_factory, make_engine, reset_database_state
from app.models import (
@@ -21,7 +21,7 @@
CourseUnitClassPlan,
School,
)
-from app.services import course_release_plans, course_release_write_gate
+from app.services import assignment_history, course_release_plans, course_release_write_gate
def _auth(token: str) -> dict[str, str]:
@@ -689,7 +689,7 @@ def test_assignment_submission_http_write_rechecks_release_after_barrier(client,
response = _post_student_write_after_release_barrier(
client,
monkeypatch,
- module=submissions,
+ module=assignment_history,
teacher_headers=_auth(teacher),
course_id=course_id,
class_id=class_id,
@@ -700,8 +700,17 @@ def test_assignment_submission_http_write_rechecks_release_after_barrier(client,
release_mode=release_mode,
)
assert response.status_code == expected_status
+ assert response.json()["detail"] == (
+ "Course unit is not visible in this class" if release_mode == "hidden" else "Course unit is locked in this class"
+ )
with get_session_factory(get_settings().database_url)() as db:
assert db.scalar(text("SELECT COUNT(*) FROM submissions WHERE assignment_id = :assignment_id"), {"assignment_id": assignment.json()["id"]}) == 0
+ assert db.scalar(text("SELECT COUNT(*) FROM assignment_attempts WHERE assignment_id = :assignment_id"), {"assignment_id": assignment.json()["id"]}) == 0
+ assert db.scalar(text("SELECT COUNT(*) FROM learning_events WHERE assignment_id = :assignment_id"), {"assignment_id": assignment.json()["id"]}) == 0
+ assert db.scalar(
+ text("SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'submission.%' AND event_result = 'success' AND class_id = :class_id"),
+ {"class_id": class_id},
+ ) == 0
@pytest.mark.parametrize(("release_mode", "expected_status"), [("hidden", 403), ("locked", 409)])
diff --git a/backend/tests/test_demo_data_initializer.py b/backend/tests/test_demo_data_initializer.py
index 9ef54d39..a458e55b 100644
--- a/backend/tests/test_demo_data_initializer.py
+++ b/backend/tests/test_demo_data_initializer.py
@@ -438,16 +438,90 @@ async def _read_control_flow_demo_scope(report: dict) -> dict:
}
+async def _read_demo_publication_state() -> dict:
+ async with DemoApi() as api:
+ admin = await api.login(
+ "astra_demo_admin", DEMO_PASSWORDS["astra_demo_admin"], "admin", "演示管理员"
+ )
+ page = initializer_module._require_status(
+ await api.get("/api/v2/candidates?limit=100", admin), 200, "read demo candidates"
+ )
+ candidates = [
+ initializer_module._require_status(
+ await api.get(f"/api/v2/candidates/{item['candidate_id']}", admin),
+ 200, "read demo candidate snapshot",
+ )
+ for item in page["items"]
+ ]
+ course_ids = sorted({item["course_id"] for item in candidates})
+ return {
+ "candidates": candidates,
+ "drafts": [
+ initializer_module._require_status(
+ await api.get(f"/api/v2/courses/{course_id}/draft", admin),
+ 200, "read demo working draft",
+ )
+ for course_id in course_ids
+ ],
+ "releases": [
+ initializer_module._require_status(
+ await api.get(f"/api/v1/courses/{course_id}/releases", admin),
+ 200, "read demo published releases",
+ )
+ for course_id in course_ids
+ ],
+ }
+
+
+async def _read_demo_assignment_history(report: dict) -> dict:
+ async with DemoApi() as api:
+ teacher = await api.login(
+ "astra_demo_teacher", DEMO_PASSWORDS["astra_demo_teacher"], "teacher", "演示教师"
+ )
+ return {
+ title: initializer_module._require_status(
+ await api.get(f"/api/v2/submissions/{submission['id']}/history", teacher),
+ 200, "read demo assignment history",
+ )
+ for title, submission in report["course_loop"]["submissions"].items()
+ }
+
+
def test_fresh_demo_and_two_reruns_are_semantically_idempotent(local_demo_environment):
first = asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
first_payloads = _read_seeded_learner_evidence_payloads()
first_scope = asyncio.run(_read_control_flow_demo_scope(first))
+ first_publications = asyncio.run(_read_demo_publication_state())
+ first_assignments = asyncio.run(_read_demo_assignment_history(first))
second = asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
second_payloads = _read_seeded_learner_evidence_payloads()
second_scope = asyncio.run(_read_control_flow_demo_scope(second))
third = asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
third_payloads = _read_seeded_learner_evidence_payloads()
third_scope = asyncio.run(_read_control_flow_demo_scope(third))
+ assert asyncio.run(_read_demo_publication_state()) == first_publications
+ assert asyncio.run(_read_demo_assignment_history(third)) == first_assignments
+ for history in first_assignments.values():
+ assert history["total"] == 1
+ assert len(history["attempts"]) == 1
+ attempt = history["attempts"][0]
+ assert attempt["provenance"] == "formal_submission"
+ assert attempt["context_key"]
+ assert attempt["course_release_id"] == first["course_loop"]["releases"]["current_release_id"]
+ assert len(first_assignments["机械能证据报告"]["grades"]) == 1
+ assert first_assignments["机械能证据报告"]["grades"][0]["score"] == 92
+ assert first_assignments["机械能拓展思考"]["grades"] == []
+ candidates = first_publications["candidates"]
+ assert len(candidates) == 2
+ assert {item["status"] for item in candidates} == {"approved"}
+ assert {item["review_version"] for item in candidates} == {2}
+ assert {item["reviewed_by_user_id"] for item in candidates} == {first["users"]["admin"]["id"]}
+ assert {item["submitted_by_user_id"] for item in candidates} == {
+ first["users"]["teacher"]["id"], first["users"]["peer_teacher"]["id"]
+ }
+ assert {item["published_release_id"] for item in candidates} == {
+ item["id"] for item in first_publications["releases"][0]
+ }
for report in (first, second, third):
assert report["status"] == "initialized"
@@ -576,6 +650,110 @@ def test_fresh_demo_and_two_reruns_are_semantically_idempotent(local_demo_enviro
assert second["course_status"]["status_patch_audits"] == third["course_status"]["status_patch_audits"]
+@pytest.mark.parametrize("interrupted_stage", [
+ "submissions", "candidate-reviews", "publications", "open", "attempts", "grades",
+])
+def test_demo_workflow_recovers_after_committed_step(
+ local_demo_environment, monkeypatch, interrupted_stage
+):
+ original_post = DemoApi.post
+
+ async def interrupt_after_commit(self, path, actor, payload):
+ response = await original_post(self, path, actor, payload)
+ if path.startswith("/api/v2/") and path.endswith(f"/{interrupted_stage}"):
+ assert response.status_code in (200, 201), response.json()
+ raise DemoInitializationError("injected interruption after committed workflow step")
+ return response
+
+ with monkeypatch.context() as local:
+ local.setattr(DemoApi, "post", interrupt_after_commit)
+ with pytest.raises(DemoInitializationError, match="injected interruption"):
+ asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
+
+ before = asyncio.run(_read_demo_publication_state())
+ assert len(before["candidates"]) == (1 if interrupted_stage in {"submissions", "candidate-reviews", "publications"} else 2)
+ original_candidate_ids = {item["candidate_id"] for item in before["candidates"]}
+ report = asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
+ after = asyncio.run(_read_demo_publication_state())
+ assert report["course_loop"]["releases"]["release_numbers"] == [2, 1]
+ assert len(after["candidates"]) == 2
+ assert original_candidate_ids <= {item["candidate_id"] for item in after["candidates"]}
+ assert all(item["status"] == "approved" and item["published_release_id"] for item in after["candidates"])
+ assert asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))["course_loop"] == report["course_loop"]
+ assert asyncio.run(_read_demo_publication_state()) == after
+
+
+@pytest.mark.parametrize("external_change", [
+ "rejected", "approved", "withdrawn", "draft_changed", "completion_changed", "version_changed", "empty_draft",
+ "foreign_candidate",
+])
+def test_demo_does_not_publish_or_replace_a_drifted_candidate(
+ local_demo_environment, monkeypatch, external_change
+):
+ original_post = DemoApi.post
+
+ async def interrupt_after_submission(self, path, actor, payload):
+ if external_change == "foreign_candidate" and path.startswith("/api/v2/courses/") and path.endswith("/submissions"):
+ payload = {**payload, "client_request_id": "external-course-submission"}
+ response = await original_post(self, path, actor, payload)
+ if path.startswith("/api/v2/courses/") and path.endswith("/submissions"):
+ assert response.status_code == 201, response.json()
+ raise DemoInitializationError("injected interruption after candidate submission")
+ return response
+
+ with monkeypatch.context() as local:
+ local.setattr(DemoApi, "post", interrupt_after_submission)
+ with pytest.raises(DemoInitializationError, match="injected interruption"):
+ asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
+
+ async def alter_candidate() -> None:
+ state = await _read_demo_publication_state()
+ candidate = state["candidates"][0]
+ async with DemoApi() as api:
+ if external_change in {"rejected", "approved", "withdrawn"}:
+ admin = await api.login(
+ "astra_demo_admin", DEMO_PASSWORDS["astra_demo_admin"], "admin", "演示管理员"
+ )
+ if external_change == "withdrawn":
+ teacher = await api.login(
+ "astra_demo_teacher", DEMO_PASSWORDS["astra_demo_teacher"], "teacher", "演示教师"
+ )
+ response = await api.post(f"/api/v2/candidates/{candidate['candidate_id']}/withdraw", teacher, {
+ "client_request_id": "external-demo-withdraw", "expected_version": candidate["review_version"],
+ })
+ else:
+ response = await api.post("/api/v2/candidate-reviews", admin, {
+ "client_request_id": "external-demo-review",
+ "items": [{"review_item_id": candidate["review_item_id"], "expected_version": candidate["review_version"]}],
+ "decision": external_change, "note": "External review must be preserved",
+ })
+ else:
+ teacher = await api.login(
+ "astra_demo_teacher", DEMO_PASSWORDS["astra_demo_teacher"], "teacher", "演示教师"
+ )
+ draft = state["drafts"][0]
+ units = [{key: unit[key] for key in ("id", "activity_key", "title", "position", "content")} for unit in draft["units"]]
+ if external_change == "draft_changed":
+ units[1]["content"]["blocks"][2]["prompt"] = "外部教师修改后的检查题,不可自动覆盖或批准。"
+ elif external_change in {"completion_changed", "foreign_candidate"}:
+ units[0]["content"]["courseUnit"]["completion"] = None
+ elif external_change == "version_changed":
+ units[0]["content"]["blocks"][1]["markdown"] = "外部教师的独立说明,不再使用演示版本标记。"
+ else:
+ units = []
+ response = await api.patch(f"/api/v1/courses/{candidate['course_id']}/draft", teacher, {
+ "expected_revision": draft["revision"], "units": units,
+ })
+ assert response.status_code == 200, response.json()
+
+ asyncio.run(alter_candidate())
+ before = asyncio.run(_read_demo_publication_state())
+ with pytest.raises(DemoInitializationError, match="(?:candidate|draft).*drifted"):
+ asyncio.run(initialize_demo_data(credentials=DEMO_PASSWORDS))
+ assert asyncio.run(_read_demo_publication_state()) == before
+ assert before["releases"] == [[]]
+
+
@pytest.mark.parametrize("database_url", [
"sqlite+pysqlite:////tmp/astra-demo.sqlite3",
"sqlite+pysqlite:///C:/Astra/astra-demo.sqlite3",
diff --git a/backend/tests/test_learning_evidence.py b/backend/tests/test_learning_evidence.py
index 6968a174..3753316b 100644
--- a/backend/tests/test_learning_evidence.py
+++ b/backend/tests/test_learning_evidence.py
@@ -5848,11 +5848,15 @@ def test_access_query_boundary_and_mysql_current_read_statements_are_explicit():
submissions_source = (
backend_root / "app/api/endpoints/submissions.py"
).read_text(encoding="utf-8")
+ submission_queries_source = (
+ backend_root / "app/services/submissions_queries.py"
+ ).read_text(encoding="utf-8")
access_source = (
backend_root / "app/services/learning_evidence_access.py"
).read_text(encoding="utf-8")
assert "from app.services.learning_evidence import" not in course_release_source
assert "from app.services.learning_evidence import" not in submissions_source
+ assert "from app.services.learning_evidence import" not in submission_queries_source
assert "course_release_plans" not in access_source
assert "services.learning_evidence import" not in access_source
diff --git a/backend/tests/test_legacy_list_limits.py b/backend/tests/test_legacy_list_limits.py
index 836640d3..84afba92 100644
--- a/backend/tests/test_legacy_list_limits.py
+++ b/backend/tests/test_legacy_list_limits.py
@@ -3,7 +3,8 @@
import pytest
from fastapi import HTTPException
-from app.api.endpoints import classes, code_judge, learning_events, submissions
+from app.api.endpoints import classes, code_judge, learning_events
+from app.services import submissions_queries
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.models import ClassMembership, CodeJudgeAttempt, LearningEvent, Submission, User
@@ -162,7 +163,7 @@ def test_paged_endpoint_url_preserves_and_encodes_legacy_filters():
("handler", "helper_name", "paged_route_token"),
[
(learning_events.list_learning_events, "list_legacy_scalars", "/api/learning-events/page"),
- (submissions.list_assignment_submissions, "list_legacy_scalars", "/submissions/page"),
+ (submissions_queries.list_assignment_submissions, "list_legacy_scalars", "/submissions/page"),
(classes.list_class_members, "list_legacy_rows", "/members/page"),
(code_judge.list_code_judge_attempts, "list_legacy_scalars", "/attempts/page"),
],
diff --git a/backend/tests/test_student_assignment_center.py b/backend/tests/test_student_assignment_center.py
index 33f2df46..a13e4041 100644
--- a/backend/tests/test_student_assignment_center.py
+++ b/backend/tests/test_student_assignment_center.py
@@ -303,7 +303,17 @@ def test_student_assignment_center_scopes_filters_feedback_and_pagination(client
json={"class_id": own_class_id, "content": {"answer": "duplicate"}},
)
assert duplicate.status_code == 409
- assert duplicate.json()["detail"] == "Assignment already submitted"
+ history = client.get(
+ f"/api/v2/submissions/{submission.json()['id']}/history", headers=_auth_header(student["token"]),
+ )
+ assert history.status_code == 200
+ assert history.json()["revision"] == 1
+ assert history.json()["total"] == 1
+ assert history.json()["attempts"][0]["content"] == {"answer": "center answer"}
+ assert history.json()["grades"] == []
+ unchanged_points = client.get("/api/points/ledger", headers=_auth_header(student["token"]))
+ assert unchanged_points.status_code == 200
+ assert unchanged_points.json() == []
grade = client.patch(
f"/api/submissions/{submission.json()['id']}/grade",
headers=_auth_header(teacher["token"]),
diff --git "a/doc/01-\345\274\200\345\217\221\350\200\205\346\211\213\345\206\214.md" "b/doc/01-\345\274\200\345\217\221\350\200\205\346\211\213\345\206\214.md"
index 31e316ac..ebfbbfb1 100644
--- "a/doc/01-\345\274\200\345\217\221\350\200\205\346\211\213\345\206\214.md"
+++ "b/doc/01-\345\274\200\345\217\221\350\200\205\346\211\213\345\206\214.md"
@@ -4,7 +4,7 @@
>
> **具体规划法则**:代码和验证决定当前事实;业务规则、任务和进度只在 [02](02-更新规划.md) 维护,提交及历史验收只在 [03](03-发布历史.md) 维护。类型和接口字段链接权威代码,不再复制多份字段表。
>
-> **当前基线**:以 `main` 提交 `b177706` 为本次代码核对基线。主站正式前端为 `qianduan/`,FastAPI 为主站教学业务权威;资源与模板、独立教学分叉、同步送审、冻结发布、版本化学习和评分历史均已有实现。教育方向入口、可选 AI 助教和 `zhijiao/` 独立职教栈也已进入代码。2026-09-17 研发暂停及当时未结束的验收保留为历史事实;本次仅更新文档,不表示恢复全部研发或完成后续验收。
+> **当前基线**:QA-036 以文档合并后的 `main / e0fb2b1` 为修复起点,代码保存于 `752c597`。主站正式前端为 `qianduan/`,FastAPI 为主站教学业务权威;资源与模板、独立教学分叉、同步送审、冻结发布、版本化学习和评分历史均已有实现。教育方向入口、可选 AI 助教和 `zhijiao/` 独立职教栈也已进入代码。本次按用户要求修复质量检查及演示初始化,具体范围见 [03 第 12 节](03-发布历史.md#12-2026-09-18-质量门禁修复qa-036);原 A—F 目标及未结束的阶段验收仍按 02 保留。
>
> **最后一次更新时间**:2026-09-18
>
@@ -260,7 +260,7 @@ flowchart TB
[ActivitySession](../qianduan/src/domain/activity-session.ts)串行发送并保留原请求重试;离页释放监听器与未发送内存队列,不把观察或答案持久化至普通浏览器存储。[CoursePlayer](../qianduan/src/portal/course-player.ts)对课程中的原实验入口执行完整页面导航,不再在课程页内嵌套 iframe;导航地址带有本次学习上下文和返回位置,可用浏览器后退返回课程。当前该分支跳过门户内运行会话,公开实验页也尚未消费传入的上下文,因此不能将完整页面打开视为已完成正式操作记录。原求解器不承担教学授权或评分。原力学重力滑杆的步长已由 50 改为 10,以避免默认 980 被浏览器归整成 1000;原保护基线不变,有独立的一致性回归与精确变更记录。
-2026-09-17 用户主动暂停。当时已有定向验证与页面操作记录,最后页面复看和其余阶段仍未结束,实际范围见 [03 暂停快照](03-发布历史.md#106-实验操作版本绑定与暂停快照)。这些证据仅适用于各自提交和场景;当前页面导航缺口、配置边界与回归状态仍需分别处理。本次文档更新不恢复产品研发,后续范围与授权仍按 02 记录。
+2026-09-17 用户主动暂停。当时已有定向验证与页面操作记录,最后页面复看和其余阶段仍未结束,实际范围见 [03 暂停快照](03-发布历史.md#106-实验操作版本绑定与暂停快照)。这些证据仅适用于各自提交和场景;当前页面导航缺口与配置边界仍需分别处理。本次 QA-036 修复质量检查,原 A—F 研发目标仍暂停,后续范围与授权按 02 记录。
### 6.7 可选 AI 助教
@@ -284,7 +284,7 @@ flowchart TB
### 7.4 当前验证范围
-2026-09-08 已完成真实建课、审核发布、学生提交、教师批改、重启后回读成绩、笔记与检查点完成回读。具体证据、测试计数和部署提交只在 [03 本阶段记录](03-发布历史.md#8-2026-09-07-正式门户接入与前后端封装) 保存;它们不是后续任一修改的自动验收结果。2026-09-16 新课程工作流的定向验证与浏览器证据见 [03 第 10.4 节](03-发布历史.md#104-课程创作同步审核与冻结发布)。当前基线仍有待归因或待修复的回归失败,不能将历史通过记录合并成当前全量通过结论。
+2026-09-08 已完成真实建课、审核发布、学生提交、教师批改、重启后回读成绩、笔记与检查点完成回读。具体证据、测试计数和部署提交只在 [03 本阶段记录](03-发布历史.md#8-2026-09-07-正式门户接入与前后端封装) 保存;它们不是后续任一修改的自动验收结果。2026-09-16 新课程工作流的定向验证与浏览器证据见 [03 第 10.4 节](03-发布历史.md#104-课程创作同步审核与冻结发布)。QA-036 的当前回归、浏览器与远端质量检查见 [03 第 12 节](03-发布历史.md#12-2026-09-18-质量门禁修复qa-036),各项结果只对应本轮代码及实际环境。
## 8. 仓库入口与开发顺序
diff --git "a/doc/02-\346\233\264\346\226\260\350\247\204\345\210\222.md" "b/doc/02-\346\233\264\346\226\260\350\247\204\345\210\222.md"
index 1a3efc08..896fdcd4 100644
--- "a/doc/02-\346\233\264\346\226\260\350\247\204\345\210\222.md"
+++ "b/doc/02-\346\233\264\346\226\260\350\247\204\345\210\222.md"
@@ -9,9 +9,9 @@
> 3. DOC-006 已完成设计落档;正式门户和 `/api/v2` 已分批接入资源、课程家族、同步审核、发布和学习历史。剩余任务仍按下方依赖收口,阶段联调不等于整个目标架构完成。
> 4. 当前代码基线与未提交工作区改动分别记录。不分配未获安排的发布号,不虚构 Git 提交、完成状态或新测试结果。
>
-> **当前提交基线**:本次文档核对以 `main / b177706` 为准,包含资源、课程工作流、学习结果及后续双站接入提交;事实索引见 [03 第 10 节](03-发布历史.md#10-2026-09-16-持续开发目标)与[第 11 节](03-发布历史.md#11-2026-09-18-双站接入与文档同步),未安排新的星序产品发布号。
+> **当前提交基线**:QA-036 从文档合并后的 `main / e0fb2b1` 修复,代码提交为 `752c597`;资源、课程工作流、学习结果及双站接入事实继续见 [03 第 10 节](03-发布历史.md#10-2026-09-16-持续开发目标)与[第 11 节](03-发布历史.md#11-2026-09-18-双站接入与文档同步),本轮修复及验证见 [第 12 节](03-发布历史.md#12-2026-09-18-质量门禁修复qa-036)。未安排新的星序产品发布号。
>
-> **当前规划周期**:2026-09-17 用户要求“先停止目标,收束暂停”,A—F 持续开发目标仍暂停,恢复须由用户明确发起。后续独立功能和本次用户要求的 DOC-008 文档更新分别记录,不据此恢复原研发任务。
+> **当前规划周期**:2026-09-17 用户要求“先停止目标,收束暂停”,A—F 持续开发目标仍暂停,恢复须由用户明确发起。后续独立功能、DOC-008 文档更新及本次用户明确要求的 QA-036 质量检查修复分别记录,不据此恢复原研发任务。
>
> **最后一次更新时间**:2026-09-18
>
@@ -249,6 +249,8 @@
## 3. 当前任务状态
+QA-036 已完成本机回归和远端五组质量检查,代码提交为 `752c597`,修复及验证归档至 [03 第 12 节](03-发布历史.md#12-2026-09-18-质量门禁修复qa-036)。本次按用户授权处理实际回归及过时契约,原 A—F 计划的其他任务仍保留原状态。
+
B—C 的课程主流程已有本机功能验证,记录见 [03 第 10.4 节](03-发布历史.md#104-课程创作同步审核与冻结发布)。A—F 目标目前暂停;下列任务在结果、权限、查询及整体迁移收口后再完成最终归档,不把阶段联调当成整个系统验收。
D1—D2 已提交至 `92552fc`,覆盖版本化检查点、作业重交与评分历史。D3 在 `4bbfc04` 保存了实验运行和声明参数观察;当时函数图像与原力学控件的真实页面记录见 [03 第 10.6 节](03-发布历史.md#106-实验操作版本绑定与暂停快照)。后续入口已改为整页导航,不能直接沿用当时 iframe 接线的验证结论。
@@ -263,7 +265,7 @@ D1—D2 已提交至 `92552fc`,覆盖版本化检查点、作业重交与评
| 空参数模板仍能配置操作完成条件 | 函数模板可不声明参数,但运行中没有对应控件;发布门禁须拒绝无法完成的操作要求 | BE-033、BE-038 |
| 旧作业读取与单元锁定不一致 | 旧兼容路径仍可返回锁定任务描述,须按当前课程开放计划统一正文读取边界 | BE-038、FE-048 |
-以上是当前源码核对结果,本次文档更新不修改业务实现,也不将其记为已修复。职教的模型启用、检索范围和分项验证边界见 [04 第 8—9 节](04-部署指南.md#8-职教子站运行与数据),不混入学术 A—F 的完成状态。
+以上是保留的源码核对结果,QA-036 不将这些阶段问题记为已修复。职教的模型启用、检索范围和分项验证边界见 [04 第 8—9 节](04-部署指南.md#8-职教子站运行与数据),不混入学术 A—F 的完成状态。
TOOLS-003 已统一开发与构建运行环境,交付记录见 [03 第 10 节](03-发布历史.md#10-2026-09-16-持续开发目标)。
@@ -365,7 +367,8 @@ CONTENT-011 可在 DOC-006 完成后独立推进规划细化,不依赖新增
| 来源 | 决策 | 本文落位 |
| --- | --- | --- |
-| 本次用户明确要求 | 更新 doc 与 README;doc 沿用既有文体,README 加强项目介绍和阅读引导 | DOC-008、03 第 11 节 |
+| 本次用户明确要求 | 彻底修复质量检查失败,继续验证被首个失败中断的契约 | QA-036、03 第 12 节 |
+| 用户文档要求 | 更新 doc 与 README;doc 沿用既有文体,README 加强项目介绍和阅读引导 | DOC-008、03 第 11 节 |
| 用户明确确认 | 前端增加广度、递进与共同质量,本轮先出修改与拓展计划 | 2.12、CONTENT-011 |
| 用户明确确认 | 系统资源预置,教师可调整并从模板简单创作 | 2.2、BE-033 |
| 用户明确确认 | 模板提供基本交互,教师配置开放项 | 2.2;不扩展为任意交互编程 |
diff --git "a/doc/03-\345\217\221\345\270\203\345\216\206\345\217\262.md" "b/doc/03-\345\217\221\345\270\203\345\216\206\345\217\262.md"
index cb8f93d5..6dfdea0e 100644
--- "a/doc/03-\345\217\221\345\270\203\345\216\206\345\217\262.md"
+++ "b/doc/03-\345\217\221\345\270\203\345\216\206\345\217\262.md"
@@ -21,12 +21,13 @@
9. [当前文档精简](#9-2026-09-16-当前阶段文档精简)
10. [持续开发阶段记录](#10-2026-09-16-持续开发目标)
11. [双站接入与文档同步](#11-2026-09-18-双站接入与文档同步)
+12. [质量门禁修复](#12-2026-09-18-质量门禁修复qa-036)
## 1. 当前交付
-本次文档核对的远端源码基线为 `main / b177706`,已包含第 10 节资源、课程和学习结果提交,以及第 11 节教育方向、可选 AI 助教和职教动态栈接入。它们属于实际 Git 开发提交,没有分配新的星序产品发布号。9 月 17 日暂停的是 A—F 持续开发目标,不抹去随后独立完成的工作。
+QA-036 在文档合并后的 `main / e0fb2b1` 上修复质量检查,代码提交为 `752c597`,实际变化与验证见第 12 节。原文档核对基线 `b177706` 已包含第 10 节资源、课程和学习结果提交,以及第 11 节教育方向、可选 AI 助教和职教动态栈接入。它们属于实际 Git 开发提交,没有分配新的星序产品发布号。9 月 17 日暂停的是 A—F 持续开发目标,不抹去随后独立完成的工作。
-上一轮整体业务验收基线为 `b832151`(2026-09-08),三角色联调、Pages 制品 `627b2b7` 和当时五组 CI 的证据保留在第 8 节。历史通过不代表 `b177706` 全部通过;当前 CI 与验证范围见第 11.2 节。线上 Pages 版本应读取实际 `build-info.json`,不由源码已推送推定。当前实现以 [01](01-开发者手册.md) 为准,后续任务只认 [02](02-更新规划.md)。
+上一轮整体业务验收基线为 `b832151`(2026-09-08),三角色联调、Pages 制品 `627b2b7` 和当时五组 CI 的证据保留在第 8 节。第 11.2 节保留 `b177706` 的失败现场,第 12 节记录本轮修复,二者不互相覆盖。线上 Pages 版本应读取实际 `build-info.json`,不由源码已推送推定。当前实现以 [01](01-开发者手册.md) 为准,后续任务只认 [02](02-更新规划.md)。
## 2. 历史里程碑
@@ -366,3 +367,39 @@ FE-049 的目录准备、UI-019 的选型与 FE-050—FE-052 的原型交付均
- **文体与保护**:沿用文档梗概、规划法则、编号章节、规则表格和具体操作叙述;科学模型正文、公式、样例与历史测试原文保留。旧章节锚点保留兼容,跨文档引用指向现行标题。
- **文档核验**:14 份 Markdown 的 415 处本地文件与章节引用无断链,`git diff --check` 通过;10 张 Mermaid 经固定版本 11.12.0 解析并在 Chromium 133 实际渲染,页面错误为 0。原有 18 项未完成任务全部保留,科学模型正文逐字一致;另经独立文档与源码交叉复核。
- **合同验证**:科学模型说明契约与旧实验保护契约通过。架构边界契约仍停在旧 `index.html` 行数限制:3759 行超过 3758 行上限;该文件及检查脚本与 `b177706` 一致,本次未修改。该失败保留为当前门禁问题,不通过改写保护规则消除。
+
+## 12. 2026-09-18 质量门禁修复(QA-036)
+
+- **用户要求**:彻底修复文档合并后显示的质量检查失败,继续检查被首个失败中断的内容。
+- **Git 提交**:`752c597`,`fix(ci): 修复质量门禁与演示初始化闭环(QA-036)`;[PR #2](https://github.com/falling-feather/Astra/pull/2)。本次不新增产品发布号。
+- **修复基线**:`e0fb2b1` 的 SQLite 为 17 failed、745 passed、12 skipped;前端在第 5 个契约退出。该结果与第 11.2 节旧基线一致,文档合并没有修改相关业务代码。
+
+### 12.1 实际修复
+
+| 问题 | 具体完成 |
+| --- | --- |
+| 演示初始化仍直接发布,作业及实验使用旧写协议 | 两次发布均走预览、候选提交、管理员审核和教师发布;作业使用版本化尝试与评分,实验及检查点使用正式学习上下文和服务器运行配置。稳定请求回执支持中断恢复;已有候选与人工变更在写入前核对。 |
+| 作业测试停留在旧提示、函数位置和积分约定 | 修正评阅权限错误提示;通过真实 HTTP 验证学校、班级和成员状态撤销,确认拒绝后不改变提交、评分、积分与审计事实。旧尝试按提交时的积分快照评分,退回追加冲正,新尝试使用新规则;重复请求不追加同一结果。 |
+| 新增接口直接依赖模型或其他 endpoint | 作业查询、管理员目录和 AI 用例分别归入服务;公共 HTTP 事务处理归入 `app/api/service_calls.py`。作业查询及公共事务处理的原函数体逐一核对,保留授权、分页、异常响应和回滚语义。 |
+| 路由缓存引用落后,旧行数与资源日期断言遮蔽后续检查 | 页面、缓存预热和 Service Worker 使用同一路由 URL;五处过期行数冻结改为带正反例的入口职责、模型与 DTO 依赖检查。AI 能力按当前可选配置标为 partial;其余保护哈希和科学模型检查继续运行。 |
+| 前端契约遇首个失败即退出 | 保留顺序和独立子进程,执行全部契约后汇总失败并返回非零;补充前失败、中成功、后失败的实际运行验证。初始化器契约同步到当前 v2 接线,Windows 启动及凭据保护继续保留。 |
+
+### 12.2 验证范围
+
+本机使用 Python 3.12.14、Node 24.20.0 和 npm 11.19.0。以下均为 `752c597` 的本轮验证,不沿用旧阶段通过记录。
+
+| 检查 | 本轮结果 |
+| --- | --- |
+| SQLite 全量 | 800 项,788 passed、12 skipped、0 failed、0 errors;594.063 秒。跳过项为 8 项显式 MySQL 环境检查及 4 项 Windows DPAPI 检查。 |
+| 初始化及新用户隔离 | 34 passed;覆盖首次运行、两次重跑、6 类提交后中断恢复和 8 类外部审核或草稿变更。已包含在后端全量中,不另行累加。 |
+| 接口分层回归 | 92 passed,覆盖作业、权限、分页、发布、媒体、AI、目录及学习上下文;已包含在后端全量中。AI 使用 MockTransport,没有调用真实模型。 |
+| 主站前端 | GitHub Windows 环境中,331 份已跟踪 JavaScript 语法检查和全部 88 份契约通过,包含两份 PowerShell 启动契约。 |
+| MySQL 专项 | GitHub MySQL 8.4 完成迁移及 `test_mysql_release_evidence.py` 的 6 项验证;不将该专项扩大为完整 MySQL 或生产环境验收。 |
+| 正式门户 | 39 项测试、TypeScript/Vite 真实 API 构建及 `/Astra/` 演示构建通过;两种制品各核对 261 个文件和 127 个活动身份。 |
+| 浏览器 | Playwright 1.60.0 调用本机 Chromium 133.0.6943.0;9 个视口场景、105 项报告检查通过,页面、控制台和网络错误为 0。 |
+| Python 协议与依赖 | v2 DTO 与 OpenAPI 一致;固定 uv 0.10.6 重新解析的依赖与锁文件一致。 |
+| C++ 制品 | 在线 Release 构建及制品清单通过;空离线缓存被拒绝,复用已校验依赖的离线构建与清单通过。 |
+
+代码提交 `752c597` 的 [Quality gates 运行 35266779874](https://github.com/falling-feather/Astra/actions/runs/35266779874)已完成,Backend · SQLite、Backend · MySQL release evidence、Frontend · syntax and contracts、Portal · Linux standalone builds、C++ · release build 五组全部通过。文档补录只修改 01—04,不改变这次验收的业务代码;合并后 main 的运行另由对应提交的 Actions 记录确认。
+
+文档保留原有 18 项未完成任务及历史失败原文,本地文件和章节引用、科学模型说明契约、旧实验保护契约及 `git diff --check` 均通过。Windows、MySQL 与本机 SQLite 分别确认;本轮不发布 Pages,不将这些检查写成原 A—F 目标、职教完整组合门禁或真实课堂效果已经验收。
diff --git "a/doc/04-\351\203\250\347\275\262\346\214\207\345\215\227.md" "b/doc/04-\351\203\250\347\275\262\346\214\207\345\215\227.md"
index ec8c1ed3..3f7664ef 100644
--- "a/doc/04-\351\203\250\347\275\262\346\214\207\345\215\227.md"
+++ "b/doc/04-\351\203\250\347\275\262\346\214\207\345\215\227.md"
@@ -76,6 +76,8 @@ powershell -ExecutionPolicy Bypass -File .\astra-local.ps1 -VirtualEnvironmentPa
演示初始化器会检查既有合成记录是否漂移,不覆盖已被人工修改的账号、课程或学习事实。保留可用原数据;需要全新演示时明确指定新的隔离目录。合成记录不代表真实课堂证据。
+课程闭环演示通过实际接口完成候选提交、管理员审核和教师发布;作业、实验记录及检查点绑定服务器给出的学习上下文和发布版本。初始化中断后,重复执行会按稳定请求编号回读已完成步骤;只有候选、快照和审核回执与本次请求一致时才继续。遇到外部候选、外部审核或草稿漂移时停止,自动改稿前还会检查未发布候选,避免覆盖人工工作。QA-036 的首次初始化、重复运行和中断恢复验证见 [03 第 12 节](03-发布历史.md#12-2026-09-18-质量门禁修复qa-036)。
+
当前 HTTP 服务读取 `qianduan/dist`,不会自动把修改过的 TypeScript 变成新的静态产物。正式验收时重新构建并重启;前端开发使用 Vite。
## 3. 独立前端开发与静态展示
diff --git a/shared/js/main.js b/shared/js/main.js
index b5f3bbdc..806b17ee 100644
--- a/shared/js/main.js
+++ b/shared/js/main.js
@@ -239,7 +239,7 @@ const CORE_HTTP_FALLBACK_ASSETS = [
'./shared/js/experiment-registry.js?v=20260824v832Show03P0', './shared/js/learning-activity-catalog.js?v=' + PAGE_REGISTRY_ASSET_VERSION, './shared/js/public-guide.js?v=' + SESSION_STYLE_ASSET_VERSION,
'./shared/js/app-session.js?v=' + SHELL_RUNTIME_ASSET_VERSION + '&patch=20260828v866StudentProjectionP0',
'./shared/js/page-registry.js?v=' + PAGE_REGISTRY_ASSET_VERSION + '&patch=20260828v866StudentProjectionP0',
- './shared/js/router.js?v=' + SHELL_RUNTIME_ASSET_VERSION + '&patch=20260828v866StudentProjectionP0',
+ './shared/js/router.js?v=20260918aiTutorV1&patch=20260918aiTutorV1',
'./shared/js/main.js?v=' + PAGE_REGISTRY_ASSET_VERSION + '&patch=20260828v866StudentProjectionP0',
'./shared/js/backend-content.js?v=' + ENGLAB_ASSET_VERSION,
'./shared/css/backend-content.css?v=' + ENGLAB_ASSET_VERSION,
diff --git a/sw.js b/sw.js
index e97256e7..b2b9ac29 100644
--- a/sw.js
+++ b/sw.js
@@ -8,7 +8,7 @@ const APP_SHELL = [
'./shared/js/auth-ui.js?v=20260828v864RoleWorkbenchHarmonyP0',
'./shared/js/experiment-registry.js?v=20260824v832Show03P0', './shared/js/learning-activity-catalog.js?v=20260825v834Show04P0',
'./shared/js/public-guide.js?v=20260824v816ExperimentRestoreP2', './shared/js/app-session.js?v=20260828v864RoleWorkbenchHarmonyP0',
- './shared/js/page-registry.js?v=20260828v864RoleWorkbenchHarmonyP0', './shared/js/router.js?v=20260828v864RoleWorkbenchHarmonyP0',
+ './shared/js/page-registry.js?v=20260828v864RoleWorkbenchHarmonyP0', './shared/js/router.js?v=20260918aiTutorV1&patch=20260918aiTutorV1',
'./shared/js/main.js?v=20260828v864RoleWorkbenchHarmonyP0',
'./shared/js/backend-content.js?v=20260716v7427RoleWorkflowGateP0',
'./shared/css/tokens.css?v=20260424ss',
diff --git a/tools/quality/declaration-boundaries.cjs b/tools/quality/declaration-boundaries.cjs
new file mode 100644
index 00000000..d5fe264e
--- /dev/null
+++ b/tools/quality/declaration-boundaries.cjs
@@ -0,0 +1,25 @@
+const DECLARATION_PATHS = Object.freeze(['backend/app/models/course.py', 'backend/app/schemas/course.py']);
+
+function declarationBoundaryViolations(relativePath, source) {
+ if (!DECLARATION_PATHS.includes(relativePath)) throw new Error(`Unknown declaration module: ${relativePath}`);
+ // Inspect imports and calls, not prose in comments, field descriptions or docstrings.
+ const code = source.replace(/"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\r\n]*/g,
+ (token) => token.replace(/[^\r\n]/g, ' ')).replace(/;/g, '\n');
+ const forbidden = relativePath.includes('/models/') ? 'api|schemas|services' : 'api|models|services';
+ const forbiddenModule = new RegExp(`^(?:app\\.|\\.+)(?:${forbidden})(?:\\.|$)`);
+ const forbiddenName = new RegExp(`\\b(?:${forbidden})\\b`);
+ const imports = [...code.matchAll(/^[ \t]*(?:from[ \t]+([.\w]+)[ \t]+import[ \t]+(\([\s\S]*?\)|[^\r\n]+)|import[ \t]+([^\r\n]+))/gm)];
+ const violations = [];
+ if (imports.some((match) => {
+ const [, namespace, names, modules] = match;
+ if (modules) return modules.split(',').some((value) => forbiddenModule.test(value.trim().split(/\s+/)[0]));
+ return forbiddenModule.test(namespace)
+ || ((namespace === 'app' || /^\.+$/.test(namespace)) && forbiddenName.test(names));
+ })) violations.push('cross-layer-import');
+ if (/\b(?:requests|httpx|urllib\s*\.\s*request)\s*\.|\.\s*(?:commit|flush|execute|executemany|rollback|query|add|add_all|delete|merge|read_text|write_text|read_bytes|write_bytes)\s*\(|\b(?:Session|AsyncSession|open)\s*\(/.test(code)) {
+ violations.push('io-authority');
+ }
+ return violations;
+}
+
+module.exports = { DECLARATION_PATHS, declarationBoundaryViolations };
diff --git a/tools/quality/shell-boundaries.cjs b/tools/quality/shell-boundaries.cjs
new file mode 100644
index 00000000..3cceefcf
--- /dev/null
+++ b/tools/quality/shell-boundaries.cjs
@@ -0,0 +1,57 @@
+const SHELL_ENTRY_PATHS = Object.freeze(['index.html', 'shared/js/main.js', 'shared/js/router.js']);
+const SHELL_GLOBALS = {
+ 'index.html': new Set(['__englabCache', '__astraBoot', '__loadProgress', '__dismissEnglabLoading']),
+ 'shared/js/main.js': new Set(['sortSpeed', 'updateFooterVisibility', '__englabCache', 'warmGalaxyCache', '__warmedGalaxies']),
+ 'shared/js/router.js': new Set(['__astraGalaxyCache', 'navigate', 'Router']),
+};
+
+function inlineCode(html) {
+ html = html.replace(//g, '');
+ const scripts = [...html.matchAll(/` : code;
+ for (const code of [
+ "fetch('/api/ai-tutor/chat', { method: 'POST' });",
+ 'AstraApiClient.request(endpoint, payload);',
+ 'new XMLHttpRequest();',
+ ]) {
+ assert.ok(shellBoundaryViolations(relativePath, inline(code)).includes('business-network-authority'),
+ `${relativePath} must reject business requests moved into the shell: ${code}`);
+ }
+ for (const code of [
+ 'window.AstraAiTutor = {};',
+ "globalThis['AstraAiTutor'] = {};",
+ "Object.defineProperty(window, 'AstraAiTutor', { value: {} });",
+ 'Object.assign(window, { AstraAiTutor: {} });',
+ 'Object . assign(window, { AstraAiTutor: {} });',
+ ]) {
+ assert.ok(shellBoundaryViolations(relativePath, inline(code)).includes('feature-global-owner'),
+ `${relativePath} must reject feature ownership moved into the shell: ${code}`);
+ }
+ for (const code of ['window["fetch"](endpoint)', 'fetch.call(window, endpoint)']) {
+ assert.ok(shellBoundaryViolations(relativePath, inline(code)).includes('network-outside-cache-warmer'),
+ `${relativePath} must reject ordinary alternate fetch calls: ${code}`);
+ }
+ assert.deepEqual(shellBoundaryViolations(relativePath, inline(
+ '// window.AstraAiTutor = {}; fetch(endpoint);\nconst description = "fetch(endpoint); Object.assign(window, {})";')), [],
+ 'comments and display strings must not be mistaken for runtime authority');
+}
+assert.deepEqual(shellBoundaryViolations('index.html',
+ ''), [],
+ 'adding a stylesheet or versioned module reference must remain an allowed composition change');
+assert.ok(shellBoundaryViolations('shared/js/main.js', 'fetch(assetUrl);').includes('network-outside-cache-warmer'),
+ 'new bootstrap networking must not silently bypass the cache warmer boundary');
+assert.ok(shellBoundaryViolations('index.html', '')
+ .includes('network-outside-cache-warmer'), 'inline event handlers must not become a second transport owner');
+assert.ok(shellBoundaryViolations('index.html', '')
+ .includes('network-outside-cache-warmer'), 'unquoted event attributes must obey the same inline boundary');
+console.log('shell-boundaries-contract: ok');
diff --git a/tools/tests/shell-entry-contract.cjs b/tools/tests/shell-entry-contract.cjs
new file mode 100644
index 00000000..99b1dfe9
--- /dev/null
+++ b/tools/tests/shell-entry-contract.cjs
@@ -0,0 +1,104 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const root = path.resolve(__dirname, '../..');
+const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
+const html = read('index.html');
+const assetKey = (value) => {
+ const url = new URL(value.replaceAll('&', '&'), 'https://astra.test/');
+ return url.pathname + url.search;
+};
+
+async function testCacheWarmupUsesTheBootRouter() {
+ const routerSrc = html.match(/