diff --git a/backend/app/api/v1/endpoints/submission.py b/backend/app/api/v1/endpoints/submission.py index 2859cd1..30b5d5a 100644 --- a/backend/app/api/v1/endpoints/submission.py +++ b/backend/app/api/v1/endpoints/submission.py @@ -507,6 +507,86 @@ async def validate_submission_materials( # CRUD 端点 # ============================================================================ + +@router.get( + "/my-history", + summary="获取我的提交历史记录", + description="获取当前用户的所有提交记录,包括提交时间、状态变更等信息。", +) +async def get_my_submission_history( + contest_id: Optional[int] = Query(None, description="比赛ID(可选)"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取当前用户的提交历史记录 + + 返回用户所有作品的提交记录,包括: + - 作品基本信息 + - 提交时间 + - 当前状态 + - 审核信息(如有) + """ + # 构建查询 + query = ( + select(Submission) + .options(selectinload(Submission.attachments)) + .where(Submission.user_id == current_user.id) + ) + + # 比赛过滤 + if contest_id is not None: + query = query.where(Submission.contest_id == contest_id) + + # 统计总数 + count_query = select(func.count(Submission.id)).where( + Submission.user_id == current_user.id + ) + if contest_id is not None: + count_query = count_query.where(Submission.contest_id == contest_id) + + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 按提交时间倒序(未提交的按创建时间) + query = query.order_by( + Submission.submitted_at.desc().nullslast(), + Submission.created_at.desc() + ) + query = query.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(query) + submissions = result.scalars().all() + + # 构建响应 + items = [] + for sub in submissions: + items.append({ + "id": sub.id, + "contest_id": sub.contest_id, + "title": sub.title, + "status": sub.status, + "image_ref": sub.image_ref, + "repo_url": sub.repo_url, + "created_at": sub.created_at.isoformat() if sub.created_at else None, + "submitted_at": sub.submitted_at.isoformat() if sub.submitted_at else None, + "validated_at": sub.validated_at.isoformat() if sub.validated_at else None, + "reviewed_at": sub.reviewed_at.isoformat() if sub.reviewed_at else None, + "review_comment": sub.review_comment, + "vote_count": sub.vote_count, + "attachments_count": len(sub.attachments) if sub.attachments else 0, + }) + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + } + + @router.get( "/", response_model=SubmissionListResponse, @@ -1051,11 +1131,81 @@ async def validate_submission( return result +async def check_submission_rate_limit( + db: AsyncSession, + user_id: int, +) -> None: + """ + 检查提交频率限制 + + 规则: + - 每 10 分钟最多提交 1 次 + - 每日最多提交 5 次 + + Raises: + HTTPException 429: 超出频率限制 + """ + from datetime import timedelta + + now = datetime.utcnow() + ten_minutes_ago = now - timedelta(minutes=10) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # 查询最近 10 分钟内的提交次数 + recent_result = await db.execute( + select(func.count(Submission.id)) + .where( + Submission.user_id == user_id, + Submission.submitted_at >= ten_minutes_ago, + Submission.submitted_at.isnot(None), + ) + ) + recent_count = recent_result.scalar() or 0 + + if recent_count >= 1: + # 查询最近一次提交时间,计算剩余等待时间 + last_submit_result = await db.execute( + select(Submission.submitted_at) + .where( + Submission.user_id == user_id, + Submission.submitted_at >= ten_minutes_ago, + Submission.submitted_at.isnot(None), + ) + .order_by(Submission.submitted_at.desc()) + .limit(1) + ) + last_submit_time = last_submit_result.scalar() + if last_submit_time: + wait_seconds = int((last_submit_time + timedelta(minutes=10) - now).total_seconds()) + wait_minutes = max(1, (wait_seconds + 59) // 60) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"提交过于频繁,请 {wait_minutes} 分钟后再试" + ) + + # 查询今日提交次数 + today_result = await db.execute( + select(func.count(Submission.id)) + .where( + Submission.user_id == user_id, + Submission.submitted_at >= today_start, + Submission.submitted_at.isnot(None), + ) + ) + today_count = today_result.scalar() or 0 + + if today_count >= 5: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="今日提交次数已达上限(5次),请明天再试" + ) + + @router.post( "/{submission_id}/finalize", response_model=SubmissionResponse, summary="最终提交作品", - description="执行强校验后最终提交作品。提交后不可修改。", + description="执行强校验后最终提交作品。提交后不可修改。限制:每10分钟1次,每日5次。", ) async def finalize_submission( submission_id: int, @@ -1067,6 +1217,9 @@ async def finalize_submission( ensure_owner(submission, current_user) ensure_editable(submission) + # 检查提交频率限制 + await check_submission_rate_limit(db, current_user.id) + # 强制校验 validation = await validate_submission_materials(db, submission) diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 5f146a2..0fb2ba4 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -109,6 +109,13 @@ class Submission(BaseModel): comment="项目文档(Markdown格式,包含安装步骤、使用说明、技术架构)" ) + # Docker 镜像引用(必须使用 @sha256 digest) + image_ref = Column( + String(500), + nullable=True, + comment="Docker镜像引用(格式:registry/image@sha256:xxx,禁止使用tag)" + ) + # 校验相关 validation_summary = Column( JSON, diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py index 462fb9c..9cd4661 100644 --- a/backend/app/schemas/submission.py +++ b/backend/app/schemas/submission.py @@ -140,6 +140,11 @@ class SubmissionCreate(BaseModel): None, description="项目文档(Markdown格式,最终提交时必填)" ) + image_ref: Optional[str] = Field( + None, + max_length=500, + description="Docker镜像引用(格式:registry/image@sha256:xxx,禁止使用tag)" + ) @field_validator("repo_url") @classmethod @@ -165,6 +170,44 @@ def validate_repo_url(cls, v: str) -> str: return v + @field_validator("image_ref") + @classmethod + def validate_image_ref(cls, v: Optional[str]) -> Optional[str]: + """ + 验证 Docker 镜像引用格式 + + 规则: + 1. 必须使用 @sha256: digest 格式 + 2. 禁止使用 :latest 或其他 tag + 3. digest 必须是 64 位十六进制字符 + """ + if v is None or v.strip() == "": + return None + + import re + v = v.strip() + + # 禁止 :latest + if ":latest" in v.lower(): + raise ValueError("禁止使用 :latest 标签,请使用 @sha256:xxx 格式") + + # 禁止使用 tag(冒号后跟非 sha256 的内容) + # 允许的格式:image@sha256:xxx 或 registry/image@sha256:xxx + if ":" in v and "@sha256:" not in v: + raise ValueError("禁止使用标签(tag),请使用 @sha256:xxx 格式指定镜像摘要") + + # 必须包含 @sha256: + if "@sha256:" not in v: + raise ValueError("镜像引用必须使用 @sha256:xxx 格式,例如:ghcr.io/user/image@sha256:abc123...") + + # 验证 sha256 digest 格式(64位十六进制) + sha256_pattern = r"@sha256:([a-fA-F0-9]{64})$" + match = re.search(sha256_pattern, v) + if not match: + raise ValueError("sha256 摘要格式不正确,必须是 64 位十六进制字符") + + return v + class SubmissionUpdate(BaseModel): """更新作品提交请求体(支持部分更新)""" @@ -174,6 +217,7 @@ class SubmissionUpdate(BaseModel): demo_url: Optional[str] = Field(None, max_length=500) video_url: Optional[str] = Field(None, max_length=500) project_doc_md: Optional[str] = None + image_ref: Optional[str] = Field(None, max_length=500) @field_validator("repo_url") @classmethod @@ -183,6 +227,14 @@ def validate_repo_url(cls, v: Optional[str]) -> Optional[str]: return v return SubmissionCreate.validate_repo_url(v) + @field_validator("image_ref") + @classmethod + def validate_image_ref(cls, v: Optional[str]) -> Optional[str]: + """验证镜像引用格式(仅当提供时)""" + if v is None: + return v + return SubmissionCreate.validate_image_ref(v) + class UserBrief(BaseModel): """用户简要信息(嵌入响应中使用)""" @@ -208,6 +260,7 @@ class SubmissionResponse(BaseModel): demo_url: Optional[str] = None video_url: Optional[str] = None project_doc_md: Optional[str] = None + image_ref: Optional[str] = None status: str vote_count: int = 0 validation_summary: Optional[dict[str, Any]] = None @@ -234,6 +287,7 @@ class SubmissionPublicResponse(BaseModel): repo_url: str demo_url: Optional[str] = None video_url: Optional[str] = None + image_ref: Optional[str] = None status: str vote_count: int = 0 submitted_at: Optional[datetime] = None diff --git a/backend/sql/026_add_image_ref.sql b/backend/sql/026_add_image_ref.sql new file mode 100644 index 0000000..862c21e --- /dev/null +++ b/backend/sql/026_add_image_ref.sql @@ -0,0 +1,6 @@ +-- 添加 Docker 镜像引用字段 +-- 用于存储作品的容器镜像地址,必须使用 @sha256 digest 格式 + +ALTER TABLE submissions +ADD COLUMN image_ref VARCHAR(500) NULL COMMENT 'Docker镜像引用(格式:registry/image@sha256:xxx,禁止使用tag)' +AFTER project_doc_md;