Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 154 additions & 1 deletion backend/app/api/v1/endpoints/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions backend/app/models/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions backend/app/schemas/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""更新作品提交请求体(支持部分更新)"""
Expand All @@ -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
Expand All @@ -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):
"""用户简要信息(嵌入响应中使用)"""
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions backend/sql/026_add_image_ref.sql
Original file line number Diff line number Diff line change
@@ -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;