diff --git a/.env.production.example b/.env.production.example index 40ded4f..bee4cf8 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,28 +1,109 @@ -# ============================================================================ +# ============================================================================ # 生产环境配置模板 # 复制此文件到服务器 /opt/chicken-king/.env 并填入真实值 # ============================================================================ # MySQL 数据库 +# MySQL root 密码 MYSQL_ROOT_PASSWORD=your_secure_root_password +# MySQL 数据库名 MYSQL_DATABASE=chicken_king +# MySQL 应用用户 MYSQL_USER=chicken +# MySQL 用户密码 MYSQL_PASSWORD=your_secure_password # 应用密钥(用于 JWT 签名,请使用随机字符串) SECRET_KEY=your_64_char_random_secret_key_here +# Worker 状态回写 Token(用于 Worker 回写状态接口鉴权) +WORKER_API_TOKEN=your_worker_api_token + +# 作品容器资源限制(部署时由 Worker 使用) +WORKER_CONTAINER_MEMORY_LIMIT=512m +WORKER_CONTAINER_CPU_LIMIT=0.5 +WORKER_CONTAINER_PIDS_LIMIT=128 +WORKER_CONTAINER_LOG_MAX_SIZE=10m +WORKER_CONTAINER_LOG_MAX_FILE=3 + +# 是否在后端容器启动时自动执行数据库迁移(建议开启) +AUTO_MIGRATE=true +# 迁移基线版本(仅在旧库且无 schema_migrations 时使用,例如:034 或 034_contest_content) +MIGRATION_BASELINE_VERSION= + +# 限流与风控 +# 限流存储地址 +RATE_LIMIT_STORAGE_URI=redis://redis:6379/1 +# 单用户每日上传总量上限(字节) +UPLOAD_DAILY_BYTES_LIMIT=3221225472 +# 触发式挑战模式:off/monitor/enforce +SECURITY_CHALLENGE_MODE=off +# 挑战提供方:pow/js/captcha/behavior +SECURITY_CHALLENGE_PROVIDER=pow +# 触发阈值(窗口内请求数) +SECURITY_CHALLENGE_THRESHOLD=20 +# 统计窗口(秒) +SECURITY_CHALLENGE_WINDOW_SECONDS=60 +# 挑战有效期(秒) +SECURITY_CHALLENGE_TTL_SECONDS=300 +# 通过后免验证时间(秒) +SECURITY_CHALLENGE_PASS_TTL_SECONDS=1800 +# PoW 难度(十六进制前导 0 个数) +SECURITY_POW_DIFFICULTY=4 +# 验证码校验地址(如 Turnstile/hCaptcha) +CAPTCHA_VERIFY_URL= +# 验证码服务密钥 +CAPTCHA_SECRET_KEY= +# 验证码站点 key +CAPTCHA_SITE_KEY= +# 忘记密码重置链接有效期(分钟) +PASSWORD_RESET_TOKEN_TTL_MINUTES=30 +# SMTP 服务器地址 +SMTP_HOST= +# SMTP 端口 +SMTP_PORT=587 +# SMTP 账号 +SMTP_USERNAME= +# SMTP 密码 +SMTP_PASSWORD= +# 是否启用 STARTTLS +SMTP_USE_TLS=true +# 是否启用 SSL(如 465 端口) +SMTP_USE_SSL=false +# 发件人邮箱 +SMTP_FROM_EMAIL= +# 发件人显示名(可选) +SMTP_FROM_NAME= +# 是否启用可信代理解析(TODO: CDN/WAF 接入后开启) +TRUSTED_PROXY_ENABLED=false +# 可信代理头(优先级顺序) +TRUSTED_PROXY_HEADERS=cf-connecting-ip,x-forwarded-for,x-real-ip + # 前端 URL FRONTEND_URL=https://pk.ikuncode.cc +# 媒体文件存储目录(容器内路径) +MEDIA_ROOT=/app/app/uploads/media + +# 作品访问域名后缀(生成 project-{submission_id}.your-domain.com) +PROJECT_DOMAIN_SUFFIX=your-domain.com +# 作品访问域名模板(默认即可) +PROJECT_DOMAIN_TEMPLATE=project-{submission_id}.{suffix} + # Linux.do OAuth +# Client ID LINUX_DO_CLIENT_ID=your_client_id +# Client Secret LINUX_DO_CLIENT_SECRET=your_client_secret +# 回调地址 LINUX_DO_REDIRECT_URI=https://pk.ikuncode.cc/api/v1/auth/linuxdo/callback # GitHub OAuth +# Client ID GITHUB_CLIENT_ID=your_github_client_id +# Client Secret GITHUB_CLIENT_SECRET=your_github_client_secret +# 回调地址 GITHUB_REDIRECT_URI=https://pk.ikuncode.cc/api/v1/auth/github/callback # 前端 API URL(生产环境使用相对路径) diff --git a/.gitignore b/.gitignore index 7944607..5283918 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,21 @@ venv/ .env.prod *.env +# 本地临时与密钥 +.ssh/ +.tmp/ +.sync.tar.gz +.spec-workflow/ + +# 媒体上传目录 +backend/app/uploads/ + +# 本地文档 +docs/进度与待办.md +待办清单.md +readme2.md +CODE_REVIEW_REPORT.md + # Build dist/ build/ diff --git a/DEPLOY.md b/DEPLOY.md index 9998d3a..16a264d 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -5,14 +5,16 @@ ``` 鸡王争霸赛/ ├── docker-compose.prod.yml # 生产环境编排 -├── .env.production.example # 环境变量模板 +├── .env.production.example # 环境变量模板(复制为 .env) +├── deploy/ +│ └── webhook/ # GitHub Webhook 自动部署 ├── nginx/ │ ├── nginx.prod.conf # Nginx 反向代理配置 │ ├── ssl/ # SSL 证书目录 │ └── logs/ # Nginx 日志 ├── backend/ │ ├── Dockerfile.prod # 后端生产镜像 -│ └── sql/init/ # 数据库初始化脚本 +│ └── sql/ # 数据库迁移脚本 └── frontend/ ├── Dockerfile.prod # 前端生产镜像 └── nginx.conf # 前端容器 Nginx 配置 @@ -43,10 +45,10 @@ cd chicken-king ```bash # 复制模板 -cp .env.production.example .env.production +cp .env.production.example .env # 编辑配置(重要!) -nano .env.production +nano .env ``` **必须修改的配置项:** @@ -61,6 +63,20 @@ nano .env.production | `LINUX_DO_CLIENT_SECRET` | Linux.do OAuth | 从 connect.linux.do 获取 | | `LINUX_DO_REDIRECT_URI` | OAuth 回调地址 | `https://your-domain.com/api/v1/auth/linuxdo/callback` | +### 3.5 数据库迁移(自动) + +生产环境默认启用自动迁移(`AUTO_MIGRATE=true`),后端容器启动时会: +- 空库导入 `backend/sql/production_clean_db.sql`(或回退 `schema.sql` + `seed_production_config.sql`) +- 旧库按 `backend/sql/NNN_*.sql` 增量执行 + +如旧库已存在历史迁移但无 `schema_migrations`,可在 `.env` 设置 `MIGRATION_BASELINE_VERSION=XXX` 作为基线。 + +### 3.6 部署前检查清单 + +- `.env` 已更新必要配置 +- Nginx 证书与域名解析已准备 +- 数据库迁移脚本已执行或初始化脚本已替换 + ### 4. 配置域名和 SSL #### 方式一:使用 Let's Encrypt(推荐) @@ -114,13 +130,13 @@ server { ```bash # 构建并启动所有服务 -docker-compose -f docker-compose.prod.yml --env-file .env.production up -d --build +docker compose -f docker-compose.prod.yml up -d --build # 查看服务状态 -docker-compose -f docker-compose.prod.yml ps +docker compose -f docker-compose.prod.yml ps # 查看日志 -docker-compose -f docker-compose.prod.yml logs -f +docker compose -f docker-compose.prod.yml logs -f ``` ### 6. 验证部署 @@ -130,11 +146,11 @@ docker-compose -f docker-compose.prod.yml logs -f curl http://localhost/health # 检查 API -curl http://localhost/api/v1/health +curl http://localhost/api/v1/contests/ # 查看各服务日志 -docker-compose -f docker-compose.prod.yml logs backend -docker-compose -f docker-compose.prod.yml logs nginx +docker compose -f docker-compose.prod.yml logs backend +docker compose -f docker-compose.prod.yml logs nginx ``` ## 常用运维命令 @@ -143,14 +159,14 @@ docker-compose -f docker-compose.prod.yml logs nginx ```bash # 停止服务 -docker-compose -f docker-compose.prod.yml down +docker compose -f docker-compose.prod.yml down # 重启单个服务 -docker-compose -f docker-compose.prod.yml restart backend +docker compose -f docker-compose.prod.yml restart backend # 更新部署 git pull -docker-compose -f docker-compose.prod.yml up -d --build +docker compose -f docker-compose.prod.yml up -d --build # 查看资源使用 docker stats @@ -186,7 +202,7 @@ tail -f nginx/logs/error.log ```bash # 使用 analytics profile 启动 -docker-compose -f docker-compose.prod.yml --env-file .env.production --profile analytics up -d +docker compose -f docker-compose.prod.yml --profile analytics up -d # Umami 默认访问地址 # http://your-domain.com:3001 @@ -210,8 +226,8 @@ docker-compose -f docker-compose.prod.yml --env-file .env.production --profile a sudo apt update && sudo apt upgrade -y # 更新 Docker 镜像 - docker-compose -f docker-compose.prod.yml pull - docker-compose -f docker-compose.prod.yml up -d + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d ``` 3. **数据备份** @@ -228,7 +244,7 @@ docker-compose -f docker-compose.prod.yml --env-file .env.production --profile a # 复制新证书 sudo cp /etc/letsencrypt/live/your-domain.com/*.pem nginx/ssl/ - docker-compose -f docker-compose.prod.yml restart nginx + docker compose -f docker-compose.prod.yml restart nginx ``` ## 故障排除 @@ -240,7 +256,7 @@ docker-compose -f docker-compose.prod.yml --env-file .env.production --profile a sudo netstat -tlnp | grep -E '80|443|8000|3306' # 检查 Docker 日志 -docker-compose -f docker-compose.prod.yml logs --tail=100 +docker compose -f docker-compose.prod.yml logs --tail=100 ``` ### 数据库连接失败 diff --git a/README.md b/README.md index c765f17..e77c8a6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ ## 功能特性 -- **Linux.do OAuth 登录** - 一键授权登录 +- **本地账号** - 注册/登录/找回密码 +- **第三方登录/绑定** - Linux.do / GitHub OAuth +- **账号安全** - 修改/设置密码、绑定冲突提示 - **比赛报名** - 选手报名与审核 - **作品提交** - 支持附件上传 - **投票系统** - 为喜欢的作品投票 @@ -44,7 +46,13 @@ │ ├── sql/ # 数据库迁移脚本 │ └── requirements.txt │ -└── docker-compose.yml # Docker 编排(含 Umami) +├── deploy/ # 部署脚本与 Webhook 服务 +│ └── webhook/ # GitHub Webhook 自动部署 +├── nginx/ # Nginx 生产反代 +├── docs/ # 项目文档 +├── docker-compose.yml # 本地开发编排 +├── docker-compose.prod.yml # 生产环境编排 +└── .env.production.example # 生产环境变量模板 ``` ## 技术栈 @@ -136,9 +144,25 @@ npm run dev ### 4. Docker 一键启动 ```bash +# 复制环境变量模板到项目根目录(Docker Compose 会读取此 .env) +cp .env.production.example .env +# 编辑 .env 填写真实配置(如 MySQL/SECRET_KEY/OAuth 等) + docker-compose up -d ``` +生产环境可使用(与自动部署保持一致): + +```bash +docker compose -f docker-compose.prod.yml up -d --build +``` + +> 说明:生产环境只使用 `docker-compose.prod.yml`,避免与开发配置互相覆盖。 +> `.env` 由 Compose 自动读取并注入到容器中,`DATABASE_URL/REDIS_URL` 由 Compose 组合生成,无需在 `.env` 中重复填写。 +> 后端容器启动时会自动执行迁移(`AUTO_MIGRATE=true`)。 + +> 生产环境建议使用 GitHub Webhook 自动部署,详见 `docs/AUTO_DEPLOY.md`。 + ## 网站统计(Umami) ### 部署 Umami @@ -180,8 +204,17 @@ VITE_UMAMI_WEBSITE_ID=你的网站ID ## 环境变量 +### Docker Compose(项目根目录 `.env`) + +- 适用于 `docker-compose.yml` / `docker-compose.prod.yml` +- 入口文件:项目根目录 `.env`(可从 `.env.production.example` 复制并填写) +- 由 Compose 注入到 backend/worker 容器 +- `DATABASE_URL/REDIS_URL` 已在 Compose 中拼接,无需在 `.env` 里写 + ### 后端 (`backend/.env`) +> 仅用于本地非 Docker 启动(如 `uvicorn` 直接运行) + ```bash DEBUG=true SECRET_KEY=your-secret-key @@ -200,6 +233,8 @@ GITHUB_CLIENT_SECRET=xxx GITHUB_TOKEN=xxx ``` +> 其他配置项(如 SMTP、触发式安全挑战、上传配额、Worker 等)详见 `backend/.env.example`。 + ### 前端 (`frontend/.env`) ```bash @@ -234,7 +269,7 @@ docker-compose down # 停止所有服务 | 模块 | 前缀 | 功能 | |------|------|------| -| auth | `/auth` | Linux.do/GitHub OAuth | +| auth | `/auth` | 本地注册/登录/刷新/找回密码 + Linux.do/GitHub OAuth 绑定 | | users | `/users` | 用户信息、成就 | | contests | `/contests` | 比赛管理 | | submissions | `/submissions` | 作品提交 | diff --git a/backend/.env.example b/backend/.env.example index 6156b8c..f44ef4e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,13 +1,119 @@ # 后端环境变量 +# 是否开启调试模式 DEBUG=true +# JWT 签名密钥(生产必须更换) SECRET_KEY=your-super-secret-key-change-in-production -DATABASE_URL=mysql+aiomysql://root:password@localhost:3306/chicken_king +# 数据库连接串(建议显式指定 utf8mb4) +DATABASE_URL=mysql+aiomysql://root:password@localhost:3306/chicken_king?charset=utf8mb4 +# Redis 连接地址 REDIS_URL=redis://localhost:6379/0 +# 是否在后端容器启动时自动执行数据库迁移 +AUTO_MIGRATE=true +# 迁移基线版本(旧库无 schema_migrations 时使用,例如:034 或 034_contest_content) +MIGRATION_BASELINE_VERSION= +# 限流存储地址(为空回退 REDIS_URL) +RATE_LIMIT_STORAGE_URI=redis://localhost:6379/1 +# 单用户每日上传总量上限(字节) +UPLOAD_DAILY_BYTES_LIMIT=3221225472 +# 触发式挑战模式:off/monitor/enforce +SECURITY_CHALLENGE_MODE=off +# 挑战提供方:pow/js/captcha/behavior +SECURITY_CHALLENGE_PROVIDER=pow +# 触发阈值(窗口内请求数) +SECURITY_CHALLENGE_THRESHOLD=20 +# 统计窗口(秒) +SECURITY_CHALLENGE_WINDOW_SECONDS=60 +# 挑战有效期(秒) +SECURITY_CHALLENGE_TTL_SECONDS=300 +# 通过后免验证时间(秒) +SECURITY_CHALLENGE_PASS_TTL_SECONDS=1800 +# PoW 难度(十六进制前导 0 个数) +SECURITY_POW_DIFFICULTY=4 +# 验证码校验地址(如 Turnstile/hCaptcha) +CAPTCHA_VERIFY_URL= +# 验证码服务密钥 +CAPTCHA_SECRET_KEY= +# 验证码站点 key +CAPTCHA_SITE_KEY= +# 忘记密码重置链接有效期(分钟) +PASSWORD_RESET_TOKEN_TTL_MINUTES=30 +# SMTP 服务器地址 +SMTP_HOST= +# SMTP 端口 +SMTP_PORT=587 +# SMTP 账号 +SMTP_USERNAME= +# SMTP 密码 +SMTP_PASSWORD= +# 是否启用 STARTTLS +SMTP_USE_TLS=true +# 是否启用 SSL(如 465 端口) +SMTP_USE_SSL=false +# 发件人邮箱 +SMTP_FROM_EMAIL= +# 发件人显示名(可选) +SMTP_FROM_NAME= +# 是否启用可信代理解析(TODO: CDN/WAF 接入后开启) +TRUSTED_PROXY_ENABLED=false +# 可信代理头(优先级顺序) +TRUSTED_PROXY_HEADERS=cf-connecting-ip,x-forwarded-for,x-real-ip # 前端地址(OAuth 回跳用) FRONTEND_URL=http://localhost:5173 +# 媒体文件存储目录 +MEDIA_ROOT=/app/app/uploads/media + +# 作品部署提交流程配置 +# 提交冷却时间(秒) +PROJECT_SUBMISSION_COOLDOWN_SECONDS=600 +# 每日提交上限 +PROJECT_SUBMISSION_DAILY_LIMIT=10 +# Worker 回写 API 令牌 +WORKER_API_TOKEN=change-me +# Worker 回写 API 基地址 +WORKER_API_BASE_URL=http://127.0.0.1:8000 +# Worker 队列 key +WORKER_QUEUE_KEY=project_submissions:queue +# 队列阻塞等待秒数 +WORKER_QUEUE_BLOCK_SECONDS=5 +# Worker 步骤间隔秒数 +WORKER_STEP_DELAY_SECONDS=2 +# 启用健康检查 +WORKER_HEALTHCHECK_ENABLED=false +# 健康检查路径 +WORKER_HEALTHCHECK_PATH=/healthz +# 健康检查超时秒数 +WORKER_HEALTHCHECK_TIMEOUT_SECONDS=5 +# 健康检查重试次数 +WORKER_HEALTHCHECK_RETRY=12 +# 健康检查间隔秒数 +WORKER_HEALTHCHECK_INTERVAL_SECONDS=5 +# Docker 连接地址 +WORKER_DOCKER_HOST=unix:///var/run/docker.sock +# 部署网络(为空自动探测) +WORKER_DEPLOY_NETWORK= +# 作品容器内部端口 +WORKER_PROJECT_PORT=8080 +# 容器内存上限 +WORKER_CONTAINER_MEMORY_LIMIT=512m +# 容器 CPU 上限(核数) +WORKER_CONTAINER_CPU_LIMIT=0.5 +# 容器 PIDs 上限 +WORKER_CONTAINER_PIDS_LIMIT=128 +# 容器日志单文件大小 +WORKER_CONTAINER_LOG_MAX_SIZE=10m +# 容器日志文件数量 +WORKER_CONTAINER_LOG_MAX_FILE=3 +# 作品域名后缀 +PROJECT_DOMAIN_SUFFIX=local +# 作品域名模板 +PROJECT_DOMAIN_TEMPLATE=project-{submission_id}.{suffix} + # Linux.do OAuth2 +# Client ID LINUX_DO_CLIENT_ID=your-client-id +# Client Secret LINUX_DO_CLIENT_SECRET=your-client-secret +# 回调地址 LINUX_DO_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/linuxdo/callback diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod index d4e1cd5..b69d689 100644 --- a/backend/Dockerfile.prod +++ b/backend/Dockerfile.prod @@ -29,6 +29,7 @@ WORKDIR /app # 安装运行时依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ + default-mysql-client \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --shell /bin/bash appuser @@ -39,6 +40,11 @@ ENV PATH="/opt/venv/bin:$PATH" # 复制应用代码 COPY --chown=appuser:appuser . . +# 预创建媒体目录,避免首次挂载权限问题 +RUN mkdir -p /app/app/uploads/media \ + && chown -R appuser:appuser /app/app/uploads \ + && chmod +x /app/scripts/entrypoint.sh /app/scripts/run_migrations.sh + # 切换到非 root 用户 USER appuser @@ -49,5 +55,8 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ # 暴露端口 EXPOSE 8000 +# 启动前执行迁移 +ENTRYPOINT ["/app/scripts/entrypoint.sh"] + # 生产环境启动命令(使用 gunicorn + uvicorn workers) -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 51ebac2..3acfd63 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -6,7 +6,8 @@ from app.api.v1.endpoints import ( auth, contest, submission, vote, user, registration, github, cheer, quota, achievement, points, lottery, prediction, admin, - review_center, announcement, exchange, task, slot_machine, gacha, puzzle + review_center, announcement, exchange, task, slot_machine, gacha, puzzle, + project, project_review_center, media ) router = APIRouter() @@ -15,6 +16,8 @@ router.include_router(user.router, prefix="/users", tags=["用户"]) router.include_router(contest.router, prefix="/contests", tags=["比赛"]) router.include_router(submission.router, prefix="/submissions", tags=["作品"]) +router.include_router(project.router, tags=["作品部署"]) +router.include_router(media.router, prefix="/media", tags=["媒体文件"]) router.include_router(vote.router, prefix="/votes", tags=["投票"]) router.include_router(registration.router, tags=["报名"]) router.include_router(github.router, tags=["GitHub统计"]) @@ -26,6 +29,7 @@ router.include_router(prediction.router, prefix="/prediction", tags=["竞猜系统"]) router.include_router(admin.router, prefix="/admin", tags=["管理后台"]) router.include_router(review_center.router, prefix="/review-center", tags=["评审中心"]) +router.include_router(project_review_center.router, prefix="/review-center", tags=["评审中心-作品"]) router.include_router(announcement.router, prefix="/announcements", tags=["公告系统"]) router.include_router(exchange.router, prefix="/exchange", tags=["积分兑换"]) router.include_router(gacha.router, prefix="/gacha", tags=["扭蛋机"]) diff --git a/backend/app/api/v1/endpoints/achievement.py b/backend/app/api/v1/endpoints/achievement.py index 6765b97..8edaadc 100644 --- a/backend/app/api/v1/endpoints/achievement.py +++ b/backend/app/api/v1/endpoints/achievement.py @@ -23,7 +23,7 @@ from app.models.points import PointsReason, PointsLedger from app.services import achievement_service from app.services.points_service import PointsService -from app.api.v1.endpoints.registration import get_current_user, get_optional_user +from app.core.dependencies import get_current_user, get_optional_user router = APIRouter() logger = logging.getLogger(__name__) diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index 3872bad..2a6230a 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -11,8 +11,9 @@ from pydantic import BaseModel from app.core.database import get_db -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user +from app.core.dependencies import get_current_user from app.models.user import User +from app.schemas.user import UserUpdateRequest as BaseUserUpdateRequest from app.models.points import ( UserPoints, PointsLedger, PointsReason, DailySignin, SigninMilestone, @@ -55,7 +56,7 @@ class UserListItem(BaseModel): created_at: str -class UserUpdateRequest(BaseModel): +class UserUpdateRequest(BaseUserUpdateRequest): role: Optional[str] = None is_active: Optional[bool] = None @@ -428,6 +429,33 @@ async def update_user( if not user: raise HTTPException(status_code=404, detail="用户不存在") + if request.username is not None: + new_username = request.username.strip() + if new_username != user.username: + exists = await db.execute( + select(User).where(User.username == new_username, User.id != user_id) + ) + if exists.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="用户名已被使用") + user.username = new_username + + if request.email is not None: + new_email = request.email + if isinstance(new_email, str): + new_email = new_email.strip().lower() + if new_email != user.email: + if new_email: + exists = await db.execute( + select(User).where(User.email == new_email, User.id != user_id) + ) + if exists.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="邮箱已被使用") + user.email = new_email or None + + if request.display_name is not None: + display_name = request.display_name.strip() if isinstance(request.display_name, str) else None + user.display_name = display_name or None + if request.role is not None: user.role = request.role if request.is_active is not None: diff --git a/backend/app/api/v1/endpoints/announcement.py b/backend/app/api/v1/endpoints/announcement.py index db5770f..8010229 100644 --- a/backend/app/api/v1/endpoints/announcement.py +++ b/backend/app/api/v1/endpoints/announcement.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from app.core.database import get_db -from app.api.v1.endpoints.registration import get_current_user, get_optional_user +from app.core.dependencies import get_current_user, get_optional_user from app.models.user import User, UserRole from app.models.announcement import Announcement from app.schemas.announcement import ( diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index c958b65..a73c551 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -2,21 +2,34 @@ 认证相关 API """ import base64 +import hashlib +import hmac import json import re +import logging +from datetime import datetime, timedelta from secrets import token_urlsafe from typing import Optional from urllib.parse import urlencode, quote -from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import RedirectResponse -from sqlalchemy import select +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse, JSONResponse +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator +from sqlalchemy import select, or_, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db -from app.core.security import create_access_token +from app.core.rate_limit import limiter, RateLimits +from app.core.security import create_access_token, decode_token, get_password_hash, verify_password from app.models.user import User +from app.models.password_reset import PasswordResetToken +from app.schemas.user import UserResponse +from app.core.dependencies import get_current_user_dep +from app.services.email_service import send_email, EmailServiceError +from app.services.security_challenge import guard_challenge +from app.services.user_merge_service import merge_users from app.services.linux_do_oauth import ( LinuxDoOAuthError, exchange_code_for_token, @@ -30,13 +43,16 @@ fetch_github_emails, get_primary_email, ) +from app.services.media_service import AVATAR_MAX_BYTES, download_image_to_media, is_local_media_url from app.services.points_service import PointsService from app.models.points import PointsReason +from app.services.log_service import log_login, log_register # 新用户注册奖励积分 REGISTER_BONUS_POINTS = 500 router = APIRouter() +logger = logging.getLogger(__name__) def _sanitize_next_path(next_path: Optional[str]) -> str: @@ -67,6 +83,27 @@ def _sanitize_next_path(next_path: Optional[str]) -> str: return quote(next_path, safe="/") +def _validate_password_value(value: str) -> str: + """校验密码格式""" + if re.search(r"\s", value): + raise ValueError("密码不能包含空白字符") + if len(value.encode("utf-8")) > 72: + raise ValueError("密码长度过长") + return value + + +def _hash_reset_token(token: str) -> str: + """生成重置令牌哈希""" + secret = (settings.SECRET_KEY or "").encode("utf-8") + return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest() + + +def _build_password_reset_link(token: str) -> str: + """构建前端重置链接""" + frontend = settings.FRONTEND_URL.rstrip("/") + return f"{frontend}/reset-password?token={token}" + + def _b64url_json(data: dict) -> str: """将字典编码为 base64url JSON""" raw = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8") @@ -127,7 +164,14 @@ async def _upsert_linuxdo_user(db: AsyncSession, userinfo: dict) -> User: linux_username = (userinfo.get("username") or "").strip() display_name = (userinfo.get("name") or "").strip() or None avatar_template = userinfo.get("avatar_template") or None - avatar_url = normalize_avatar_url(avatar_template) + download_url = normalize_avatar_url(avatar_template) + avatar_url = None + if download_url and (user is None or not is_local_media_url(user.avatar_url)): + try: + media = await download_image_to_media(download_url, "avatars", AVATAR_MAX_BYTES) + avatar_url = media.url if media else None + except HTTPException as exc: + logger.warning("Linux.do 头像下载失败: %s", getattr(exc, "detail", exc)) active = bool(userinfo.get("active", True)) trust_level = userinfo.get("trust_level") silenced = bool(userinfo.get("silenced", False)) @@ -183,6 +227,42 @@ async def _upsert_linuxdo_user(db: AsyncSession, userinfo: dict) -> User: return user +async def _apply_linuxdo_profile(target_user: User, userinfo: dict) -> str: + """绑定时更新 Linux.do 用户信息""" + linux_do_id = str(userinfo.get("id")) + if not linux_do_id or linux_do_id == "None": + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Linux.do 用户信息缺少 id", + ) + + linux_username = (userinfo.get("username") or "").strip() + display_name = (userinfo.get("name") or "").strip() or None + avatar_template = userinfo.get("avatar_template") or None + download_url = normalize_avatar_url(avatar_template) + avatar_url = None + if download_url and (not target_user.avatar_url or not is_local_media_url(target_user.avatar_url)): + try: + media = await download_image_to_media(download_url, "avatars", AVATAR_MAX_BYTES) + avatar_url = media.url if media else None + except HTTPException as exc: + logger.warning("Linux.do 头像下载失败: %s", getattr(exc, "detail", exc)) + + active = bool(userinfo.get("active", True)) + trust_level = userinfo.get("trust_level") + silenced = bool(userinfo.get("silenced", False)) + + target_user.linux_do_id = linux_do_id + target_user.linux_do_username = linux_username or target_user.linux_do_username + target_user.display_name = display_name or target_user.display_name + target_user.linux_do_avatar_template = avatar_template or target_user.linux_do_avatar_template + target_user.avatar_url = avatar_url or target_user.avatar_url + target_user.is_active = active + target_user.trust_level = int(trust_level) if trust_level is not None else target_user.trust_level + target_user.is_silenced = silenced + return linux_do_id + + async def _generate_unique_username_for_github( db: AsyncSession, base_username: str, @@ -237,7 +317,14 @@ async def _upsert_github_user(db: AsyncSession, userinfo: dict, email: Optional[ # 解析用户信息 github_username = (userinfo.get("login") or "").strip() display_name = (userinfo.get("name") or "").strip() or None - avatar_url = userinfo.get("avatar_url") or None + avatar_source_url = userinfo.get("avatar_url") or None + avatar_url = None + if avatar_source_url and (user is None or not is_local_media_url(user.avatar_url)): + try: + media = await download_image_to_media(avatar_source_url, "avatars", AVATAR_MAX_BYTES) + avatar_url = media.url if media else None + except HTTPException as exc: + logger.warning("GitHub 头像下载失败: %s", getattr(exc, "detail", exc)) github_email = email or userinfo.get("email") is_new_user = False @@ -288,25 +375,468 @@ async def _upsert_github_user(db: AsyncSession, userinfo: dict, email: Optional[ return user -@router.post("/register") -async def register(): +async def _apply_github_profile( + target_user: User, + userinfo: dict, + email: Optional[str] = None, +) -> str: + """绑定时更新 GitHub 用户信息""" + github_id = str(userinfo.get("id")) + if not github_id or github_id == "None": + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="GitHub 用户信息缺少 id", + ) + + github_username = (userinfo.get("login") or "").strip() + display_name = (userinfo.get("name") or "").strip() or None + avatar_source_url = userinfo.get("avatar_url") or None + avatar_url = None + if avatar_source_url and (not target_user.avatar_url or not is_local_media_url(target_user.avatar_url)): + try: + media = await download_image_to_media(avatar_source_url, "avatars", AVATAR_MAX_BYTES) + avatar_url = media.url if media else None + except HTTPException as exc: + logger.warning("GitHub 头像下载失败: %s", getattr(exc, "detail", exc)) + + github_email = email or userinfo.get("email") + + target_user.github_id = github_id + target_user.github_username = github_username or target_user.github_username + target_user.github_avatar_url = avatar_url or target_user.github_avatar_url + target_user.github_email = github_email or target_user.github_email + target_user.display_name = display_name or target_user.display_name + target_user.avatar_url = avatar_url or target_user.avatar_url + if not target_user.email and github_email: + target_user.email = github_email + return github_id + + +class RegisterRequest(BaseModel): + """本地注册请求""" + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8) + email: Optional[EmailStr] = None + display_name: Optional[str] = Field(None, max_length=100) + + @field_validator("username") + @classmethod + def validate_username(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("用户名不能为空") + if re.search(r"\s", value): + raise ValueError("用户名不能包含空白字符") + return value + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + return _validate_password_value(value) + + @field_validator("display_name") + @classmethod + def validate_display_name(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + value = value.strip() + return value or None + + +class LoginRequest(BaseModel): + """本地登录请求""" + model_config = ConfigDict(populate_by_name=True) + + account: str = Field(..., alias="username", min_length=2, max_length=255) + password: str = Field(..., min_length=8) + + @field_validator("account") + @classmethod + def validate_account(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("账号不能为空") + return value + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + return _validate_password_value(value) + + +class PasswordForgotRequest(BaseModel): + """忘记密码请求""" + email: EmailStr + + +class PasswordResetRequest(BaseModel): + """密码重置请求""" + token: str = Field(..., min_length=10, max_length=255) + password: str = Field(..., min_length=8) + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + return _validate_password_value(value) + + +class PasswordChangeRequest(BaseModel): + """修改密码请求""" + old_password: str = Field(..., min_length=8) + new_password: str = Field(..., min_length=8) + + @field_validator("old_password", "new_password") + @classmethod + def validate_password(cls, value: str) -> str: + return _validate_password_value(value) + + +class PasswordSetRequest(BaseModel): + """设置密码请求(OAuth 用户)""" + password: str = Field(..., min_length=8) + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + return _validate_password_value(value) + + +class OAuthBindInitRequest(BaseModel): + """绑定第三方账号请求""" + next: Optional[str] = None + + +class AuthTokenResponse(BaseModel): + """认证返回""" + access_token: str + token_type: str = "bearer" + user: UserResponse + + +@router.post("/register", response_model=AuthTokenResponse, summary="本地注册") +@limiter.limit(RateLimits.AUTH) +async def register( + payload: RegisterRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): """用户注册""" - # TODO: 实现本地注册逻辑 - return {"message": "注册接口"} + await guard_challenge(request, scope="auth") + username = payload.username.strip() + email = payload.email.lower() if payload.email else None + + conditions = [User.username == username] + if email: + conditions.append(User.email == email) + result = await db.execute(select(User).where(or_(*conditions))) + existing = result.scalar_one_or_none() + if existing: + if existing.username == username: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="用户名已被使用", + ) + if email and existing.email == email: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="邮箱已被使用", + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="账号已存在", + ) + + user = User( + email=email, + username=username, + hashed_password=get_password_hash(payload.password), + display_name=payload.display_name, + is_active=True, + ) + db.add(user) + try: + await db.flush() + await log_register(db, user.id, user.username, request=request) + if REGISTER_BONUS_POINTS > 0: + await PointsService.add_points( + db=db, + user_id=user.id, + amount=REGISTER_BONUS_POINTS, + reason=PointsReason.REGISTER_BONUS, + ref_type="register", + ref_id=user.id, + description=f"新用户注册奖励 {REGISTER_BONUS_POINTS} 积分", + auto_commit=False, + ) + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="用户名或邮箱已被使用", + ) + + await db.refresh(user) + jwt_token = create_access_token({ + "sub": str(user.id), + "provider": "local", + "username": user.username, + }) + return AuthTokenResponse( + access_token=jwt_token, + token_type="bearer", + user=UserResponse.model_validate(user), + ) -@router.post("/login") -async def login(): +@router.post("/login", response_model=AuthTokenResponse, summary="本地登录") +@limiter.limit(RateLimits.AUTH) +async def login( + payload: LoginRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): """用户登录""" - # TODO: 实现本地登录逻辑 - return {"message": "登录接口"} + await guard_challenge(request, scope="auth") + account = payload.account.strip() + result = await db.execute( + select(User).where(or_(User.username == account, User.email == account)) + ) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) -@router.post("/refresh") -async def refresh_token(): + if not user.hashed_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该账号未设置密码,请使用 OAuth 登录", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账号已被禁用", + ) + + if not verify_password(payload.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + await log_login(db, user.id, request=request, success=True) + await db.commit() + + jwt_token = create_access_token({ + "sub": str(user.id), + "provider": "local", + "username": user.username, + }) + return AuthTokenResponse( + access_token=jwt_token, + token_type="bearer", + user=UserResponse.model_validate(user), + ) + + +@router.post("/refresh", response_model=AuthTokenResponse, summary="刷新 Token") +@limiter.limit(RateLimits.AUTH) +async def refresh_token( + request: Request, + db: AsyncSession = Depends(get_db), +): """刷新 Token""" - # TODO: 实现 Token 刷新 - return {"message": "Token 刷新接口"} + await guard_challenge(request, scope="auth") + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录", + ) + token = auth_header.split(" ", 1)[1].strip() + payload = decode_token(token) + if not payload or "sub" not in payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="登录已过期,请重新登录", + ) + + try: + user_id = int(payload["sub"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证信息", + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账号已被禁用", + ) + + token_payload = { + "sub": str(user.id), + "provider": payload.get("provider") or "local", + "username": user.username, + } + if payload.get("linux_do_id"): + token_payload["linux_do_id"] = payload.get("linux_do_id") + + jwt_token = create_access_token(token_payload) + return AuthTokenResponse( + access_token=jwt_token, + token_type="bearer", + user=UserResponse.model_validate(user), + ) + + +@router.post("/password/forgot", summary="忘记密码") +@limiter.limit(RateLimits.AUTH) +async def forgot_password( + payload: PasswordForgotRequest, + request: Request, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), +): + """发送重置密码链接""" + await guard_challenge(request, scope="auth") + email = payload.email.strip().lower() + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user and user.is_active: + token = token_urlsafe(32) + token_hash = _hash_reset_token(token) + expires_at = datetime.utcnow() + timedelta(minutes=settings.PASSWORD_RESET_TOKEN_TTL_MINUTES) + now = datetime.utcnow() + + await db.execute( + update(PasswordResetToken) + .where( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used_at.is_(None), + ) + .values(used_at=now) + ) + + db.add(PasswordResetToken( + user_id=user.id, + token_hash=token_hash, + expires_at=expires_at, + requested_ip=request.client.host if request.client else None, + )) + await db.commit() + + reset_link = _build_password_reset_link(token) + subject = "重置密码" + content = ( + "你好,\n\n" + f"请在 {settings.PASSWORD_RESET_TOKEN_TTL_MINUTES} 分钟内通过以下链接重置密码:\n" + f"{reset_link}\n\n" + "如果不是你本人操作,请忽略本邮件。" + ) + try: + if settings.SMTP_HOST and settings.SMTP_FROM_EMAIL: + background_tasks.add_task(send_email, user.email, subject, content, False) + else: + raise EmailServiceError("SMTP 未配置") + except EmailServiceError: + if settings.DEBUG: + return {"message": "重置链接已生成", "reset_link": reset_link} + logger.warning("SMTP 未配置,重置链接未发送") + + return {"message": "如果邮箱存在,重置链接已发送"} + + +@router.post("/password/reset", summary="重置密码") +@limiter.limit(RateLimits.AUTH) +async def reset_password( + payload: PasswordResetRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """使用重置令牌设置新密码""" + await guard_challenge(request, scope="auth") + token_hash = _hash_reset_token(payload.token) + result = await db.execute( + select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash) + ) + record = result.scalar_one_or_none() + now = datetime.utcnow() + + if record is None or record.used_at or record.expires_at < now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="重置链接无效或已过期", + ) + + user = await db.get(User, record.user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在", + ) + + user.hashed_password = get_password_hash(payload.password) + record.used_at = now + await db.commit() + + return {"message": "密码已重置"} + + +@router.post("/password/change", summary="修改密码") +@limiter.limit(RateLimits.AUTH) +async def change_password( + payload: PasswordChangeRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """修改当前用户密码""" + if not current_user.hashed_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该账号尚未设置本地密码", + ) + if not verify_password(payload.old_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="旧密码不正确", + ) + current_user.hashed_password = get_password_hash(payload.new_password) + await db.commit() + return {"message": "密码已更新"} + + +@router.post("/password/set", summary="设置本地密码") +@limiter.limit(RateLimits.AUTH) +async def set_password( + payload: PasswordSetRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """为 OAuth 用户设置本地密码""" + if current_user.hashed_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该账号已设置本地密码", + ) + current_user.hashed_password = get_password_hash(payload.password) + await db.commit() + return {"message": "密码已设置"} @router.get("/linuxdo/login") @@ -368,6 +898,66 @@ async def linuxdo_login(request: Request, next: Optional[str] = None): return resp +@router.post("/linuxdo/bind", summary="绑定 Linux.do 账号") +@limiter.limit(RateLimits.AUTH) +async def linuxdo_bind( + payload: OAuthBindInitRequest, + request: Request, + current_user: User = Depends(get_current_user_dep), +): + """初始化 Linux.do 绑定流程""" + if not settings.LINUX_DO_CLIENT_ID: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="LINUX_DO_CLIENT_ID 未配置", + ) + if not settings.LINUX_DO_REDIRECT_URI: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="LINUX_DO_REDIRECT_URI 未配置", + ) + + state = token_urlsafe(32) + params = { + "response_type": "code", + "client_id": settings.LINUX_DO_CLIENT_ID, + "redirect_uri": settings.LINUX_DO_REDIRECT_URI, + "state": state, + "scope": settings.LINUX_DO_SCOPE, + } + url = f"{settings.LINUX_DO_AUTHORIZE_URL}?{urlencode(params)}" + + resp = JSONResponse({"authorize_url": url}) + is_secure = not settings.DEBUG + resp.set_cookie( + "linuxdo_oauth_state", + state, + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + next_path = _sanitize_next_path(payload.next) + if next_path: + resp.set_cookie( + "linuxdo_oauth_next", + next_path, + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + resp.set_cookie( + "linuxdo_bind_user", + str(current_user.id), + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + return resp + + @router.get("/linuxdo/callback") async def linuxdo_callback( request: Request, @@ -411,13 +1001,78 @@ async def linuxdo_callback( detail=str(e), ) - # 创建/更新用户 - user = await _upsert_linuxdo_user(db, userinfo) + bind_user_id = request.cookies.get("linuxdo_bind_user") + if bind_user_id: + try: + target_user_id = int(bind_user_id) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="绑定信息无效", + ) - # 记录登录日志 - from app.services.log_service import log_login - await log_login(db, user.id, request=request, success=True) - await db.commit() + target_user = await db.get(User, target_user_id) + if target_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="绑定账号不存在", + ) + + linux_do_id = str(userinfo.get("id")) + if not linux_do_id or linux_do_id == "None": + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Linux.do 用户信息缺少 id", + ) + if target_user.linux_do_id and target_user.linux_do_id != linux_do_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="已绑定其他 Linux.do 账号", + ) + + result = await db.execute(select(User).where(User.linux_do_id == linux_do_id)) + existing_user = result.scalar_one_or_none() + if existing_user and existing_user.id != target_user.id: + frontend = settings.FRONTEND_URL.rstrip("/") + redirect_to = f"{frontend}/account/security?merge=linuxdo" + resp = RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) + is_secure = not settings.DEBUG + resp.set_cookie( + "linuxdo_merge_source", + str(existing_user.id), + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + resp.set_cookie( + "linuxdo_merge_target", + str(target_user.id), + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + resp.delete_cookie("linuxdo_oauth_state") + resp.delete_cookie("linuxdo_oauth_next") + resp.delete_cookie("linuxdo_bind_user") + return resp + + await _apply_linuxdo_profile(target_user, userinfo) + await db.commit() + await db.refresh(target_user) + user = target_user + next_path = _sanitize_next_path(request.cookies.get("linuxdo_oauth_next")) or "/account/security" + else: + # 创建/更新用户 + user = await _upsert_linuxdo_user(db, userinfo) + + # 记录登录日志 + from app.services.log_service import log_login + await log_login(db, user.id, request=request, success=True) + await db.commit() + + next_path = _sanitize_next_path(request.cookies.get("linuxdo_oauth_next")) # 签发 JWT(包含用户名和 Linux.do ID,用于日志记录) jwt_token = create_access_token({ @@ -428,13 +1083,11 @@ async def linuxdo_callback( }) # 构建重定向 URL - next_path = _sanitize_next_path(request.cookies.get("linuxdo_oauth_next")) frontend = settings.FRONTEND_URL.rstrip("/") redirect_to = f"{frontend}/login#token={jwt_token}" if next_path: redirect_to += f"&next={next_path}" - # 附加用户信息(base64 编码) redirect_to += "&user=" + _b64url_json({ "id": user.id, @@ -454,12 +1107,66 @@ async def linuxdo_callback( # 清除 OAuth cookie resp.delete_cookie("linuxdo_oauth_state") resp.delete_cookie("linuxdo_oauth_next") + resp.delete_cookie("linuxdo_bind_user") return resp # ==================== GitHub OAuth2 ==================== +@router.post("/linuxdo/merge/confirm", response_model=UserResponse, summary="确认合并 Linux.do 账号") +async def linuxdo_merge_confirm( + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """ + 确认合并 Linux.do 账号 + + 仅在绑定流程中检测到冲突时使用。 + """ + source_id = request.cookies.get("linuxdo_merge_source") + target_id = request.cookies.get("linuxdo_merge_target") + if not source_id or not target_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="没有待确认的合并请求", + ) + if str(current_user.id) != str(target_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="合并请求与当前账号不匹配", + ) + + source_user = await db.get(User, int(source_id)) + if source_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="待合并账号不存在", + ) + + await merge_users(db, current_user, source_user) + await db.commit() + await db.refresh(current_user) + + response.delete_cookie("linuxdo_merge_source") + response.delete_cookie("linuxdo_merge_target") + return current_user + + +@router.post("/linuxdo/merge/cancel", summary="取消合并 Linux.do 账号") +async def linuxdo_merge_cancel( + response: Response, + current_user: User = Depends(get_current_user_dep), +): + """ + 取消 Linux.do 账号合并请求 + """ + response.delete_cookie("linuxdo_merge_source") + response.delete_cookie("linuxdo_merge_target") + return {"ok": True} + @router.get("/github/login") async def github_login(request: Request, next: Optional[str] = None): """ @@ -513,6 +1220,60 @@ async def github_login(request: Request, next: Optional[str] = None): return resp +@router.post("/github/bind", summary="绑定 GitHub 账号") +@limiter.limit(RateLimits.AUTH) +async def github_bind( + payload: OAuthBindInitRequest, + request: Request, + current_user: User = Depends(get_current_user_dep), +): + """初始化 GitHub 绑定流程""" + if not settings.GITHUB_CLIENT_ID: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="GITHUB_CLIENT_ID 未配置", + ) + + state = token_urlsafe(32) + params = { + "client_id": settings.GITHUB_CLIENT_ID, + "redirect_uri": settings.GITHUB_REDIRECT_URI, + "state": state, + "scope": settings.GITHUB_SCOPE, + } + url = f"{settings.GITHUB_AUTHORIZE_URL}?{urlencode(params)}" + + resp = JSONResponse({"authorize_url": url}) + is_secure = not settings.DEBUG + resp.set_cookie( + "github_oauth_state", + state, + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + next_path = _sanitize_next_path(payload.next) + if next_path: + resp.set_cookie( + "github_oauth_next", + next_path, + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + resp.set_cookie( + "github_bind_user", + str(current_user.id), + httponly=True, + samesite="none" if settings.DEBUG else "lax", + secure=is_secure, + max_age=600, + ) + return resp + + @router.get("/github/callback") async def github_callback( request: Request, @@ -559,13 +1320,55 @@ async def github_callback( detail=str(e), ) - # 创建/更新用户 - user = await _upsert_github_user(db, userinfo, primary_email) + bind_user_id = request.cookies.get("github_bind_user") + if bind_user_id: + try: + target_user_id = int(bind_user_id) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="绑定信息无效", + ) + + target_user = await db.get(User, target_user_id) + if target_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="绑定账号不存在", + ) - # 记录登录日志 - from app.services.log_service import log_login - await log_login(db, user.id, request=request, success=True) - await db.commit() + github_id = str(userinfo.get("id")) + if not github_id or github_id == "None": + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="GitHub 用户信息缺少 id", + ) + if target_user.github_id and target_user.github_id != github_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="已绑定其他 GitHub 账号", + ) + + result = await db.execute(select(User).where(User.github_id == github_id)) + existing_user = result.scalar_one_or_none() + if existing_user and existing_user.id != target_user.id: + await merge_users(db, target_user, existing_user) + + await _apply_github_profile(target_user, userinfo, primary_email) + await db.commit() + await db.refresh(target_user) + user = target_user + next_path = _sanitize_next_path(request.cookies.get("github_oauth_next")) or "/account/security" + else: + # 创建/更新用户 + user = await _upsert_github_user(db, userinfo, primary_email) + + # 记录登录日志 + from app.services.log_service import log_login + await log_login(db, user.id, request=request, success=True) + await db.commit() + + next_path = _sanitize_next_path(request.cookies.get("github_oauth_next")) # 签发 JWT(包含用户名,用于日志记录) jwt_token = create_access_token({ @@ -575,7 +1378,6 @@ async def github_callback( }) # 构建重定向 URL - next_path = _sanitize_next_path(request.cookies.get("github_oauth_next")) frontend = settings.FRONTEND_URL.rstrip("/") redirect_to = f"{frontend}/login#token={jwt_token}" @@ -601,5 +1403,6 @@ async def github_callback( # 清除 OAuth cookie resp.delete_cookie("github_oauth_state") resp.delete_cookie("github_oauth_next") + resp.delete_cookie("github_bind_user") return resp diff --git a/backend/app/api/v1/endpoints/cheer.py b/backend/app/api/v1/endpoints/cheer.py index b84c24c..a29827f 100644 --- a/backend/app/api/v1/endpoints/cheer.py +++ b/backend/app/api/v1/endpoints/cheer.py @@ -22,7 +22,8 @@ from app.models.cheer import Cheer, CheerType, CheerStats from app.models.user import User from app.models.points import UserItem -from app.api.v1.endpoints.registration import get_current_user, get_optional_user, get_contest_or_404 +from app.api.v1.endpoints.registration import get_contest_or_404 +from app.core.dependencies import get_current_user, get_optional_user from app.services import achievement_service # 道具分数配置(给选手加的分数) diff --git a/backend/app/api/v1/endpoints/contest.py b/backend/app/api/v1/endpoints/contest.py index 858ddf2..cf60687 100644 --- a/backend/app/api/v1/endpoints/contest.py +++ b/backend/app/api/v1/endpoints/contest.py @@ -3,18 +3,38 @@ 提供比赛信息的查询功能。 """ +from datetime import datetime +from decimal import Decimal from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status, File, UploadFile from pydantic import BaseModel, ConfigDict -from sqlalchemy import select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from app.core.dependencies import get_current_user, get_optional_user from app.core.database import get_db -from app.models.contest import Contest, ContestPhase +from app.core.rate_limit import limiter, RateLimits +from app.models.contest import Contest, ContestPhase, ContestVisibility +from app.models.project import Project, ProjectStatus +from app.models.project_favorite import ProjectFavorite +from app.models.project_like import ProjectLike +from app.models.project_review import ProjectReview +from app.models.project_review_assignment import ProjectReviewAssignment +from app.models.registration import Registration, RegistrationStatus +from app.models.submission import Submission, SubmissionStatus +from app.models.user import User +from app.services.media_service import delete_media_file, ensure_local_media_url, save_upload_file +from app.services.security_challenge import guard_challenge +from app.schemas.review_center import ReviewStatsResponse +from app.schemas.submission import UserBrief router = APIRouter() +# 图片大小限制(字节) +MAX_BANNER_BYTES = 5 * 1024 * 1024 + class ContestResponse(BaseModel): """比赛响应体""" @@ -24,6 +44,21 @@ class ContestResponse(BaseModel): title: str description: Optional[str] = None phase: ContestPhase + visibility: ContestVisibility = ContestVisibility.PUBLISHED + home_visible: bool = False + banner_url: Optional[str] = None + rules_md: Optional[str] = None + prizes_md: Optional[str] = None + review_rules_md: Optional[str] = None + faq_md: Optional[str] = None + template_config: Optional[dict] = None + signup_start: Optional[datetime] = None + signup_end: Optional[datetime] = None + submit_start: Optional[datetime] = None + submit_end: Optional[datetime] = None + vote_start: Optional[datetime] = None + vote_end: Optional[datetime] = None + auto_phase_enabled: bool = True class ContestListResponse(BaseModel): @@ -32,10 +67,327 @@ class ContestListResponse(BaseModel): total: int +class ContestRegistrationStats(BaseModel): + """比赛报名统计""" + total: int + draft: int + submitted: int + approved: int + rejected: int + withdrawn: int + + +class ContestSubmissionStats(BaseModel): + """比赛作品提交统计""" + total: int + draft: int + validating: int + submitted: int + approved: int + rejected: int + finalized: int + coverage_rate: float + + +class ContestReviewStats(BaseModel): + """比赛评审统计""" + assignment_count: int + reviewed_count: int + assignment_coverage_rate: float + assigned_project_count: int + reviewed_project_count: int + project_coverage_rate: float + + +class ContestStatsResponse(BaseModel): + """比赛统计汇总响应""" + registration: ContestRegistrationStats + submission: ContestSubmissionStats + review: ContestReviewStats + + +class ContestCreateRequest(BaseModel): + """创建比赛请求体""" + title: str + description: Optional[str] = None + phase: Optional[ContestPhase] = None + visibility: Optional[ContestVisibility] = None + home_visible: Optional[bool] = None + banner_url: Optional[str] = None + rules_md: Optional[str] = None + prizes_md: Optional[str] = None + review_rules_md: Optional[str] = None + faq_md: Optional[str] = None + template_config: Optional[dict] = None + signup_start: Optional[datetime] = None + signup_end: Optional[datetime] = None + submit_start: Optional[datetime] = None + submit_end: Optional[datetime] = None + vote_start: Optional[datetime] = None + vote_end: Optional[datetime] = None + auto_phase_enabled: Optional[bool] = None + + +class ContestUpdateRequest(BaseModel): + """更新比赛请求体""" + title: Optional[str] = None + description: Optional[str] = None + phase: Optional[ContestPhase] = None + visibility: Optional[ContestVisibility] = None + home_visible: Optional[bool] = None + banner_url: Optional[str] = None + rules_md: Optional[str] = None + prizes_md: Optional[str] = None + review_rules_md: Optional[str] = None + faq_md: Optional[str] = None + template_config: Optional[dict] = None + signup_start: Optional[datetime] = None + signup_end: Optional[datetime] = None + submit_start: Optional[datetime] = None + submit_end: Optional[datetime] = None + vote_start: Optional[datetime] = None + vote_end: Optional[datetime] = None + auto_phase_enabled: Optional[bool] = None + + +class ContestPhaseUpdateRequest(BaseModel): + """比赛阶段更新请求体""" + phase: ContestPhase + + +class ProjectRankingItem(BaseModel): + """作品排行榜条目""" + rank: int + project_id: int + title: str + status: ProjectStatus + user: Optional[UserBrief] = None + stats: ReviewStatsResponse + + +class ContestRankingResponse(BaseModel): + """比赛排行榜响应""" + items: list[ProjectRankingItem] + total: int + + +class ProjectRankingDetailResponse(BaseModel): + """比赛排行榜详情响应""" + rank: int + project_id: int + title: str + summary: Optional[str] = None + description: Optional[str] = None + repo_url: Optional[str] = None + demo_url: Optional[str] = None + readme_url: Optional[str] = None + status: ProjectStatus + user: Optional[UserBrief] = None + stats: ReviewStatsResponse + + +class ProjectInteractionLeaderboardItem(BaseModel): + """作品互动排行榜条目""" + rank: int + project_id: int + title: str + status: ProjectStatus + user: Optional[UserBrief] = None + count: int + + +class ContestInteractionLeaderboardResponse(BaseModel): + """作品互动排行榜响应""" + items: list[ProjectInteractionLeaderboardItem] + total: int + type: str + + +def require_admin(user: User) -> None: + """检查管理员权限""" + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") + + +def ensure_contest_visible(contest: Contest, user: Optional[User]) -> None: + """确保比赛对当前用户可见""" + if contest.visibility != ContestVisibility.PUBLISHED.value: + if not (user and user.is_admin): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="比赛不存在") + + +def build_empty_review_stats() -> ReviewStatsResponse: + """构建空的评分统计""" + return ReviewStatsResponse( + review_count=0, + final_score=None, + avg_score=None, + min_score=None, + max_score=None, + ) + + +def build_review_stats( + review_count: int, + total_score: Optional[int], + avg_score: Optional[float], + min_score: Optional[int], + max_score: Optional[int], +) -> ReviewStatsResponse: + """构建评分统计""" + if review_count == 0: + return build_empty_review_stats() + + avg_value = Decimal(str(avg_score)).quantize(Decimal("0.01")) if avg_score is not None else None + if review_count >= 3 and total_score is not None and min_score is not None and max_score is not None: + final_score = Decimal(str(total_score - max_score - min_score)) / Decimal(review_count - 2) + final_score = final_score.quantize(Decimal("0.01")) + else: + final_score = avg_value + + return ReviewStatsResponse( + review_count=review_count, + final_score=final_score, + avg_score=avg_value, + min_score=min_score, + max_score=max_score, + ) + + +def calculate_rate(numerator: int, denominator: int) -> float: + """计算覆盖率""" + if denominator <= 0: + return 0.0 + return round(numerator / denominator, 4) + + +def validate_contest_schedule(schedule: dict) -> None: + """验证比赛时间窗口配置是否合理""" + signup_start = schedule.get("signup_start") + signup_end = schedule.get("signup_end") + submit_start = schedule.get("submit_start") + submit_end = schedule.get("submit_end") + vote_start = schedule.get("vote_start") + vote_end = schedule.get("vote_end") + + def check_window(start: Optional[datetime], end: Optional[datetime], label: str) -> None: + if start and end and start > end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{label}时间范围不合法", + ) + + def check_order( + left: Optional[datetime], + right: Optional[datetime], + left_label: str, + right_label: str, + ) -> None: + if left and right and left > right: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{left_label}不能晚于{right_label}", + ) + + check_window(signup_start, signup_end, "报名") + check_window(submit_start, submit_end, "提交") + check_window(vote_start, vote_end, "投票") + + check_order(signup_start, submit_start, "报名开始时间", "提交开始时间") + check_order(signup_end, submit_start, "报名截止时间", "提交开始时间") + check_order(submit_start, vote_start, "提交开始时间", "投票开始时间") + check_order(submit_end, vote_start, "提交截止时间", "投票开始时间") + + +async def build_project_ranking_items( + db: AsyncSession, + contest_id: int, +) -> list[ProjectRankingItem]: + """构建比赛作品排行榜列表""" + project_result = await db.execute( + select(Project) + .join( + Registration, + (Registration.contest_id == Project.contest_id) + & (Registration.user_id == Project.user_id), + ) + .options(selectinload(Project.user)) + .where( + Project.contest_id == contest_id, + Project.status.in_({ProjectStatus.SUBMITTED.value, ProjectStatus.ONLINE.value}), + Registration.status != RegistrationStatus.WITHDRAWN.value, + ) + ) + projects = project_result.scalars().all() + if not projects: + return [] + + project_ids = [project.id for project in projects] + stats_result = await db.execute( + select( + ProjectReview.project_id.label("project_id"), + func.count(ProjectReview.id).label("count"), + func.sum(ProjectReview.score).label("sum"), + func.avg(ProjectReview.score).label("avg"), + func.min(ProjectReview.score).label("min"), + func.max(ProjectReview.score).label("max"), + ) + .where(ProjectReview.project_id.in_(project_ids)) + .group_by(ProjectReview.project_id) + ) + stats_map = {row.project_id: row for row in stats_result} + + entries = [] + for project in projects: + row = stats_map.get(project.id) + stats = build_review_stats( + review_count=row.count if row else 0, + total_score=row.sum if row else None, + avg_score=row.avg if row else None, + min_score=row.min if row else None, + max_score=row.max if row else None, + ) + owner = UserBrief.model_validate(project.user) if project.user else None + entries.append({ + "project": project, + "stats": stats, + "owner": owner, + }) + + def sort_key(entry: dict) -> tuple[float, int, int]: + stats = entry["stats"] + score_value = float(stats.final_score) if stats.final_score is not None else -1.0 + project = entry["project"] + return (score_value, stats.review_count, project.id) + + entries.sort(key=sort_key, reverse=True) + ranked_items: list[ProjectRankingItem] = [] + for rank, entry in enumerate(entries, 1): + project = entry["project"] + ranked_items.append( + ProjectRankingItem( + rank=rank, + project_id=project.id, + title=project.title, + status=project.status_enum, + user=entry["owner"], + stats=entry["stats"], + ) + ) + + return ranked_items + + @router.get("/", response_model=ContestListResponse) -async def list_contests(db: AsyncSession = Depends(get_db)): +async def list_contests( + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): """获取比赛列表""" - result = await db.execute(select(Contest).order_by(Contest.id.desc())) + query = select(Contest).order_by(Contest.id.desc()) + if not (current_user and current_user.is_admin): + query = query.where(Contest.visibility == ContestVisibility.PUBLISHED.value) + result = await db.execute(query) contests = result.scalars().all() return ContestListResponse( @@ -43,9 +395,67 @@ async def list_contests(db: AsyncSession = Depends(get_db)): total=len(contests) ) +@router.get("/homepage", response_model=ContestResponse, summary="获取首页展示比赛") +async def get_homepage_contest( + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取首页展示比赛,未配置时返回 404。""" + query = select(Contest).where(Contest.home_visible.is_(True)) + if not (current_user and current_user.is_admin): + query = query.where(Contest.visibility == ContestVisibility.PUBLISHED.value) + result = await db.execute(query.order_by(Contest.updated_at.desc(), Contest.id.desc())) + contest = result.scalars().first() + + if contest is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="未配置首页展示比赛", + ) + + return ContestResponse.model_validate(contest) + + +@router.get("/current", response_model=ContestResponse, summary="获取当前比赛") +async def get_current_contest( + include_ended: bool = False, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """ + 获取当前比赛(默认只取未结束的比赛)。 + + - include_ended=True 时,若未找到进行中的比赛,则回退到最新一场。 + """ + is_admin = current_user is not None and current_user.is_admin + query = select(Contest).where(Contest.phase != ContestPhase.ENDED.value) + if not is_admin: + query = query.where(Contest.visibility == ContestVisibility.PUBLISHED.value) + result = await db.execute(query.order_by(Contest.id.desc())) + contest = result.scalars().first() + + if contest is None and include_ended: + fallback_query = select(Contest) + if not is_admin: + fallback_query = fallback_query.where(Contest.visibility == ContestVisibility.PUBLISHED.value) + fallback_result = await db.execute(fallback_query.order_by(Contest.id.desc())) + contest = fallback_result.scalars().first() + + if contest is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="比赛不存在", + ) + + return ContestResponse.model_validate(contest) + @router.get("/{contest_id}", response_model=ContestResponse) -async def get_contest(contest_id: int, db: AsyncSession = Depends(get_db)): +async def get_contest( + contest_id: int, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): """获取比赛详情""" result = await db.execute(select(Contest).where(Contest.id == contest_id)) contest = result.scalar_one_or_none() @@ -56,11 +466,135 @@ async def get_contest(contest_id: int, db: AsyncSession = Depends(get_db)): detail="比赛不存在" ) + ensure_contest_visible(contest, current_user) return ContestResponse.model_validate(contest) -@router.get("/{contest_id}/ranking") -async def get_ranking(contest_id: int, db: AsyncSession = Depends(get_db)): +@router.get( + "/{contest_id}/stats", + response_model=ContestStatsResponse, + summary="获取比赛统计汇总(管理员)", +) +async def get_contest_stats( + contest_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取比赛统计汇总""" + require_admin(current_user) + + contest_result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = contest_result.scalar_one_or_none() + if contest is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="比赛不存在", + ) + + registration_rows = ( + await db.execute( + select( + Registration.status.label("status"), + func.count(Registration.id).label("count"), + ) + .where(Registration.contest_id == contest_id) + .group_by(Registration.status) + ) + ).all() + registration_counts = {status.value: 0 for status in RegistrationStatus} + for row in registration_rows: + if row.status in registration_counts: + registration_counts[row.status] = int(row.count or 0) + registration_total = sum(registration_counts.values()) + + submission_rows = ( + await db.execute( + select( + Submission.status.label("status"), + func.count(Submission.id).label("count"), + ) + .where(Submission.contest_id == contest_id) + .group_by(Submission.status) + ) + ).all() + submission_counts = {status.value: 0 for status in SubmissionStatus} + for row in submission_rows: + if row.status in submission_counts: + submission_counts[row.status] = int(row.count or 0) + submission_total = sum(submission_counts.values()) + submission_finalized = ( + submission_counts[SubmissionStatus.SUBMITTED.value] + + submission_counts[SubmissionStatus.APPROVED.value] + + submission_counts[SubmissionStatus.REJECTED.value] + ) + eligible_registrations = registration_counts[RegistrationStatus.APPROVED.value] + submission_coverage_rate = calculate_rate(submission_finalized, eligible_registrations) + + assignment_count_result = await db.execute( + select(func.count(ProjectReviewAssignment.id)) + .join(Project, Project.id == ProjectReviewAssignment.project_id) + .where(Project.contest_id == contest_id) + ) + assignment_count = assignment_count_result.scalar() or 0 + + reviewed_count_result = await db.execute( + select(func.count(ProjectReview.id)) + .join(Project, Project.id == ProjectReview.project_id) + .where(Project.contest_id == contest_id) + ) + reviewed_count = reviewed_count_result.scalar() or 0 + + assigned_project_count_result = await db.execute( + select(func.count(func.distinct(ProjectReviewAssignment.project_id))) + .join(Project, Project.id == ProjectReviewAssignment.project_id) + .where(Project.contest_id == contest_id) + ) + assigned_project_count = assigned_project_count_result.scalar() or 0 + + reviewed_project_count_result = await db.execute( + select(func.count(func.distinct(ProjectReview.project_id))) + .join(Project, Project.id == ProjectReview.project_id) + .where(Project.contest_id == contest_id) + ) + reviewed_project_count = reviewed_project_count_result.scalar() or 0 + + return ContestStatsResponse( + registration=ContestRegistrationStats( + total=registration_total, + draft=registration_counts[RegistrationStatus.DRAFT.value], + submitted=registration_counts[RegistrationStatus.SUBMITTED.value], + approved=registration_counts[RegistrationStatus.APPROVED.value], + rejected=registration_counts[RegistrationStatus.REJECTED.value], + withdrawn=registration_counts[RegistrationStatus.WITHDRAWN.value], + ), + submission=ContestSubmissionStats( + total=submission_total, + draft=submission_counts[SubmissionStatus.DRAFT.value], + validating=submission_counts[SubmissionStatus.VALIDATING.value], + submitted=submission_counts[SubmissionStatus.SUBMITTED.value], + approved=submission_counts[SubmissionStatus.APPROVED.value], + rejected=submission_counts[SubmissionStatus.REJECTED.value], + finalized=submission_finalized, + coverage_rate=submission_coverage_rate, + ), + review=ContestReviewStats( + assignment_count=assignment_count, + reviewed_count=reviewed_count, + assignment_coverage_rate=calculate_rate(reviewed_count, assignment_count), + assigned_project_count=assigned_project_count, + reviewed_project_count=reviewed_project_count, + project_coverage_rate=calculate_rate(reviewed_project_count, assigned_project_count), + ), + ) + + +@router.get("/{contest_id}/ranking", response_model=ContestRankingResponse, summary="获取排行榜") +async def get_ranking( + contest_id: int, + limit: int = 50, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): """获取排行榜""" # 验证比赛存在 result = await db.execute(select(Contest).where(Contest.id == contest_id)) @@ -71,6 +605,314 @@ async def get_ranking(contest_id: int, db: AsyncSession = Depends(get_db)): status_code=status.HTTP_404_NOT_FOUND, detail="比赛不存在" ) + ensure_contest_visible(contest, current_user) + + if limit < 1 or limit > 200: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit 必须在 1-200 之间") + ranked_items = await build_project_ranking_items(db, contest_id) + total = len(ranked_items) + return ContestRankingResponse(items=ranked_items[:limit], total=total) + + +@router.get( + "/{contest_id}/ranking/{project_id}", + response_model=ProjectRankingDetailResponse, + summary="获取排行榜详情", +) +async def get_ranking_detail( + contest_id: int, + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取排行榜详情""" + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="比赛不存在" + ) + ensure_contest_visible(contest, current_user) + + project_result = await db.execute( + select(Project) + .options(selectinload(Project.user)) + .where( + Project.id == project_id, + Project.contest_id == contest_id, + Project.status.in_({ProjectStatus.SUBMITTED.value, ProjectStatus.ONLINE.value}), + ) + ) + project = project_result.scalar_one_or_none() + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品不存在") + + ranked_items = await build_project_ranking_items(db, contest_id) + target_item = next((item for item in ranked_items if item.project_id == project_id), None) + if target_item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品未进入排行榜") + + return ProjectRankingDetailResponse( + rank=target_item.rank, + project_id=project.id, + title=project.title, + summary=project.summary, + description=project.description, + repo_url=project.repo_url, + demo_url=project.demo_url, + readme_url=project.readme_url, + status=project.status_enum, + user=target_item.user, + stats=target_item.stats, + ) + + +@router.get( + "/{contest_id}/interaction-leaderboard", + response_model=ContestInteractionLeaderboardResponse, + summary="获取互动排行榜", +) +async def get_interaction_leaderboard( + contest_id: int, + type: str = "like", + limit: int = 50, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取作品点赞/收藏排行榜""" + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="比赛不存在" + ) + ensure_contest_visible(contest, current_user) + + if type not in {"like", "favorite"}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="type 仅支持 like 或 favorite") + + if limit < 1 or limit > 200: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit 必须在 1-200 之间") + + if type == "like": + model = ProjectLike + else: + model = ProjectFavorite + + stats_result = await db.execute( + select(model.project_id.label("project_id"), func.count(model.id).label("count")) + .join(Project, Project.id == model.project_id) + .where( + Project.contest_id == contest_id, + Project.status == ProjectStatus.ONLINE.value, + ) + .group_by(model.project_id) + ) + stats_rows = stats_result.all() + if not stats_rows: + return ContestInteractionLeaderboardResponse(items=[], total=0, type=type) + + count_map = {row.project_id: int(row.count or 0) for row in stats_rows} + project_ids = list(count_map.keys()) + + project_result = await db.execute( + select(Project) + .options(selectinload(Project.user)) + .where(Project.id.in_(project_ids)) + ) + projects = project_result.scalars().all() + project_map = {project.id: project for project in projects} + + entries = [] + for project_id, count in count_map.items(): + project = project_map.get(project_id) + if not project: + continue + owner = UserBrief.model_validate(project.user) if project.user else None + entries.append({ + "project": project, + "owner": owner, + "count": count, + }) + + entries.sort(key=lambda item: (item["count"], item["project"].id), reverse=True) + ranked_items: list[ProjectInteractionLeaderboardItem] = [] + for rank, entry in enumerate(entries, 1): + project = entry["project"] + ranked_items.append( + ProjectInteractionLeaderboardItem( + rank=rank, + project_id=project.id, + title=project.title, + status=project.status_enum, + user=entry["owner"], + count=entry["count"], + ) + ) + + total = len(ranked_items) + return ContestInteractionLeaderboardResponse( + items=ranked_items[:limit], + total=total, + type=type, + ) + + +@router.post( + "/", + response_model=ContestResponse, + status_code=status.HTTP_201_CREATED, + summary="创建比赛(管理员)", +) +async def create_contest( + payload: ContestCreateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """创建比赛""" + require_admin(current_user) + + validate_contest_schedule(payload.model_dump()) + auto_phase_enabled = True if payload.auto_phase_enabled is None else payload.auto_phase_enabled + visibility_value = ( + payload.visibility.value + if payload.visibility + else ContestVisibility.PUBLISHED.value + ) + banner_url = ensure_local_media_url(payload.banner_url, "赛事 Banner") + home_visible = bool(payload.home_visible) if payload.home_visible is not None else False + + if home_visible: + await db.execute(update(Contest).values(home_visible=False)) + + contest = Contest( + title=payload.title, + description=payload.description, + phase=(payload.phase.value if payload.phase else ContestPhase.UPCOMING.value), + visibility=visibility_value, + home_visible=home_visible, + banner_url=banner_url, + rules_md=payload.rules_md, + prizes_md=payload.prizes_md, + review_rules_md=payload.review_rules_md, + faq_md=payload.faq_md, + template_config=payload.template_config, + signup_start=payload.signup_start, + signup_end=payload.signup_end, + submit_start=payload.submit_start, + submit_end=payload.submit_end, + vote_start=payload.vote_start, + vote_end=payload.vote_end, + auto_phase_enabled=auto_phase_enabled, + ) + db.add(contest) + await db.commit() + await db.refresh(contest) + return ContestResponse.model_validate(contest) + + +@router.patch( + "/{contest_id}", + response_model=ContestResponse, + summary="更新比赛(管理员)", +) +async def update_contest( + contest_id: int, + payload: ContestUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """更新比赛""" + require_admin(current_user) + + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="比赛不存在") + + update_data = payload.model_dump(exclude_unset=True) + if update_data.get("home_visible") is True: + await db.execute( + update(Contest).where(Contest.id != contest_id).values(home_visible=False) + ) + if "banner_url" in update_data: + update_data["banner_url"] = ensure_local_media_url(update_data.get("banner_url"), "赛事 Banner") + schedule = { + "signup_start": update_data.get("signup_start", contest.signup_start), + "signup_end": update_data.get("signup_end", contest.signup_end), + "submit_start": update_data.get("submit_start", contest.submit_start), + "submit_end": update_data.get("submit_end", contest.submit_end), + "vote_start": update_data.get("vote_start", contest.vote_start), + "vote_end": update_data.get("vote_end", contest.vote_end), + } + validate_contest_schedule(schedule) + for field, value in update_data.items(): + if isinstance(value, ContestPhase): + value = value.value + if isinstance(value, ContestVisibility): + value = value.value + setattr(contest, field, value) + + await db.commit() + await db.refresh(contest) + return ContestResponse.model_validate(contest) + + +@router.post( + "/{contest_id}/banner", + response_model=ContestResponse, + summary="上传赛事 Banner(管理员)", +) +@limiter.limit(RateLimits.UPLOAD) +async def upload_contest_banner( + request: Request, + contest_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """上传赛事 Banner""" + require_admin(current_user) + await guard_challenge(request, scope="upload", user_id=current_user.id) - # TODO: 实现排行榜逻辑 - return {"message": f"比赛 {contest_id} 排行榜", "items": []} + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="比赛不存在") + + media = await save_upload_file(file, "contest-banners", MAX_BANNER_BYTES, owner_id=current_user.id) + old_url = contest.banner_url + contest.banner_url = media.url + await db.commit() + await db.refresh(contest) + + delete_media_file(old_url) + return ContestResponse.model_validate(contest) + + +@router.put( + "/{contest_id}/phase", + response_model=ContestResponse, + summary="更新比赛阶段(管理员)", +) +async def update_contest_phase( + contest_id: int, + payload: ContestPhaseUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """更新比赛阶段""" + require_admin(current_user) + + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="比赛不存在") + + contest.phase = payload.phase.value + contest.auto_phase_enabled = False + await db.commit() + await db.refresh(contest) + return ContestResponse.model_validate(contest) diff --git a/backend/app/api/v1/endpoints/exchange.py b/backend/app/api/v1/endpoints/exchange.py index 1550ae2..1014997 100644 --- a/backend/app/api/v1/endpoints/exchange.py +++ b/backend/app/api/v1/endpoints/exchange.py @@ -7,7 +7,7 @@ from typing import Optional, List from app.core.database import get_db -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user +from app.core.dependencies import get_current_user from app.models.user import User from app.services.exchange_service import ExchangeService diff --git a/backend/app/api/v1/endpoints/gacha.py b/backend/app/api/v1/endpoints/gacha.py index b638879..450709e 100644 --- a/backend/app/api/v1/endpoints/gacha.py +++ b/backend/app/api/v1/endpoints/gacha.py @@ -17,7 +17,7 @@ from app.core.database import get_db from app.core.rate_limit import limiter, RateLimits -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user +from app.core.dependencies import get_current_user from app.models.user import User from app.models.points import PointsReason, UserItem from app.models.gacha import GachaConfig, GachaPrize, GachaDraw, GachaPrizeType diff --git a/backend/app/api/v1/endpoints/lottery.py b/backend/app/api/v1/endpoints/lottery.py index 8dbe74b..0bc5773 100644 --- a/backend/app/api/v1/endpoints/lottery.py +++ b/backend/app/api/v1/endpoints/lottery.py @@ -10,7 +10,7 @@ from app.core.database import get_db from app.core.rate_limit import limiter, RateLimits -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user, get_current_user_optional +from app.core.dependencies import get_current_user, get_current_user_optional from app.models.user import User from app.models.points import ApiKeyCode, ApiKeyStatus from app.services.lottery_service import LotteryService diff --git a/backend/app/api/v1/endpoints/media.py b/backend/app/api/v1/endpoints/media.py new file mode 100644 index 0000000..3660ebf --- /dev/null +++ b/backend/app/api/v1/endpoints/media.py @@ -0,0 +1,22 @@ +""" +媒体文件访问 +""" +from pathlib import Path + +from fastapi import APIRouter, HTTPException, status +from fastapi.responses import FileResponse + +from app.services.media_service import MEDIA_CATEGORIES, _build_storage_path + +router = APIRouter() + + +@router.get("/{category}/{filename}", summary="获取媒体文件") +async def get_media_file(category: str, filename: str): + """获取媒体文件""" + if category not in MEDIA_CATEGORIES: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在") + path = _build_storage_path(category, filename) + if not Path(path).exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在") + return FileResponse(path) diff --git a/backend/app/api/v1/endpoints/points.py b/backend/app/api/v1/endpoints/points.py index dfc8815..d9f8a47 100644 --- a/backend/app/api/v1/endpoints/points.py +++ b/backend/app/api/v1/endpoints/points.py @@ -9,7 +9,7 @@ from app.core.database import get_db from app.core.rate_limit import limiter, RateLimits -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user +from app.core.dependencies import get_current_user from app.models.user import User from app.services.points_service import PointsService, SigninService diff --git a/backend/app/api/v1/endpoints/prediction.py b/backend/app/api/v1/endpoints/prediction.py index a7a4034..73c3cb9 100644 --- a/backend/app/api/v1/endpoints/prediction.py +++ b/backend/app/api/v1/endpoints/prediction.py @@ -11,7 +11,7 @@ from app.core.rate_limit import limiter, RateLimits from app.core.database import get_db -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user +from app.core.dependencies import get_current_user from app.models.user import User from app.models.points import MarketStatus from app.services.prediction_service import PredictionService diff --git a/backend/app/api/v1/endpoints/project.py b/backend/app/api/v1/endpoints/project.py new file mode 100644 index 0000000..bb786cb --- /dev/null +++ b/backend/app/api/v1/endpoints/project.py @@ -0,0 +1,1294 @@ +""" +作品与部署提交相关 API +""" +from datetime import datetime, time +import logging +from typing import Optional, Tuple + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status, Request, File, UploadFile +import httpx +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.v1.endpoints.submission import require_approved_registration +from app.core.dependencies import get_current_user, get_optional_user +from app.core.config import settings +from app.core.database import get_db +from app.core.rate_limit import limiter, RateLimits +from app.core.redis import close_redis, get_redis +from app.services.media_service import ( + delete_media_file, + ensure_local_media_url, + ensure_local_media_urls, + save_upload_file, +) +from app.services.worker_queue import enqueue_worker_action +from app.services.security_challenge import guard_challenge +from app.models.contest import Contest, ContestPhase +from app.models.project import Project, ProjectStatus +from app.models.project_like import ProjectLike +from app.models.project_favorite import ProjectFavorite +from app.models.project_review_assignment import ProjectReviewAssignment +from app.models.project_submission import ProjectSubmission, ProjectSubmissionStatus +from app.models.registration import Registration, RegistrationStatus +from app.models.user import User +from app.schemas.project import ( + ProjectAdminActionRequest, + ProjectAccessResponse, + ProjectCreate, + ProjectInteractionResponse, + ProjectListResponse, + ProjectResponse, + ProjectSubmissionCreate, + ProjectSubmissionListResponse, + ProjectSubmissionResponse, + ProjectSubmissionStatusUpdate, + ProjectUpdate, + ProjectReviewAssignRequest, + ProjectReviewerItem, + ProjectReviewerListResponse, +) +from app.schemas.submission import UserBrief +from app.services.project_domain import build_project_domain + +router = APIRouter() +logger = logging.getLogger(__name__) + +# 图片大小限制(字节) +MAX_COVER_BYTES = 5 * 1024 * 1024 +MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024 + +ALLOWED_REGISTRIES = {"ghcr.io", "docker.io"} +ALLOWED_REPO_HOSTS = ("https://github.com/", "https://gitee.com/") +REPO_CHECK_TIMEOUT_SECONDS = 6.0 + + +def require_admin(user: User) -> None: + """检查管理员权限""" + if not user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") + + +async def get_contest_or_404(db: AsyncSession, contest_id: int) -> Contest: + """获取比赛,不存在则抛出 404""" + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="比赛不存在") + return contest + + +async def get_project_or_404(db: AsyncSession, project_id: int) -> Project: + """获取作品,不存在则抛出 404""" + result = await db.execute( + select(Project) + .options(selectinload(Project.user)) + .where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品不存在") + return project + + +def ensure_owner(project: Project, user: User) -> None: + """确保是作品所有者""" + if project.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限操作该作品") + + +def ensure_owner_or_admin(project: Project, user: Optional[User]) -> None: + """确保是作品所有者或管理员""" + if user is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限访问该作品") + if project.user_id == user.id or user.is_admin: + return + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限访问该作品") + + +def check_project_phase(contest: Contest) -> None: + """检查比赛是否允许创建/编辑作品""" + if contest.phase not in {ContestPhase.SIGNUP.value, ContestPhase.SUBMISSION.value}: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前比赛阶段不允许创建或编辑作品" + ) + + +def check_submission_phase(contest: Contest) -> None: + """检查比赛是否处于作品提交阶段""" + if contest.phase != ContestPhase.SUBMISSION.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前比赛不在提交阶段" + ) + + +def parse_image_ref(image_ref: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """解析镜像引用""" + if "@sha256:" not in image_ref: + return None, None, None + ref, digest = image_ref.split("@", 1) + parts = ref.split("/") + if len(parts) < 2: + return None, None, None + registry = parts[0] + repo = "/".join(parts[1:]) if len(parts) > 1 else None + return registry, repo, digest + + +async def ensure_public_repo_url(repo_url: str) -> None: + """校验仓库链接可访问性(GitHub/Gitee)""" + url = repo_url.strip() + if not url: + return + if not url.startswith(ALLOWED_REPO_HOSTS): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仓库链接仅支持 GitHub 或 Gitee") + + headers = {"User-Agent": "ikun-repo-check/1.0"} + try: + async with httpx.AsyncClient( + follow_redirects=True, + timeout=REPO_CHECK_TIMEOUT_SECONDS, + ) as client: + response = await client.head(url, headers=headers) + if response.status_code >= 400: + response = await client.get(url, headers=headers) + except httpx.RequestError as exc: + logger.warning("仓库链接访问失败: url=%s, error=%s", url, exc) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="仓库链接无法访问,请确认仓库为公开", + ) from exc + + if response.status_code >= 400: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="仓库链接无法访问,请确认仓库为公开", + ) + + +def append_status_history( + submission: ProjectSubmission, + status_value: str, + now: datetime, + message: Optional[str] = None, + error_code: Optional[str] = None, +) -> None: + """追加状态历史""" + history = submission.status_history or [] + history.append({ + "status": status_value, + "timestamp": now.isoformat(), + "message": message, + "error_code": error_code, + }) + submission.status_history = history + + +def build_project_response( + project: Project, + interaction: Optional[dict] = None, +) -> ProjectResponse: + """构建作品响应体""" + response = ProjectResponse.model_validate(project) + if project.user: + response.owner = UserBrief.model_validate(project.user) + if interaction: + response.like_count = interaction.get("like_count", 0) + response.favorite_count = interaction.get("favorite_count", 0) + response.liked = bool(interaction.get("liked", False)) + response.favorited = bool(interaction.get("favorited", False)) + return response + + +async def build_project_interaction_map( + db: AsyncSession, + project_ids: list[int], + user_id: Optional[int], +) -> dict[int, dict]: + """批量构建作品互动信息""" + if not project_ids: + return {} + + like_result = await db.execute( + select(ProjectLike.project_id, func.count(ProjectLike.id).label("count")) + .where(ProjectLike.project_id.in_(project_ids)) + .group_by(ProjectLike.project_id) + ) + like_counts = {row.project_id: int(row.count or 0) for row in like_result} + + favorite_result = await db.execute( + select(ProjectFavorite.project_id, func.count(ProjectFavorite.id).label("count")) + .where(ProjectFavorite.project_id.in_(project_ids)) + .group_by(ProjectFavorite.project_id) + ) + favorite_counts = {row.project_id: int(row.count or 0) for row in favorite_result} + + liked_ids: set[int] = set() + favorited_ids: set[int] = set() + if user_id is not None: + liked_result = await db.execute( + select(ProjectLike.project_id).where( + ProjectLike.project_id.in_(project_ids), + ProjectLike.user_id == user_id, + ) + ) + liked_ids = set(liked_result.scalars().all()) + + favorited_result = await db.execute( + select(ProjectFavorite.project_id).where( + ProjectFavorite.project_id.in_(project_ids), + ProjectFavorite.user_id == user_id, + ) + ) + favorited_ids = set(favorited_result.scalars().all()) + + interaction_map: dict[int, dict] = {} + for project_id in project_ids: + interaction_map[project_id] = { + "like_count": like_counts.get(project_id, 0), + "favorite_count": favorite_counts.get(project_id, 0), + "liked": project_id in liked_ids, + "favorited": project_id in favorited_ids, + } + return interaction_map + + +async def build_project_interaction_response( + db: AsyncSession, + project_id: int, + user_id: Optional[int], +) -> ProjectInteractionResponse: + """构建单个作品互动响应""" + interaction_map = await build_project_interaction_map(db, [project_id], user_id) + data = interaction_map.get(project_id, { + "like_count": 0, + "favorite_count": 0, + "liked": False, + "favorited": False, + }) + return ProjectInteractionResponse(project_id=project_id, **data) + + +def build_project_reviewer_item(assignment: ProjectReviewAssignment) -> ProjectReviewerItem: + """构建作品评审员信息""" + item = ProjectReviewerItem.model_validate(assignment) + if assignment.reviewer: + item.reviewer = UserBrief.model_validate(assignment.reviewer) + return item + + +async def get_project_review_assignments( + db: AsyncSession, + project_id: int, +) -> list[ProjectReviewAssignment]: + """获取作品评审员分配记录""" + result = await db.execute( + select(ProjectReviewAssignment) + .options(selectinload(ProjectReviewAssignment.reviewer)) + .where(ProjectReviewAssignment.project_id == project_id) + .order_by(ProjectReviewAssignment.id.desc()) + ) + return result.scalars().all() + + +async def build_project_reviewer_list_response( + db: AsyncSession, + project_id: int, +) -> ProjectReviewerListResponse: + """构建作品评审员列表响应""" + assignments = await get_project_review_assignments(db, project_id) + items = [build_project_reviewer_item(a) for a in assignments] + return ProjectReviewerListResponse(items=items, total=len(items)) + + +async def ensure_submission_rate_limit( + db: AsyncSession, + project_id: int, + user_id: int, + now: datetime, +) -> None: + """检查提交限频""" + cooldown_seconds = settings.PROJECT_SUBMISSION_COOLDOWN_SECONDS + daily_limit = settings.PROJECT_SUBMISSION_DAILY_LIMIT + + last_result = await db.execute( + select(ProjectSubmission) + .where( + ProjectSubmission.project_id == project_id, + ProjectSubmission.user_id == user_id, + ) + .order_by(ProjectSubmission.submitted_at.desc(), ProjectSubmission.id.desc()) + .limit(1) + ) + last_submission = last_result.scalar_one_or_none() + if last_submission and last_submission.submitted_at: + delta = (now - last_submission.submitted_at).total_seconds() + if delta < cooldown_seconds: + retry_after = int(cooldown_seconds - delta) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"提交过于频繁,请在 {retry_after} 秒后重试" + ) + + day_start = datetime.combine(now.date(), time.min) + count_result = await db.execute( + select(func.count(ProjectSubmission.id)) + .where( + ProjectSubmission.project_id == project_id, + ProjectSubmission.user_id == user_id, + ProjectSubmission.submitted_at >= day_start, + ) + ) + daily_count = count_result.scalar() or 0 + if daily_count >= daily_limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="今日提交次数已达上限" + ) + + +@router.post( + "/projects", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, + summary="创建作品", +) +async def create_project( + payload: ProjectCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """创建作品""" + contest = await get_contest_or_404(db, payload.contest_id) + check_project_phase(contest) + + if payload.repo_url: + await ensure_public_repo_url(payload.repo_url) + + existing_result = await db.execute( + select(Project).where( + Project.contest_id == payload.contest_id, + Project.user_id == current_user.id, + ) + ) + existing = existing_result.scalar_one_or_none() + if existing is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="该比赛已存在作品,请使用更新接口" + ) + + cover_image_url = ensure_local_media_url(payload.cover_image_url, "封面图") + screenshot_urls = ensure_local_media_urls(payload.screenshot_urls, "截图") + project = Project( + contest_id=payload.contest_id, + user_id=current_user.id, + title=payload.title, + summary=payload.summary, + description=payload.description, + repo_url=payload.repo_url, + cover_image_url=cover_image_url, + screenshot_urls=screenshot_urls, + readme_url=payload.readme_url, + demo_url=payload.demo_url, + status=ProjectStatus.DRAFT.value, + ) + db.add(project) + await db.commit() + await db.refresh(project) + return build_project_response(project) + + +@router.get( + "/projects", + response_model=ProjectListResponse, + summary="获取作品列表", +) +async def list_projects( + contest_id: Optional[int] = Query(None, description="比赛ID(可选)"), + mine: bool = Query(False, description="仅查看我的作品"), + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取作品列表""" + query = select(Project).options(selectinload(Project.user)) + + if contest_id is not None: + query = query.where(Project.contest_id == contest_id) + + if mine: + if current_user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录") + query = query.where(Project.user_id == current_user.id) + else: + is_admin = current_user is not None and current_user.is_admin + if not is_admin: + query = query.join( + Registration, + (Registration.contest_id == Project.contest_id) + & (Registration.user_id == Project.user_id), + ) + query = query.where(Registration.status != RegistrationStatus.WITHDRAWN.value) + query = query.where(Project.status == ProjectStatus.ONLINE.value) + + query = query.order_by(Project.id.desc()) + result = await db.execute(query) + projects = result.scalars().all() + + project_ids = [project.id for project in projects] + interaction_map = await build_project_interaction_map( + db, + project_ids, + current_user.id if current_user else None, + ) + items = [build_project_response(p, interaction_map.get(p.id)) for p in projects] + return ProjectListResponse(items=items, total=len(items)) + + +@router.get( + "/projects/{project_id}", + response_model=ProjectResponse, + summary="获取作品详情", +) +async def get_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取作品详情""" + project = await get_project_or_404(db, project_id) + + if project.status != ProjectStatus.ONLINE.value: + ensure_owner_or_admin(project, current_user) + + interaction_map = await build_project_interaction_map( + db, + [project.id], + current_user.id if current_user else None, + ) + return build_project_response(project, interaction_map.get(project.id)) + + +@router.post( + "/projects/{project_id}/like", + response_model=ProjectInteractionResponse, + summary="点赞作品", +) +@limiter.limit(RateLimits.INTERACTION) +async def like_project( + request: Request, + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """点赞作品""" + project = await get_project_or_404(db, project_id) + if project.status != ProjectStatus.ONLINE.value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作品未上线,暂不支持点赞") + if project.user_id == current_user.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能给自己的作品点赞") + + existing_result = await db.execute( + select(ProjectLike).where( + ProjectLike.project_id == project_id, + ProjectLike.user_id == current_user.id, + ) + ) + if existing_result.scalar_one_or_none(): + return await build_project_interaction_response(db, project_id, current_user.id) + + db.add(ProjectLike(project_id=project_id, user_id=current_user.id)) + try: + await db.commit() + except IntegrityError: + await db.rollback() + + return await build_project_interaction_response(db, project_id, current_user.id) + + +@router.delete( + "/projects/{project_id}/like", + response_model=ProjectInteractionResponse, + summary="取消点赞", +) +@limiter.limit(RateLimits.INTERACTION) +async def unlike_project( + request: Request, + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """取消点赞""" + project = await get_project_or_404(db, project_id) + if project.status != ProjectStatus.ONLINE.value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作品未上线,暂不支持点赞") + + result = await db.execute( + select(ProjectLike).where( + ProjectLike.project_id == project_id, + ProjectLike.user_id == current_user.id, + ) + ) + record = result.scalar_one_or_none() + if record: + await db.delete(record) + await db.commit() + + return await build_project_interaction_response(db, project_id, current_user.id) + + +@router.post( + "/projects/{project_id}/favorite", + response_model=ProjectInteractionResponse, + summary="收藏作品", +) +@limiter.limit(RateLimits.INTERACTION) +async def favorite_project( + request: Request, + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """收藏作品""" + project = await get_project_or_404(db, project_id) + if project.status != ProjectStatus.ONLINE.value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作品未上线,暂不支持收藏") + + existing_result = await db.execute( + select(ProjectFavorite).where( + ProjectFavorite.project_id == project_id, + ProjectFavorite.user_id == current_user.id, + ) + ) + if existing_result.scalar_one_or_none(): + return await build_project_interaction_response(db, project_id, current_user.id) + + db.add(ProjectFavorite(project_id=project_id, user_id=current_user.id)) + try: + await db.commit() + except IntegrityError: + await db.rollback() + + return await build_project_interaction_response(db, project_id, current_user.id) + + +@router.delete( + "/projects/{project_id}/favorite", + response_model=ProjectInteractionResponse, + summary="取消收藏", +) +@limiter.limit(RateLimits.INTERACTION) +async def unfavorite_project( + request: Request, + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """取消收藏""" + project = await get_project_or_404(db, project_id) + if project.status != ProjectStatus.ONLINE.value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作品未上线,暂不支持收藏") + + result = await db.execute( + select(ProjectFavorite).where( + ProjectFavorite.project_id == project_id, + ProjectFavorite.user_id == current_user.id, + ) + ) + record = result.scalar_one_or_none() + if record: + await db.delete(record) + await db.commit() + + return await build_project_interaction_response(db, project_id, current_user.id) + + +@router.get( + "/projects/{project_id}/access", + response_model=ProjectAccessResponse, + summary="获取作品访问入口", +) +async def get_project_access( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取作品访问入口信息""" + project = await get_project_or_404(db, project_id) + + if project.status != ProjectStatus.ONLINE.value: + ensure_owner_or_admin(project, current_user) + + submission_id = project.current_submission_id + domain = None + if submission_id: + result = await db.execute( + select(ProjectSubmission).where(ProjectSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission and submission.domain: + domain = submission.domain + else: + domain = build_project_domain(submission_id) + + message = "作品已上线" if project.status == ProjectStatus.ONLINE.value else "作品未上线" + return ProjectAccessResponse( + project_id=project.id, + status=project.status_enum, + submission_id=submission_id, + domain=domain, + message=message, + ) + + +@router.patch( + "/projects/{project_id}", + response_model=ProjectResponse, + summary="更新作品", +) +async def update_project( + project_id: int, + payload: ProjectUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """更新作品信息""" + project = await get_project_or_404(db, project_id) + ensure_owner(project, current_user) + + update_data = payload.model_dump(exclude_unset=True) + if "repo_url" in update_data and update_data["repo_url"]: + await ensure_public_repo_url(update_data["repo_url"]) + if "cover_image_url" in update_data: + update_data["cover_image_url"] = ensure_local_media_url(update_data.get("cover_image_url"), "封面图") + if "screenshot_urls" in update_data: + update_data["screenshot_urls"] = ensure_local_media_urls(update_data.get("screenshot_urls"), "截图") + for field, value in update_data.items(): + setattr(project, field, value) + + await db.commit() + await db.refresh(project) + return build_project_response(project) + + +@router.post( + "/projects/{project_id}/cover", + response_model=ProjectResponse, + summary="上传作品封面", +) +@limiter.limit(RateLimits.UPLOAD) +async def upload_project_cover( + request: Request, + project_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """上传作品封面""" + project = await get_project_or_404(db, project_id) + ensure_owner_or_admin(project, current_user) + + await guard_challenge(request, scope="upload", user_id=current_user.id) + media = await save_upload_file(file, "project-covers", MAX_COVER_BYTES, owner_id=current_user.id) + old_url = project.cover_image_url + project.cover_image_url = media.url + await db.commit() + await db.refresh(project) + + delete_media_file(old_url) + return build_project_response(project) + + +@router.delete( + "/projects/{project_id}/cover", + response_model=ProjectResponse, + summary="删除作品封面", +) +async def delete_project_cover( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """删除作品封面""" + project = await get_project_or_404(db, project_id) + ensure_owner_or_admin(project, current_user) + + old_url = project.cover_image_url + project.cover_image_url = None + await db.commit() + await db.refresh(project) + + delete_media_file(old_url) + return build_project_response(project) + + +@router.post( + "/projects/{project_id}/screenshots", + response_model=ProjectResponse, + summary="上传作品截图", +) +@limiter.limit(RateLimits.UPLOAD) +async def upload_project_screenshot( + request: Request, + project_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """上传作品截图""" + project = await get_project_or_404(db, project_id) + ensure_owner_or_admin(project, current_user) + + await guard_challenge(request, scope="upload", user_id=current_user.id) + media = await save_upload_file(file, "project-screenshots", MAX_SCREENSHOT_BYTES, owner_id=current_user.id) + urls = list(project.screenshot_urls or []) + urls.append(media.url) + project.screenshot_urls = urls + await db.commit() + await db.refresh(project) + return build_project_response(project) + + +@router.delete( + "/projects/{project_id}/screenshots", + response_model=ProjectResponse, + summary="删除作品截图", +) +async def delete_project_screenshot( + project_id: int, + url: str = Query(..., description="截图URL"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """删除作品截图""" + project = await get_project_or_404(db, project_id) + ensure_owner_or_admin(project, current_user) + ensure_local_media_url(url, "截图") + + urls = list(project.screenshot_urls or []) + if url not in urls: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="截图不存在") + project.screenshot_urls = [item for item in urls if item != url] or None + await db.commit() + await db.refresh(project) + + delete_media_file(url) + return build_project_response(project) + + +@router.post( + "/projects/{project_id}/submissions", + response_model=ProjectSubmissionResponse, + status_code=status.HTTP_201_CREATED, + summary="提交镜像", +) +async def create_project_submission( + project_id: int, + payload: ProjectSubmissionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """提交镜像""" + project = await get_project_or_404(db, project_id) + ensure_owner(project, current_user) + + contest = await get_contest_or_404(db, project.contest_id) + check_submission_phase(contest) + await require_approved_registration( + db, + project.contest_id, + current_user.id, + message="报名未审核通过,无法提交部署", + ) + + registry, repo, digest = parse_image_ref(payload.image_ref) + if not registry or registry not in ALLOWED_REGISTRIES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="镜像仓库仅支持 ghcr.io 或 docker.io") + if repo and repo.lower().endswith(":latest"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="镜像标签禁止使用 :latest") + if not digest or not digest.startswith("sha256:") or len(digest) != 71: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="镜像 digest 格式不正确") + + now = datetime.utcnow() + await ensure_submission_rate_limit(db, project_id, current_user.id, now) + + if payload.repo_url: + await ensure_public_repo_url(payload.repo_url) + project.repo_url = payload.repo_url + + submission = ProjectSubmission( + project_id=project.id, + contest_id=project.contest_id, + user_id=current_user.id, + image_ref=payload.image_ref, + image_registry=registry, + image_repo=repo, + image_digest=digest, + status=ProjectSubmissionStatus.CREATED.value, + submitted_at=now, + status_history=[{ + "status": ProjectSubmissionStatus.CREATED.value, + "timestamp": now.isoformat(), + "message": "已创建提交", + }], + ) + db.add(submission) + await db.flush() + + queued = False + redis_client = None + try: + redis_client = await get_redis() + await redis_client.rpush(settings.WORKER_QUEUE_KEY, str(submission.id)) + queued = True + except Exception as exc: + logger.warning("提交入队失败: submission_id=%s, error=%s", submission.id, exc) + finally: + await close_redis(redis_client) + + if queued: + submission.status = ProjectSubmissionStatus.QUEUED.value + submission.status_message = "已进入队列" + append_status_history( + submission=submission, + status_value=ProjectSubmissionStatus.QUEUED.value, + now=now, + message=submission.status_message, + ) + + if project.status in {ProjectStatus.DRAFT.value, ProjectStatus.OFFLINE.value}: + project.status = ProjectStatus.SUBMITTED.value + + await db.commit() + await db.refresh(submission) + return ProjectSubmissionResponse.model_validate(submission) + + +@router.get( + "/projects/{project_id}/submissions", + response_model=ProjectSubmissionListResponse, + summary="获取提交记录列表", +) +async def list_project_submissions( + project_id: int, + status_filter: Optional[str] = Query(None, description="按状态过滤"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取提交记录列表""" + project = await get_project_or_404(db, project_id) + ensure_owner_or_admin(project, current_user) + + query = select(ProjectSubmission).where(ProjectSubmission.project_id == project_id) + if status_filter: + query = query.where(ProjectSubmission.status == status_filter) + query = query.order_by(ProjectSubmission.id.desc()) + + result = await db.execute(query) + items = result.scalars().all() + return ProjectSubmissionListResponse( + items=[ProjectSubmissionResponse.model_validate(x) for x in items], + total=len(items), + ) + + +@router.get( + "/projects/{project_id}/submissions/current", + response_model=ProjectSubmissionResponse, + summary="获取当前线上版本", +) +async def get_current_project_submission( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Optional[User] = Depends(get_optional_user), +): + """获取当前线上版本""" + project = await get_project_or_404(db, project_id) + if project.status != ProjectStatus.ONLINE.value: + ensure_owner_or_admin(project, current_user) + + if not project.current_submission_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="暂无线上版本") + + result = await db.execute( + select(ProjectSubmission).where(ProjectSubmission.id == project.current_submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线上版本不存在") + return ProjectSubmissionResponse.model_validate(submission) + + +@router.get( + "/project-submissions/{submission_id}", + response_model=ProjectSubmissionResponse, + summary="获取提交详情", +) +async def get_project_submission( + submission_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取提交详情""" + result = await db.execute( + select(ProjectSubmission) + .options(selectinload(ProjectSubmission.project)) + .where(ProjectSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提交记录不存在") + + if submission.project: + ensure_owner_or_admin(submission.project, current_user) + + return ProjectSubmissionResponse.model_validate(submission) + + +@router.patch( + "/project-submissions/{submission_id}/status", + response_model=ProjectSubmissionResponse, + summary="更新提交状态(Worker)", +) +async def update_project_submission_status( + submission_id: int, + payload: ProjectSubmissionStatusUpdate, + db: AsyncSession = Depends(get_db), + worker_token: Optional[str] = Header(None, alias="X-Worker-Token"), +): + """状态回写接口""" + if not settings.WORKER_API_TOKEN or worker_token != settings.WORKER_API_TOKEN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无效的 Worker Token") + + result = await db.execute( + select(ProjectSubmission) + .options(selectinload(ProjectSubmission.project)) + .where(ProjectSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提交记录不存在") + + now = datetime.utcnow() + submission.status = payload.status.value + submission.status_message = payload.status_message + submission.error_code = payload.error_code + if payload.domain: + submission.domain = payload.domain + + if payload.log_append: + if submission.log: + submission.log += f"\n{payload.log_append}" + else: + submission.log = payload.log_append + + append_status_history( + submission=submission, + status_value=payload.status.value, + now=now, + message=payload.status_message, + error_code=payload.error_code, + ) + + if payload.status == ProjectSubmissionStatus.ONLINE: + submission.online_at = now + if submission.project: + submission.project.current_submission_id = submission.id + submission.project.status = ProjectStatus.ONLINE.value + elif payload.status == ProjectSubmissionStatus.FAILED: + submission.failed_at = now + if submission.project and submission.project.current_submission_id == submission.id: + submission.project.status = ProjectStatus.OFFLINE.value + elif payload.status == ProjectSubmissionStatus.STOPPED: + submission.failed_at = None + if submission.project and submission.project.current_submission_id == submission.id: + submission.project.status = ProjectStatus.OFFLINE.value + + await db.commit() + await db.refresh(submission) + return ProjectSubmissionResponse.model_validate(submission) + + +@router.get( + "/admin/projects/{project_id}/reviewers", + response_model=ProjectReviewerListResponse, + summary="管理员获取作品评审员", +) +async def admin_list_project_reviewers( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员获取作品评审员""" + require_admin(current_user) + await get_project_or_404(db, project_id) + return await build_project_reviewer_list_response(db, project_id) + + +@router.post( + "/admin/projects/{project_id}/reviewers", + response_model=ProjectReviewerListResponse, + summary="管理员分配评委", +) +async def admin_assign_project_reviewers( + project_id: int, + payload: ProjectReviewAssignRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员分配评委""" + require_admin(current_user) + project = await get_project_or_404(db, project_id) + + reviewer_ids = list(dict.fromkeys(payload.reviewer_ids)) + if not reviewer_ids: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="评审员列表不能为空") + + if project.user_id in reviewer_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="作品作者不能被分配为评审员", + ) + + user_result = await db.execute(select(User).where(User.id.in_(reviewer_ids))) + users = user_result.scalars().all() + user_map = {user.id: user for user in users} + missing_ids = [reviewer_id for reviewer_id in reviewer_ids if reviewer_id not in user_map] + if missing_ids: + missing_text = ", ".join(str(item) for item in missing_ids) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"评审员不存在: {missing_text}") + + invalid_role_ids = [user.id for user in users if not user.is_reviewer] + if invalid_role_ids: + invalid_text = ", ".join(str(item) for item in invalid_role_ids) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"用户不是评审员: {invalid_text}", + ) + + registration_result = await db.execute( + select(Registration.user_id) + .where( + Registration.contest_id == project.contest_id, + Registration.user_id.in_(reviewer_ids), + Registration.status != RegistrationStatus.WITHDRAWN.value, + ) + ) + conflict_ids = registration_result.scalars().all() + if conflict_ids: + conflict_text = ", ".join(str(item) for item in conflict_ids) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"已报名参赛,不能分配为评审员: {conflict_text}", + ) + + existing_result = await db.execute( + select(ProjectReviewAssignment.reviewer_id).where( + ProjectReviewAssignment.project_id == project_id, + ProjectReviewAssignment.reviewer_id.in_(reviewer_ids), + ) + ) + existing_ids = set(existing_result.scalars().all()) + for reviewer_id in reviewer_ids: + if reviewer_id in existing_ids: + continue + db.add(ProjectReviewAssignment(project_id=project_id, reviewer_id=reviewer_id)) + + await db.commit() + return await build_project_reviewer_list_response(db, project_id) + + +@router.delete( + "/admin/projects/{project_id}/reviewers/{reviewer_id}", + response_model=ProjectReviewerListResponse, + summary="管理员移除评委", +) +async def admin_remove_project_reviewer( + project_id: int, + reviewer_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员移除评委""" + require_admin(current_user) + await get_project_or_404(db, project_id) + + result = await db.execute( + select(ProjectReviewAssignment).where( + ProjectReviewAssignment.project_id == project_id, + ProjectReviewAssignment.reviewer_id == reviewer_id, + ) + ) + assignment = result.scalar_one_or_none() + if assignment is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="评审分配不存在") + + await db.delete(assignment) + await db.commit() + return await build_project_reviewer_list_response(db, project_id) + + +@router.post( + "/admin/projects/{project_id}/offline", + response_model=ProjectResponse, + summary="管理员下架作品", +) +async def admin_offline_project( + project_id: int, + payload: ProjectAdminActionRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员下架作品""" + require_admin(current_user) + project = await get_project_or_404(db, project_id) + + now = datetime.utcnow() + if project.current_submission_id: + result = await db.execute( + select(ProjectSubmission).where(ProjectSubmission.id == project.current_submission_id) + ) + submission = result.scalar_one_or_none() + if submission: + await enqueue_worker_action("stop", submission.id) + submission.status = ProjectSubmissionStatus.STOPPED.value + submission.status_message = payload.message or "管理员下架" + submission.error_code = "admin_offline" + submission.failed_at = None + append_status_history( + submission=submission, + status_value=ProjectSubmissionStatus.STOPPED.value, + now=now, + message=submission.status_message, + error_code=submission.error_code, + ) + + project.status = ProjectStatus.OFFLINE.value + await db.commit() + await db.refresh(project) + return build_project_response(project) + + +@router.post( + "/admin/project-submissions/{submission_id}/redeploy", + response_model=ProjectSubmissionResponse, + summary="管理员触发重部署", +) +async def admin_redeploy_submission( + submission_id: int, + payload: ProjectAdminActionRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员触发重部署""" + require_admin(current_user) + + result = await db.execute( + select(ProjectSubmission) + .options(selectinload(ProjectSubmission.project)) + .where(ProjectSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提交记录不存在") + + await enqueue_worker_action("deploy", submission.id) + + now = datetime.utcnow() + submission.status = ProjectSubmissionStatus.QUEUED.value + submission.status_message = payload.message or "管理员触发重部署" + submission.error_code = None + submission.failed_at = None + submission.online_at = None + append_status_history( + submission=submission, + status_value=ProjectSubmissionStatus.QUEUED.value, + now=now, + message=submission.status_message, + ) + + if submission.project: + submission.project.status = ProjectStatus.SUBMITTED.value + + await db.commit() + await db.refresh(submission) + return ProjectSubmissionResponse.model_validate(submission) + + +@router.post( + "/admin/project-submissions/{submission_id}/stop", + response_model=ProjectSubmissionResponse, + summary="管理员强制停止运行", +) +async def admin_stop_submission( + submission_id: int, + payload: ProjectAdminActionRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员强制停止运行""" + require_admin(current_user) + + result = await db.execute( + select(ProjectSubmission) + .options(selectinload(ProjectSubmission.project)) + .where(ProjectSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提交记录不存在") + + await enqueue_worker_action("stop", submission.id) + + now = datetime.utcnow() + submission.status = ProjectSubmissionStatus.STOPPED.value + submission.status_message = payload.message or "管理员强制停止运行" + submission.error_code = "admin_stop" + submission.failed_at = None + append_status_history( + submission=submission, + status_value=ProjectSubmissionStatus.STOPPED.value, + now=now, + message=submission.status_message, + error_code=submission.error_code, + ) + + if submission.project and submission.project.current_submission_id == submission.id: + submission.project.status = ProjectStatus.OFFLINE.value + + await db.commit() + await db.refresh(submission) + return ProjectSubmissionResponse.model_validate(submission) + + +@router.get( + "/admin/project-submissions/{submission_id}/logs", + summary="管理员查看提交日志", +) +async def admin_get_submission_logs( + submission_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """管理员查看提交日志""" + require_admin(current_user) + + result = await db.execute( + select(ProjectSubmission).where(ProjectSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提交记录不存在") + + return { + "id": submission.id, + "status": submission.status, + "status_message": submission.status_message, + "error_code": submission.error_code, + "log": submission.log or "", + "updated_at": submission.updated_at, + } diff --git a/backend/app/api/v1/endpoints/project_review_center.py b/backend/app/api/v1/endpoints/project_review_center.py new file mode 100644 index 0000000..01c67a9 --- /dev/null +++ b/backend/app/api/v1/endpoints/project_review_center.py @@ -0,0 +1,435 @@ +""" +作品评审中心 API +""" +import logging +from decimal import Decimal +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.dependencies import get_current_user +from app.core.database import get_db +from app.models.project import Project +from app.models.project_review import ProjectReview +from app.models.project_review_assignment import ProjectReviewAssignment +from app.models.project_submission import ProjectSubmission +from app.models.user import User +from app.schemas.project import ProjectReviewItem, ProjectReviewListResponse +from app.schemas.review_center import ( + MyReviewResponse, + ReviewScoreRequest, + ReviewStatsResponse, + ReviewerStatsResponse, +) +from app.schemas.submission import UserBrief +from app.services.project_domain import build_project_domain + +router = APIRouter() +logger = logging.getLogger(__name__) + + +async def require_reviewer( + current_user: User = Depends(get_current_user), +) -> User: + """要求评审员权限""" + if not current_user.is_reviewer and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要评审员权限", + ) + return current_user + + +def build_empty_stats() -> ReviewStatsResponse: + """构建空的评分统计""" + return ReviewStatsResponse( + review_count=0, + final_score=None, + avg_score=None, + min_score=None, + max_score=None, + ) + + +def build_review_stats( + review_count: int, + total_score: Optional[int], + avg_score: Optional[float], + min_score: Optional[int], + max_score: Optional[int], +) -> ReviewStatsResponse: + """构建评分统计""" + if review_count == 0: + return build_empty_stats() + + avg_value = Decimal(str(avg_score)).quantize(Decimal("0.01")) if avg_score is not None else None + if review_count >= 3 and total_score is not None and min_score is not None and max_score is not None: + final_score = Decimal(str(total_score - max_score - min_score)) / Decimal(review_count - 2) + final_score = final_score.quantize(Decimal("0.01")) + else: + final_score = avg_value + + return ReviewStatsResponse( + review_count=review_count, + final_score=final_score, + avg_score=avg_value, + min_score=min_score, + max_score=max_score, + ) + + +@router.get( + "/stats", + response_model=ReviewerStatsResponse, + summary="评审员统计", +) +async def get_reviewer_stats( + db: AsyncSession = Depends(get_db), + reviewer: User = Depends(require_reviewer), +): + """获取评审员统计""" + total_result = await db.execute( + select(func.count(ProjectReviewAssignment.id)).where( + ProjectReviewAssignment.reviewer_id == reviewer.id + ) + ) + total = total_result.scalar() or 0 + + review_result = await db.execute( + select( + func.count(ProjectReview.id).label("count"), + func.avg(ProjectReview.score).label("avg"), + ).where(ProjectReview.reviewer_id == reviewer.id) + ) + row = review_result.one() + reviewed_count = row.count or 0 + avg_score = Decimal(str(row.avg)).quantize(Decimal("0.01")) if row.avg is not None else None + + return ReviewerStatsResponse( + total_submissions=total, + reviewed_count=reviewed_count, + pending_count=max(total - reviewed_count, 0), + avg_score_given=avg_score, + ) + + +async def get_review_stats_map( + db: AsyncSession, + project_ids: list[int], +) -> dict[int, ReviewStatsResponse]: + """批量获取作品评分统计""" + if not project_ids: + return {} + + result = await db.execute( + select( + ProjectReview.project_id.label("project_id"), + func.count(ProjectReview.id).label("count"), + func.sum(ProjectReview.score).label("sum"), + func.avg(ProjectReview.score).label("avg"), + func.min(ProjectReview.score).label("min"), + func.max(ProjectReview.score).label("max"), + ) + .where(ProjectReview.project_id.in_(project_ids)) + .group_by(ProjectReview.project_id) + ) + + stats_map: dict[int, ReviewStatsResponse] = {} + for row in result: + stats_map[row.project_id] = build_review_stats( + review_count=row.count or 0, + total_score=row.sum, + avg_score=row.avg, + min_score=row.min, + max_score=row.max, + ) + return stats_map + + +async def get_my_review_map( + db: AsyncSession, + reviewer_id: int, + project_ids: list[int], +) -> dict[int, MyReviewResponse]: + """批量获取我的评分""" + if not project_ids: + return {} + + result = await db.execute( + select(ProjectReview).where( + ProjectReview.project_id.in_(project_ids), + ProjectReview.reviewer_id == reviewer_id, + ) + ) + reviews = result.scalars().all() + return {review.project_id: MyReviewResponse.model_validate(review) for review in reviews} + + +async def get_assignment_or_403( + db: AsyncSession, + project_id: int, + reviewer_id: int, +) -> ProjectReviewAssignment: + """确保评审员已被分配""" + result = await db.execute( + select(ProjectReviewAssignment) + .options( + selectinload(ProjectReviewAssignment.project).selectinload(Project.user) + ) + .where( + ProjectReviewAssignment.project_id == project_id, + ProjectReviewAssignment.reviewer_id == reviewer_id, + ) + ) + assignment = result.scalar_one_or_none() + if assignment is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="未分配该作品评审权限", + ) + return assignment + + +async def resolve_submission_domains( + db: AsyncSession, + projects: list[Project], +) -> dict[int, Optional[str]]: + """批量解析作品访问域名""" + submission_ids = [ + project.current_submission_id + for project in projects + if project.current_submission_id + ] + if not submission_ids: + return {} + + result = await db.execute( + select(ProjectSubmission).where(ProjectSubmission.id.in_(submission_ids)) + ) + submissions = result.scalars().all() + submission_map = {submission.id: submission for submission in submissions} + + domain_map: dict[int, Optional[str]] = {} + for project in projects: + submission_id = project.current_submission_id + if not submission_id: + domain_map[project.id] = None + continue + submission = submission_map.get(submission_id) + if submission and submission.domain: + domain_map[project.id] = submission.domain + else: + domain_map[project.id] = build_project_domain(submission_id) + return domain_map + + +def build_project_review_item( + project: Project, + domain: Optional[str], + my_review: Optional[MyReviewResponse], + stats: ReviewStatsResponse, +) -> ProjectReviewItem: + """构建评审中心作品信息""" + owner = UserBrief.model_validate(project.user) if project.user else None + return ProjectReviewItem( + id=project.id, + title=project.title, + summary=project.summary, + description=project.description, + repo_url=project.repo_url, + demo_url=project.demo_url, + readme_url=project.readme_url, + status=project.status_enum, + current_submission_id=project.current_submission_id, + domain=domain, + created_at=project.created_at, + owner=owner, + my_review=my_review, + stats=stats, + ) + + +async def build_project_review_detail( + db: AsyncSession, + reviewer: User, + project: Project, +) -> ProjectReviewItem: + """构建评审中心作品详情""" + stats_map = await get_review_stats_map(db, [project.id]) + my_review_map = await get_my_review_map(db, reviewer.id, [project.id]) + domain_map = await resolve_submission_domains(db, [project]) + return build_project_review_item( + project=project, + domain=domain_map.get(project.id), + my_review=my_review_map.get(project.id), + stats=stats_map.get(project.id, build_empty_stats()), + ) + + +@router.get( + "/projects", + response_model=ProjectReviewListResponse, + summary="评审中心作品列表", +) +async def list_projects_for_review( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + scored: Optional[str] = Query(None, description="评分状态过滤: yes/no"), + db: AsyncSession = Depends(get_db), + reviewer: User = Depends(require_reviewer), +): + """获取评审员待评作品列表""" + filters = [ProjectReviewAssignment.reviewer_id == reviewer.id] + if scored: + if scored not in {"yes", "no"}: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="评分状态仅支持 yes/no", + ) + reviewed_subquery = select(ProjectReview.project_id).where( + ProjectReview.reviewer_id == reviewer.id + ) + if scored == "yes": + filters.append(ProjectReviewAssignment.project_id.in_(reviewed_subquery)) + else: + filters.append(ProjectReviewAssignment.project_id.notin_(reviewed_subquery)) + + total_result = await db.execute( + select(func.count(ProjectReviewAssignment.id)).where( + *filters + ) + ) + total = total_result.scalar() or 0 + offset = (page - 1) * page_size + + assignment_result = await db.execute( + select(ProjectReviewAssignment) + .options( + selectinload(ProjectReviewAssignment.project).selectinload(Project.user) + ) + .where(*filters) + .order_by(ProjectReviewAssignment.id.desc()) + .offset(offset) + .limit(page_size) + ) + assignments = assignment_result.scalars().all() + projects = [assignment.project for assignment in assignments if assignment.project] + if not projects: + return ProjectReviewListResponse(items=[], total=total, page=page, page_size=page_size) + + project_ids = [project.id for project in projects] + stats_map = await get_review_stats_map(db, project_ids) + my_review_map = await get_my_review_map(db, reviewer.id, project_ids) + domain_map = await resolve_submission_domains(db, projects) + + items = [] + for project in projects: + items.append( + build_project_review_item( + project=project, + domain=domain_map.get(project.id), + my_review=my_review_map.get(project.id), + stats=stats_map.get(project.id, build_empty_stats()), + ) + ) + + return ProjectReviewListResponse( + items=items, + total=total, + page=page, + page_size=page_size, + ) + + +@router.get( + "/projects/{project_id}", + response_model=ProjectReviewItem, + summary="评审中心作品详情", +) +async def get_project_for_review( + project_id: int, + db: AsyncSession = Depends(get_db), + reviewer: User = Depends(require_reviewer), +): + """获取评审中心作品详情""" + assignment = await get_assignment_or_403(db, project_id, reviewer.id) + if assignment.project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品不存在") + return await build_project_review_detail(db, reviewer, assignment.project) + + +@router.post( + "/projects/{project_id}/score", + response_model=ProjectReviewItem, + summary="提交作品评分", +) +async def submit_project_review( + project_id: int, + payload: ReviewScoreRequest, + db: AsyncSession = Depends(get_db), + reviewer: User = Depends(require_reviewer), +): + """提交或更新作品评分""" + assignment = await get_assignment_or_403(db, project_id, reviewer.id) + if assignment.project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品不存在") + + result = await db.execute( + select(ProjectReview).where( + ProjectReview.project_id == project_id, + ProjectReview.reviewer_id == reviewer.id, + ) + ) + existing_review = result.scalar_one_or_none() + if existing_review: + existing_review.score = payload.score + existing_review.comment = payload.comment + logger.info("评审员 %s 更新作品 #%s 评分: %s", reviewer.username, project_id, payload.score) + else: + db.add( + ProjectReview( + project_id=project_id, + reviewer_id=reviewer.id, + score=payload.score, + comment=payload.comment, + ) + ) + logger.info("评审员 %s 对作品 #%s 评分: %s", reviewer.username, project_id, payload.score) + + await db.commit() + await db.refresh(assignment.project) + return await build_project_review_detail(db, reviewer, assignment.project) + + +@router.delete( + "/projects/{project_id}/score", + response_model=ProjectReviewItem, + summary="删除作品评分", +) +async def delete_project_review( + project_id: int, + db: AsyncSession = Depends(get_db), + reviewer: User = Depends(require_reviewer), +): + """删除作品评分""" + assignment = await get_assignment_or_403(db, project_id, reviewer.id) + if assignment.project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品不存在") + + result = await db.execute( + select(ProjectReview).where( + ProjectReview.project_id == project_id, + ProjectReview.reviewer_id == reviewer.id, + ) + ) + existing_review = result.scalar_one_or_none() + if existing_review is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="评分不存在") + + await db.delete(existing_review) + await db.commit() + await db.refresh(assignment.project) + return await build_project_review_detail(db, reviewer, assignment.project) diff --git a/backend/app/api/v1/endpoints/puzzle.py b/backend/app/api/v1/endpoints/puzzle.py index 7821d22..b1c8adf 100644 --- a/backend/app/api/v1/endpoints/puzzle.py +++ b/backend/app/api/v1/endpoints/puzzle.py @@ -11,7 +11,7 @@ import json from app.core.database import get_db -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user, get_current_user_optional +from app.core.dependencies import get_current_user, get_current_user_optional from app.models.user import User router = APIRouter() diff --git a/backend/app/api/v1/endpoints/registration.py b/backend/app/api/v1/endpoints/registration.py index 5b563f1..44594df 100644 --- a/backend/app/api/v1/endpoints/registration.py +++ b/backend/app/api/v1/endpoints/registration.py @@ -11,15 +11,17 @@ from datetime import datetime from typing import Optional -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.dependencies import get_current_user, get_optional_user from app.core.database import get_db -from app.core.security import decode_token from app.models.contest import Contest, ContestPhase +from app.models.project import Project, ProjectStatus +from app.models.project_submission import ProjectSubmission, ProjectSubmissionStatus from app.models.registration import Registration, RegistrationStatus from app.models.user import User, UserRole from app.schemas.registration import ( @@ -27,97 +29,12 @@ RegistrationResponse, RegistrationUpdate, ) +from app.services.worker_queue import enqueue_worker_action router = APIRouter() logger = logging.getLogger(__name__) -# ============================================================================ -# 依赖注入 -# ============================================================================ - -async def get_current_user( - authorization: str = Header(None, alias="Authorization"), - db: AsyncSession = Depends(get_db), -) -> User: - """ - JWT 认证依赖:解析 Bearer Token 并返回当前用户 - - Raises: - HTTPException 401: Token 无效或用户不存在 - """ - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录", - headers={"WWW-Authenticate": "Bearer"}, - ) - - token = authorization.split(" ", 1)[1].strip() - payload = decode_token(token) - - if not payload or "sub" not in payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="登录已过期,请重新登录", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - user_id = int(payload["sub"]) - except (TypeError, ValueError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效的认证信息", - ) - - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在", - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="账号已被禁用", - ) - - return user - - -async def get_optional_user( - authorization: Optional[str] = Header(None, alias="Authorization"), - db: AsyncSession = Depends(get_db), -) -> Optional[User]: - """可选的用户认证:未登录时返回 None""" - if not authorization or not authorization.startswith("Bearer "): - return None - - try: - token = authorization.split(" ", 1)[1].strip() - payload = decode_token(token) - - if not payload or "sub" not in payload: - return None - - user_id = int(payload["sub"]) - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if user and user.is_active: - return user - except (TypeError, ValueError) as e: - logger.debug(f"Token 解析失败: {e}") - except Exception as e: - logger.warning(f"可选用户认证异常: {e}") - - return None - - # ============================================================================ # 辅助函数 # ============================================================================ @@ -370,8 +287,8 @@ async def update_my_registration( @router.delete( "/contests/{contest_id}/registrations/me", response_model=RegistrationResponse, - summary="撤回报名", - description="撤回当前登录用户的报名(软删除,状态变为 withdrawn)。", + summary="撤回报名/退赛", + description="报名期内可撤回报名;报名期结束且已审核通过时视为退赛(状态变为 withdrawn)。", ) async def withdraw_my_registration( contest_id: int, @@ -379,7 +296,7 @@ async def withdraw_my_registration( current_user: User = Depends(get_current_user), ): """撤回报名(软删除)""" - await get_contest_or_404(db, contest_id) + contest = await get_contest_or_404(db, contest_id) registration = await get_user_registration(db, contest_id, current_user.id) @@ -395,6 +312,36 @@ async def withdraw_my_registration( detail="报名已撤回" ) + is_retired = ( + contest.phase != ContestPhase.SIGNUP.value + and registration.status == RegistrationStatus.APPROVED.value + ) + status_message = "已退赛,已下线" if is_retired else "报名已撤回,已下线" + + project_result = await db.execute( + select(Project).where( + Project.contest_id == contest_id, + Project.user_id == current_user.id, + ) + ) + project = project_result.scalar_one_or_none() + if project is not None: + if project.current_submission_id: + submission_result = await db.execute( + select(ProjectSubmission).where( + ProjectSubmission.id == project.current_submission_id + ) + ) + submission = submission_result.scalar_one_or_none() + if submission is not None: + await enqueue_worker_action("stop", submission.id) + submission.status = ProjectSubmissionStatus.STOPPED.value + submission.status_message = status_message + submission.error_code = "registration_withdrawn" + submission.failed_at = None + + project.status = ProjectStatus.OFFLINE.value + registration.status = RegistrationStatus.WITHDRAWN.value await db.commit() diff --git a/backend/app/api/v1/endpoints/review_center.py b/backend/app/api/v1/endpoints/review_center.py index 2e7d425..8a070ca 100644 --- a/backend/app/api/v1/endpoints/review_center.py +++ b/backend/app/api/v1/endpoints/review_center.py @@ -40,7 +40,7 @@ ) # 复用 submission.py 中的认证依赖 -from app.api.v1.endpoints.submission import get_current_user +from app.core.dependencies import get_current_user router = APIRouter() logger = logging.getLogger(__name__) diff --git a/backend/app/api/v1/endpoints/slot_machine.py b/backend/app/api/v1/endpoints/slot_machine.py index 2a8ff50..6b94344 100644 --- a/backend/app/api/v1/endpoints/slot_machine.py +++ b/backend/app/api/v1/endpoints/slot_machine.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.api.v1.endpoints.user import get_current_user_dep as get_current_user, get_current_user_optional +from app.core.dependencies import get_current_user, get_current_user_optional from app.models.user import User from app.models.slot_machine import SlotMachineRule, SlotMachineConfig, SlotRuleType from app.services.slot_machine_service import SlotMachineService diff --git a/backend/app/api/v1/endpoints/submission.py b/backend/app/api/v1/endpoints/submission.py index 2859cd1..bb0f9b6 100644 --- a/backend/app/api/v1/endpoints/submission.py +++ b/backend/app/api/v1/endpoints/submission.py @@ -4,19 +4,20 @@ 提供作品提交的完整功能,包括: - CRUD 操作:创建草稿、更新、获取、删除 - 附件上传:init/complete 两步上传模式 -- 材料校验:校验5种必填材料完整性 +- 材料校验:校验必填材料完整性(演示视频可选) - 最终提交:强校验后锁定作品 - 管理审核:通过/拒绝作品 -5种必填材料: +作品材料说明: 1. 项目源码 - GitHub/Gitee 仓库链接(必须 Public) -2. 演示视频 - MP4/AVI,3-5分钟 -3. 项目文档 - Markdown 格式 -4. API调用证明 - 截图 + 日志文件 -5. 参赛报名表 - 关联 registrations 表 +2. 演示视频 - MP4/AVI,3-5分钟(可选) +3. 项目文档 - Markdown 格式(必填) +4. API调用证明 - 截图 + 日志文件(必填) +5. 参赛报名表 - 关联 registrations 表(必填) """ import hashlib import logging +import os import re from datetime import datetime from pathlib import Path @@ -28,8 +29,8 @@ Depends, File, Form, - Header, HTTPException, + Request, Query, UploadFile, status, @@ -39,8 +40,9 @@ from sqlalchemy.orm import selectinload from app.core.config import settings +from app.core.dependencies import get_current_user, get_optional_user from app.core.database import get_db -from app.core.security import decode_token +from app.core.rate_limit import limiter, RateLimits from app.models.contest import Contest, ContestPhase from app.models.registration import Registration, RegistrationStatus from app.models.submission import ( @@ -63,6 +65,8 @@ SubmissionValidateResponse, ValidationError, ) +from app.services.security_challenge import guard_challenge +from app.services.upload_quota import commit_upload_quota, ensure_upload_quota router = APIRouter() logger = logging.getLogger(__name__) @@ -71,8 +75,8 @@ # 配置常量 # ============================================================================ -# 上传根目录 -UPLOAD_ROOT = Path("uploads") / "submissions" +# 上传根目录(默认放在容器内可写目录) +UPLOAD_ROOT = Path(os.getenv("UPLOAD_ROOT", "/app/app/uploads")) / "submissions" # 各类型附件的最大文件大小(字节) MAX_SIZE_BY_TYPE: dict[str, int] = { @@ -121,91 +125,6 @@ VIDEO_DURATION_TOLERANCE = 10 # 容差10秒 -# ============================================================================ -# 认证依赖 -# ============================================================================ - -async def get_current_user( - authorization: str = Header(None, alias="Authorization"), - db: AsyncSession = Depends(get_db), -) -> User: - """ - JWT 认证依赖:解析 Bearer Token 并返回当前用户 - - Raises: - HTTPException 401: Token 无效或用户不存在 - HTTPException 403: 用户已被禁用 - """ - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录", - headers={"WWW-Authenticate": "Bearer"}, - ) - - token = authorization.split(" ", 1)[1].strip() - payload = decode_token(token) - - if not payload or "sub" not in payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="登录已过期,请重新登录", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - user_id = int(payload["sub"]) - except (TypeError, ValueError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效的认证信息", - ) - - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在", - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="账号已被禁用", - ) - - return user - - -async def get_optional_user( - authorization: Optional[str] = Header(None, alias="Authorization"), - db: AsyncSession = Depends(get_db), -) -> Optional[User]: - """可选的用户认证:未登录时返回 None""" - if not authorization or not authorization.startswith("Bearer "): - return None - - try: - token = authorization.split(" ", 1)[1].strip() - payload = decode_token(token) - - if not payload or "sub" not in payload: - return None - - user_id = int(payload["sub"]) - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if user and user.is_active: - return user - except Exception as e: - logger.debug(f"可选认证失败: {e}") - - return None - - async def require_admin_or_reviewer( current_user: User = Depends(get_current_user), ) -> User: @@ -297,6 +216,19 @@ async def require_registration( return registration +async def require_approved_registration( + db: AsyncSession, + contest_id: int, + user_id: int, + message: str = "报名未审核通过,无法提交作品", +) -> Registration: + """要求报名已通过审核""" + registration = await require_registration(db, contest_id, user_id) + if registration.status != RegistrationStatus.APPROVED.value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + return registration + + async def get_submission_or_404( db: AsyncSession, submission_id: int, @@ -358,7 +290,7 @@ async def validate_submission_materials( submission: Submission, ) -> SubmissionValidateResponse: """ - 校验作品提交的5种必填材料 + 校验作品提交的必填材料(演示视频可选) Returns: SubmissionValidateResponse: 校验结果 @@ -403,13 +335,22 @@ async def validate_submission_materials( )) else: doc = submission.project_doc_md.strip() - # 检查必要章节(简单正则) + # 检查必要章节(兼容常见标题与关键词) + def has_doc_section(text: str, keywords: tuple[str, ...]) -> bool: + lower = text.lower() + for keyword in keywords: + keyword_lower = keyword.lower() + pattern = rf"(?m)^\s*#+\s*{re.escape(keyword_lower)}" + if re.search(pattern, lower): + return True + return any(keyword.lower() in lower for keyword in keywords) + required_sections = [ - ("安装", "MISSING_INSTALL_SECTION", "项目文档缺少「安装步骤」章节"), - ("使用", "MISSING_USAGE_SECTION", "项目文档缺少「使用说明」章节"), + (("安装", "部署", "installation", "install"), "MISSING_INSTALL_SECTION", "项目文档缺少「安装步骤」章节"), + (("使用", "操作", "运行", "启动", "usage", "quick start", "quickstart"), "MISSING_USAGE_SECTION", "项目文档缺少「使用说明」章节"), ] - for keyword, code, message in required_sections: - if keyword not in doc: + for keywords, code, message in required_sections: + if not has_doc_section(doc, keywords): errors.append(ValidationError( field="project_doc_md", code=code, @@ -460,15 +401,7 @@ async def validate_submission_materials( "total_count": len(uploaded_attachments), } - # 4.1 演示视频(必须至少1个) - if AttachmentType.DEMO_VIDEO.value not in uploaded_types: - errors.append(ValidationError( - field="demo_video", - code="REQUIRED", - message="演示视频(MP4/AVI,3-5分钟)必传" - )) - - # 4.2 API调用证明截图(必须至少1个) + # 4.1 API调用证明截图(必须至少1张) if AttachmentType.API_SCREENSHOT.value not in uploaded_types: errors.append(ValidationError( field="api_screenshot", @@ -476,7 +409,7 @@ async def validate_submission_materials( message="API调用证明截图必传(至少1张)" )) - # 4.3 API调用证明日志(必须至少1个) + # 4.2 API调用证明日志(必须至少1个) if AttachmentType.API_LOG.value not in uploaded_types: errors.append(ValidationError( field="api_log", @@ -550,6 +483,8 @@ async def list_submissions( elif not is_privileged: # 普通用户只能看已通过的 query = query.where(Submission.status == SubmissionStatus.APPROVED.value) + query = query.join(Registration, Registration.id == Submission.registration_id) + query = query.where(Registration.status != RegistrationStatus.WITHDRAWN.value) # 统计总数(复用相同的过滤条件,但不含分页) count_query = select(func.count(Submission.id)) @@ -576,6 +511,8 @@ async def list_submissions( count_query = count_query.where(Submission.status == status_filter) elif not is_privileged: count_query = count_query.where(Submission.status == SubmissionStatus.APPROVED.value) + count_query = count_query.join(Registration, Registration.id == Submission.registration_id) + count_query = count_query.where(Registration.status != RegistrationStatus.WITHDRAWN.value) count_result = await db.execute(count_query) total = count_result.scalar() or 0 @@ -783,7 +720,9 @@ async def delete_submission( summary="初始化附件上传", description="初始化附件上传,返回上传URL和附件ID。", ) +@limiter.limit(RateLimits.UPLOAD) async def init_attachment_upload( + request: Request, submission_id: int, payload: AttachmentInitRequest, db: AsyncSession = Depends(get_db), @@ -794,6 +733,8 @@ async def init_attachment_upload( ensure_owner(submission, current_user) ensure_editable(submission) + await guard_challenge(request, scope="upload", user_id=current_user.id) + attachment_type = payload.type.value max_size = MAX_SIZE_BY_TYPE.get(attachment_type, 50 * 1024 * 1024) @@ -804,6 +745,8 @@ async def init_attachment_upload( detail=f"文件过大,该类型附件最大允许 {max_size // (1024 * 1024)} MB" ) + await ensure_upload_quota(current_user.id, "submission", payload.size_bytes) + # 检查 MIME 类型 allowed_types = ALLOWED_MIME_TYPES.get(attachment_type, set()) if allowed_types and payload.content_type not in allowed_types: @@ -858,7 +801,9 @@ async def init_attachment_upload( summary="完成附件上传", description="上传文件并完成附件上传。使用 multipart/form-data。", ) +@limiter.limit(RateLimits.UPLOAD) async def complete_attachment_upload( + request: Request, submission_id: int, attachment_id: int, file: UploadFile = File(...), @@ -870,6 +815,8 @@ async def complete_attachment_upload( ensure_owner(submission, current_user) ensure_editable(submission) + await guard_challenge(request, scope="upload", user_id=current_user.id) + # 查找附件 attachment = next( (a for a in (submission.attachments or []) if a.id == attachment_id), @@ -930,6 +877,12 @@ async def complete_attachment_upload( finally: await file.close() + try: + await commit_upload_quota(current_user.id, "submission", total_bytes) + except HTTPException: + dest_path.unlink(missing_ok=True) + raise + # 更新附件记录 attachment.content_type = actual_content_type attachment.size_bytes = total_bytes @@ -1008,7 +961,7 @@ async def delete_attachment( "/{submission_id}/validate", response_model=SubmissionValidateResponse, summary="校验作品材料", - description="校验作品的5种必填材料是否完整。", + description="校验作品的必填材料是否完整(演示视频可选)。", ) async def validate_submission( submission_id: int, @@ -1066,6 +1019,12 @@ async def finalize_submission( submission = await get_submission_or_404(db, submission_id, load_attachments=True) ensure_owner(submission, current_user) ensure_editable(submission) + await require_approved_registration( + db, + submission.contest_id, + current_user.id, + message="报名未审核通过,无法最终提交", + ) # 强制校验 validation = await validate_submission_materials(db, submission) diff --git a/backend/app/api/v1/endpoints/task.py b/backend/app/api/v1/endpoints/task.py index 80e82b9..5601370 100644 --- a/backend/app/api/v1/endpoints/task.py +++ b/backend/app/api/v1/endpoints/task.py @@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.api.v1.endpoints.registration import get_current_user +from app.core.dependencies import get_current_user from app.models.user import User, UserRole from app.models.task import TaskSchedule, TaskType from app.services.task_service import TaskService diff --git a/backend/app/api/v1/endpoints/user.py b/backend/app/api/v1/endpoints/user.py index 42d12b6..e23fefe 100644 --- a/backend/app/api/v1/endpoints/user.py +++ b/backend/app/api/v1/endpoints/user.py @@ -2,17 +2,20 @@ 用户相关 API """ from datetime import datetime -from typing import Literal, Optional +from typing import Literal -from fastapi import APIRouter, Depends, Header, HTTPException, status -from pydantic import BaseModel, ConfigDict +from fastapi import APIRouter, Depends, HTTPException, Request, status, File, UploadFile +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.dependencies import get_current_user_dep, get_current_user_optional from app.core.database import get_db -from app.core.security import decode_token -from app.core.config import settings +from app.core.rate_limit import limiter, RateLimits from app.models.user import User +from app.schemas.user import UserResponse, UserPublicResponse, UserUpdateRequest +from app.services.media_service import AVATAR_MAX_BYTES, delete_media_file, save_upload_file +from app.services.security_challenge import guard_challenge router = APIRouter() @@ -22,122 +25,104 @@ SELECTABLE_ROLES = ["contestant", "spectator"] -class UserResponse(BaseModel): - """用户响应模型""" - model_config = ConfigDict(from_attributes=True) - - id: int - username: str - email: Optional[str] = None - display_name: Optional[str] = None - avatar_url: Optional[str] = None - role: str # admin / reviewer / contestant / spectator - original_role: Optional[str] = None # 用户的原始角色(管理员角色切换用) - role_selected: bool = False # 是否已完成角色选择引导 - role_selected_at: Optional[datetime] = None # 角色选择时间 - is_active: bool - linux_do_id: Optional[str] = None - linux_do_username: Optional[str] = None - trust_level: Optional[int] = None - is_silenced: bool = False - - -async def get_current_user_optional( - authorization: str = Header(None, alias="Authorization"), - db: AsyncSession = Depends(get_db), -) -> Optional[User]: - """可选 JWT 认证依赖,未登录返回 None""" - if not authorization or not authorization.startswith("Bearer "): - return None - - token = authorization.split(" ", 1)[1].strip() - payload = decode_token(token) - - if not payload or "sub" not in payload: - return None - - try: - user_id = int(payload["sub"]) - except (TypeError, ValueError): - return None - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() +@router.get("/me", response_model=UserResponse, summary="获取当前用户信息") +async def get_current_user( + current_user: User = Depends(get_current_user_dep), +): + """获取当前登录用户的完整信息,包含角色""" + return UserResponse.model_validate(current_user) - if user is None or not user.is_active: - return None - return user +@router.post("/me/avatar", response_model=UserResponse, summary="上传头像") +@limiter.limit(RateLimits.UPLOAD) +async def upload_avatar( + request: Request, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """上传头像""" + await guard_challenge(request, scope="upload", user_id=current_user.id) + media = await save_upload_file(file, "avatars", AVATAR_MAX_BYTES, owner_id=current_user.id) + old_url = current_user.avatar_url + current_user.avatar_url = media.url + await db.commit() + await db.refresh(current_user) + delete_media_file(old_url) + return UserResponse.model_validate(current_user) -async def get_current_user_dep( - authorization: str = Header(None, alias="Authorization"), +@router.put("/me", response_model=UserResponse, summary="更新当前用户信息") +async def update_current_user( + payload: UserUpdateRequest, db: AsyncSession = Depends(get_db), -) -> User: - """JWT 认证依赖""" - if not authorization or not authorization.startswith("Bearer "): + current_user: User = Depends(get_current_user_dep), +): + """更新当前用户信息""" + update_data = payload.model_dump(exclude_unset=True) + if not update_data: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录", - headers={"WWW-Authenticate": "Bearer"}, + status_code=status.HTTP_400_BAD_REQUEST, + detail="未提供更新字段", ) - token = authorization.split(" ", 1)[1].strip() - payload = decode_token(token) + if "username" in update_data: + new_username = update_data.get("username") + if new_username and new_username != current_user.username: + result = await db.execute( + select(User).where(User.username == new_username, User.id != current_user.id) + ) + exists = result.scalar_one_or_none() + if exists: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="用户名已被使用", + ) + current_user.username = new_username + + if "email" in update_data: + new_email = update_data.get("email") + if isinstance(new_email, str): + new_email = new_email.strip().lower() + if new_email != current_user.email: + if new_email: + result = await db.execute( + select(User).where(User.email == new_email, User.id != current_user.id) + ) + exists = result.scalar_one_or_none() + if exists: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="邮箱已被使用", + ) + current_user.email = new_email + + if "display_name" in update_data: + display_name = update_data.get("display_name") + if isinstance(display_name, str): + display_name = display_name.strip() + current_user.display_name = display_name or None - if not payload or "sub" not in payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="登录已过期,请重新登录", - headers={"WWW-Authenticate": "Bearer"}, - ) + await db.commit() + await db.refresh(current_user) + return UserResponse.model_validate(current_user) - try: - user_id = int(payload["sub"]) - except (TypeError, ValueError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="无效的认证信息", - ) +@router.get("/{user_id}", response_model=UserPublicResponse, summary="获取指定用户信息") +async def get_user( + user_id: int, + db: AsyncSession = Depends(get_db), +): + """获取指定用户信息""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - - if user is None: + if user is None or not user.is_active: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在", ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="账号已被禁用", - ) - - return user - - -@router.get("/me", response_model=UserResponse, summary="获取当前用户信息") -async def get_current_user( - current_user: User = Depends(get_current_user_dep), -): - """获取当前登录用户的完整信息,包含角色""" - return UserResponse.model_validate(current_user) - - -@router.put("/me") -async def update_current_user(): - """更新当前用户信息""" - # TODO: 实现更新逻辑 - return {"message": "更新用户接口"} - - -@router.get("/{user_id}") -async def get_user(user_id: int): - """获取指定用户信息""" - # TODO: 实现查询逻辑 - return {"message": f"获取用户 {user_id}"} + return UserPublicResponse.model_validate(user) class RoleSwitchRequest(BaseModel): diff --git a/backend/app/api/v1/endpoints/vote.py b/backend/app/api/v1/endpoints/vote.py index a197e8c..aa55f60 100644 --- a/backend/app/api/v1/endpoints/vote.py +++ b/backend/app/api/v1/endpoints/vote.py @@ -1,34 +1,273 @@ """ 投票相关 API """ -from fastapi import APIRouter, Depends, Query -from sqlalchemy import select, func, desc +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.dependencies import get_current_user from app.core.database import get_db +from app.core.rate_limit import limiter, RateLimits +from app.models.contest import Contest, ContestPhase +from app.models.registration import Registration, RegistrationStatus from app.models.submission import Submission, SubmissionStatus +from app.models.task import TaskType from app.models.user import User +from app.models.vote import Vote +from app.services.log_service import log_vote +from app.services.task_service import TaskService router = APIRouter() -@router.post("/{submission_id}") -async def vote_submission(submission_id: int): +class VoteActionResponse(BaseModel): + """投票动作响应""" + submission_id: int + vote_count: int + voted: bool + + +class MyVoteItem(BaseModel): + """我的投票记录条目""" + submission_id: int + contest_id: int + title: str + voted_at: datetime + + +class MyVoteListResponse(BaseModel): + """我的投票记录响应""" + items: list[MyVoteItem] + total: int + + +async def get_contest_or_404(db: AsyncSession, contest_id: int) -> Contest: + """获取比赛,不存在则抛出 404""" + result = await db.execute(select(Contest).where(Contest.id == contest_id)) + contest = result.scalar_one_or_none() + if contest is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="比赛不存在", + ) + return contest + + +async def get_submission_or_404(db: AsyncSession, submission_id: int) -> Submission: + """获取作品,不存在则抛出 404""" + result = await db.execute(select(Submission).where(Submission.id == submission_id)) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="作品不存在", + ) + return submission + + +def ensure_voting_phase(contest: Contest) -> None: + """确保比赛处于投票阶段""" + if contest.phase != ContestPhase.VOTING.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前比赛不在投票阶段", + ) + + +async def refresh_submission_vote_count(db: AsyncSession, submission: Submission) -> None: + """刷新作品票数""" + count_result = await db.execute( + select(func.count(Vote.id)).where(Vote.submission_id == submission.id) + ) + submission.vote_count = int(count_result.scalar() or 0) + + +@router.post( + "/{submission_id}", + response_model=VoteActionResponse, + summary="为作品投票", +) +@limiter.limit(RateLimits.VOTE) +async def vote_submission( + request: Request, + submission_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """为作品投票""" - return {"message": f"为作品 {submission_id} 投票"} + submission = await get_submission_or_404(db, submission_id) + if submission.status != SubmissionStatus.APPROVED.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="作品未通过审核,暂不可投票", + ) + + registration = None + if submission.registration_id: + result = await db.execute( + select(Registration).where(Registration.id == submission.registration_id) + ) + registration = result.scalar_one_or_none() + else: + result = await db.execute( + select(Registration).where( + Registration.contest_id == submission.contest_id, + Registration.user_id == submission.user_id, + ) + ) + registration = result.scalar_one_or_none() + + if registration is not None and registration.status == RegistrationStatus.WITHDRAWN.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="作品已撤回报名,暂不可投票", + ) + contest = await get_contest_or_404(db, submission.contest_id) + ensure_voting_phase(contest) -@router.delete("/{submission_id}") -async def cancel_vote(submission_id: int): + if submission.user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="不能给自己的作品投票", + ) + + existing_result = await db.execute( + select(Vote).where( + Vote.user_id == current_user.id, + Vote.submission_id == submission.id, + ) + ) + existing_vote = existing_result.scalar_one_or_none() + if existing_vote: + await refresh_submission_vote_count(db, submission) + await db.commit() + return VoteActionResponse( + submission_id=submission.id, + vote_count=submission.vote_count, + voted=True, + ) + + vote = Vote(user_id=current_user.id, submission_id=submission.id) + db.add(vote) + try: + await db.flush() + except IntegrityError: + await db.rollback() + submission = await get_submission_or_404(db, submission_id) + await refresh_submission_vote_count(db, submission) + await db.commit() + return VoteActionResponse( + submission_id=submission.id, + vote_count=submission.vote_count, + voted=True, + ) + + await refresh_submission_vote_count(db, submission) + await log_vote(db, current_user.id, submission.title, request=request) + await TaskService.record_event( + db=db, + user_id=current_user.id, + task_type=TaskType.VOTE, + delta=1, + event_key=f"vote:{vote.id}", + ref_type="vote", + ref_id=vote.id, + auto_claim=True, + ) + + await db.commit() + await db.refresh(submission) + return VoteActionResponse( + submission_id=submission.id, + vote_count=submission.vote_count, + voted=True, + ) + + +@router.delete( + "/{submission_id}", + response_model=VoteActionResponse, + summary="取消投票", +) +@limiter.limit(RateLimits.VOTE) +async def cancel_vote( + request: Request, + submission_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """取消投票""" - return {"message": f"取消作品 {submission_id} 投票"} + submission = await get_submission_or_404(db, submission_id) + contest = await get_contest_or_404(db, submission.contest_id) + ensure_voting_phase(contest) + + existing_result = await db.execute( + select(Vote).where( + Vote.user_id == current_user.id, + Vote.submission_id == submission.id, + ) + ) + existing_vote = existing_result.scalar_one_or_none() + if existing_vote is None: + await refresh_submission_vote_count(db, submission) + await db.commit() + return VoteActionResponse( + submission_id=submission.id, + vote_count=submission.vote_count, + voted=False, + ) + + await db.delete(existing_vote) + await db.flush() + await refresh_submission_vote_count(db, submission) + await db.commit() + await db.refresh(submission) + return VoteActionResponse( + submission_id=submission.id, + vote_count=submission.vote_count, + voted=False, + ) -@router.get("/my") -async def get_my_votes(): +@router.get( + "/my", + response_model=MyVoteListResponse, + summary="获取我的投票记录", +) +async def get_my_votes( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """获取我的投票记录""" - return {"message": "我的投票记录"} + result = await db.execute( + select(Vote) + .options(selectinload(Vote.submission)) + .where(Vote.user_id == current_user.id) + .order_by(Vote.created_at.desc()) + ) + votes = result.scalars().all() + + items = [] + for vote in votes: + submission = vote.submission + if not submission: + continue + items.append( + MyVoteItem( + submission_id=submission.id, + contest_id=submission.contest_id, + title=submission.title, + voted_at=vote.created_at, + ) + ) + + return MyVoteListResponse(items=items, total=len(items)) @router.get("/leaderboard") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6ee287f..5187eed 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,9 +16,32 @@ class Settings(BaseSettings): # 安全配置 SECRET_KEY: str = "your-secret-key-change-in-production" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + RATE_LIMIT_STORAGE_URI: Optional[str] = None # 为空则回退到 REDIS_URL + UPLOAD_DAILY_BYTES_LIMIT: int = 3 * 1024 * 1024 * 1024 # 单用户每日上传总量上限(字节) + SECURITY_CHALLENGE_MODE: str = "off" # off/monitor/enforce + SECURITY_CHALLENGE_PROVIDER: str = "pow" # pow/captcha/js/behavior + SECURITY_CHALLENGE_THRESHOLD: int = 20 # 触发挑战的请求次数阈值 + SECURITY_CHALLENGE_WINDOW_SECONDS: int = 60 # 统计窗口 + SECURITY_CHALLENGE_TTL_SECONDS: int = 300 # 挑战有效期 + SECURITY_CHALLENGE_PASS_TTL_SECONDS: int = 1800 # 挑战通过后免验证时间 + SECURITY_POW_DIFFICULTY: int = 4 # PoW 前导 0 个数(十六进制) + CAPTCHA_VERIFY_URL: Optional[str] = None + CAPTCHA_SECRET_KEY: Optional[str] = None + CAPTCHA_SITE_KEY: Optional[str] = None + PASSWORD_RESET_TOKEN_TTL_MINUTES: int = 30 # 忘记密码重置链接有效期(分钟) + SMTP_HOST: Optional[str] = None # SMTP 服务器地址 + SMTP_PORT: int = 587 # SMTP 端口 + SMTP_USERNAME: Optional[str] = None # SMTP 账号 + SMTP_PASSWORD: Optional[str] = None # SMTP 密码 + SMTP_USE_TLS: bool = True # 是否启用 STARTTLS + SMTP_USE_SSL: bool = False # 是否启用 SSL(如 465 端口) + SMTP_FROM_EMAIL: Optional[str] = None # 发件人邮箱 + SMTP_FROM_NAME: Optional[str] = None # 发件人显示名(可选) + TRUSTED_PROXY_ENABLED: bool = False # TODO: 接入 CDN/WAF 后开启 + TRUSTED_PROXY_HEADERS: str = "cf-connecting-ip,x-forwarded-for,x-real-ip" # 数据库配置 (MySQL) - DATABASE_URL: str = "mysql+aiomysql://root:password@localhost:3306/chicken_king" + DATABASE_URL: str = "mysql+aiomysql://root:password@localhost:3306/chicken_king?charset=utf8mb4" # Redis 配置 REDIS_URL: str = "redis://localhost:6379/0" @@ -39,6 +62,9 @@ class Settings(BaseSettings): # 前端地址(OAuth 回跳用) FRONTEND_URL: str = "http://127.0.0.1:5173" + # 媒体文件存储 + MEDIA_ROOT: str = "/app/app/uploads/media" + # GitHub API(用于提高 API 限额,可选) GITHUB_TOKEN: Optional[str] = None @@ -78,6 +104,32 @@ class Settings(BaseSettings): ONLINE_STATUS_CACHE_TTL_STALE_SECONDS: int = 60 # stale-while-revalidate 窗口 ONLINE_STATUS_CACHE_TTL_ERROR_SECONDS: int = 10 # 失败缓存(更短) + # 作品部署提交流程配置 + PROJECT_SUBMISSION_COOLDOWN_SECONDS: int = 600 # 每次提交最小间隔 + PROJECT_SUBMISSION_DAILY_LIMIT: int = 10 # 每日提交上限 + WORKER_API_TOKEN: Optional[str] = None # Worker 状态回写 Token + WORKER_API_BASE_URL: str = "http://127.0.0.1:8000" # Worker 回写 API 基址 + WORKER_QUEUE_KEY: str = "project_submissions:queue" # 提交队列 Key + WORKER_QUEUE_BLOCK_SECONDS: int = 5 # 阻塞等待队列秒数 + WORKER_STEP_DELAY_SECONDS: int = 2 # 模拟部署每步等待秒数 + WORKER_HEALTHCHECK_ENABLED: bool = False # 是否启用健康检查 + WORKER_HEALTHCHECK_PATH: str = "/healthz" # 健康检查路径 + WORKER_HEALTHCHECK_TIMEOUT_SECONDS: int = 5 # 健康检查超时 + WORKER_HEALTHCHECK_RETRY: int = 12 # 健康检查重试次数 + WORKER_HEALTHCHECK_INTERVAL_SECONDS: int = 5 # 健康检查重试间隔 + WORKER_DOCKER_HOST: str = "unix:///var/run/docker.sock" # Docker 连接地址 + WORKER_DEPLOY_NETWORK: Optional[str] = None # 部署容器使用的网络(为空则自动探测) + WORKER_PROJECT_PORT: int = 8080 # 作品容器对内端口 + WORKER_CONTAINER_MEMORY_LIMIT: str = "512m" # 作品容器内存上限 + WORKER_CONTAINER_CPU_LIMIT: float = 0.5 # 作品容器 CPU 上限(核数) + WORKER_CONTAINER_PIDS_LIMIT: int = 128 # 作品容器进程数上限 + WORKER_CONTAINER_LOG_MAX_SIZE: str = "10m" # 作品容器日志单文件上限 + WORKER_CONTAINER_LOG_MAX_FILE: int = 3 # 作品容器日志文件数量 + + # 作品访问域名规则 + PROJECT_DOMAIN_SUFFIX: str = "local" # 作品域名后缀 + PROJECT_DOMAIN_TEMPLATE: str = "project-{submission_id}.{suffix}" # 域名模板 + class Config: env_file = ".env" case_sensitive = True diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 2665615..d6abe55 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -6,7 +6,11 @@ from app.core.config import settings -engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + connect_args={"charset": "utf8mb4"}, +) AsyncSessionLocal = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..18a6a4e --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,93 @@ +""" +通用依赖:统一的用户认证依赖 +""" +from typing import Optional + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import decode_token +from app.models.user import User + + +async def get_current_user( + authorization: str = Header(None, alias="Authorization"), + db: AsyncSession = Depends(get_db), +) -> User: + """JWT 认证依赖:解析 Bearer Token 并返回当前用户""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = authorization.split(" ", 1)[1].strip() + payload = decode_token(token) + + if not payload or "sub" not in payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="登录已过期,请重新登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + user_id = int(payload["sub"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证信息", + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账号已被禁用", + ) + + return user + + +async def get_optional_user( + authorization: Optional[str] = Header(None, alias="Authorization"), + db: AsyncSession = Depends(get_db), +) -> Optional[User]: + """可选 JWT 认证依赖,未登录返回 None""" + if not authorization or not authorization.startswith("Bearer "): + return None + + token = authorization.split(" ", 1)[1].strip() + payload = decode_token(token) + + if not payload or "sub" not in payload: + return None + + try: + user_id = int(payload["sub"]) + except (TypeError, ValueError): + return None + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None or not user.is_active: + return None + + return user + + +# 兼容旧命名 +get_current_user_dep = get_current_user +get_current_user_optional = get_optional_user diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index f0d4715..2ace3f9 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -6,11 +6,47 @@ - DoS 攻击 - 恶意脚本滥用 """ -from slowapi import Limiter -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded +import ipaddress + from fastapi import Request from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from app.core.config import settings + + +def _extract_ip(value: str) -> str | None: + parts = [item.strip() for item in value.split(",") if item.strip()] + if not parts: + return None + candidate = parts[0] + try: + ipaddress.ip_address(candidate) + return candidate + except ValueError: + return None + + +def get_client_ip(request: Request) -> str: + """ + 获取客户端 IP。 + + TODO: 接入 CDN/WAF 后启用可信代理解析,否则容易被伪造。 + """ + if settings.TRUSTED_PROXY_ENABLED: + headers = settings.TRUSTED_PROXY_HEADERS + if isinstance(headers, str): + headers = [item.strip() for item in headers.split(",") if item.strip()] + for header in headers: + value = request.headers.get(header) + if not value: + continue + ip_value = _extract_ip(value) + if ip_value: + return ip_value + return get_remote_address(request) def get_user_identifier(request: Request) -> str: @@ -31,14 +67,15 @@ def get_user_identifier(request: Request) -> str: pass # 回退到 IP 地址 - return get_remote_address(request) + return get_client_ip(request) # 创建限制器实例 +storage_uri = settings.RATE_LIMIT_STORAGE_URI or settings.REDIS_URL or "memory://" limiter = Limiter( key_func=get_user_identifier, default_limits=["200/minute"], # 默认限制 - storage_uri="memory://", # 使用内存存储(生产环境建议使用 Redis) + storage_uri=storage_uri, ) @@ -83,3 +120,9 @@ class RateLimits: # 管理员操作 - 每分钟最多 60 次 ADMIN = "60/minute" + + # 点赞/收藏 - 每分钟最多 30 次 + INTERACTION = "30/minute" + + # 投票 - 每分钟最多 30 次 + VOTE = "30/minute" diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 0000000..bf684ae --- /dev/null +++ b/backend/app/core/redis.py @@ -0,0 +1,19 @@ +""" +Redis 客户端封装 +""" +from typing import Optional + +from redis.asyncio import Redis + +from app.core.config import settings + + +async def get_redis() -> Redis: + """创建 Redis 客户端(异步)""" + return Redis.from_url(settings.REDIS_URL, decode_responses=True) + + +async def close_redis(client: Optional[Redis]) -> None: + """关闭 Redis 客户端""" + if client is not None: + await client.close() diff --git a/backend/app/main.py b/backend/app/main.py index 3201b28..177c115 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,8 +4,9 @@ import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded @@ -76,7 +77,7 @@ def _check_security_config(): CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, - allow_methods=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], allow_headers=["*"], ) @@ -84,6 +85,16 @@ def _check_security_config(): app.include_router(api_router, prefix=settings.API_V1_PREFIX) +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + """兜底异常处理,避免直接返回 500 堆栈""" + logger.exception("未捕获异常: %s %s", request.method, request.url.path) + return JSONResponse( + status_code=500, + content={"detail": "服务器内部错误"}, + ) + + @app.get("/health") async def health_check(): """健康检查""" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8ced340..5546542 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,12 @@ from app.models.submission_review import SubmissionReview from app.models.registration import Registration, RegistrationStatus from app.models.vote import Vote +from app.models.project import Project, ProjectStatus +from app.models.project_submission import ProjectSubmission, ProjectSubmissionStatus +from app.models.project_review_assignment import ProjectReviewAssignment +from app.models.project_review import ProjectReview +from app.models.project_like import ProjectLike +from app.models.project_favorite import ProjectFavorite from app.models.github_stats import GitHubStats, GitHubSyncLog from app.models.cheer import Cheer, CheerType, CheerStats from app.models.achievement import ( @@ -21,6 +27,7 @@ ) from app.models.system_log import SystemLog, LogAction from app.models.request_log import RequestLog +from app.models.password_reset import PasswordResetToken __all__ = [ "Base", @@ -35,6 +42,14 @@ "Registration", "RegistrationStatus", "Vote", + "Project", + "ProjectStatus", + "ProjectSubmission", + "ProjectSubmissionStatus", + "ProjectReviewAssignment", + "ProjectReview", + "ProjectLike", + "ProjectFavorite", "GitHubStats", "GitHubSyncLog", "Cheer", @@ -50,4 +65,5 @@ "SystemLog", "LogAction", "RequestLog", + "PasswordResetToken", ] diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 65b6bbc..69f28f6 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -1,7 +1,7 @@ """ 比赛模型 """ -from sqlalchemy import Column, String, Text, DateTime, Enum as SQLEnum +from sqlalchemy import Column, String, Text, DateTime, Enum as SQLEnum, Boolean, JSON import enum from app.models.base import BaseModel @@ -15,12 +15,41 @@ class ContestPhase(str, enum.Enum): ENDED = "ended" # 已结束 +class ContestVisibility(str, enum.Enum): + """比赛可见性""" + DRAFT = "draft" # 草稿 + PUBLISHED = "published" # 已发布 + HIDDEN = "hidden" # 已隐藏 + + class Contest(BaseModel): """比赛表""" __tablename__ = "contests" title = Column(String(200), nullable=False) description = Column(Text, nullable=True) + visibility = Column( + SQLEnum( + 'draft', 'published', 'hidden', + name='contestvisibility' + ), + default='published', + nullable=False, + comment="比赛可见性" + ) + home_visible = Column( + Boolean, + nullable=False, + default=False, + server_default="0", + comment="是否首页展示", + ) + banner_url = Column(String(500), nullable=True, comment="比赛 Banner 图片") + rules_md = Column(Text, nullable=True, comment="比赛规则(Markdown)") + prizes_md = Column(Text, nullable=True, comment="奖项说明(Markdown)") + review_rules_md = Column(Text, nullable=True, comment="评审规则(Markdown)") + faq_md = Column(Text, nullable=True, comment="常见问题(Markdown)") + template_config = Column(JSON, nullable=True, comment="首页模板配置(JSON)") phase = Column( SQLEnum( 'upcoming', 'signup', 'submission', 'voting', 'ended', @@ -34,6 +63,13 @@ class Contest(BaseModel): submit_end = Column(DateTime, nullable=True) vote_start = Column(DateTime, nullable=True) vote_end = Column(DateTime, nullable=True) + auto_phase_enabled = Column( + Boolean, + nullable=False, + default=True, + server_default="1", + comment="是否自动同步比赛阶段", + ) @property def phase_enum(self) -> ContestPhase: diff --git a/backend/app/models/password_reset.py b/backend/app/models/password_reset.py new file mode 100644 index 0000000..4ef6650 --- /dev/null +++ b/backend/app/models/password_reset.py @@ -0,0 +1,17 @@ +""" +密码重置令牌模型 +""" +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String + +from app.models.base import BaseModel + + +class PasswordResetToken(BaseModel): + """密码重置令牌""" + __tablename__ = "password_reset_tokens" + + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + token_hash = Column(String(64), nullable=False, unique=True, comment="重置令牌哈希") + expires_at = Column(DateTime, nullable=False, comment="过期时间") + used_at = Column(DateTime, nullable=True, comment="使用时间") + requested_ip = Column(String(50), nullable=True, comment="请求IP") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..32d8c1b --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,63 @@ +""" +作品模型 +""" +from typing import TYPE_CHECKING + +from sqlalchemy import Column, Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import relationship +import enum + +from app.models.base import BaseModel + +if TYPE_CHECKING: + from app.models.contest import Contest + from app.models.user import User + from app.models.project_submission import ProjectSubmission + + +class ProjectStatus(str, enum.Enum): + """作品状态枚举""" + DRAFT = "draft" # 草稿 + SUBMITTED = "submitted" # 已提交(等待部署) + ONLINE = "online" # 已上线 + OFFLINE = "offline" # 已下线 + + +class Project(BaseModel): + """作品表""" + __tablename__ = "projects" + + contest_id = Column(Integer, ForeignKey("contests.id"), nullable=False, comment="关联比赛ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, comment="创建者ID") + + title = Column(String(200), nullable=False, comment="作品名称") + summary = Column(String(500), nullable=True, comment="作品简介") + description = Column(Text, nullable=True, comment="作品详情") + + repo_url = Column(String(500), nullable=True, comment="开源仓库地址") + cover_image_url = Column(String(500), nullable=True, comment="封面图") + screenshot_urls = Column(JSON, nullable=True, comment="截图列表") + readme_url = Column(String(500), nullable=True, comment="README 链接") + demo_url = Column(String(500), nullable=True, comment="演示地址") + + status = Column( + SQLEnum('draft', 'submitted', 'online', 'offline', name='projectstatus'), + default='draft', + nullable=False, + comment="作品状态" + ) + current_submission_id = Column(Integer, nullable=True, comment="当前线上 submission_id") + + contest = relationship("Contest", backref="projects") + user = relationship("User", backref="projects") + submissions = relationship( + "ProjectSubmission", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin" + ) + + @property + def status_enum(self) -> ProjectStatus: + """获取状态枚举值""" + return ProjectStatus(self.status) if self.status else ProjectStatus.DRAFT diff --git a/backend/app/models/project_favorite.py b/backend/app/models/project_favorite.py new file mode 100644 index 0000000..ed6e38a --- /dev/null +++ b/backend/app/models/project_favorite.py @@ -0,0 +1,33 @@ +""" +作品收藏模型 +""" +from sqlalchemy import Column, ForeignKey, Integer, UniqueConstraint, Index +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class ProjectFavorite(BaseModel): + """作品收藏表""" + __tablename__ = "project_favorites" + __table_args__ = ( + UniqueConstraint("project_id", "user_id", name="uq_project_favorite"), + Index("ix_project_favorites_project", "project_id"), + Index("ix_project_favorites_user", "user_id"), + ) + + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联作品ID", + ) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + comment="收藏用户ID", + ) + + project = relationship("Project", backref="favorite_records") + user = relationship("User", backref="project_favorites") diff --git a/backend/app/models/project_like.py b/backend/app/models/project_like.py new file mode 100644 index 0000000..af30cec --- /dev/null +++ b/backend/app/models/project_like.py @@ -0,0 +1,33 @@ +""" +作品点赞模型 +""" +from sqlalchemy import Column, ForeignKey, Integer, UniqueConstraint, Index +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class ProjectLike(BaseModel): + """作品点赞表""" + __tablename__ = "project_likes" + __table_args__ = ( + UniqueConstraint("project_id", "user_id", name="uq_project_like"), + Index("ix_project_likes_project", "project_id"), + Index("ix_project_likes_user", "user_id"), + ) + + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + comment="关联作品ID", + ) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + comment="点赞用户ID", + ) + + project = relationship("Project", backref="like_records") + user = relationship("User", backref="project_likes") diff --git a/backend/app/models/project_review.py b/backend/app/models/project_review.py new file mode 100644 index 0000000..11ae0b1 --- /dev/null +++ b/backend/app/models/project_review.py @@ -0,0 +1,33 @@ +""" +作品评审评分模型 +""" +from sqlalchemy import Column, ForeignKey, Integer, SmallInteger, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class ProjectReview(BaseModel): + """作品评审评分表""" + __tablename__ = "project_reviews" + __table_args__ = ( + UniqueConstraint("project_id", "reviewer_id", name="uk_project_reviewer"), + ) + + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + comment="关联作品ID", + ) + reviewer_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + comment="评审员用户ID", + ) + score = Column(SmallInteger, nullable=False, comment="评分(1-100)") + comment = Column(String(2000), nullable=True, comment="评审意见(可选)") + + project = relationship("Project", backref="reviews") + reviewer = relationship("User", foreign_keys=[reviewer_id]) diff --git a/backend/app/models/project_review_assignment.py b/backend/app/models/project_review_assignment.py new file mode 100644 index 0000000..0113fba --- /dev/null +++ b/backend/app/models/project_review_assignment.py @@ -0,0 +1,21 @@ +""" +作品评审分配模型 +""" +from sqlalchemy import Column, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class ProjectReviewAssignment(BaseModel): + """作品评审分配表""" + __tablename__ = "project_review_assignments" + __table_args__ = ( + UniqueConstraint("project_id", "reviewer_id", name="uk_project_review_assignment"), + ) + + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, comment="关联作品ID") + reviewer_id = Column(Integer, ForeignKey("users.id"), nullable=False, comment="评审员用户ID") + + project = relationship("Project", backref="review_assignments") + reviewer = relationship("User", foreign_keys=[reviewer_id]) diff --git a/backend/app/models/project_submission.py b/backend/app/models/project_submission.py new file mode 100644 index 0000000..0cc819d --- /dev/null +++ b/backend/app/models/project_submission.py @@ -0,0 +1,70 @@ +""" +作品部署提交模型 +""" +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Column, DateTime, Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import relationship +import enum + +from app.models.base import BaseModel + +if TYPE_CHECKING: + from app.models.project import Project + from app.models.contest import Contest + from app.models.user import User + + +class ProjectSubmissionStatus(str, enum.Enum): + """提交状态枚举""" + CREATED = "created" # 已创建 + QUEUED = "queued" # 排队中 + PULLING = "pulling" # 拉取镜像 + DEPLOYING = "deploying" # 部署中 + HEALTHCHECKING = "healthchecking" # 健康检查中 + ONLINE = "online" # 已上线 + FAILED = "failed" # 失败 + STOPPED = "stopped" # 已停止 + + +class ProjectSubmission(BaseModel): + """作品部署提交表""" + __tablename__ = "project_submissions" + + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, comment="关联作品ID") + contest_id = Column(Integer, ForeignKey("contests.id"), nullable=False, comment="关联比赛ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, comment="提交者ID") + + image_ref = Column(String(500), nullable=False, comment="镜像引用(含 digest)") + image_registry = Column(String(100), nullable=True, comment="镜像仓库域名") + image_repo = Column(String(300), nullable=True, comment="镜像仓库路径") + image_digest = Column(String(128), nullable=True, comment="镜像 digest") + + status = Column( + SQLEnum( + 'created', 'queued', 'pulling', 'deploying', 'healthchecking', 'online', 'failed', 'stopped', + name='projectsubmissionstatus' + ), + default='created', + nullable=False, + comment="提交状态" + ) + status_message = Column(String(500), nullable=True, comment="状态说明") + error_code = Column(String(100), nullable=True, comment="错误码") + log = Column(Text, nullable=True, comment="部署日志") + domain = Column(String(255), nullable=True, comment="访问域名") + status_history = Column(JSON, nullable=True, comment="状态历史") + + submitted_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="提交时间") + online_at = Column(DateTime, nullable=True, comment="上线时间") + failed_at = Column(DateTime, nullable=True, comment="失败时间") + + project = relationship("Project", back_populates="submissions") + contest = relationship("Contest") + user = relationship("User") + + @property + def status_enum(self) -> ProjectSubmissionStatus: + """获取状态枚举值""" + return ProjectSubmissionStatus(self.status) if self.status else ProjectSubmissionStatus.CREATED diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 5f146a2..cf34497 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -2,7 +2,7 @@ 作品提交模型 定义作品提交表和附件表的数据模型。 -支持5种必填材料:项目源码、演示视频、项目文档、API调用证明、参赛报名表。 +支持5种材料:项目源码、演示视频(可选)、项目文档、API调用证明、参赛报名表。 """ import enum from typing import TYPE_CHECKING @@ -62,9 +62,9 @@ class Submission(BaseModel): 作品提交表 每个用户在每个比赛只能提交一个作品。 - 作品提交需要包含5种必填材料(finalize时强校验): + 作品提交需要包含必填材料(finalize时强校验,演示视频可选): 1. 项目源码 - repo_url 字段 - 2. 演示视频 - 通过 attachments 关联 + 2. 演示视频(可选) - 通过 attachments 关联 3. 项目文档 - project_doc_md 字段 4. API调用证明 - 通过 attachments 关联(截图+日志) 5. 参赛报名表 - 通过 registration_id 关联 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f6bd5d8..aef26db 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -82,3 +82,8 @@ def is_contestant(self) -> bool: def is_spectator(self) -> bool: """是否为吃瓜用户""" return self.role == UserRole.SPECTATOR.value + + @property + def has_password(self) -> bool: + """是否设置了本地密码""" + return bool(self.hashed_password) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 5ee5a65..52b5962 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -7,10 +7,14 @@ RegistrationResponse, UserPublic, ) +from app.schemas.user import UserPublicResponse, UserResponse, UserUpdateRequest __all__ = [ "RegistrationCreate", "RegistrationUpdate", "RegistrationResponse", "UserPublic", + "UserPublicResponse", + "UserResponse", + "UserUpdateRequest", ] diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..ed92a88 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,238 @@ +""" +作品与部署提交相关 Schemas +""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.models.project import ProjectStatus +from app.models.project_submission import ProjectSubmissionStatus +from app.schemas.review_center import MyReviewResponse, ReviewStatsResponse +from app.schemas.submission import UserBrief + + +class ProjectCreate(BaseModel): + """创建作品请求体""" + contest_id: int = Field(..., description="比赛ID") + title: str = Field(..., min_length=2, max_length=200, description="作品名称") + summary: Optional[str] = Field(None, max_length=500, description="作品简介") + description: Optional[str] = Field(None, max_length=5000, description="作品详情") + repo_url: Optional[str] = Field(None, max_length=500, description="开源仓库地址") + cover_image_url: Optional[str] = Field(None, max_length=500, description="封面图") + screenshot_urls: Optional[list[str]] = Field(None, description="截图列表") + readme_url: Optional[str] = Field(None, max_length=500, description="README 链接") + demo_url: Optional[str] = Field(None, max_length=500, description="演示地址") + + @field_validator("repo_url") + @classmethod + def validate_repo_url(cls, v: Optional[str]) -> Optional[str]: + """验证仓库URL格式(可选)""" + if v is None: + return v + v = v.strip() + if not v: + return None + if not v.startswith("https://"): + raise ValueError("仓库URL必须使用HTTPS协议") + return v + + +class ProjectUpdate(BaseModel): + """更新作品请求体""" + title: Optional[str] = Field(None, min_length=2, max_length=200) + summary: Optional[str] = Field(None, max_length=500) + description: Optional[str] = Field(None, max_length=5000) + repo_url: Optional[str] = Field(None, max_length=500) + cover_image_url: Optional[str] = Field(None, max_length=500) + screenshot_urls: Optional[list[str]] = None + readme_url: Optional[str] = Field(None, max_length=500) + demo_url: Optional[str] = Field(None, max_length=500) + + @field_validator("repo_url") + @classmethod + def validate_repo_url(cls, v: Optional[str]) -> Optional[str]: + """验证仓库URL格式(可选)""" + if v is None: + return v + v = v.strip() + if not v: + return None + if not v.startswith("https://"): + raise ValueError("仓库URL必须使用HTTPS协议") + return v + + +class ProjectResponse(BaseModel): + """作品响应体""" + model_config = ConfigDict(from_attributes=True) + + id: int + contest_id: int + user_id: int + title: str + summary: Optional[str] = None + description: Optional[str] = None + repo_url: Optional[str] = None + cover_image_url: Optional[str] = None + screenshot_urls: Optional[list[str]] = None + readme_url: Optional[str] = None + demo_url: Optional[str] = None + status: ProjectStatus + current_submission_id: Optional[int] = None + created_at: datetime + updated_at: datetime + owner: Optional[UserBrief] = None + like_count: int = 0 + favorite_count: int = 0 + liked: bool = False + favorited: bool = False + + +class ProjectListResponse(BaseModel): + """作品列表响应体""" + items: list[ProjectResponse] + total: int + + +class ProjectInteractionResponse(BaseModel): + """作品互动响应体""" + project_id: int + like_count: int + favorite_count: int + liked: bool + favorited: bool + + +class ProjectAccessResponse(BaseModel): + """作品访问入口响应体""" + project_id: int + status: ProjectStatus + submission_id: Optional[int] = None + domain: Optional[str] = None + message: Optional[str] = None + + +class ProjectSubmissionCreate(BaseModel): + """创建部署提交请求体""" + image_ref: str = Field(..., min_length=10, max_length=500, description="镜像引用(含 digest)") + repo_url: Optional[str] = Field(None, max_length=500, description="开源仓库地址(可选)") + + @field_validator("image_ref") + @classmethod + def validate_image_ref(cls, v: str) -> str: + """验证镜像引用必须包含 digest""" + v = v.strip() + if "@sha256:" not in v: + raise ValueError("镜像引用必须包含 @sha256 digest") + ref = v.split("@", 1)[0].strip() + if ref.lower().endswith(":latest"): + raise ValueError("镜像标签禁止使用 :latest") + return v + + @field_validator("repo_url") + @classmethod + def validate_repo_url(cls, v: Optional[str]) -> Optional[str]: + """验证仓库URL格式(可选)""" + if v is None: + return v + v = v.strip() + if not v: + return None + if not v.startswith("https://"): + raise ValueError("仓库URL必须使用HTTPS协议") + return v + + +class ProjectSubmissionResponse(BaseModel): + """部署提交响应体""" + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + contest_id: int + user_id: int + image_ref: str + image_registry: Optional[str] = None + image_repo: Optional[str] = None + image_digest: Optional[str] = None + status: ProjectSubmissionStatus + status_message: Optional[str] = None + error_code: Optional[str] = None + log: Optional[str] = None + domain: Optional[str] = None + status_history: Optional[list[dict]] = None + submitted_at: Optional[datetime] = None + online_at: Optional[datetime] = None + failed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + +class ProjectSubmissionListResponse(BaseModel): + """提交列表响应体""" + items: list[ProjectSubmissionResponse] + total: int + + +class ProjectSubmissionStatusUpdate(BaseModel): + """状态回写请求体(Worker)""" + status: ProjectSubmissionStatus = Field(..., description="新的提交状态") + status_message: Optional[str] = Field(None, max_length=500, description="状态说明") + error_code: Optional[str] = Field(None, max_length=100, description="错误码") + log_append: Optional[str] = Field(None, description="追加日志内容") + domain: Optional[str] = Field(None, max_length=255, description="访问域名") + + +class ProjectAdminActionRequest(BaseModel): + """管理员操作请求体""" + message: Optional[str] = Field(None, max_length=500, description="操作说明") + + +class ProjectReviewAssignRequest(BaseModel): + """管理员分配评委请求体""" + reviewer_ids: list[int] = Field(..., description="评审员用户ID列表") + + +class ProjectReviewerItem(BaseModel): + """作品评审员信息""" + model_config = ConfigDict(from_attributes=True) + + reviewer_id: int + reviewer: Optional[UserBrief] = None + created_at: datetime + updated_at: datetime + + +class ProjectReviewerListResponse(BaseModel): + """作品评审员列表响应体""" + items: list[ProjectReviewerItem] + total: int + + +class ProjectReviewItem(BaseModel): + """评审中心作品信息""" + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + summary: Optional[str] = None + description: Optional[str] = None + repo_url: Optional[str] = None + demo_url: Optional[str] = None + readme_url: Optional[str] = None + status: ProjectStatus + current_submission_id: Optional[int] = None + domain: Optional[str] = None + created_at: datetime + owner: Optional[UserBrief] = None + my_review: Optional[MyReviewResponse] = None + stats: ReviewStatsResponse + + +class ProjectReviewListResponse(BaseModel): + """评审中心作品列表响应体""" + items: list[ProjectReviewItem] + total: int + page: int + page_size: int diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py index 462fb9c..6420065 100644 --- a/backend/app/schemas/submission.py +++ b/backend/app/schemas/submission.py @@ -2,7 +2,7 @@ 作品提交相关 Pydantic Schemas 定义作品提交的请求体、响应体和数据验证规则。 -支持5种必填材料:项目源码、演示视频、项目文档、API调用证明、参赛报名表。 +支持5种材料:项目源码、演示视频(可选)、项目文档、API调用证明、参赛报名表。 """ from datetime import datetime from typing import Any, Optional diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..ae0278f --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,62 @@ +""" +用户相关 Pydantic Schemas +""" +from datetime import datetime +from typing import Optional +import re + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + + +class UserPublicResponse(BaseModel): + """公开用户信息""" + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + display_name: Optional[str] = None + avatar_url: Optional[str] = None + role: str + + +class UserResponse(UserPublicResponse): + """用户完整信息""" + email: Optional[str] = None + original_role: Optional[str] = None + role_selected: bool = False + role_selected_at: Optional[datetime] = None + is_active: bool + linux_do_id: Optional[str] = None + linux_do_username: Optional[str] = None + github_id: Optional[str] = None + github_username: Optional[str] = None + trust_level: Optional[int] = None + is_silenced: bool = False + has_password: bool = False + + +class UserUpdateRequest(BaseModel): + """更新用户请求""" + username: Optional[str] = Field(None, min_length=3, max_length=50) + email: Optional[EmailStr] = None + display_name: Optional[str] = Field(None, max_length=100) + + @field_validator("username") + @classmethod + def validate_username(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + value = value.strip() + if not value: + raise ValueError("用户名不能为空") + if re.search(r"\s", value): + raise ValueError("用户名不能包含空白字符") + return value + + @field_validator("display_name") + @classmethod + def validate_display_name(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + value = value.strip() + return value or None diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..99ae537 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,49 @@ +""" +邮件发送服务 +""" +import smtplib +from email.message import EmailMessage + +from app.core.config import settings + + +class EmailServiceError(RuntimeError): + """邮件发送异常""" + + +def _build_from_header() -> str: + """构建发件人显示""" + if settings.SMTP_FROM_NAME: + return f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>" + return settings.SMTP_FROM_EMAIL or "" + + +def send_email(to_email: str, subject: str, content: str, html: bool = False) -> None: + """发送邮件""" + if not settings.SMTP_HOST or not settings.SMTP_FROM_EMAIL: + raise EmailServiceError("SMTP 未配置,无法发送邮件") + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = _build_from_header() + msg["To"] = to_email + if html: + msg.add_alternative(content, subtype="html") + else: + msg.set_content(content) + + if settings.SMTP_USE_SSL: + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server: + if settings.SMTP_USERNAME: + server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD or "") + server.send_message(msg) + return + + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server: + server.ehlo() + if settings.SMTP_USE_TLS: + server.starttls() + server.ehlo() + if settings.SMTP_USERNAME: + server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD or "") + server.send_message(msg) diff --git a/backend/app/services/media_service.py b/backend/app/services/media_service.py new file mode 100644 index 0000000..7b468ca --- /dev/null +++ b/backend/app/services/media_service.py @@ -0,0 +1,227 @@ +""" +媒体文件服务 + +统一处理图片上传、下载与本地路径解析。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional +from uuid import uuid4 + +import aiofiles +import httpx +from fastapi import HTTPException, UploadFile, status + +from app.core.config import settings +from app.services.upload_quota import commit_upload_quota + + +MEDIA_CATEGORIES = { + "avatars": "avatars", + "contest-banners": "contest-banners", + "project-covers": "project-covers", + "project-screenshots": "project-screenshots", +} + +IMAGE_CONTENT_TYPES = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", +} + +AVATAR_MAX_BYTES = 2 * 1024 * 1024 + + +@dataclass(frozen=True) +class MediaFile: + url: str + size_bytes: int + storage_path: Path + + +def _media_root() -> Path: + return Path(settings.MEDIA_ROOT).resolve() + + +def _media_url_prefix() -> str: + return f"{settings.API_V1_PREFIX}/media" + + +def is_local_media_url(url: Optional[str]) -> bool: + if not url: + return False + return url.startswith(_media_url_prefix() + "/") + + +def ensure_local_media_url(url: Optional[str], field_label: str) -> Optional[str]: + if not url: + return None + if is_local_media_url(url): + return url + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{field_label} 必须通过上传获取", + ) + + +def ensure_local_media_urls(urls: Optional[list[str]], field_label: str) -> Optional[list[str]]: + if not urls: + return urls + cleaned: list[str] = [] + for url in urls: + normalized = ensure_local_media_url(url, field_label) + if normalized: + cleaned.append(normalized) + return cleaned + + +def _safe_category(category: str) -> str: + if category not in MEDIA_CATEGORIES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的图片类别") + return MEDIA_CATEGORIES[category] + + +def _safe_filename(filename: str) -> str: + return Path(filename).name + + +def _build_storage_path(category: str, filename: str) -> Path: + root = _media_root() + path = (root / _safe_category(category) / _safe_filename(filename)).resolve() + if not str(path).startswith(str(root)): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法文件路径") + return path + + +def _build_public_url(category: str, filename: str) -> str: + return f"{_media_url_prefix()}/{_safe_category(category)}/{_safe_filename(filename)}" + + +def _resolve_extension(content_type: Optional[str], filename: Optional[str]) -> str: + if content_type and content_type.lower() in IMAGE_CONTENT_TYPES: + return IMAGE_CONTENT_TYPES[content_type.lower()] + if filename: + ext = Path(filename).suffix.lower() + if ext in IMAGE_CONTENT_TYPES.values(): + return ext + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的图片格式") + + +async def save_upload_file( + file: UploadFile, + category: str, + max_bytes: int, + owner_id: int | None = None, + quota_scope: str = "media", +) -> MediaFile: + extension = _resolve_extension(file.content_type, file.filename) + filename = f"{uuid4().hex}{extension}" + dest_path = _build_storage_path(category, filename) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + total_bytes = 0 + try: + async with aiofiles.open(dest_path, "wb") as out_file: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + total_bytes += len(chunk) + if total_bytes > max_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"图片过大,最大允许 {max_bytes // (1024 * 1024)} MB", + ) + await out_file.write(chunk) + finally: + await file.close() + + if owner_id: + try: + await commit_upload_quota(owner_id, quota_scope, total_bytes) + except HTTPException: + try: + dest_path.unlink() + except Exception: + pass + raise + + return MediaFile( + url=_build_public_url(category, filename), + size_bytes=total_bytes, + storage_path=dest_path, + ) + + +async def download_image_to_media( + url: str, + category: str, + max_bytes: int, +) -> Optional[MediaFile]: + if not url: + return None + timeout = httpx.Timeout(10.0, read=10.0) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + try: + resp = await client.get(url) + except httpx.HTTPError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"头像下载失败: {exc}", + ) from exc + + if resp.status_code >= 400: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"头像下载失败: HTTP {resp.status_code}", + ) + + content_type = resp.headers.get("content-type", "").split(";")[0].strip().lower() + extension = _resolve_extension(content_type, url) + filename = f"{uuid4().hex}{extension}" + dest_path = _build_storage_path(category, filename) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + content = resp.content or b"" + if not content: + return None + if len(content) > max_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"头像过大,最大允许 {max_bytes // (1024 * 1024)} MB", + ) + + async with aiofiles.open(dest_path, "wb") as out_file: + await out_file.write(content) + + return MediaFile( + url=_build_public_url(category, filename), + size_bytes=len(content), + storage_path=dest_path, + ) + + +def resolve_media_path(url: str) -> Optional[Path]: + if not is_local_media_url(url): + return None + prefix = _media_url_prefix() + "/" + rel = url[len(prefix):] + root = _media_root() + path = (root / rel).resolve() + if not str(path).startswith(str(root)): + return None + return path + + +def delete_media_file(url: Optional[str]) -> None: + path = resolve_media_path(url or "") + if not path or not path.exists(): + return + try: + path.unlink() + except Exception: + pass diff --git a/backend/app/services/project_domain.py b/backend/app/services/project_domain.py new file mode 100644 index 0000000..1ac9d1f --- /dev/null +++ b/backend/app/services/project_domain.py @@ -0,0 +1,11 @@ +""" +作品域名规则 +""" +from app.core.config import settings + + +def build_project_domain(submission_id: int) -> str: + """生成作品访问域名""" + suffix = settings.PROJECT_DOMAIN_SUFFIX.lstrip(".") + template = settings.PROJECT_DOMAIN_TEMPLATE + return template.format(submission_id=submission_id, suffix=suffix) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index d7e428d..b26ea6f 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import async_session_maker +from app.models.contest import Contest, ContestPhase from app.models.registration import Registration, RegistrationStatus from app.models.github_stats import GitHubStats, GitHubSyncLog from app.services.github_service import github_service, GitHubService @@ -187,6 +188,89 @@ async def generate_daily_report(): logger.error(f"生成每日战报异常: {e}") +def resolve_contest_phase(contest: Contest, now: datetime) -> Optional[str]: + """根据时间配置计算比赛阶段(无时间配置则返回 None)""" + if not any([ + contest.signup_start, + contest.signup_end, + contest.submit_start, + contest.submit_end, + contest.vote_start, + contest.vote_end, + ]): + return None + + if contest.vote_end and now >= contest.vote_end: + return ContestPhase.ENDED.value + + if contest.vote_start and now >= contest.vote_start: + if contest.vote_end is None or now < contest.vote_end: + return ContestPhase.VOTING.value + + if contest.submit_start and now >= contest.submit_start: + if contest.submit_end is None or now < contest.submit_end: + return ContestPhase.SUBMISSION.value + + if contest.signup_start and now >= contest.signup_start: + if contest.signup_end is None or now < contest.signup_end: + return ContestPhase.SIGNUP.value + + if contest.submit_end and now >= contest.submit_end and contest.vote_start is None: + return ContestPhase.ENDED.value + + if contest.signup_end and now >= contest.signup_end and contest.submit_start is None and contest.vote_start is None: + return ContestPhase.ENDED.value + + starts = [value for value in [contest.signup_start, contest.submit_start, contest.vote_start] if value] + if starts and now < min(starts): + return ContestPhase.UPCOMING.value + + return ContestPhase.UPCOMING.value + + +async def sync_contest_phases(): + """ + 同步比赛阶段 + + 根据比赛配置的时间窗口自动更新阶段。 + """ + logger.info("开始同步比赛阶段...") + + async with async_session_maker() as db: + try: + result = await db.execute(select(Contest)) + contests = result.scalars().all() + if not contests: + logger.info("没有需要同步的比赛") + return + + now = datetime.now() + updated = 0 + for contest in contests: + if hasattr(contest, "auto_phase_enabled") and not contest.auto_phase_enabled: + continue + target_phase = resolve_contest_phase(contest, now) + if not target_phase: + continue + if contest.phase != target_phase: + logger.info( + "比赛 %s 阶段变更:%s -> %s", + contest.id, + contest.phase, + target_phase, + ) + contest.phase = target_phase + updated += 1 + + if updated: + await db.commit() + logger.info("比赛阶段同步完成:更新 %s 个", updated) + + except Exception as e: + logger.error(f"比赛阶段同步异常: {e}") + await db.rollback() + + def init_scheduler(): """初始化定时任务调度器""" global scheduler @@ -211,6 +295,15 @@ def init_scheduler(): replace_existing=True, ) + # 每分钟同步比赛阶段 + scheduler.add_job( + sync_contest_phases, + CronTrigger(minute="*/1"), + id="sync_contest_phases", + name="同步比赛阶段", + replace_existing=True, + ) + logger.info("定时任务调度器初始化完成") return scheduler diff --git a/backend/app/services/security_challenge.py b/backend/app/services/security_challenge.py new file mode 100644 index 0000000..552fa5d --- /dev/null +++ b/backend/app/services/security_challenge.py @@ -0,0 +1,302 @@ +""" +触发式挑战(验证码/JS Challenge/PoW) +""" +from __future__ import annotations + +import hashlib +import json +import logging +import secrets +from typing import Any, Optional + +from fastapi import HTTPException, Request, status +import httpx + +from app.core.config import settings +from app.core.rate_limit import get_client_ip +from app.core.redis import get_redis, close_redis + + +logger = logging.getLogger(__name__) + +CHALLENGE_HEADER_ID = "X-Challenge-Id" +CHALLENGE_HEADER_ANSWER = "X-Challenge-Answer" +CAPTCHA_HEADER = "X-Captcha-Token" + + +def _challenge_mode() -> str: + return (settings.SECURITY_CHALLENGE_MODE or "off").strip().lower() + + +def _challenge_provider() -> str: + provider = (settings.SECURITY_CHALLENGE_PROVIDER or "pow").strip().lower() + if provider == "behavior": + return "captcha" + return provider + + +def _is_enabled() -> bool: + return _challenge_mode() in {"monitor", "enforce"} + + +def _is_enforce() -> bool: + return _challenge_mode() == "enforce" + + +def _build_identifier(request: Request, user_id: Optional[int]) -> str: + if user_id: + return f"user:{user_id}" + return f"ip:{get_client_ip(request)}" + + +def _counter_key(scope: str, identifier: str) -> str: + return f"security:challenge:counter:{scope}:{identifier}" + + +def _pass_key(scope: str, identifier: str) -> str: + return f"security:challenge:pass:{scope}:{identifier}" + + +def _challenge_key(challenge_id: str) -> str: + return f"security:challenge:data:{challenge_id}" + + +async def _increment_counter(scope: str, identifier: str) -> int: + client = await get_redis() + try: + key = _counter_key(scope, identifier) + count = await client.incr(key) + if count == 1: + await client.expire(key, settings.SECURITY_CHALLENGE_WINDOW_SECONDS) + return int(count) + finally: + await close_redis(client) + + +async def _mark_passed(scope: str, identifier: str) -> None: + client = await get_redis() + try: + await client.setex(_pass_key(scope, identifier), settings.SECURITY_CHALLENGE_PASS_TTL_SECONDS, "1") + await client.delete(_counter_key(scope, identifier)) + finally: + await close_redis(client) + + +async def _has_passed(scope: str, identifier: str) -> bool: + client = await get_redis() + try: + return bool(await client.get(_pass_key(scope, identifier))) + finally: + await close_redis(client) + + +async def _issue_pow_challenge(scope: str, identifier: str) -> dict[str, Any]: + challenge_id = secrets.token_urlsafe(16) + prefix = secrets.token_hex(16) + difficulty = max(1, int(settings.SECURITY_POW_DIFFICULTY)) + payload = { + "type": "pow", + "scope": scope, + "identifier": identifier, + "prefix": prefix, + "difficulty": difficulty, + } + client = await get_redis() + try: + await client.setex( + _challenge_key(challenge_id), + settings.SECURITY_CHALLENGE_TTL_SECONDS, + json.dumps(payload, ensure_ascii=False), + ) + finally: + await close_redis(client) + return { + "type": "pow", + "challenge_id": challenge_id, + "expires_in": settings.SECURITY_CHALLENGE_TTL_SECONDS, + "prefix": prefix, + "difficulty": difficulty, + } + + +async def _issue_js_challenge(scope: str, identifier: str) -> dict[str, Any]: + # TODO: 接入真实 JS Challenge(此处为占位实现) + challenge_id = secrets.token_urlsafe(16) + token = secrets.token_urlsafe(24) + payload = { + "type": "js", + "scope": scope, + "identifier": identifier, + "token": token, + } + client = await get_redis() + try: + await client.setex( + _challenge_key(challenge_id), + settings.SECURITY_CHALLENGE_TTL_SECONDS, + json.dumps(payload, ensure_ascii=False), + ) + finally: + await close_redis(client) + return { + "type": "js", + "challenge_id": challenge_id, + "expires_in": settings.SECURITY_CHALLENGE_TTL_SECONDS, + "hint": "需在前端完成 JS Challenge 后回传答案", + } + + +async def _issue_captcha_challenge(scope: str, identifier: str) -> dict[str, Any]: + challenge_id = secrets.token_urlsafe(16) + payload = { + "type": "captcha", + "scope": scope, + "identifier": identifier, + } + client = await get_redis() + try: + await client.setex( + _challenge_key(challenge_id), + settings.SECURITY_CHALLENGE_TTL_SECONDS, + json.dumps(payload, ensure_ascii=False), + ) + finally: + await close_redis(client) + return { + "type": "captcha", + "challenge_id": challenge_id, + "expires_in": settings.SECURITY_CHALLENGE_TTL_SECONDS, + "site_key": settings.CAPTCHA_SITE_KEY, + } + + +async def _issue_challenge(scope: str, identifier: str) -> dict[str, Any]: + provider = _challenge_provider() + if provider == "captcha": + return await _issue_captcha_challenge(scope, identifier) + if provider == "js": + return await _issue_js_challenge(scope, identifier) + return await _issue_pow_challenge(scope, identifier) + + +async def _load_challenge(challenge_id: str) -> Optional[dict[str, Any]]: + if not challenge_id: + return None + client = await get_redis() + try: + raw = await client.get(_challenge_key(challenge_id)) + if not raw: + return None + return json.loads(raw) + finally: + await close_redis(client) + + +async def _consume_challenge(challenge_id: str) -> None: + if not challenge_id: + return + client = await get_redis() + try: + await client.delete(_challenge_key(challenge_id)) + finally: + await close_redis(client) + + +async def _verify_pow(challenge: dict[str, Any], answer: str) -> bool: + prefix = challenge.get("prefix") or "" + difficulty = int(challenge.get("difficulty") or 0) + if not prefix or not answer or difficulty <= 0: + return False + digest = hashlib.sha256(f"{prefix}{answer}".encode("utf-8")).hexdigest() + return digest.startswith("0" * difficulty) + + +async def _verify_js(challenge: dict[str, Any], answer: str) -> bool: + expected = challenge.get("token") or "" + return bool(expected and answer and answer == expected) + + +async def _verify_captcha(request: Request) -> bool: + token = request.headers.get(CAPTCHA_HEADER, "") + if not token: + return False + if not settings.CAPTCHA_VERIFY_URL or not settings.CAPTCHA_SECRET_KEY: + logger.warning("验证码未配置,无法校验") + return False + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + settings.CAPTCHA_VERIFY_URL, + data={ + "secret": settings.CAPTCHA_SECRET_KEY, + "response": token, + "remoteip": get_client_ip(request), + }, + ) + data = resp.json() if resp.status_code == 200 else {} + return bool(data.get("success")) + except Exception as exc: + logger.warning("验证码校验失败: %s", exc) + return False + + +async def _verify_challenge( + request: Request, + scope: str, + identifier: str, +) -> bool: + provider = _challenge_provider() + if provider == "captcha": + return await _verify_captcha(request) + + challenge_id = request.headers.get(CHALLENGE_HEADER_ID, "") + answer = request.headers.get(CHALLENGE_HEADER_ANSWER, "") + challenge = await _load_challenge(challenge_id) + if not challenge: + return False + if challenge.get("scope") != scope or challenge.get("identifier") != identifier: + return False + + if provider == "js": + ok = await _verify_js(challenge, answer) + else: + ok = await _verify_pow(challenge, answer) + + if ok: + await _consume_challenge(challenge_id) + return ok + + +async def guard_challenge(request: Request, scope: str, user_id: Optional[int] = None) -> None: + """触发式挑战入口(按需调用)。""" + if not _is_enabled(): + return + + identifier = _build_identifier(request, user_id) + try: + if await _has_passed(scope, identifier): + return + count = await _increment_counter(scope, identifier) + if count < settings.SECURITY_CHALLENGE_THRESHOLD: + return + if not _is_enforce(): + logger.warning("挑战触发(监控模式): scope=%s, id=%s", scope, identifier) + return + if await _verify_challenge(request, scope, identifier): + await _mark_passed(scope, identifier) + return + challenge = await _issue_challenge(scope, identifier) + except HTTPException: + raise + except Exception as exc: + logger.warning("挑战机制异常: %s", exc) + return + + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "code": "challenge_required", + "scope": scope, + **challenge, + }, + ) diff --git a/backend/app/services/upload_quota.py b/backend/app/services/upload_quota.py new file mode 100644 index 0000000..94a870b --- /dev/null +++ b/backend/app/services/upload_quota.py @@ -0,0 +1,76 @@ +""" +上传配额控制 +""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from fastapi import HTTPException, status + +from app.core.config import settings +from app.core.redis import get_redis, close_redis + + +logger = logging.getLogger(__name__) + + +def _build_quota_key(scope: str, user_id: int, now: datetime) -> tuple[str, int]: + day_key = now.strftime("%Y%m%d") + key = f"quota:upload:{scope}:user:{user_id}:{day_key}" + next_day = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + ttl_seconds = max(60, int((next_day - now).total_seconds())) + return key, ttl_seconds + + +async def ensure_upload_quota(user_id: int, scope: str, size_bytes: int) -> None: + """检查配额是否足够(不占用配额)。""" + if not user_id or size_bytes <= 0: + return + limit = settings.UPLOAD_DAILY_BYTES_LIMIT + if not limit or limit <= 0: + return + + client = await get_redis() + try: + key, _ = _build_quota_key(scope, user_id, datetime.utcnow()) + used = int(await client.get(key) or 0) + if used + size_bytes > limit: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="上传额度不足,请减少文件大小或稍后再试", + ) + except HTTPException: + raise + except Exception as exc: + logger.warning("上传配额检查失败: %s", exc) + finally: + await close_redis(client) + + +async def commit_upload_quota(user_id: int, scope: str, size_bytes: int) -> None: + """提交配额占用(原子递增),超过上限则回滚。""" + if not user_id or size_bytes <= 0: + return + limit = settings.UPLOAD_DAILY_BYTES_LIMIT + if not limit or limit <= 0: + return + + client = await get_redis() + try: + key, ttl_seconds = _build_quota_key(scope, user_id, datetime.utcnow()) + total = await client.incrby(key, size_bytes) + if total == size_bytes: + await client.expire(key, ttl_seconds) + if total > limit: + await client.decrby(key, size_bytes) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="上传额度已用尽,请稍后再试", + ) + except HTTPException: + raise + except Exception as exc: + logger.warning("上传配额扣减失败: %s", exc) + finally: + await close_redis(client) diff --git a/backend/app/services/user_merge_service.py b/backend/app/services/user_merge_service.py new file mode 100644 index 0000000..c70332e --- /dev/null +++ b/backend/app/services/user_merge_service.py @@ -0,0 +1,373 @@ +""" +用户合并服务 +""" +from datetime import datetime +from typing import Iterable +import logging + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.models.achievement import UserAchievement, UserBadgeShowcase, UserStats +from app.models.points import UserPoints +from app.models.password_reset import PasswordResetToken + + +ROLE_PRIORITY = { + "spectator": 0, + "contestant": 1, + "reviewer": 2, + "admin": 3, +} + +ACHIEVEMENT_PRIORITY = { + "locked": 0, + "unlocked": 1, + "claimed": 2, +} + +logger = logging.getLogger(__name__) + + +async def _get_existing_tables(db: AsyncSession) -> set[str]: + result = await db.execute( + text("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()") + ) + return {row[0] for row in result.fetchall()} + + +def _pick_role(target_role: str, source_role: str) -> str: + """保留权限更高的角色""" + target_level = ROLE_PRIORITY.get(target_role or "", 0) + source_level = ROLE_PRIORITY.get(source_role or "", 0) + return source_role if source_level > target_level else target_role + + +def _merge_list(left: Iterable | None, right: Iterable | None) -> list | None: + """合并列表去重""" + if not left and not right: + return None + merged = [] + if left: + merged.extend(left) + if right: + merged.extend(right) + return list(dict.fromkeys(merged)) + + +async def _dedupe_and_update( + db: AsyncSession, + table: str, + column: str, + key_columns: list[str], + source_id: int, + target_id: int, +) -> None: + """删除冲突后更新外键""" + join_conditions = " AND ".join([f"t.{key} = s.{key}" for key in key_columns]) + delete_sql = f""" + DELETE s FROM {table} s + JOIN {table} t ON {join_conditions} + WHERE s.{column} = :source_id AND t.{column} = :target_id + """ + await db.execute(text(delete_sql), {"source_id": source_id, "target_id": target_id}) + await db.execute( + text(f"UPDATE {table} SET {column} = :target_id WHERE {column} = :source_id"), + {"source_id": source_id, "target_id": target_id}, + ) + + +async def _update_column( + db: AsyncSession, + table: str, + column: str, + source_id: int, + target_id: int, +) -> None: + """批量更新外键列""" + await db.execute( + text(f"UPDATE {table} SET {column} = :target_id WHERE {column} = :source_id"), + {"source_id": source_id, "target_id": target_id}, + ) + + +async def _merge_user_points(db: AsyncSession, target_id: int, source_id: int) -> None: + """合并积分汇总""" + target = await db.get(UserPoints, target_id) + source = await db.get(UserPoints, source_id) + if not source: + return + if target: + target.balance += source.balance + target.total_earned += source.total_earned + target.total_spent += source.total_spent + await db.delete(source) + return + source.user_id = target_id + + +async def _merge_user_stats(db: AsyncSession, target_id: int, source_id: int) -> None: + """合并用户统计""" + target = await db.get(UserStats, target_id) + source = await db.get(UserStats, source_id) + if not source: + return + if not target: + source.user_id = target_id + return + + target.total_cheers_given += source.total_cheers_given + target.total_cheers_with_message += source.total_cheers_with_message + target.cheer_types_used = _merge_list(target.cheer_types_used, source.cheer_types_used) + target.consecutive_days = max(target.consecutive_days, source.consecutive_days) + target.max_consecutive_days = max(target.max_consecutive_days, source.max_consecutive_days) + target.last_cheer_date = max( + filter(None, [target.last_cheer_date, source.last_cheer_date]), + default=target.last_cheer_date or source.last_cheer_date, + ) + target.total_gacha_count += source.total_gacha_count + target.gacha_rare_count += source.gacha_rare_count + target.prediction_total += source.prediction_total + target.prediction_correct += source.prediction_correct + target.daily_task_streak = max(target.daily_task_streak, source.daily_task_streak) + target.max_daily_task_streak = max(target.max_daily_task_streak, source.max_daily_task_streak) + target.weekly_tasks_completed += source.weekly_tasks_completed + target.last_task_date = max( + filter(None, [target.last_task_date, source.last_task_date]), + default=target.last_task_date or source.last_task_date, + ) + target.total_points += source.total_points + target.achievements_unlocked += source.achievements_unlocked + await db.delete(source) + + +async def _merge_user_achievements(db: AsyncSession, target_id: int, source_id: int) -> None: + """合并成就状态""" + target_rows = ( + await db.execute(select(UserAchievement).where(UserAchievement.user_id == target_id)) + ).scalars().all() + source_rows = ( + await db.execute(select(UserAchievement).where(UserAchievement.user_id == source_id)) + ).scalars().all() + + target_map = {row.achievement_key: row for row in target_rows} + + for row in source_rows: + target_row = target_map.get(row.achievement_key) + if not target_row: + row.user_id = target_id + continue + + target_status = ACHIEVEMENT_PRIORITY.get(target_row.status, 0) + source_status = ACHIEVEMENT_PRIORITY.get(row.status, 0) + if source_status > target_status: + target_row.status = row.status + target_row.progress_value = max(target_row.progress_value, row.progress_value) + target_row.progress_data = target_row.progress_data or row.progress_data + target_row.unlocked_at = row.unlocked_at or target_row.unlocked_at + target_row.claimed_at = row.claimed_at or target_row.claimed_at + elif source_status == target_status: + target_row.progress_value = max(target_row.progress_value, row.progress_value) + if not target_row.progress_data: + target_row.progress_data = row.progress_data + await db.delete(row) + + +async def _merge_user_badges(db: AsyncSession, target_id: int, source_id: int) -> None: + """合并徽章展示""" + target_rows = ( + await db.execute(select(UserBadgeShowcase).where(UserBadgeShowcase.user_id == target_id)) + ).scalars().all() + source_rows = ( + await db.execute(select(UserBadgeShowcase).where(UserBadgeShowcase.user_id == source_id)) + ).scalars().all() + + target_slots = {row.slot for row in target_rows} + for row in source_rows: + if row.slot in target_slots: + await db.delete(row) + continue + row.user_id = target_id + + +async def merge_users(db: AsyncSession, target_user: User, source_user: User) -> User: + """合并两个用户账号(以 target_user 为主账号)""" + if target_user.id == source_user.id: + return target_user + + existing_tables = await _get_existing_tables(db) + + def has_table(name: str) -> bool: + return name in existing_tables + + # 角色与基础信息合并 + target_user.role = _pick_role(target_user.role, source_user.role) + if target_user.original_role is None: + target_user.original_role = source_user.original_role or source_user.role or target_user.role + else: + if ROLE_PRIORITY.get(target_user.role or "", 0) > ROLE_PRIORITY.get(target_user.original_role or "", 0): + target_user.original_role = target_user.role + source_email = source_user.email + source_github_id = source_user.github_id + source_github_username = source_user.github_username + source_github_email = source_user.github_email + source_linux_do_id = source_user.linux_do_id + source_linux_do_username = source_user.linux_do_username + source_linux_do_avatar_template = source_user.linux_do_avatar_template + + if not target_user.display_name and source_user.display_name: + target_user.display_name = source_user.display_name + if not target_user.avatar_url and source_user.avatar_url: + target_user.avatar_url = source_user.avatar_url + if target_user.trust_level is None and source_user.trust_level is not None: + target_user.trust_level = source_user.trust_level + elif source_user.trust_level is not None: + target_user.trust_level = max(target_user.trust_level or 0, source_user.trust_level) + target_user.is_silenced = target_user.is_silenced or source_user.is_silenced + target_user.role_selected = target_user.role_selected or source_user.role_selected + if not target_user.role_selected_at and source_user.role_selected_at: + target_user.role_selected_at = source_user.role_selected_at + + # 先清理 source 的唯一字段并落库,避免合并过程触发唯一约束冲突 + source_user.email = None + source_user.github_id = None + source_user.github_username = None + source_user.github_email = None + source_user.linux_do_id = None + source_user.linux_do_username = None + source_user.linux_do_avatar_template = None + await db.flush() + + if not target_user.email and source_email: + target_user.email = source_email + if not target_user.github_id and source_github_id: + target_user.github_id = source_github_id + if not target_user.github_username and source_github_username: + target_user.github_username = source_github_username + if not target_user.github_email and source_github_email: + target_user.github_email = source_github_email + if not target_user.linux_do_id and source_linux_do_id: + target_user.linux_do_id = source_linux_do_id + if not target_user.linux_do_username and source_linux_do_username: + target_user.linux_do_username = source_linux_do_username + if not target_user.linux_do_avatar_template and source_linux_do_avatar_template: + target_user.linux_do_avatar_template = source_linux_do_avatar_template + + # 先合并统计与徽章(表不存在时跳过) + if has_table(UserPoints.__tablename__): + await _merge_user_points(db, target_user.id, source_user.id) + else: + logger.warning("跳过合并积分表,表不存在: %s", UserPoints.__tablename__) + + if has_table(UserStats.__tablename__): + await _merge_user_stats(db, target_user.id, source_user.id) + else: + logger.warning("跳过合并用户统计表,表不存在: %s", UserStats.__tablename__) + + if has_table(UserAchievement.__tablename__): + await _merge_user_achievements(db, target_user.id, source_user.id) + else: + logger.warning("跳过合并用户成就表,表不存在: %s", UserAchievement.__tablename__) + + if has_table(UserBadgeShowcase.__tablename__): + await _merge_user_badges(db, target_user.id, source_user.id) + else: + logger.warning("跳过合并徽章展示表,表不存在: %s", UserBadgeShowcase.__tablename__) + + # 清理密码重置令牌(表不存在时跳过) + if has_table(PasswordResetToken.__tablename__): + await db.execute( + text("DELETE FROM password_reset_tokens WHERE user_id = :source_id"), + {"source_id": source_user.id}, + ) + else: + logger.warning("跳过清理密码重置令牌,表不存在: %s", PasswordResetToken.__tablename__) + + user_id_dedupe_rules = [ + ("registrations", ["contest_id"]), + ("project_likes", ["project_id"]), + ("project_favorites", ["project_id"]), + ("votes", ["submission_id"]), + ("daily_signins", ["signin_date"]), + ("user_items", ["item_type"]), + ("user_exchange_quotas", ["quota_type"]), + ("user_task_progress", ["task_id", "period_start"]), + ("user_task_claims", ["task_id", "period_start"]), + ("user_task_events", ["schedule", "period_start", "event_key"]), + ] + for table, keys in user_id_dedupe_rules: + if not has_table(table): + logger.warning("跳过合并表(不存在): %s", table) + continue + await _dedupe_and_update(db, table, "user_id", keys, source_user.id, target_user.id) + + reviewer_id_dedupe_rules = [ + ("submission_reviews", ["submission_id"]), + ("project_review_assignments", ["project_id"]), + ("project_reviews", ["project_id"]), + ] + for table, keys in reviewer_id_dedupe_rules: + if not has_table(table): + logger.warning("跳过合并表(不存在): %s", table) + continue + await _dedupe_and_update(db, table, "reviewer_id", keys, source_user.id, target_user.id) + + user_id_tables = [ + "projects", + "submissions", + "project_submissions", + "cheers", + "points_ledger", + "exchange_records", + "lottery_draws", + "scratch_cards", + "gacha_draws", + "slot_machine_draws", + "prediction_bets", + "request_logs", + "system_logs", + ] + for table in user_id_tables: + if not has_table(table): + logger.warning("跳过合并表(不存在): %s", table) + continue + await _update_column(db, table, "user_id", source_user.id, target_user.id) + + reviewer_tables = [ + "submissions", + ] + for table in reviewer_tables: + if not has_table(table): + logger.warning("跳过合并表(不存在): %s", table) + continue + await _update_column(db, table, "reviewer_id", source_user.id, target_user.id) + + other_columns = [ + ("announcements", "author_id"), + ("api_key_codes", "assigned_user_id"), + ("task_definitions", "created_by"), + ("lottery_configs", "created_by"), + ("prediction_markets", "created_by"), + ("gacha_configs", "created_by"), + ("slot_machine_configs", "created_by"), + ] + for table, column in other_columns: + if not has_table(table): + logger.warning("跳过合并表(不存在): %s", table) + continue + await _update_column(db, table, column, source_user.id, target_user.id) + + # 解除 source 用户的第三方绑定,禁用账号 + source_user.is_active = False + source_user.github_id = None + source_user.github_username = None + source_user.github_email = None + source_user.linux_do_id = None + source_user.linux_do_username = None + source_user.linux_do_avatar_template = None + if source_user.email == target_user.email: + source_user.email = None + + source_user.updated_at = datetime.utcnow() + return target_user diff --git a/backend/app/services/worker_queue.py b/backend/app/services/worker_queue.py new file mode 100644 index 0000000..edc3852 --- /dev/null +++ b/backend/app/services/worker_queue.py @@ -0,0 +1,38 @@ +""" +Worker 队列服务 +""" +import json +import logging + +from fastapi import HTTPException, status + +from app.core.config import settings +from app.core.redis import close_redis, get_redis + + +logger = logging.getLogger(__name__) + + +async def enqueue_worker_action(action: str, submission_id: int) -> None: + """提交 Worker 任务""" + payload = json.dumps( + {"action": action, "submission_id": submission_id}, + ensure_ascii=False, + ) + redis_client = None + try: + redis_client = await get_redis() + await redis_client.rpush(settings.WORKER_QUEUE_KEY, payload) + except Exception as exc: + logger.warning( + "任务入队失败: action=%s, submission_id=%s, error=%s", + action, + submission_id, + exc, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="任务入队失败,请稍后重试", + ) from exc + finally: + await close_redis(redis_client) diff --git a/backend/app/worker.py b/backend/app/worker.py new file mode 100644 index 0000000..c217d0d --- /dev/null +++ b/backend/app/worker.py @@ -0,0 +1,460 @@ +""" +作品部署 Worker + +功能: +- 消费 Redis 队列中的提交 +- 拉取镜像并启动容器 +- 驱动提交状态流转并回写日志 +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from dataclasses import dataclass +from typing import Optional + +import docker +import httpx +from docker.errors import NotFound +from docker.types import LogConfig +from sqlalchemy import select + +from app.core.config import settings +from app.core.database import AsyncSessionLocal +from app.core.redis import close_redis, get_redis +from app.models.project import Project +from app.models.project_submission import ProjectSubmission, ProjectSubmissionStatus +from app.services.project_domain import build_project_domain + +logger = logging.getLogger(__name__) + + +def _build_status_payload( + status: ProjectSubmissionStatus, + message: Optional[str] = None, + error_code: Optional[str] = None, + log_append: Optional[str] = None, + domain: Optional[str] = None, +) -> dict: + """构造状态回写 payload""" + payload: dict = { + "status": status.value, + } + if message: + payload["status_message"] = message + if error_code: + payload["error_code"] = error_code + if log_append: + payload["log_append"] = log_append + if domain: + payload["domain"] = domain + return payload + + +def _build_status_url(submission_id: int) -> str: + """构建状态回写 URL""" + base = settings.WORKER_API_BASE_URL.rstrip("/") + return f"{base}{settings.API_V1_PREFIX}/project-submissions/{submission_id}/status" + + +async def _update_status( + client: httpx.AsyncClient, + submission_id: int, + payload: dict, +) -> None: + """调用状态回写接口""" + if not settings.WORKER_API_TOKEN: + raise RuntimeError("WORKER_API_TOKEN 未配置,无法回写状态") + + headers = {"X-Worker-Token": settings.WORKER_API_TOKEN} + url = _build_status_url(submission_id) + response = await client.patch(url, json=payload, headers=headers, timeout=10.0) + if response.status_code >= 400: + raise RuntimeError(f"状态回写失败: {response.status_code} {response.text}") + + +async def _safe_update_status( + client: httpx.AsyncClient, + submission_id: int, + payload: dict, +) -> None: + """安全回写状态,失败仅记录日志""" + try: + await _update_status(client, submission_id, payload) + except Exception as exc: + logger.error("状态回写失败: submission_id=%s, error=%s", submission_id, exc) + + +@dataclass(frozen=True) +class QueueJob: + """队列任务""" + action: str + submission_id: int + + +def _parse_queue_item(raw_value: bytes) -> Optional[QueueJob]: + """解析队列消息""" + if raw_value is None: + return None + + text = raw_value.decode(errors="ignore") if isinstance(raw_value, (bytes, bytearray)) else str(raw_value) + text = text.strip() + if not text: + return None + + try: + return QueueJob(action="deploy", submission_id=int(text)) + except ValueError: + pass + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.warning("无效的队列消息: %s", text) + return None + + if not isinstance(data, dict): + logger.warning("无效的队列消息: %s", text) + return None + + action = str(data.get("action", "")).lower() + submission_id = data.get("submission_id") + try: + submission_id = int(submission_id) + except (TypeError, ValueError): + logger.warning("无效的提交ID: %s", submission_id) + return None + + if action not in {"deploy", "stop"}: + logger.warning("未知队列动作: %s", action) + return None + + return QueueJob(action=action, submission_id=submission_id) + + +@dataclass(frozen=True) +class DeployTarget: + """部署目标信息""" + submission_id: int + project_id: int + image_ref: str + domain: str + current_submission_id: Optional[int] + + +async def _load_deploy_target(submission_id: int) -> DeployTarget: + """加载部署信息""" + async with AsyncSessionLocal() as session: + result = await session.execute( + select(ProjectSubmission, Project.current_submission_id) + .join(Project, Project.id == ProjectSubmission.project_id) + .where(ProjectSubmission.id == submission_id) + ) + row = result.first() + + if not row: + raise RuntimeError("提交记录不存在") + + submission = row[0] + current_submission_id = row[1] + if not submission.image_ref: + raise RuntimeError("提交缺少镜像引用") + + return DeployTarget( + submission_id=submission.id, + project_id=submission.project_id, + image_ref=submission.image_ref, + domain=build_project_domain(submission_id), + current_submission_id=current_submission_id, + ) + + +def _docker_client() -> docker.DockerClient: + """创建 Docker 客户端""" + return docker.DockerClient(base_url=settings.WORKER_DOCKER_HOST) + + +def _resolve_deploy_network(client: docker.DockerClient) -> Optional[str]: + """解析部署网络""" + if settings.WORKER_DEPLOY_NETWORK: + try: + client.networks.get(settings.WORKER_DEPLOY_NETWORK) + except NotFound: + logger.warning("部署网络不存在: %s", settings.WORKER_DEPLOY_NETWORK) + return None + return settings.WORKER_DEPLOY_NETWORK + + container_id = os.environ.get("HOSTNAME") + if not container_id: + return None + + try: + container = client.containers.get(container_id) + except NotFound: + return None + + networks = container.attrs.get("NetworkSettings", {}).get("Networks") or {} + if not networks: + return None + return next(iter(networks.keys())) + + +def _build_container_name(submission_id: int) -> str: + """生成容器名""" + return f"project-{submission_id}" + + +def _build_container_labels( + target: DeployTarget, + network_name: Optional[str], +) -> dict: + """生成容器标签""" + router_name = f"project-{target.submission_id}" + labels = { + "traefik.enable": "true", + f"traefik.http.routers.{router_name}.rule": f"Host(`{target.domain}`)", + f"traefik.http.routers.{router_name}.entrypoints": "web", + f"traefik.http.services.{router_name}.loadbalancer.server.port": str(settings.WORKER_PROJECT_PORT), + "com.ikuncode.project_submission_id": str(target.submission_id), + "com.ikuncode.project_id": str(target.project_id), + } + if network_name: + labels["traefik.docker.network"] = network_name + return labels + + +def _remove_container_if_exists(client: docker.DockerClient, container_name: str) -> None: + """删除已有同名容器""" + try: + container = client.containers.get(container_name) + except NotFound: + return + container.remove(force=True) + + +def _docker_pull_sync(image_ref: str) -> None: + client = _docker_client() + try: + client.images.pull(image_ref) + finally: + client.close() + + +async def _docker_pull(image_ref: str) -> None: + """拉取镜像""" + await asyncio.to_thread(_docker_pull_sync, image_ref) + + +def _start_container_sync(target: DeployTarget) -> str: + client = _docker_client() + try: + network_name = _resolve_deploy_network(client) + if not network_name: + raise RuntimeError("未检测到部署网络,请配置 WORKER_DEPLOY_NETWORK") + + container_name = _build_container_name(target.submission_id) + _remove_container_if_exists(client, container_name) + + log_config = LogConfig( + type="json-file", + config={ + "max-size": settings.WORKER_CONTAINER_LOG_MAX_SIZE, + "max-file": str(settings.WORKER_CONTAINER_LOG_MAX_FILE), + }, + ) + + run_kwargs = { + "name": container_name, + "detach": True, + "environment": {"PORT": str(settings.WORKER_PROJECT_PORT)}, + "labels": _build_container_labels(target, network_name), + "read_only": True, + "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges:true"], + "tmpfs": {"/tmp": "rw,noexec,nosuid,size=64m"}, + "mem_limit": settings.WORKER_CONTAINER_MEMORY_LIMIT, + "pids_limit": settings.WORKER_CONTAINER_PIDS_LIMIT, + "restart_policy": {"Name": "unless-stopped"}, + "log_config": log_config, + } + if network_name: + run_kwargs["network"] = network_name + + cpu_limit = settings.WORKER_CONTAINER_CPU_LIMIT + if cpu_limit > 0: + run_kwargs["nano_cpus"] = int(cpu_limit * 1_000_000_000) + + client.containers.run(target.image_ref, **run_kwargs) + return container_name + finally: + client.close() + + +async def _start_container(target: DeployTarget) -> str: + """启动容器""" + return await asyncio.to_thread(_start_container_sync, target) + + +def _remove_container_sync(container_name: str) -> None: + client = _docker_client() + try: + _remove_container_if_exists(client, container_name) + finally: + client.close() + + +async def _remove_container(container_name: str) -> None: + """删除容器""" + await asyncio.to_thread(_remove_container_sync, container_name) + + +async def _health_check(client: httpx.AsyncClient, container_name: str) -> bool: + """健康检查(可选)""" + if not settings.WORKER_HEALTHCHECK_ENABLED: + return True + + url = f"http://{container_name}:{settings.WORKER_PROJECT_PORT}{settings.WORKER_HEALTHCHECK_PATH}" + for _ in range(settings.WORKER_HEALTHCHECK_RETRY): + try: + response = await client.get(url, timeout=settings.WORKER_HEALTHCHECK_TIMEOUT_SECONDS) + if response.status_code == 200: + return True + except httpx.HTTPError: + pass + await asyncio.sleep(settings.WORKER_HEALTHCHECK_INTERVAL_SECONDS) + return False + + +async def _cleanup_old_container(current_id: Optional[int], new_id: int) -> None: + """清理旧版本容器""" + if not current_id or current_id == new_id: + return + container_name = _build_container_name(current_id) + try: + await _remove_container(container_name) + except Exception as exc: + logger.warning("清理旧容器失败: container=%s, error=%s", container_name, exc) + + +async def _stop_submission(submission_id: int) -> None: + """停止运行中的容器""" + container_name = _build_container_name(submission_id) + try: + await _remove_container(container_name) + logger.info("已停止容器: submission_id=%s", submission_id) + except Exception as exc: + logger.warning("停止容器失败: submission_id=%s, error=%s", submission_id, exc) + + +async def _process_submission(client: httpx.AsyncClient, submission_id: int) -> None: + """处理单个提交""" + container_name = _build_container_name(submission_id) + target = await _load_deploy_target(submission_id) + + try: + await _update_status( + client, + submission_id, + _build_status_payload( + status=ProjectSubmissionStatus.PULLING, + message="拉取镜像中", + log_append=f"开始拉取镜像: {target.image_ref}", + ), + ) + await _docker_pull(target.image_ref) + + await _update_status( + client, + submission_id, + _build_status_payload( + status=ProjectSubmissionStatus.DEPLOYING, + message="部署中", + log_append="创建容器并接入网关", + ), + ) + await _start_container(target) + + await _update_status( + client, + submission_id, + _build_status_payload( + status=ProjectSubmissionStatus.HEALTHCHECKING, + message="健康检查中", + log_append="开始健康检查", + ), + ) + + ok = await _health_check(client, container_name) + if not ok: + raise RuntimeError("健康检查失败") + + await _update_status( + client, + submission_id, + _build_status_payload( + status=ProjectSubmissionStatus.ONLINE, + message="上线成功", + log_append="健康检查通过,已上线", + domain=target.domain, + ), + ) + await _cleanup_old_container(target.current_submission_id, submission_id) + except Exception as exc: + logger.warning("提交处理失败: submission_id=%s, error=%s", submission_id, exc) + try: + await _remove_container(container_name) + except Exception as cleanup_exc: + logger.warning("容器清理失败: container=%s, error=%s", container_name, cleanup_exc) + await _safe_update_status( + client, + submission_id, + _build_status_payload( + status=ProjectSubmissionStatus.FAILED, + message="部署失败", + error_code="worker_failed", + log_append=f"部署失败: {exc}\n已执行清理", + ), + ) + + +async def _worker_loop() -> None: + """Worker 主循环""" + if not settings.WORKER_API_TOKEN: + logger.error("WORKER_API_TOKEN 未配置,Worker 无法回写状态") + return + + redis_client = None + try: + redis_client = await get_redis() + async with httpx.AsyncClient() as client: + while True: + item = await redis_client.blpop( + settings.WORKER_QUEUE_KEY, + timeout=settings.WORKER_QUEUE_BLOCK_SECONDS, + ) + if not item: + continue + + _, raw_value = item + job = _parse_queue_item(raw_value) + if not job: + continue + + if job.action == "deploy": + await _process_submission(client, job.submission_id) + elif job.action == "stop": + await _stop_submission(job.submission_id) + finally: + await close_redis(redis_client) + + +def main() -> None: + """启动入口""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + asyncio.run(_worker_loop()) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index 0352b3b..aaf63fd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,7 @@ redis>=5.0.0 # Auth python-jose[cryptography]>=3.3.0 passlib[bcrypt]>=1.7.4 +bcrypt<4.0.0 # Validation pydantic>=2.5.0 @@ -33,3 +34,4 @@ slowapi>=0.1.9 pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.25.0 +docker>=7.0.0 diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100644 index 0000000..cc66ce1 --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +AUTO_MIGRATE="${AUTO_MIGRATE:-true}" + +if [ "$AUTO_MIGRATE" = "true" ]; then + echo "[entrypoint] 开始自动数据库迁移" + /app/scripts/run_migrations.sh + echo "[entrypoint] 数据库迁移完成" +else + echo "[entrypoint] AUTO_MIGRATE=false,跳过数据库迁移" +fi + +exec "$@" diff --git a/backend/scripts/run_migrations.sh b/backend/scripts/run_migrations.sh new file mode 100644 index 0000000..4daabcf --- /dev/null +++ b/backend/scripts/run_migrations.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +PROJECT_DIR="${PROJECT_DIR:-/app}" +MIGRATIONS_DIR="${MIGRATIONS_DIR:-$PROJECT_DIR/sql}" +MYSQL_HOST="${MYSQL_HOST:-db}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_DATABASE="${MYSQL_DATABASE:-chicken_king}" +MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" +MIGRATION_BASELINE_VERSION="${MIGRATION_BASELINE_VERSION:-}" + +# 历史迁移重命名映射(新版本 -> 旧版本) +declare -A MIGRATION_ALIASES=( + ["038_submission_reviews"]="012_submission_reviews" + ["039_slot_machine_config"]="019_slot_machine_config" + ["040_user_stats_gacha_fields"]="022_user_stats_gacha_fields" +) + +if [ -z "$MYSQL_ROOT_PASSWORD" ]; then + log "缺少 MYSQL_ROOT_PASSWORD,无法执行迁移" + exit 1 +fi + +export MYSQL_PWD="$MYSQL_ROOT_PASSWORD" + +sql_escape() { + echo "$1" | sed "s/'/''/g" +} + +sha256_file() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$file" | awk '{print $2}' + return 0 + fi + echo "no-checksum" +} + +resolve_legacy_version() { + local version="$1" + if [ -n "${MIGRATION_ALIASES[$version]:-}" ]; then + echo "${MIGRATION_ALIASES[$version]}" + fi +} + +db_query() { + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot -N -B "$MYSQL_DATABASE" -e "$1" +} + +db_exec() { + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot "$MYSQL_DATABASE" -e "$1" +} + +wait_for_db() { + local max_retries="${1:-30}" + local retry=0 + + log "等待 MySQL 就绪..." + while [ $retry -lt $max_retries ]; do + if mysqladmin ping -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot --silent >/dev/null 2>&1; then + log "MySQL 已就绪" + return 0 + fi + retry=$((retry + 1)) + log "等待 MySQL 启动... ($retry/$max_retries)" + sleep 2 + done + + log "MySQL 未在预期时间内就绪" + return 1 +} + +ensure_database_exists() { + log "确保数据库存在..." + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot -e \ + "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >/dev/null 2>&1 + log "数据库已就绪: $MYSQL_DATABASE" +} + +ensure_migrations_table() { + log "确保迁移记录表存在..." + db_exec " +CREATE TABLE IF NOT EXISTS schema_migrations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + version VARCHAR(255) NOT NULL COMMENT '迁移版本(如 002_github_and_cheer)', + version_num INT UNSIGNED NOT NULL COMMENT '版本号(用于排序)', + filename VARCHAR(255) NOT NULL COMMENT '迁移文件名', + checksum CHAR(64) NOT NULL COMMENT '文件 SHA256 校验和', + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间', + PRIMARY KEY (id), + UNIQUE KEY uq_version (version), + KEY idx_version_num (version_num) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库迁移版本跟踪表'; +" + log "迁移记录表就绪" +} + +validate_migration_filename() { + local filename="$1" + if [[ ! "$filename" =~ ^[0-9]{3}_[A-Za-z0-9_]+\.sql$ ]]; then + return 1 + fi + return 0 +} + +acquire_lock() { + local lock_name="chicken_king_schema_migrations" + local got_lock + got_lock="$(db_query "SELECT GET_LOCK('$(sql_escape "$lock_name")', 30);")" + if [ "$got_lock" != "1" ]; then + log "获取迁移锁失败(可能有其他迁移在执行)" + return 1 + fi + trap "db_query \"SELECT RELEASE_LOCK('$(sql_escape "$lock_name")');\" >/dev/null 2>&1 || true" EXIT + log "迁移锁已获取" +} + +resolve_baseline_num() { + local baseline="$1" + if [ -z "$baseline" ]; then + echo "" + return 0 + fi + if [[ "$baseline" =~ ^[0-9]{1,3}$ ]]; then + printf "%d" "$((10#$baseline))" + return 0 + fi + if [[ "$baseline" =~ ^[0-9]{3}_.+ ]]; then + local num="${baseline%%_*}" + printf "%d" "$((10#$num))" + return 0 + fi + return 1 +} + +mark_migrations_as_applied() { + local files="$1" + local baseline_num="${2:-}" + + while IFS= read -r file; do + [ -z "$file" ] && continue + [ ! -f "$file" ] && continue + + local base version version_num checksum existing_checksum version_num_int + base="$(basename "$file")" + if ! validate_migration_filename "$base"; then + log "跳过非法文件名: $base(仅允许:NNN_name.sql)" + continue + fi + + version="${base%.sql}" + version_num="${version%%_*}" + version_num_int=$((10#$version_num)) + + if [ -n "$baseline_num" ] && [ "$version_num_int" -gt "$baseline_num" ]; then + continue + fi + + checksum="$(sha256_file "$file")" + existing_checksum="$(db_query "SELECT checksum FROM schema_migrations WHERE version='$(sql_escape "$version")' LIMIT 1;")" + if [ -n "$existing_checksum" ]; then + continue + fi + + db_exec "INSERT INTO schema_migrations (version, version_num, filename, checksum) VALUES ('$(sql_escape "$version")', $version_num_int, '$(sql_escape "$base")', '$(sql_escape "$checksum")');" + done <<< "$files" +} + +run_migrations() { + if [ ! -d "$MIGRATIONS_DIR" ]; then + log "迁移目录不存在,跳过: $MIGRATIONS_DIR" + return 0 + fi + + local files + files="$(find "$MIGRATIONS_DIR" -maxdepth 1 -name '[0-9][0-9][0-9]_*.sql' -type f 2>/dev/null | sort || true)" + if [ -z "$files" ]; then + log "未发现迁移文件(格式:[0-9][0-9][0-9]_*.sql),跳过" + return 0 + fi + + wait_for_db 30 + ensure_database_exists + acquire_lock + + local table_count + table_count="$(db_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE();" 2>/dev/null || echo "0")" + + if [ "$table_count" = "0" ] || [ -z "$table_count" ]; then + log "数据库为空,导入初始数据库..." + if [ -f "$MIGRATIONS_DIR/production_clean_db.sql" ]; then + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot "$MYSQL_DATABASE" < "$MIGRATIONS_DIR/production_clean_db.sql" + log "初始数据库导入成功(production_clean_db.sql)" + elif [ -f "$MIGRATIONS_DIR/schema.sql" ]; then + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot "$MYSQL_DATABASE" < "$MIGRATIONS_DIR/schema.sql" + log "基础 schema 执行成功(schema.sql)" + if [ -f "$MIGRATIONS_DIR/seed_production_config.sql" ]; then + mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot "$MYSQL_DATABASE" < "$MIGRATIONS_DIR/seed_production_config.sql" || true + log "配置数据导入完成(seed_production_config.sql)" + fi + else + log "未找到初始化文件(production_clean_db.sql 或 schema.sql)" + return 1 + fi + else + log "数据库已有 $table_count 个表,跳过初始化" + fi + + ensure_migrations_table + + local migrations_count + migrations_count="$(db_query "SELECT COUNT(*) FROM schema_migrations;" 2>/dev/null || echo "0")" + + if [ "$table_count" = "0" ] || [ "$table_count" = "" ]; then + log "初始库已导入,标记历史迁移为已执行" + mark_migrations_as_applied "$files" + return 0 + fi + + if [ "$migrations_count" = "0" ] && [ -n "$MIGRATION_BASELINE_VERSION" ]; then + local baseline_num + if ! baseline_num="$(resolve_baseline_num "$MIGRATION_BASELINE_VERSION")"; then + log "MIGRATION_BASELINE_VERSION 格式不正确: $MIGRATION_BASELINE_VERSION" + return 1 + fi + log "检测到 MIGRATION_BASELINE_VERSION=$MIGRATION_BASELINE_VERSION,标记 <= $baseline_num 的迁移为已执行" + mark_migrations_as_applied "$files" "$baseline_num" + fi + + log "开始执行增量迁移..." + while IFS= read -r file; do + [ -z "$file" ] && continue + [ ! -f "$file" ] && continue + + local base version version_num checksum existing_checksum version_num_int + base="$(basename "$file")" + if ! validate_migration_filename "$base"; then + log "跳过非法文件名: $base(仅允许:NNN_name.sql)" + continue + fi + + version="${base%.sql}" + version_num="${version%%_*}" + version_num_int=$((10#$version_num)) + checksum="$(sha256_file "$file")" + + existing_checksum="$(db_query "SELECT checksum FROM schema_migrations WHERE version='$(sql_escape "$version")' LIMIT 1;")" + if [ -n "$existing_checksum" ]; then + if [ "$existing_checksum" != "$checksum" ] && [ "$checksum" != "no-checksum" ]; then + log "已执行但校验和不一致: $base(请勿修改已执行迁移)" + else + log "跳过已执行迁移: $base" + fi + continue + fi + + local legacy_version legacy_checksum + legacy_version="$(resolve_legacy_version "$version")" + if [ -n "$legacy_version" ]; then + legacy_checksum="$(db_query "SELECT checksum FROM schema_migrations WHERE version='$(sql_escape "$legacy_version")' LIMIT 1;")" + if [ -n "$legacy_checksum" ]; then + log "检测到旧版本 $legacy_version 已执行,跳过并标记新版本 $version" + db_exec "INSERT INTO schema_migrations (version, version_num, filename, checksum) VALUES ('$(sql_escape "$version")', $version_num_int, '$(sql_escape "$base")', '$(sql_escape "$checksum")');" + continue + fi + fi + + log "执行迁移: $base" + if ! mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -uroot "$MYSQL_DATABASE" < "$file"; then + log "迁移失败: $base" + return 1 + fi + db_exec "INSERT INTO schema_migrations (version, version_num, filename, checksum) VALUES ('$(sql_escape "$version")', $version_num_int, '$(sql_escape "$base")', '$(sql_escape "$checksum")');" + log "迁移完成: $base" + done <<< "$files" + + log "数据库迁移完成" +} + +run_migrations diff --git a/backend/sql/026_project_deployments.sql b/backend/sql/026_project_deployments.sql new file mode 100644 index 0000000..0f7169c --- /dev/null +++ b/backend/sql/026_project_deployments.sql @@ -0,0 +1,98 @@ +-- ============================================================================ +-- ikuncode - 作品与部署提交流程(Project + Submission) +-- 数据库: MySQL 8.x +-- 描述: 新增 projects 与 project_submissions 表,支持镜像部署链路 +-- ============================================================================ + +USE `chicken_king`; + +-- ============================================================================ +-- 1. 作品表(projects) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `projects` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `contest_id` INT NOT NULL COMMENT '关联比赛ID', + `user_id` INT NOT NULL COMMENT '创建者ID', + + `title` VARCHAR(200) NOT NULL COMMENT '作品名称', + `summary` VARCHAR(500) NULL COMMENT '作品简介', + `description` TEXT NULL COMMENT '作品详情', + `repo_url` VARCHAR(500) NULL COMMENT '开源仓库地址', + `cover_image_url` VARCHAR(500) NULL COMMENT '封面图', + `screenshot_urls` JSON NULL COMMENT '截图列表', + `readme_url` VARCHAR(500) NULL COMMENT 'README 链接', + `demo_url` VARCHAR(500) NULL COMMENT '演示地址', + + `status` ENUM('draft', 'submitted', 'online', 'offline') + NOT NULL DEFAULT 'draft' COMMENT '作品状态', + `current_submission_id` INT NULL COMMENT '当前线上 submission_id', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_projects_contest_user` (`contest_id`, `user_id`), + KEY `ix_projects_contest` (`contest_id`), + KEY `ix_projects_user` (`user_id`), + KEY `ix_projects_status` (`status`), + CONSTRAINT `fk_projects_contest_id` + FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_projects_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品表'; + +-- ============================================================================ +-- 2. 作品部署提交表(project_submissions) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `project_submissions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `contest_id` INT NOT NULL COMMENT '关联比赛ID', + `user_id` INT NOT NULL COMMENT '提交者ID', + + `image_ref` VARCHAR(500) NOT NULL COMMENT '镜像引用(含 digest)', + `image_registry` VARCHAR(100) NULL COMMENT '镜像仓库域名', + `image_repo` VARCHAR(300) NULL COMMENT '镜像仓库路径', + `image_digest` VARCHAR(128) NULL COMMENT '镜像 digest', + + `status` ENUM('created', 'queued', 'pulling', 'deploying', 'healthchecking', 'online', 'failed', 'stopped') + NOT NULL DEFAULT 'created' COMMENT '提交状态', + `status_message` VARCHAR(500) NULL COMMENT '状态说明', + `error_code` VARCHAR(100) NULL COMMENT '错误码', + `log` LONGTEXT NULL COMMENT '部署日志', + `domain` VARCHAR(255) NULL COMMENT '访问域名', + `status_history` JSON NULL COMMENT '状态历史', + + `submitted_at` DATETIME(6) NULL COMMENT '提交时间', + `online_at` DATETIME(6) NULL COMMENT '上线时间', + `failed_at` DATETIME(6) NULL COMMENT '失败时间', + + PRIMARY KEY (`id`), + KEY `ix_project_submissions_project` (`project_id`), + KEY `ix_project_submissions_contest` (`contest_id`), + KEY `ix_project_submissions_user` (`user_id`), + KEY `ix_project_submissions_status` (`status`), + KEY `ix_project_submissions_submitted` (`submitted_at`), + CONSTRAINT `fk_project_submissions_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_contest_id` + FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品部署提交表'; + +-- ============================================================================ +-- 3. 验证迁移 +-- ============================================================================ + +SELECT '026_project_deployments.sql 迁移完成' AS message; diff --git a/backend/sql/027_project_review_assignments.sql b/backend/sql/027_project_review_assignments.sql new file mode 100644 index 0000000..8afa72d --- /dev/null +++ b/backend/sql/027_project_review_assignments.sql @@ -0,0 +1,37 @@ +-- ============================================================================ +-- ikuncode - 作品评审分配 +-- 数据库: MySQL 8.x +-- 描述: 新增 project_review_assignments 表,支持管理员分配评审员 +-- ============================================================================ + +USE `chicken_king`; + +-- ============================================================================ +-- 1. 作品评审分配表(project_review_assignments) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `project_review_assignments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `reviewer_id` INT NOT NULL COMMENT '评审员用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_review_assignment` (`project_id`, `reviewer_id`), + KEY `ix_project_review_assignments_project` (`project_id`), + KEY `ix_project_review_assignments_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_review_assignments_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_project_review_assignments_reviewer_id` + FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审分配表'; + +-- ============================================================================ +-- 2. 验证迁移 +-- ============================================================================ + +SELECT '027_project_review_assignments.sql 迁移完成' AS message; diff --git a/backend/sql/028_project_reviews.sql b/backend/sql/028_project_reviews.sql new file mode 100644 index 0000000..9093fd3 --- /dev/null +++ b/backend/sql/028_project_reviews.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- ikuncode - 作品评审评分 +-- 数据库: MySQL 8.x +-- 描述: 新增 project_reviews 表,记录评审员评分 +-- ============================================================================ + +USE `chicken_king`; + +-- ============================================================================ +-- 1. 作品评审评分表(project_reviews) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `project_reviews` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `reviewer_id` INT NOT NULL COMMENT '评审员用户ID', + `score` SMALLINT NOT NULL COMMENT '评分(1-100)', + `comment` VARCHAR(2000) NULL COMMENT '评审意见(可选)', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_reviewer` (`project_id`, `reviewer_id`), + KEY `ix_project_reviews_project` (`project_id`), + KEY `ix_project_reviews_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_reviews_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_project_reviews_reviewer_id` + FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审评分表'; + +-- ============================================================================ +-- 2. 验证迁移 +-- ============================================================================ + +SELECT '028_project_reviews.sql 迁移完成' AS message; diff --git a/backend/sql/029_project_interactions.sql b/backend/sql/029_project_interactions.sql new file mode 100644 index 0000000..9ba4d34 --- /dev/null +++ b/backend/sql/029_project_interactions.sql @@ -0,0 +1,61 @@ +-- ============================================================================ +-- ikuncode - 作品互动(点赞/收藏) +-- 数据库: MySQL 8.x +-- 描述: 新增 project_likes / project_favorites 表 +-- ============================================================================ + +USE `chicken_king`; + +-- ============================================================================ +-- 1. 作品点赞表(project_likes) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `project_likes` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `user_id` INT NOT NULL COMMENT '点赞用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_like` (`project_id`, `user_id`), + KEY `ix_project_likes_project` (`project_id`), + KEY `ix_project_likes_user` (`user_id`), + CONSTRAINT `fk_project_likes_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_project_likes_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品点赞表'; + +-- ============================================================================ +-- 2. 作品收藏表(project_favorites) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS `project_favorites` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `user_id` INT NOT NULL COMMENT '收藏用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_favorite` (`project_id`, `user_id`), + KEY `ix_project_favorites_project` (`project_id`), + KEY `ix_project_favorites_user` (`user_id`), + CONSTRAINT `fk_project_favorites_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) + ON DELETE CASCADE, + CONSTRAINT `fk_project_favorites_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品收藏表'; + +-- ============================================================================ +-- 3. 验证迁移 +-- ============================================================================ + +SELECT '029_project_interactions.sql 迁移完成' AS message; diff --git a/backend/sql/030_project_submission_stopped.sql b/backend/sql/030_project_submission_stopped.sql new file mode 100644 index 0000000..b84255d --- /dev/null +++ b/backend/sql/030_project_submission_stopped.sql @@ -0,0 +1,11 @@ +-- ============================================================================ +-- 030_project_submission_stopped.sql +-- 描述: 提交状态增加 stopped +-- ============================================================================ + +ALTER TABLE `project_submissions` + MODIFY COLUMN `status` ENUM( + 'created', 'queued', 'pulling', 'deploying', 'healthchecking', 'online', 'failed', 'stopped' + ) NOT NULL DEFAULT 'created' COMMENT '提交状态'; + +SELECT '030_project_submission_stopped.sql 迁移完成' AS message; diff --git a/backend/sql/031_contest_auto_phase.sql b/backend/sql/031_contest_auto_phase.sql new file mode 100644 index 0000000..4f76e29 --- /dev/null +++ b/backend/sql/031_contest_auto_phase.sql @@ -0,0 +1,2 @@ +ALTER TABLE contests + ADD COLUMN auto_phase_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动同步比赛阶段'; diff --git a/backend/sql/032_votes.sql b/backend/sql/032_votes.sql new file mode 100644 index 0000000..f2b1c55 --- /dev/null +++ b/backend/sql/032_votes.sql @@ -0,0 +1,33 @@ +-- ============================================================================ +-- 投票功能落地 +-- 数据库: MySQL 8.x +-- 描述: 新增 votes 表并补充 submissions.vote_count 字段 +-- ============================================================================ + +USE `chicken_king`; + +-- 1. submissions 表补充 vote_count(如已存在则跳过) +ALTER TABLE `submissions` + ADD COLUMN `vote_count` INT NOT NULL DEFAULT 0 COMMENT '票数' AFTER `status`; + +-- 2. votes 表(投票记录) +CREATE TABLE IF NOT EXISTS `votes` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `user_id` INT NOT NULL COMMENT '投票用户ID', + `submission_id` INT NOT NULL COMMENT '作品ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_vote_user_submission` (`user_id`, `submission_id`), + KEY `ix_votes_submission` (`submission_id`), + CONSTRAINT `fk_votes_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_votes_submission_id` FOREIGN KEY (`submission_id`) REFERENCES `submissions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='投票记录表'; + +-- 3. 回填票数 +UPDATE `submissions` s +SET s.`vote_count` = ( + SELECT COUNT(*) FROM `votes` v WHERE v.`submission_id` = s.`id` +); + +SELECT '032_votes.sql 迁移完成' AS message; diff --git a/backend/sql/033_contest_visibility.sql b/backend/sql/033_contest_visibility.sql new file mode 100644 index 0000000..f5c41bb --- /dev/null +++ b/backend/sql/033_contest_visibility.sql @@ -0,0 +1,14 @@ +-- ============================================================================ +-- 比赛可见性配置 +-- 数据库: MySQL 8.x +-- 描述: 为 contests 表增加 visibility 字段 +-- ============================================================================ + +USE `chicken_king`; + +ALTER TABLE `contests` + ADD COLUMN `visibility` ENUM('draft', 'published', 'hidden') + NOT NULL DEFAULT 'published' + COMMENT '赛事可见性' AFTER `description`; + +SELECT '033_contest_visibility.sql 迁移完成' AS message; diff --git a/backend/sql/034_contest_content.sql b/backend/sql/034_contest_content.sql new file mode 100644 index 0000000..2e6a034 --- /dev/null +++ b/backend/sql/034_contest_content.sql @@ -0,0 +1,16 @@ +-- ============================================================================ +-- 比赛内容配置 +-- 数据库: MySQL 8.x +-- 描述: 为 contests 表增加内容配置字段 +-- ============================================================================ + +USE `chicken_king`; + +ALTER TABLE `contests` + ADD COLUMN `banner_url` VARCHAR(500) NULL COMMENT '比赛 Banner 图片' AFTER `visibility`, + ADD COLUMN `rules_md` LONGTEXT NULL COMMENT '比赛规则(Markdown)' AFTER `banner_url`, + ADD COLUMN `prizes_md` LONGTEXT NULL COMMENT '奖项说明(Markdown)' AFTER `rules_md`, + ADD COLUMN `review_rules_md` LONGTEXT NULL COMMENT '评审规则(Markdown)' AFTER `prizes_md`, + ADD COLUMN `faq_md` LONGTEXT NULL COMMENT '常见问题(Markdown)' AFTER `review_rules_md`; + +SELECT '034_contest_content.sql 迁移完成' AS message; diff --git a/backend/sql/035_password_reset_tokens.sql b/backend/sql/035_password_reset_tokens.sql new file mode 100644 index 0000000..9bb2a4a --- /dev/null +++ b/backend/sql/035_password_reset_tokens.sql @@ -0,0 +1,24 @@ +-- ============================================================================ +-- 密码重置令牌表 +-- 数据库 MySQL 8.x +-- 描述: 新增 password_reset_tokens 表 +-- ============================================================================ + +USE `chicken_king`; + +CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `token_hash` varchar(64) NOT NULL COMMENT '重置令牌哈希', + `expires_at` datetime NOT NULL COMMENT '过期时间', + `used_at` datetime DEFAULT NULL COMMENT '使用时间', + `requested_ip` varchar(50) DEFAULT NULL COMMENT '请求IP', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_password_reset_token_hash` (`token_hash`), + KEY `idx_password_reset_user` (`user_id`), + CONSTRAINT `fk_password_reset_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='密码重置令牌表'; + +SELECT '035_password_reset_tokens.sql 迁移完成' AS message; diff --git a/backend/sql/036_contest_home_visible.sql b/backend/sql/036_contest_home_visible.sql new file mode 100644 index 0000000..49f8a9b --- /dev/null +++ b/backend/sql/036_contest_home_visible.sql @@ -0,0 +1,12 @@ +-- ============================================================================ +-- 比赛首页展示配置 +-- 数据库: MySQL 8.x +-- 描述: 为 contests 表增加首页展示标记 +-- ============================================================================ + +USE `chicken_king`; + +ALTER TABLE `contests` + ADD COLUMN `home_visible` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否首页展示' AFTER `visibility`; + +SELECT '036_contest_home_visible.sql 迁移完成' AS message; diff --git a/backend/sql/037_contest_template_config.sql b/backend/sql/037_contest_template_config.sql new file mode 100644 index 0000000..62eab1f --- /dev/null +++ b/backend/sql/037_contest_template_config.sql @@ -0,0 +1,12 @@ +-- ============================================================================ +-- 比赛首页模板配置 +-- 数据库: MySQL 8.x +-- 描述: 为 contests 表增加首页模板配置(JSON) +-- ============================================================================ + +USE `chicken_king`; + +ALTER TABLE `contests` + ADD COLUMN `template_config` JSON NULL COMMENT '首页模板配置(JSON)' AFTER `faq_md`; + +SELECT '037_contest_template_config.sql 迁移完成' AS message; diff --git a/backend/sql/012_submission_reviews.sql b/backend/sql/038_submission_reviews.sql similarity index 98% rename from backend/sql/012_submission_reviews.sql rename to backend/sql/038_submission_reviews.sql index 568f52f..0e1a6f7 100644 --- a/backend/sql/012_submission_reviews.sql +++ b/backend/sql/038_submission_reviews.sql @@ -1,7 +1,7 @@ -- ============================================================================ --- 012 - 评审员评分表 +-- 038 - 评审员评分表 -- 数据库: MySQL 8.x --- 执行方式: mysql -u root -proot -P 3306 chicken_king < backend/sql/012_submission_reviews.sql +-- 执行方式: mysql -u root -proot -P 3306 chicken_king < backend/sql/038_submission_reviews.sql -- ============================================================================ USE `chicken_king`; diff --git a/backend/sql/019_slot_machine_config.sql b/backend/sql/039_slot_machine_config.sql similarity index 99% rename from backend/sql/019_slot_machine_config.sql rename to backend/sql/039_slot_machine_config.sql index 66b0338..0c8fc4f 100644 --- a/backend/sql/019_slot_machine_config.sql +++ b/backend/sql/039_slot_machine_config.sql @@ -1,5 +1,5 @@ -- ===================================================== --- 019_slot_machine_config.sql +-- 039_slot_machine_config.sql -- 老虎机配置(符号/倍率/权重)- 支持管理员控制胜率 -- ===================================================== diff --git a/backend/sql/022_user_stats_gacha_fields.sql b/backend/sql/040_user_stats_gacha_fields.sql similarity index 97% rename from backend/sql/022_user_stats_gacha_fields.sql rename to backend/sql/040_user_stats_gacha_fields.sql index 06ca8ec..8ef32c6 100644 --- a/backend/sql/022_user_stats_gacha_fields.sql +++ b/backend/sql/040_user_stats_gacha_fields.sql @@ -1,4 +1,4 @@ --- 022_user_stats_gacha_fields.sql +-- 040_user_stats_gacha_fields.sql -- 添加扭蛋机、竞猜和任务统计字段到 user_stats 表 -- 修复 gacha_draws 表缺少 updated_at 字段的问题 diff --git a/backend/sql/041_add_missing_indexes.sql b/backend/sql/041_add_missing_indexes.sql new file mode 100644 index 0000000..ce755e8 --- /dev/null +++ b/backend/sql/041_add_missing_indexes.sql @@ -0,0 +1,76 @@ +-- ============================================================================ +-- 041 - 补充关键复合索引 +-- 数据库: MySQL 8.x +-- 执行方式: mysql -u root -proot -P 3306 chicken_king < backend/sql/041_add_missing_indexes.sql +-- ============================================================================ + +USE `chicken_king`; +SET NAMES utf8mb4; + +-- 1) submissions:按比赛 + 状态 + 投票数排序的复合索引 +SET @idx_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'chicken_king' + AND TABLE_NAME = 'submissions' + AND INDEX_NAME = 'ix_submissions_contest_status_vote' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX `ix_submissions_contest_status_vote` ON `submissions` (`contest_id`, `status`, `vote_count` DESC)', + 'SELECT ''ix_submissions_contest_status_vote index already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) points_ledger:按用户 + 时间倒序查询的复合索引 +SET @idx_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'chicken_king' + AND TABLE_NAME = 'points_ledger' + AND INDEX_NAME = 'idx_user_created_at' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX `idx_user_created_at` ON `points_ledger` (`user_id`, `created_at` DESC)', + 'SELECT ''idx_user_created_at index already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3) prediction_bets:按用户 + 市场组合查询的复合索引 +SET @idx_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'chicken_king' + AND TABLE_NAME = 'prediction_bets' + AND INDEX_NAME = 'idx_user_market' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX `idx_user_market` ON `prediction_bets` (`user_id`, `market_id`)', + 'SELECT ''idx_user_market index already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4) project_submissions:按比赛 + 状态过滤的复合索引 +SET @idx_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'chicken_king' + AND TABLE_NAME = 'project_submissions' + AND INDEX_NAME = 'ix_project_submissions_contest_status' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX `ix_project_submissions_contest_status` ON `project_submissions` (`contest_id`, `status`)', + 'SELECT ''ix_project_submissions_contest_status index already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/sql/init/00_schema.sql b/backend/sql/init/00_schema.sql index a7ec1cb..6cf545e 100644 --- a/backend/sql/init/00_schema.sql +++ b/backend/sql/init/00_schema.sql @@ -134,10 +134,170 @@ CREATE TABLE IF NOT EXISTS `submissions` ( KEY `ix_submissions_contest` (`contest_id`), KEY `ix_submissions_status` (`status`), KEY `ix_submissions_vote_count` (`vote_count` DESC), + KEY `ix_submissions_contest_status_vote` (`contest_id`, `status`, `vote_count` DESC), CONSTRAINT `fk_submissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_submissions_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品提交表'; +-- ============================================================================ +-- 作品表(部署体系) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `projects` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `contest_id` INT NOT NULL COMMENT '关联比赛ID', + `user_id` INT NOT NULL COMMENT '创建者ID', + + `title` VARCHAR(200) NOT NULL COMMENT '作品名称', + `summary` VARCHAR(500) NULL COMMENT '作品简介', + `description` TEXT NULL COMMENT '作品详情', + `repo_url` VARCHAR(500) NULL COMMENT '开源仓库地址', + `cover_image_url` VARCHAR(500) NULL COMMENT '封面图', + `screenshot_urls` JSON NULL COMMENT '截图列表', + `readme_url` VARCHAR(500) NULL COMMENT 'README 链接', + `demo_url` VARCHAR(500) NULL COMMENT '演示地址', + + `status` ENUM('draft', 'submitted', 'online', 'offline') NOT NULL DEFAULT 'draft' COMMENT '作品状态', + `current_submission_id` INT NULL COMMENT '当前线上 submission_id', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_projects_contest_user` (`contest_id`, `user_id`), + KEY `ix_projects_contest` (`contest_id`), + KEY `ix_projects_user` (`user_id`), + KEY `ix_projects_status` (`status`), + CONSTRAINT `fk_projects_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_projects_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品表'; + +-- ============================================================================ +-- 作品部署提交表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `project_submissions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `contest_id` INT NOT NULL COMMENT '关联比赛ID', + `user_id` INT NOT NULL COMMENT '提交者ID', + + `image_ref` VARCHAR(500) NOT NULL COMMENT '镜像引用(含 digest)', + `image_registry` VARCHAR(100) NULL COMMENT '镜像仓库域名', + `image_repo` VARCHAR(300) NULL COMMENT '镜像仓库路径', + `image_digest` VARCHAR(128) NULL COMMENT '镜像 digest', + + `status` ENUM('created', 'queued', 'pulling', 'deploying', 'healthchecking', 'online', 'failed', 'stopped') + NOT NULL DEFAULT 'created' COMMENT '提交状态', + `status_message` VARCHAR(500) NULL COMMENT '状态说明', + `error_code` VARCHAR(100) NULL COMMENT '错误码', + `log` LONGTEXT NULL COMMENT '部署日志', + `domain` VARCHAR(255) NULL COMMENT '访问域名', + `status_history` JSON NULL COMMENT '状态历史', + + `submitted_at` DATETIME(6) NULL COMMENT '提交时间', + `online_at` DATETIME(6) NULL COMMENT '上线时间', + `failed_at` DATETIME(6) NULL COMMENT '失败时间', + + PRIMARY KEY (`id`), + KEY `ix_project_submissions_project` (`project_id`), + KEY `ix_project_submissions_contest` (`contest_id`), + KEY `ix_project_submissions_user` (`user_id`), + KEY `ix_project_submissions_status` (`status`), + KEY `ix_project_submissions_contest_status` (`contest_id`, `status`), + KEY `ix_project_submissions_submitted` (`submitted_at`), + CONSTRAINT `fk_project_submissions_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品部署提交表'; + +-- ============================================================================ +-- 作品评审分配表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `project_review_assignments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `reviewer_id` INT NOT NULL COMMENT '评审员用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_review_assignment` (`project_id`, `reviewer_id`), + KEY `ix_project_review_assignments_project` (`project_id`), + KEY `ix_project_review_assignments_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_review_assignments_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_review_assignments_reviewer_id` + FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审分配表'; + +-- ============================================================================ +-- 作品评审评分表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `project_reviews` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `reviewer_id` INT NOT NULL COMMENT '评审员用户ID', + `score` SMALLINT NOT NULL COMMENT '评分(1-100)', + `comment` VARCHAR(2000) NULL COMMENT '评审意见(可选)', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_reviewer` (`project_id`, `reviewer_id`), + KEY `ix_project_reviews_project` (`project_id`), + KEY `ix_project_reviews_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_reviews_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_reviews_reviewer_id` + FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审评分表'; + +-- ============================================================================ +-- 作品点赞表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `project_likes` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `user_id` INT NOT NULL COMMENT '点赞用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_like` (`project_id`, `user_id`), + KEY `ix_project_likes_project` (`project_id`), + KEY `ix_project_likes_user` (`user_id`), + CONSTRAINT `fk_project_likes_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_likes_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品点赞表'; + +-- ============================================================================ +-- 作品收藏表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `project_favorites` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `user_id` INT NOT NULL COMMENT '收藏用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_favorite` (`project_id`, `user_id`), + KEY `ix_project_favorites_project` (`project_id`), + KEY `ix_project_favorites_user` (`user_id`), + CONSTRAINT `fk_project_favorites_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_favorites_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品收藏表'; + -- ============================================================================ -- 投票表 -- ============================================================================ @@ -309,6 +469,26 @@ CREATE TABLE IF NOT EXISTS `point_records` ( CONSTRAINT `fk_point_records_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分记录表'; +-- ============================================================================ +-- 密码重置令牌表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `user_id` INT NOT NULL COMMENT '用户ID', + `token_hash` VARCHAR(64) NOT NULL COMMENT '重置令牌哈希', + `expires_at` DATETIME(6) NOT NULL COMMENT '过期时间', + `used_at` DATETIME(6) NULL COMMENT '使用时间', + `requested_ip` VARCHAR(50) NULL COMMENT '请求IP', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_password_reset_token_hash` (`token_hash`), + KEY `ix_password_reset_user` (`user_id`), + CONSTRAINT `fk_password_reset_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='密码重置令牌表'; + SET FOREIGN_KEY_CHECKS = 1; -- ============================================================================ diff --git a/backend/sql/production_clean_db.sql b/backend/sql/production_clean_db.sql index aff164b..5376129 100644 --- a/backend/sql/production_clean_db.sql +++ b/backend/sql/production_clean_db.sql @@ -1,4 +1,4 @@ --- MySQL dump 10.13 Distrib 8.0.43, for macos13.7 (arm64) +-- MySQL dump 10.13 Distrib 8.0.43, for macos13.7 (arm64) -- -- Host: localhost Database: chicken_king -- ------------------------------------------------------ @@ -300,6 +300,14 @@ CREATE TABLE `contests` ( `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '比赛标题', `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '比赛描述', + `visibility` enum('draft','published','hidden') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'published' COMMENT 'Contest visibility', + `home_visible` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否首页展示', + `banner_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Contest banner url', + `rules_md` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Contest rules markdown', + `prizes_md` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Contest prizes markdown', + `review_rules_md` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Contest review rules markdown', + `faq_md` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Contest faq markdown', + `template_config` json DEFAULT NULL COMMENT '首页模板配置(JSON)', `phase` enum('upcoming','signup','submission','voting','ended') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'upcoming' COMMENT '比赛阶段', `signup_start` datetime DEFAULT NULL COMMENT '报名开始时间', `signup_end` datetime DEFAULT NULL COMMENT '报名结束时间', @@ -307,6 +315,7 @@ CREATE TABLE `contests` ( `submit_end` datetime DEFAULT NULL COMMENT '提交结束时间', `vote_start` datetime DEFAULT NULL COMMENT '投票开始时间', `vote_end` datetime DEFAULT NULL COMMENT '投票结束时间', + `auto_phase_enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Auto phase sync', PRIMARY KEY (`id`), KEY `ix_contests_phase` (`phase`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='比赛表'; @@ -908,6 +917,7 @@ CREATE TABLE `points_ledger` ( UNIQUE KEY `idx_request_id` (`request_id`), KEY `idx_user_id` (`user_id`), KEY `idx_created_at` (`created_at`), + KEY `idx_user_created_at` (`user_id`, `created_at`), KEY `idx_ref` (`ref_type`,`ref_id`), CONSTRAINT `points_ledger_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=1296 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分流水表'; @@ -944,6 +954,7 @@ CREATE TABLE `prediction_bets` ( UNIQUE KEY `idx_request_id` (`request_id`), KEY `idx_market_id` (`market_id`), KEY `idx_user_id` (`user_id`), + KEY `idx_user_market` (`user_id`, `market_id`), KEY `idx_option_id` (`option_id`), CONSTRAINT `prediction_bets_ibfk_1` FOREIGN KEY (`market_id`) REFERENCES `prediction_markets` (`id`) ON DELETE CASCADE, CONSTRAINT `prediction_bets_ibfk_2` FOREIGN KEY (`option_id`) REFERENCES `prediction_options` (`id`) ON DELETE CASCADE, @@ -1527,6 +1538,7 @@ CREATE TABLE `submissions` ( KEY `ix_submissions_contest` (`contest_id`), KEY `ix_submissions_status` (`status`), KEY `ix_submissions_vote_count` (`vote_count` DESC), + KEY `ix_submissions_contest_status_vote` (`contest_id`, `status`, `vote_count` DESC), KEY `ix_submissions_registration` (`registration_id`), KEY `ix_submissions_reviewer` (`reviewer_id`), KEY `idx_final_score` (`final_score` DESC), @@ -2053,6 +2065,223 @@ LOCK TABLES `votes` WRITE; /*!40000 ALTER TABLE `votes` DISABLE KEYS */; /*!40000 ALTER TABLE `votes` ENABLE KEYS */; UNLOCK TABLES; + +-- +-- Table structure for table `projects` +-- + +DROP TABLE IF EXISTS `projects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `projects` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `contest_id` int NOT NULL COMMENT '关联比赛ID', + `user_id` int NOT NULL COMMENT '创建者ID', + `title` varchar(200) NOT NULL COMMENT '作品名称', + `summary` varchar(500) DEFAULT NULL COMMENT '作品简介', + `description` text COMMENT '作品详情', + `repo_url` varchar(500) DEFAULT NULL COMMENT '开源仓库地址', + `cover_image_url` varchar(500) DEFAULT NULL COMMENT '封面图', + `screenshot_urls` json DEFAULT NULL COMMENT '截图列表', + `readme_url` varchar(500) DEFAULT NULL COMMENT 'README 链接', + `demo_url` varchar(500) DEFAULT NULL COMMENT '演示地址', + `status` enum('draft','submitted','online','offline') NOT NULL DEFAULT 'draft' COMMENT '作品状态', + `current_submission_id` int DEFAULT NULL COMMENT '当前线上 submission_id', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_projects_contest_user` (`contest_id`,`user_id`), + KEY `ix_projects_contest` (`contest_id`), + KEY `ix_projects_user` (`user_id`), + KEY `ix_projects_status` (`status`), + CONSTRAINT `fk_projects_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_projects_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `projects` +-- + +LOCK TABLES `projects` WRITE; +/*!40000 ALTER TABLE `projects` DISABLE KEYS */; +/*!40000 ALTER TABLE `projects` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_submissions` +-- + +DROP TABLE IF EXISTS `project_submissions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_submissions` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `contest_id` int NOT NULL COMMENT '关联比赛ID', + `user_id` int NOT NULL COMMENT '提交者ID', + `image_ref` varchar(500) NOT NULL COMMENT '镜像引用(含 digest)', + `image_registry` varchar(100) DEFAULT NULL COMMENT '镜像仓库域名', + `image_repo` varchar(300) DEFAULT NULL COMMENT '镜像仓库路径', + `image_digest` varchar(128) DEFAULT NULL COMMENT '镜像 digest', + `status` enum('created','queued','pulling','deploying','healthchecking','online','failed','stopped') NOT NULL DEFAULT 'created' COMMENT '提交状态', + `status_message` varchar(500) DEFAULT NULL COMMENT '状态说明', + `error_code` varchar(100) DEFAULT NULL COMMENT '错误码', + `log` longtext COMMENT '部署日志', + `domain` varchar(255) DEFAULT NULL COMMENT '访问域名', + `status_history` json DEFAULT NULL COMMENT '状态历史', + `submitted_at` datetime(6) DEFAULT NULL COMMENT '提交时间', + `online_at` datetime(6) DEFAULT NULL COMMENT '上线时间', + `failed_at` datetime(6) DEFAULT NULL COMMENT '失败时间', + PRIMARY KEY (`id`), + KEY `ix_project_submissions_project` (`project_id`), + KEY `ix_project_submissions_contest` (`contest_id`), + KEY `ix_project_submissions_user` (`user_id`), + KEY `ix_project_submissions_status` (`status`), + KEY `ix_project_submissions_contest_status` (`contest_id`, `status`), + KEY `ix_project_submissions_submitted` (`submitted_at`), + CONSTRAINT `fk_project_submissions_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品部署提交表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_submissions` +-- + +LOCK TABLES `project_submissions` WRITE; +/*!40000 ALTER TABLE `project_submissions` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_submissions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_review_assignments` +-- + +DROP TABLE IF EXISTS `project_review_assignments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_review_assignments` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `reviewer_id` int NOT NULL COMMENT '评审员用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_review_assignment` (`project_id`,`reviewer_id`), + KEY `ix_project_review_assignments_project` (`project_id`), + KEY `ix_project_review_assignments_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_review_assignments_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_review_assignments_reviewer_id` FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审分配表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_review_assignments` +-- + +LOCK TABLES `project_review_assignments` WRITE; +/*!40000 ALTER TABLE `project_review_assignments` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_review_assignments` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_reviews` +-- + +DROP TABLE IF EXISTS `project_reviews`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_reviews` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `reviewer_id` int NOT NULL COMMENT '评审员用户ID', + `score` smallint NOT NULL COMMENT '评分(1-100)', + `comment` varchar(2000) DEFAULT NULL COMMENT '评审意见(可选)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_reviewer` (`project_id`,`reviewer_id`), + KEY `ix_project_reviews_project` (`project_id`), + KEY `ix_project_reviews_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_reviews_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_reviews_reviewer_id` FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审评分表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_reviews` +-- + +LOCK TABLES `project_reviews` WRITE; +/*!40000 ALTER TABLE `project_reviews` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_reviews` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_likes` +-- + +DROP TABLE IF EXISTS `project_likes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_likes` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `user_id` int NOT NULL COMMENT '点赞用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_like` (`project_id`,`user_id`), + KEY `ix_project_likes_project` (`project_id`), + KEY `ix_project_likes_user` (`user_id`), + CONSTRAINT `fk_project_likes_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_likes_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品点赞表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_likes` +-- + +LOCK TABLES `project_likes` WRITE; +/*!40000 ALTER TABLE `project_likes` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_likes` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_favorites` +-- + +DROP TABLE IF EXISTS `project_favorites`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_favorites` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `user_id` int NOT NULL COMMENT '收藏用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_favorite` (`project_id`,`user_id`), + KEY `ix_project_favorites_project` (`project_id`), + KEY `ix_project_favorites_user` (`user_id`), + CONSTRAINT `fk_project_favorites_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_favorites_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品收藏表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_favorites` +-- + +LOCK TABLES `project_favorites` WRITE; +/*!40000 ALTER TABLE `project_favorites` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_favorites` ENABLE KEYS */; +UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/backend/sql/production_initial_db.sql b/backend/sql/production_initial_db.sql index ce98b9a..4ab40af 100644 --- a/backend/sql/production_initial_db.sql +++ b/backend/sql/production_initial_db.sql @@ -1123,6 +1123,222 @@ LOCK TABLES `puzzle_progress` WRITE; /*!40000 ALTER TABLE `puzzle_progress` DISABLE KEYS */; /*!40000 ALTER TABLE `puzzle_progress` ENABLE KEYS */; UNLOCK TABLES; + +-- +-- Table structure for table `projects` +-- + +DROP TABLE IF EXISTS `projects`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `projects` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `contest_id` int NOT NULL COMMENT '关联比赛ID', + `user_id` int NOT NULL COMMENT '创建者ID', + `title` varchar(200) NOT NULL COMMENT '作品名称', + `summary` varchar(500) DEFAULT NULL COMMENT '作品简介', + `description` text COMMENT '作品详情', + `repo_url` varchar(500) DEFAULT NULL COMMENT '开源仓库地址', + `cover_image_url` varchar(500) DEFAULT NULL COMMENT '封面图', + `screenshot_urls` json DEFAULT NULL COMMENT '截图列表', + `readme_url` varchar(500) DEFAULT NULL COMMENT 'README 链接', + `demo_url` varchar(500) DEFAULT NULL COMMENT '演示地址', + `status` enum('draft','submitted','online','offline') NOT NULL DEFAULT 'draft' COMMENT '作品状态', + `current_submission_id` int DEFAULT NULL COMMENT '当前线上 submission_id', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_projects_contest_user` (`contest_id`,`user_id`), + KEY `ix_projects_contest` (`contest_id`), + KEY `ix_projects_user` (`user_id`), + KEY `ix_projects_status` (`status`), + CONSTRAINT `fk_projects_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_projects_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `projects` +-- + +LOCK TABLES `projects` WRITE; +/*!40000 ALTER TABLE `projects` DISABLE KEYS */; +/*!40000 ALTER TABLE `projects` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_submissions` +-- + +DROP TABLE IF EXISTS `project_submissions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_submissions` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `contest_id` int NOT NULL COMMENT '关联比赛ID', + `user_id` int NOT NULL COMMENT '提交者ID', + `image_ref` varchar(500) NOT NULL COMMENT '镜像引用(含 digest)', + `image_registry` varchar(100) DEFAULT NULL COMMENT '镜像仓库域名', + `image_repo` varchar(300) DEFAULT NULL COMMENT '镜像仓库路径', + `image_digest` varchar(128) DEFAULT NULL COMMENT '镜像 digest', + `status` enum('created','queued','pulling','deploying','healthchecking','online','failed','stopped') NOT NULL DEFAULT 'created' COMMENT '提交状态', + `status_message` varchar(500) DEFAULT NULL COMMENT '状态说明', + `error_code` varchar(100) DEFAULT NULL COMMENT '错误码', + `log` longtext COMMENT '部署日志', + `domain` varchar(255) DEFAULT NULL COMMENT '访问域名', + `status_history` json DEFAULT NULL COMMENT '状态历史', + `submitted_at` datetime(6) DEFAULT NULL COMMENT '提交时间', + `online_at` datetime(6) DEFAULT NULL COMMENT '上线时间', + `failed_at` datetime(6) DEFAULT NULL COMMENT '失败时间', + PRIMARY KEY (`id`), + KEY `ix_project_submissions_project` (`project_id`), + KEY `ix_project_submissions_contest` (`contest_id`), + KEY `ix_project_submissions_user` (`user_id`), + KEY `ix_project_submissions_status` (`status`), + KEY `ix_project_submissions_submitted` (`submitted_at`), + CONSTRAINT `fk_project_submissions_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品部署提交表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_submissions` +-- + +LOCK TABLES `project_submissions` WRITE; +/*!40000 ALTER TABLE `project_submissions` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_submissions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_review_assignments` +-- + +DROP TABLE IF EXISTS `project_review_assignments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_review_assignments` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `reviewer_id` int NOT NULL COMMENT '评审员用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_review_assignment` (`project_id`,`reviewer_id`), + KEY `ix_project_review_assignments_project` (`project_id`), + KEY `ix_project_review_assignments_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_review_assignments_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_review_assignments_reviewer_id` FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审分配表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_review_assignments` +-- + +LOCK TABLES `project_review_assignments` WRITE; +/*!40000 ALTER TABLE `project_review_assignments` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_review_assignments` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_reviews` +-- + +DROP TABLE IF EXISTS `project_reviews`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_reviews` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `reviewer_id` int NOT NULL COMMENT '评审员用户ID', + `score` smallint NOT NULL COMMENT '评分(1-100)', + `comment` varchar(2000) DEFAULT NULL COMMENT '评审意见(可选)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_reviewer` (`project_id`,`reviewer_id`), + KEY `ix_project_reviews_project` (`project_id`), + KEY `ix_project_reviews_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_reviews_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_reviews_reviewer_id` FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审评分表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_reviews` +-- + +LOCK TABLES `project_reviews` WRITE; +/*!40000 ALTER TABLE `project_reviews` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_reviews` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_likes` +-- + +DROP TABLE IF EXISTS `project_likes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_likes` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `user_id` int NOT NULL COMMENT '点赞用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_like` (`project_id`,`user_id`), + KEY `ix_project_likes_project` (`project_id`), + KEY `ix_project_likes_user` (`user_id`), + CONSTRAINT `fk_project_likes_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_likes_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品点赞表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_likes` +-- + +LOCK TABLES `project_likes` WRITE; +/*!40000 ALTER TABLE `project_likes` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_likes` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `project_favorites` +-- + +DROP TABLE IF EXISTS `project_favorites`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `project_favorites` ( + `id` int NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `project_id` int NOT NULL COMMENT '关联作品ID', + `user_id` int NOT NULL COMMENT '收藏用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_favorite` (`project_id`,`user_id`), + KEY `ix_project_favorites_project` (`project_id`), + KEY `ix_project_favorites_user` (`user_id`), + CONSTRAINT `fk_project_favorites_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_favorites_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品收藏表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `project_favorites` +-- + +LOCK TABLES `project_favorites` WRITE; +/*!40000 ALTER TABLE `project_favorites` DISABLE KEYS */; +/*!40000 ALTER TABLE `project_favorites` ENABLE KEYS */; +UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/backend/sql/schema.sql b/backend/sql/schema.sql index 9751249..32df409 100644 --- a/backend/sql/schema.sql +++ b/backend/sql/schema.sql @@ -18,6 +18,12 @@ USE `chicken_king`; -- 删除旧表(按依赖顺序) -- ============================================================================ DROP TABLE IF EXISTS `votes`; +DROP TABLE IF EXISTS `project_favorites`; +DROP TABLE IF EXISTS `project_likes`; +DROP TABLE IF EXISTS `project_reviews`; +DROP TABLE IF EXISTS `project_review_assignments`; +DROP TABLE IF EXISTS `project_submissions`; +DROP TABLE IF EXISTS `projects`; DROP TABLE IF EXISTS `submissions`; DROP TABLE IF EXISTS `registrations`; DROP TABLE IF EXISTS `contests`; @@ -144,10 +150,170 @@ CREATE TABLE `submissions` ( KEY `ix_submissions_contest` (`contest_id`), KEY `ix_submissions_status` (`status`), KEY `ix_submissions_vote_count` (`vote_count` DESC), + KEY `ix_submissions_contest_status_vote` (`contest_id`, `status`, `vote_count` DESC), CONSTRAINT `fk_submissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_submissions_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品提交表'; +-- ============================================================================ +-- 作品表(部署体系) +-- ============================================================================ +CREATE TABLE `projects` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `contest_id` INT NOT NULL COMMENT '关联比赛ID', + `user_id` INT NOT NULL COMMENT '创建者ID', + + `title` VARCHAR(200) NOT NULL COMMENT '作品名称', + `summary` VARCHAR(500) NULL COMMENT '作品简介', + `description` TEXT NULL COMMENT '作品详情', + `repo_url` VARCHAR(500) NULL COMMENT '开源仓库地址', + `cover_image_url` VARCHAR(500) NULL COMMENT '封面图', + `screenshot_urls` JSON NULL COMMENT '截图列表', + `readme_url` VARCHAR(500) NULL COMMENT 'README 链接', + `demo_url` VARCHAR(500) NULL COMMENT '演示地址', + + `status` ENUM('draft', 'submitted', 'online', 'offline') NOT NULL DEFAULT 'draft' COMMENT '作品状态', + `current_submission_id` INT NULL COMMENT '当前线上 submission_id', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_projects_contest_user` (`contest_id`, `user_id`), + KEY `ix_projects_contest` (`contest_id`), + KEY `ix_projects_user` (`user_id`), + KEY `ix_projects_status` (`status`), + CONSTRAINT `fk_projects_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_projects_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品表'; + +-- ============================================================================ +-- 作品部署提交表 +-- ============================================================================ +CREATE TABLE `project_submissions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `contest_id` INT NOT NULL COMMENT '关联比赛ID', + `user_id` INT NOT NULL COMMENT '提交者ID', + + `image_ref` VARCHAR(500) NOT NULL COMMENT '镜像引用(含 digest)', + `image_registry` VARCHAR(100) NULL COMMENT '镜像仓库域名', + `image_repo` VARCHAR(300) NULL COMMENT '镜像仓库路径', + `image_digest` VARCHAR(128) NULL COMMENT '镜像 digest', + + `status` ENUM('created', 'queued', 'pulling', 'deploying', 'healthchecking', 'online', 'failed', 'stopped') + NOT NULL DEFAULT 'created' COMMENT '提交状态', + `status_message` VARCHAR(500) NULL COMMENT '状态说明', + `error_code` VARCHAR(100) NULL COMMENT '错误码', + `log` LONGTEXT NULL COMMENT '部署日志', + `domain` VARCHAR(255) NULL COMMENT '访问域名', + `status_history` JSON NULL COMMENT '状态历史', + + `submitted_at` DATETIME(6) NULL COMMENT '提交时间', + `online_at` DATETIME(6) NULL COMMENT '上线时间', + `failed_at` DATETIME(6) NULL COMMENT '失败时间', + + PRIMARY KEY (`id`), + KEY `ix_project_submissions_project` (`project_id`), + KEY `ix_project_submissions_contest` (`contest_id`), + KEY `ix_project_submissions_user` (`user_id`), + KEY `ix_project_submissions_status` (`status`), + KEY `ix_project_submissions_contest_status` (`contest_id`, `status`), + KEY `ix_project_submissions_submitted` (`submitted_at`), + CONSTRAINT `fk_project_submissions_project_id` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_contest_id` FOREIGN KEY (`contest_id`) REFERENCES `contests` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_submissions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品部署提交表'; + +-- ============================================================================ +-- 作品评审分配表 +-- ============================================================================ +CREATE TABLE `project_review_assignments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `reviewer_id` INT NOT NULL COMMENT '评审员用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_review_assignment` (`project_id`, `reviewer_id`), + KEY `ix_project_review_assignments_project` (`project_id`), + KEY `ix_project_review_assignments_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_review_assignments_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_review_assignments_reviewer_id` + FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审分配表'; + +-- ============================================================================ +-- 作品评审评分表 +-- ============================================================================ +CREATE TABLE `project_reviews` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `reviewer_id` INT NOT NULL COMMENT '评审员用户ID', + `score` SMALLINT NOT NULL COMMENT '评分(1-100)', + `comment` VARCHAR(2000) NULL COMMENT '评审意见(可选)', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_reviewer` (`project_id`, `reviewer_id`), + KEY `ix_project_reviews_project` (`project_id`), + KEY `ix_project_reviews_reviewer` (`reviewer_id`), + CONSTRAINT `fk_project_reviews_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_reviews_reviewer_id` + FOREIGN KEY (`reviewer_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评审评分表'; + +-- ============================================================================ +-- 作品点赞表 +-- ============================================================================ +CREATE TABLE `project_likes` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `user_id` INT NOT NULL COMMENT '点赞用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_like` (`project_id`, `user_id`), + KEY `ix_project_likes_project` (`project_id`), + KEY `ix_project_likes_user` (`user_id`), + CONSTRAINT `fk_project_likes_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_likes_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品点赞表'; + +-- ============================================================================ +-- 作品收藏表 +-- ============================================================================ +CREATE TABLE `project_favorites` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `project_id` INT NOT NULL COMMENT '关联作品ID', + `user_id` INT NOT NULL COMMENT '收藏用户ID', + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_project_favorite` (`project_id`, `user_id`), + KEY `ix_project_favorites_project` (`project_id`), + KEY `ix_project_favorites_user` (`user_id`), + CONSTRAINT `fk_project_favorites_project_id` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_project_favorites_user_id` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品收藏表'; + -- ============================================================================ -- 投票表 -- ============================================================================ @@ -167,6 +333,26 @@ CREATE TABLE `votes` ( CONSTRAINT `fk_votes_submission_id` FOREIGN KEY (`submission_id`) REFERENCES `submissions` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='投票表'; +-- ============================================================================ +-- 密码重置令牌表 +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( + `id` INT NOT NULL AUTO_INCREMENT, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + `user_id` INT NOT NULL COMMENT '用户ID', + `token_hash` VARCHAR(64) NOT NULL COMMENT '重置令牌哈希', + `expires_at` DATETIME(6) NOT NULL COMMENT '过期时间', + `used_at` DATETIME(6) NULL COMMENT '使用时间', + `requested_ip` VARCHAR(50) NULL COMMENT '请求IP', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_password_reset_token_hash` (`token_hash`), + KEY `ix_password_reset_user` (`user_id`), + CONSTRAINT `fk_password_reset_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='密码重置令牌表'; + SET FOREIGN_KEY_CHECKS = 1; -- ============================================================================ diff --git a/backend/sql/seed_production_config.sql b/backend/sql/seed_production_config.sql index 6f06de9..0aba2ee 100644 --- a/backend/sql/seed_production_config.sql +++ b/backend/sql/seed_production_config.sql @@ -3,7 +3,7 @@ -- 表: contests -INSERT INTO `contests` VALUES (1,'2025-12-16 16:39:21.705059','2025-12-16 16:39:21.705059','第一届鸡王争霸赛','# ikuncode 开发者实战大赏\n\n这是一场面向所有开发者的创意编程比赛,展示你的技术实力,赢取丰厚奖品!','signup','2025-12-16 16:39:21','2026-01-15 16:39:21',NULL,NULL,NULL,NULL); +INSERT INTO `contests` (`id`, `created_at`, `updated_at`, `title`, `description`, `phase`, `signup_start`, `signup_end`, `submit_start`, `submit_end`, `vote_start`, `vote_end`) VALUES (1,'2025-12-16 16:39:21.705059','2025-12-16 16:39:21.705059','第一届鸡王争霸赛','# ikuncode 开发者实战大赏\n\n这是一场面向所有开发者的创意编程比赛,展示你的技术实力,赢取丰厚奖品!','signup','2025-12-16 16:39:21','2026-01-15 16:39:21',NULL,NULL,NULL,NULL); -- 表: achievement_definitions diff --git a/deploy/webhook/MIGRATIONS.md b/deploy/webhook/MIGRATIONS.md index 6375680..a5f5d01 100644 --- a/deploy/webhook/MIGRATIONS.md +++ b/deploy/webhook/MIGRATIONS.md @@ -1,6 +1,7 @@ # 数据库迁移自动化 -部署脚本现在会在每次部署时自动执行数据库迁移,确保数据库 schema 与代码保持同步。 +默认由后端容器启动时自动执行数据库迁移(`AUTO_MIGRATE=true`),确保数据库 schema 与代码保持同步。 +如需仍由 `deploy/webhook/deploy.sh` 执行迁移,可设置 `AUTO_MIGRATE=false`。 ## 工作原理 @@ -8,9 +9,9 @@ **当数据库为空时**,自动导入 `backend/sql/production_clean_db.sql`: -- ✅ 50个表结构 -- ✅ 配置数据(比赛、成就、任务、抽奖、扭蛋、积分商城等) -- ✅ 示例报名项目(API Key Tool,包含完整的 GitHub 仓库和 API Key) +- 50个表结构 +- 配置数据(比赛、成就、任务、抽奖、扭蛋、积分商城等) +- 示例报名项目(API Key Tool,包含完整的 GitHub 仓库和 API Key) **兼容性**:如果找不到 `production_clean_db.sql`,会回退到旧方式: 1. 执行 `schema.sql`(表结构) @@ -31,9 +32,9 @@ CREATE TABLE schema_migrations ( ); ``` -### 3. 执行流程 +### 3. 执行流程(AUTO_MIGRATE=true) -部署脚本会在**容器重启后、健康检查前**自动执行: +后端容器 entrypoint 在启动时自动执行,部署脚本仅负责触发重建并等待健康检查: 1. 等待 MySQL 就绪(最多 30 秒) 2. 检查数据库表数量 @@ -41,14 +42,15 @@ CREATE TABLE schema_migrations ( - **如果 > 0** → 跳过初始化 3. 创建 `schema_migrations` 表(如果不存在) 4. 获取文件锁(防止并发执行) -5. 按文件序号顺序扫描 `backend/sql/[0-9][0-9][0-9]_*.sql` -6. 对每个迁移文件: +5. **若为首次初始化**:导入完成后自动将现有迁移标记为已执行,跳过增量迁移 +6. 按文件序号顺序扫描 `backend/sql/[0-9][0-9][0-9]_*.sql` +7. 对每个迁移文件: - 计算 SHA256 校验和 - 检查是否已执行(查询 `schema_migrations` 表) - 如已执行且校验和一致 → 跳过 - 如已执行但校验和不一致 → 告警(文件可能被改动) - 如未执行 → 执行 SQL 并记录版本 -7. 释放文件锁 +8. 释放文件锁 ### 4. 安全特性 @@ -76,7 +78,7 @@ tail -f /opt/chicken-king/deploy/webhook/logs/migrations.log cat /opt/chicken-king/deploy/webhook/logs/deploy.log # 或通过 Web 接口 -curl https://pk.ikuncode.cc/logs +curl https:///logs ``` ### 查询已执行的迁移 @@ -187,7 +189,7 @@ lsof /tmp/chicken_king_migrations.lock # 查看哪个进程持有锁 cd /opt/chicken-king # 手动执行迁移脚本(查看源码了解更多) -# 注意:通常不需要手动执行,部署时会自动运行 +# 注意:默认由后端容器启动时自动执行;如需强制手动执行,请先临时将 AUTO_MIGRATE=false bash deploy/webhook/deploy.sh ``` @@ -202,15 +204,13 @@ bash deploy/webhook/deploy.sh ## 示例:处理同序号文件 -注意:目前代码库中存在同序号的迁移文件(如 `004_*.sql`、`012_*.sql`),系统会按文件名排序执行它们: +注意:目前代码库中仍存在少量同序号迁移(如 `004_*.sql`),系统会按文件名排序执行它们: ```bash # 这些文件都会被执行(按字母序) 004_add_api_key.sql 004_announcements.sql - -012_request_logs.sql -012_submission_reviews.sql ``` +历史冲突文件已统一改为新序号(如 038/039/040),迁移执行器会兼容旧版本名。 建议:未来新增迁移时避免序号冲突,使用递增序号。 diff --git a/deploy/webhook/README.md b/deploy/webhook/README.md index e0b94b6..6f48995 100644 --- a/deploy/webhook/README.md +++ b/deploy/webhook/README.md @@ -1,6 +1,6 @@ # GitHub Webhook 自动部署 -当你 `git push` 到 `main` 分支时,服务器会自动拉取代码、执行数据库迁移、重新部署,并发送微信通知。 +当你 `git push` 到 `main` 分支时,服务器会自动拉取代码、执行数据库迁移并重新部署;如配置 `WECHAT_PUSH_URL` 则发送微信通知。 ## 服务器配置步骤 @@ -13,7 +13,7 @@ openssl rand -hex 32 记住这个密钥,后面要用。 -### 2. 配置环境变量 +### 2. 配置 Webhook 环境变量 ```bash cd /opt/chicken-king/deploy/webhook @@ -23,7 +23,20 @@ cp .env.example .env nano .env ``` -### 3. 启动 Webhook 服务 +可选配置: +- `WECHAT_PUSH_URL`:部署完成后的微信推送地址(不配置则不推送) + +### 3. 配置主项目 .env + +```bash +cd /opt/chicken-king +cp .env.production.example .env +nano .env +``` + +> `deploy/webhook/deploy.sh` 部署时会检查 `.env`,缺失则复制模板并中断,请先填写真实配置。 + +### 4. 启动 Webhook 服务 ```bash cd /opt/chicken-king/deploy/webhook @@ -38,7 +51,7 @@ docker compose up -d docker compose logs -f ``` -### 4. 配置 Nginx 反向代理 +### 5. 配置 Nginx 反向代理 在 Nginx 配置中添加: @@ -64,7 +77,7 @@ sudo nginx -t && sudo systemctl reload nginx 1. 打开仓库:https://github.com/deijing/ikun 2. 进入 **Settings** → **Webhooks** → **Add webhook** 3. 填写配置: - - **Payload URL**: `https://pk.ikuncode.cc/webhook` + - **Payload URL**: `https:///webhook` - **Content type**: `application/json` - **Secret**: 填入你生成的密钥(与服务器 .env 中的一致) - **Which events**: 选择 `Just the push event` @@ -75,12 +88,12 @@ sudo nginx -t && sudo systemctl reload nginx 1. 检查 Webhook 服务状态: ```bash -curl https://pk.ikuncode.cc/webhook/health +curl https:///webhook/health ``` 2. 查看部署日志: ```bash -curl https://pk.ikuncode.cc/logs +curl https:///logs # 或者 cat /opt/chicken-king/deploy/webhook/logs/deploy.log ``` @@ -131,8 +144,17 @@ bash deploy/webhook/deploy.sh - 详细迁移日志:`logs/migrations.log` - 详见:[MIGRATIONS.md](./MIGRATIONS.md) -### 微信推送通知 -部署完成后自动推送微信消息,包含: +### 微信推送通知(可选) +配置 `WECHAT_PUSH_URL` 后才会推送,包含: - 部署时间 - 环境信息 - 健康检查状态 + +--- + +## 补充说明 + +- 主项目 `.env` 需要配置 `WORKER_API_TOKEN`,用于 Worker 回写状态鉴权。 +- 生产 Nginx 证书需放在 `/opt/chicken-king/nginx/ssl`(`fullchain.pem` + `privkey.pem`)。 +- 首次部署会导入 `production_clean_db.sql` 并跳过增量迁移,请保证该文件为最新结构。 + diff --git a/deploy/webhook/deploy.sh b/deploy/webhook/deploy.sh index af3caea..0a16754 100644 --- a/deploy/webhook/deploy.sh +++ b/deploy/webhook/deploy.sh @@ -5,7 +5,7 @@ # # 工作流程: # 1. 拉取最新代码 -# 2. 自动生成生产环境配置(.env) +# 2. 检查生产环境配置(.env) # 3. 重建 Docker 容器 # 4. 执行数据库迁移(自动跟踪版本,避免重复执行) # 5. 健康检查 @@ -22,8 +22,8 @@ ENV_FILE="/opt/chicken-king/.env" MIGRATIONS_DIR="/opt/chicken-king/backend/sql" DB_CONTAINER="chicken_king_db" -# 微信推送 URL(从环境变量读取,避免硬编码凭据) -WECHAT_PUSH_URL="${WECHAT_PUSH_URL:-https://push.showdoc.com.cn/server/api/push/5409535da22b34bd6286f6e1caf4e9b41584836843}" +# 微信推送 URL(从环境变量读取) +WECHAT_PUSH_URL="${WECHAT_PUSH_URL:-}" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" @@ -50,9 +50,9 @@ send_wechat_notification() { -d "{\"title\":\"$title\",\"content\":\"$content\"}" \ --max-time 5 \ -o /dev/null -w "%{http_code}" | grep -q "200"; then - log "✅ 微信推送发送成功" + log "微信推送发送成功" else - log "⚠️ 微信推送发送失败(不影响部署)" + log "微信推送发送失败(不影响部署)" fi } @@ -95,7 +95,7 @@ wait_for_db() { log_migration "等待 MySQL 就绪..." while [ $retry -lt $max_retries ]; do if docker exec "$DB_CONTAINER" sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqladmin ping -h 127.0.0.1 -uroot --silent' >/dev/null 2>&1; then - log_migration "✅ MySQL 已就绪" + log_migration "MySQL 已就绪" return 0 fi retry=$((retry + 1)) @@ -103,7 +103,7 @@ wait_for_db() { sleep 2 done - log_migration "❌ MySQL 未在预期时间内就绪" + log_migration "MySQL 未在预期时间内就绪" return 1 } @@ -113,9 +113,9 @@ ensure_database_exists() { # 使用 mysql 系统数据库连接,创建目标数据库 if docker exec "$DB_CONTAINER" sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"' >/dev/null 2>&1; then - log_migration "✅ 数据库已就绪: \$MYSQL_DATABASE" + log_migration "数据库已就绪: \$MYSQL_DATABASE" else - log_migration "❌ 创建数据库失败" + log_migration "创建数据库失败" return 1 fi } @@ -153,10 +153,9 @@ validate_migration_filename() { run_migrations() { local files local lock_file="/tmp/chicken_king_migrations.lock" - local lock_fd if [ ! -d "$MIGRATIONS_DIR" ]; then - log_migration "⚠️ 迁移目录不存在,跳过: $MIGRATIONS_DIR" + log_migration "迁移目录不存在,跳过: $MIGRATIONS_DIR" return 0 fi @@ -172,15 +171,15 @@ run_migrations() { # 获取文件锁,防止并发执行(使用 flock) log_migration "尝试获取迁移锁: $lock_file" - exec {lock_fd}>"$lock_file" - if ! flock -n "$lock_fd"; then - log_migration "❌ 获取迁移锁失败(可能有另一个部署/迁移在运行)" - exec {lock_fd}>&- + exec 200>"$lock_file" + if ! flock -n 200; then + log_migration "获取迁移锁失败(可能有另一个部署/迁移在运行)" + exec 200>&- return 1 fi # 确保退出时释放锁 - trap "flock -u $lock_fd; exec {lock_fd}>&-" EXIT + trap "flock -u 200; exec 200>&-" EXIT # 检查是否需要执行初始数据库导入 local table_count is_fresh_install @@ -194,22 +193,22 @@ run_migrations() { # 优先使用 production_clean_db.sql(包含完整表结构 + 配置 + 示例数据) if [ -f "$MIGRATIONS_DIR/production_clean_db.sql" ]; then if docker exec -i "$DB_CONTAINER" sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot "$MYSQL_DATABASE"' < "$MIGRATIONS_DIR/production_clean_db.sql" >> "$MIGRATION_LOG_FILE" 2>&1; then - log_migration "✅ 初始数据库导入成功(包含表结构、配置数据、示例报名)" + log_migration "初始数据库导入成功(包含表结构、配置数据、示例报名)" else - log_migration "❌ 初始数据库导入失败(详见 $MIGRATION_LOG_FILE)" + log_migration "初始数据库导入失败(详见 $MIGRATION_LOG_FILE)" return 1 fi # 兼容旧方式:schema.sql + seed_production_config.sql elif [ -f "$MIGRATIONS_DIR/schema.sql" ]; then - log_migration "⚠️ 未找到 production_clean_db.sql,使用旧方式 schema.sql..." + log_migration "未找到 production_clean_db.sql,使用旧方式 schema.sql..." if docker exec -i "$DB_CONTAINER" sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot "$MYSQL_DATABASE"' < "$MIGRATIONS_DIR/schema.sql" >> "$MIGRATION_LOG_FILE" 2>&1; then - log_migration "✅ 基础 schema 执行成功" + log_migration "基础 schema 执行成功" # 导入配置数据 if [ -f "$MIGRATIONS_DIR/seed_production_config.sql" ]; then log_migration "导入配置数据..." if docker exec -i "$DB_CONTAINER" sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot "$MYSQL_DATABASE"' < "$MIGRATIONS_DIR/seed_production_config.sql" >> "$MIGRATION_LOG_FILE" 2>&1; then - log_migration "✅ 配置数据导入成功" + log_migration "配置数据导入成功" else log_migration "⚠️ 配置数据导入失败(非致命错误)" fi @@ -228,6 +227,42 @@ run_migrations() { ensure_migrations_table + if [ "$is_fresh_install" = true ]; then + log_migration "初始库已导入,标记历史迁移为已执行..." + while IFS= read -r file; do + [ -z "$file" ] && continue + [ ! -f "$file" ] && continue + + local base version version_num checksum existing_checksum + base="$(basename "$file")" + if ! validate_migration_filename "$base"; then + log_migration "跳过非法文件名: $base(仅允许:NNN_name.sql)" + continue + fi + + version="${base%.sql}" + version_num="${version%%_*}" + version_num=$((10#$version_num)) + checksum="$(sha256_file "$file")" + if [ "$checksum" = "unknown" ]; then + checksum="no-checksum" + fi + + existing_checksum="$(db_query "SELECT checksum FROM schema_migrations WHERE version='$(sql_escape "$version")' LIMIT 1;")" + if [ -n "$existing_checksum" ]; then + continue + fi + + db_exec "INSERT INTO schema_migrations (version, version_num, filename, checksum) VALUES ('$(sql_escape "$version")', $version_num, '$(sql_escape "$base")', '$(sql_escape "$checksum")');" + done <<< "$files" + + log_migration "已同步迁移记录,跳过增量迁移" + trap - EXIT + flock -u 200 2>/dev/null || true + exec 200>&- 2>/dev/null || true + return 0 + fi + log_migration "========== 开始执行数据库迁移 ==========" while IFS= read -r file; do [ -z "$file" ] && continue @@ -238,7 +273,7 @@ run_migrations() { # 文件名白名单校验 if ! validate_migration_filename "$base"; then - log_migration "⚠️ 跳过非法文件名: $base(仅允许:NNN_name.sql)" + log_migration "跳过非法文件名: $base(仅允许:NNN_name.sql)" continue fi @@ -248,7 +283,7 @@ run_migrations() { checksum="$(sha256_file "$file")" if [ "$checksum" = "unknown" ]; then - log_migration "⚠️ 无法计算校验和: $base(sha256sum/shasum 不可用)" + log_migration "无法计算校验和: $base(sha256sum/shasum 不可用)" checksum="no-checksum" fi @@ -256,39 +291,37 @@ run_migrations() { existing_checksum="$(db_query "SELECT checksum FROM schema_migrations WHERE version='$(sql_escape "$version")' LIMIT 1;")" if [ -n "$existing_checksum" ]; then if [ "$existing_checksum" != "$checksum" ] && [ "$checksum" != "no-checksum" ]; then - log_migration "⚠️ 已执行但校验和不一致(文件可能被改动): $base" - log_migration " 数据库: $existing_checksum" - log_migration " 文件: $checksum" + log_migration "已执行但校验和不一致(文件可能被改动): $base" + log_migration " 数据库: $existing_checksum" + log_migration " 文件: $checksum" else - log_migration "⏭️ 跳过已执行迁移: $base" + log_migration "跳过已执行迁移: $base" fi continue fi # 执行迁移 - log_migration "➡️ 执行迁移: $base" + log_migration "执行迁移: $base" { echo "[$(date '+%Y-%m-%d %H:%M:%S')] === APPLY $base ===" } >> "$MIGRATION_LOG_FILE" if ! docker exec -i "$DB_CONTAINER" sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot "$MYSQL_DATABASE"' < "$file" >> "$MIGRATION_LOG_FILE" 2>&1; then - log_migration "❌ 迁移失败: $base(详见 $MIGRATION_LOG_FILE)" + log_migration "迁移失败: $base(详见 $MIGRATION_LOG_FILE)" return 1 fi # 记录迁移版本 db_exec "INSERT INTO schema_migrations (version, version_num, filename, checksum) VALUES ('$(sql_escape "$version")', $version_num, '$(sql_escape "$base")', '$(sql_escape "$checksum")');" - log_migration "✅ 迁移完成: $base" + log_migration "迁移完成: $base" done <<< "$files" log_migration "========== 数据库迁移完成 ==========" # 清除 trap 并释放锁 trap - EXIT - if [ -n "${lock_fd:-}" ]; then - flock -u "$lock_fd" 2>/dev/null || true - exec {lock_fd}>&- 2>/dev/null || true - fi + flock -u 200 2>/dev/null || true + exec 200>&- 2>/dev/null || true } # 检查必要的依赖命令 @@ -306,13 +339,17 @@ else DOCKER_COMPOSE="docker-compose" fi +# Compose 文件(线上部署使用生产配置) +COMPOSE_FILES="-f docker-compose.prod.yml" +COMPOSE_CMD="$DOCKER_COMPOSE $COMPOSE_FILES" + # 确保日志目录存在 mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$MIGRATION_LOG_FILE")" touch "$LOG_FILE" "$MIGRATION_LOG_FILE" log "========== 开始部署 ==========" -log "使用命令: $DOCKER_COMPOSE" +log "使用命令: $COMPOSE_CMD" cd "$PROJECT_DIR" @@ -334,71 +371,42 @@ if [ "$SCRIPT_VERSION_BEFORE" != "$SCRIPT_VERSION_AFTER" ] && [ "$SCRIPT_VERSION exec bash "$0" "$@" fi -# 2. 生成生产环境配置 -log "生成生产环境配置..." +# 2. 检查生产环境配置 +log "检查生产环境配置..." -# 保留现有密码(如果 .env 文件已存在) EXISTING_MYSQL_ROOT_PASSWORD="" EXISTING_MYSQL_PASSWORD="" if [ -f "$ENV_FILE" ]; then EXISTING_MYSQL_ROOT_PASSWORD=$(grep "^MYSQL_ROOT_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2-) EXISTING_MYSQL_PASSWORD=$(grep "^MYSQL_PASSWORD=" "$ENV_FILE" | cut -d'=' -f2-) + log "已检测到 .env,跳过自动生成" +else + if [ -f "$PROJECT_DIR/.env.production.example" ]; then + cp "$PROJECT_DIR/.env.production.example" "$ENV_FILE" + log "未检测到 .env,已复制 .env.production.example,请填写后重试" + else + log "未检测到 .env 且缺少 .env.production.example,无法继续部署" + fi + exit 1 fi -# 使用现有密码或默认密码 +# 使用现有密码或默认密码(用于迁移连接) MYSQL_ROOT_PASSWORD="${EXISTING_MYSQL_ROOT_PASSWORD:-password}" MYSQL_PASSWORD="${EXISTING_MYSQL_PASSWORD:-password}" +AUTO_MIGRATE_FLAG="$(grep "^AUTO_MIGRATE=" "$ENV_FILE" | cut -d'=' -f2- | tr '[:upper:]' '[:lower:]')" +AUTO_MIGRATE_FLAG="${AUTO_MIGRATE_FLAG:-false}" -# 先写入固定内容的模板 -cat > "$ENV_FILE" << 'ENVEOF' -# ============================================================================ -# 生产环境配置 - 由部署脚本自动生成 -# ============================================================================ - -# MySQL 数据库(注意:修改密码需要删除 mysql 数据卷重建) -MYSQL_ROOT_PASSWORD=__MYSQL_ROOT_PASSWORD__ -MYSQL_DATABASE=chicken_king -MYSQL_USER=chicken -MYSQL_PASSWORD=__MYSQL_PASSWORD__ - -# 应用密钥(用于 JWT 签名) -SECRET_KEY=a3f8c9d2e5b7a1f4c8d0e3b6a9f2c5d8e1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6 - -# 前端 URL -FRONTEND_URL=https://pk.ikuncode.cc - -# Linux.do OAuth -LINUX_DO_CLIENT_ID=ZFdemXAFDfy9mQfCI1tsOTyzBEKJYXT1 -LINUX_DO_CLIENT_SECRET=xSAW4rhOyz6ejc9xrflf4XeRYY39Etex -LINUX_DO_REDIRECT_URI=https://pk.ikuncode.cc/api/v1/auth/linuxdo/callback - -# GitHub OAuth -GITHUB_CLIENT_ID=Ov23liWnv2Zcv4FPoi3H -GITHUB_CLIENT_SECRET=205aa31cc830ea3ed5f9f5cc5edbe9b61c9c59ca -GITHUB_REDIRECT_URI=https://pk.ikuncode.cc/api/v1/auth/github/callback - -# 前端 API URL(生产环境使用相对路径) -VITE_API_URL=/api/v1 - -# Umami 统计 -UMAMI_APP_SECRET=chicken_king_umami_secret_2024 -ENVEOF - -# 使用 sed 安全地替换密码(避免特殊字符问题) -# 使用 @ 作为分隔符而不是 / 以避免密码中的 / 字符冲突 -sed -i.bak "s@__MYSQL_ROOT_PASSWORD__@${MYSQL_ROOT_PASSWORD}@g" "$ENV_FILE" -sed -i.bak "s@__MYSQL_PASSWORD__@${MYSQL_PASSWORD}@g" "$ENV_FILE" -rm -f "${ENV_FILE}.bak" - -log "✅ 生产环境配置已生成" - -# 3. 重启容器(代码已通过 volume 挂载,无需重新构建) -log "重启容器以应用最新代码..." -$DOCKER_COMPOSE up -d +# 3. 重建并重启容器 +log "重建并重启容器以应用最新代码..." +$COMPOSE_CMD up -d --build # 4. 容器重启后执行数据库迁移(健康检查前) -log "执行数据库迁移..." -run_migrations +if [ "$AUTO_MIGRATE_FLAG" = "true" ]; then + log "检测到 AUTO_MIGRATE=true,跳过 deploy.sh 内置迁移" +else + log "执行数据库迁移..." + run_migrations +fi # 5. 等待服务启动并健康检查 log "等待服务启动..." @@ -412,7 +420,7 @@ HEALTH_OK=false while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do # 检查后端 API if curl -s http://localhost:8000/docs > /dev/null 2>&1; then - log "✅ 后端服务正常" + log "后端服务正常" HEALTH_OK=true break fi @@ -422,14 +430,14 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do done if [ "$HEALTH_OK" = false ]; then - log "⚠️ 后端服务可能还在启动中" + log "后端服务可能还在启动中" fi # 检查前端 if curl -s http://localhost:5174/health > /dev/null 2>&1; then - log "✅ 前端服务正常" + log "前端服务正常" else - log "⚠️ 前端健康检查未响应(可能正常,nginx 会代理)" + log "前端健康检查未响应(可能正常,nginx 会代理)" fi # 6. 清理旧镜像 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7bb0213..c603231 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,7 +22,6 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD} volumes: - mysql_data:/var/lib/mysql - - ./backend/sql/init:/docker-entrypoint-initdb.d:ro command: > --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 @@ -68,18 +67,12 @@ services: dockerfile: Dockerfile.prod container_name: chicken_king_backend restart: unless-stopped + env_file: + - ./.env environment: - - DEBUG=false - - SECRET_KEY=${SECRET_KEY} - - DATABASE_URL=mysql+aiomysql://${MYSQL_USER:-chicken}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-chicken_king} + - DEBUG=${DEBUG:-false} + - DATABASE_URL=mysql+aiomysql://${MYSQL_USER:-chicken}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-chicken_king}?charset=utf8mb4 - REDIS_URL=redis://redis:6379/0 - - FRONTEND_URL=${FRONTEND_URL} - - LINUX_DO_CLIENT_ID=${LINUX_DO_CLIENT_ID} - - LINUX_DO_CLIENT_SECRET=${LINUX_DO_CLIENT_SECRET} - - LINUX_DO_REDIRECT_URI=${LINUX_DO_REDIRECT_URI} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - - GITHUB_REDIRECT_URI=${GITHUB_REDIRECT_URI} depends_on: db: condition: service_healthy @@ -90,6 +83,42 @@ services: # 内部网络,不直接暴露 expose: - "8000" + # 持久化上传媒体目录 + volumes: + - media_data:/app/app/uploads + + # ========================================================================== + # Worker 服务(部署流程) + # ========================================================================== + worker: + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: chicken_king_worker + restart: unless-stopped + command: ["python", "-m", "app.worker"] + env_file: + - ./.env + environment: + - DEBUG=${DEBUG:-false} + - DATABASE_URL=mysql+aiomysql://${MYSQL_USER:-chicken}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-chicken_king}?charset=utf8mb4 + - REDIS_URL=redis://redis:6379/0 + - WORKER_API_BASE_URL=http://backend:8000 + - AUTO_MIGRATE=false + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + backend: + condition: service_started + volumes: + - /var/run/docker.sock:/var/run/docker.sock + user: root + networks: + - chicken_king_network + healthcheck: + disable: true # ========================================================================== # React 前端 (Nginx) @@ -107,6 +136,22 @@ services: expose: - "80" + # ========================================================================== + # Traefik 项目网关 + # ========================================================================== + traefik: + image: traefik:v2.10 + container_name: chicken_king_traefik + restart: unless-stopped + command: + - --entrypoints.web.address=:8081 + - --providers.docker=true + - --providers.docker.exposedbydefault=false + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - chicken_king_network + # ========================================================================== # Nginx 反向代理 (入口) # ========================================================================== @@ -124,10 +169,11 @@ services: depends_on: - frontend - backend + - traefik networks: - chicken_king_network healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"] interval: 30s timeout: 10s retries: 3 @@ -193,3 +239,5 @@ volumes: driver: local umami_db_data: driver: local + media_data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index f18f8d9..0052a85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,17 +38,11 @@ services: container_name: chicken_king_backend ports: - "127.0.0.1:8000:8000" # 仅绑定本地,通过 nginx 代理访问 + env_file: + - ./.env environment: - - DATABASE_URL=mysql+aiomysql://root:${MYSQL_ROOT_PASSWORD:-password}@db:3306/${MYSQL_DATABASE:-chicken_king} + - DATABASE_URL=mysql+aiomysql://root:${MYSQL_ROOT_PASSWORD:-password}@db:3306/${MYSQL_DATABASE:-chicken_king}?charset=utf8mb4 - REDIS_URL=redis://redis:6379/0 - - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production} - - FRONTEND_URL=${FRONTEND_URL:-http://127.0.0.1:5173} - - LINUX_DO_CLIENT_ID=${LINUX_DO_CLIENT_ID:-} - - LINUX_DO_CLIENT_SECRET=${LINUX_DO_CLIENT_SECRET:-} - - LINUX_DO_REDIRECT_URI=${LINUX_DO_REDIRECT_URI:-} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} - - GITHUB_REDIRECT_URI=${GITHUB_REDIRECT_URI:-} depends_on: db: condition: service_healthy @@ -58,6 +52,31 @@ services: - ./backend:/app restart: unless-stopped # 自动重启 + # Worker 服务(部署流程) + worker: + build: + context: ./backend + dockerfile: Dockerfile + container_name: chicken_king_worker + command: ["python", "-m", "app.worker"] + env_file: + - ./.env + environment: + - DATABASE_URL=mysql+aiomysql://root:${MYSQL_ROOT_PASSWORD:-password}@db:3306/${MYSQL_DATABASE:-chicken_king}?charset=utf8mb4 + - REDIS_URL=redis://redis:6379/0 + - WORKER_API_BASE_URL=http://backend:8000 + depends_on: + backend: + condition: service_started + redis: + condition: service_started + volumes: + - ./backend:/app + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped # 自动重启 + healthcheck: + disable: true + # React 前端 (生产模式 - Nginx 静态文件服务) frontend: build: @@ -102,6 +121,18 @@ services: retries: 5 restart: unless-stopped + # Traefik 项目网关 + traefik: + image: traefik:v2.10 + container_name: chicken_king_traefik + command: + - --entrypoints.web.address=:8081 + - --providers.docker=true + - --providers.docker.exposedbydefault=false + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + # Nginx 反向代理 nginx: image: nginx:alpine @@ -116,6 +147,7 @@ services: depends_on: - backend - frontend + - traefik restart: unless-stopped networks: - default diff --git a/docs/AUTO_DEPLOY.md b/docs/AUTO_DEPLOY.md index d5c2faf..5766921 100644 --- a/docs/AUTO_DEPLOY.md +++ b/docs/AUTO_DEPLOY.md @@ -2,128 +2,130 @@ ## 概述 -本项目已配置 GitHub Webhook 自动部署功能。当你 `git push` 到 `main` 分支时,服务器会自动拉取最新代码并重新部署所有服务。 +本项目使用 GitHub Webhook 自动部署。当你 `git push` 到 `main` 分支时,服务器会自动拉取最新代码、重建容器、执行数据库迁移并做健康检查。部署入口脚本为 `deploy/webhook/deploy.sh`,必要时可在服务器上手动执行作为兜底。 ## 架构图 -``` -┌─────────────┐ push ┌─────────────┐ -│ 本地开发 │ ───────────▶ │ GitHub │ -└─────────────┘ └──────┬──────┘ - │ webhook - ▼ -┌────────────────────────────────────────────────────────┐ -│ 服务器 (Ubuntu) │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Docker 容器 │ │ -│ │ │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ -│ │ │ nginx │ │ backend │ │ frontend │ │ │ -│ │ │ :80/443 │ │ :8000 │ │ :5174 │ │ │ -│ │ └────┬─────┘ └──────────┘ └──────────┘ │ │ -│ │ │ │ │ -│ │ ┌────┴─────┐ ┌──────────┐ ┌──────────┐ │ │ -│ │ │ webhook │ │ mysql │ │ redis │ │ │ -│ │ │ :9000 │ │ :3306 │ │ :6379 │ │ │ -│ │ └──────────┘ └──────────┘ └──────────┘ │ │ -│ └─────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + subgraph Deploy["部署链路"] + Dev[开发者] -->|git push| GitHub[GitHub] + GitHub -->|webhook| WebhookDeploy[webhook:9000] + WebhookDeploy -->|deploy.sh| Compose[docker compose] + Compose --> Stack[nginx / backend / frontend / worker / db / redis / traefik] + end + + subgraph Runtime["运行链路(请求)"] + Browser[浏览器] -->|https://| Nginx[nginx:80/443] + Nginx -->|/| Frontend[frontend:80] + Nginx -->|/api/*| Backend[backend:8000] + Backend --> MySQL[mysql:3306] + Backend --> Redis[redis:6379] + Nginx -->|/webhook| WebhookRuntime[webhook:9000] + Nginx -->|project-*| Traefik[traefik:8081] + Traefik --> Project[项目容器] + end + + subgraph WorkerTask["后台任务"] + Worker[worker] -->|调用 API| BackendTask[backend:8000] + Worker --- DockerSock[/var/run/docker.sock/] + end + + subgraph Optional["可选"] + Umami[umami:3000] --> UmamiDB[umami-db:5432] + end ``` ## 工作流程 1. **开发者推送代码** → `git push origin main` -2. **GitHub 发送 Webhook** → `POST https://pk.ikuncode.cc/webhook` -3. **Webhook 服务验证签名** → 使用 HMAC-SHA256 验证请求来源 -4. **执行部署脚本** → 生成 `.env` 生产配置 + 重建容器 -5. **拉取最新代码** → `git fetch && git reset --hard origin/main` -6. **重建 Docker 容器** → `docker compose build --no-cache && docker compose up -d` -7. **健康检查** → 等待后端服务启动并验证 +2. **GitHub 发送 Webhook** → `POST https:///webhook` +3. **Webhook 服务验证签名** → 触发 `deploy/webhook/deploy.sh` +4. **拉取最新代码** → `git fetch && git reset --hard origin/main` +5. **重建 Docker 容器** → `docker compose -f docker-compose.prod.yml up -d --build` +6. **执行数据库迁移** → 自动跟踪版本 +7. **健康检查** → 后端/前端检查 8. **清理旧镜像** → `docker image prune -f` --- ## 首次部署步骤 -### 1. 服务器准备 +### 1. 服务器准备(Git 克隆) ```bash # 克隆代码 cd /opt -git clone https://github.com/deijing/ikun.git chicken-king -cd chicken-king +git clone https://github.com/dejing/ikun.git chicken-king +cd /opt/chicken-king ``` +> 说明:Webhook 部署依赖 `git fetch/reset`,服务器目录必须是 Git 仓库。 + ### 2. 创建生产环境配置 ```bash -nano /opt/chicken-king/.env +cd /opt/chicken-king +cp .env.production.example .env +nano .env ``` -粘贴以下内容: +按 `Ctrl+O` 保存,`Ctrl+X` 退出。 + +> 注意:`deploy/webhook/deploy.sh` 会在部署时检查 `.env`,缺失会复制 `.env.production.example` 并中断,请确保已填写正确配置。 + +### 3. 启动主项目 ```bash -MYSQL_ROOT_PASSWORD=password -MYSQL_DATABASE=chicken_king -MYSQL_USER=chicken -MYSQL_PASSWORD=password -SECRET_KEY=a3f8c9d2e5b7a1f4c8d0e3b6a9f2c5d8e1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6 -FRONTEND_URL=https://pk.ikuncode.cc -LINUX_DO_CLIENT_ID=ZFdemXAFDfy9mQfCI1tsOTyzBEKJYXT1 -LINUX_DO_CLIENT_SECRET=xSAW4rhOyz6ejc9xrflf4XeRYY39Etex -LINUX_DO_REDIRECT_URI=https://pk.ikuncode.cc/api/v1/auth/linuxdo/callback -GITHUB_CLIENT_ID=Ov23liWnv2Zcv4FPoi3H -GITHUB_CLIENT_SECRET=205aa31cc830ea3ed5f9f5cc5edbe9b61c9c59ca -GITHUB_REDIRECT_URI=https://pk.ikuncode.cc/api/v1/auth/github/callback -VITE_API_URL=/api/v1 +cd /opt/chicken-king +docker compose -f docker-compose.prod.yml up -d --build ``` -按 `Ctrl+O` 保存,`Ctrl+X` 退出。 +> 说明:生产环境仅使用 `docker-compose.prod.yml`,会自动创建 `chicken-king_chicken_king_network` 网络供 webhook 使用。 -### 3. 启动 Webhook 服务 +### 4. 初始化数据库(自动) -```bash -# 创建 webhook 网络 -docker network create chicken-king_chicken_king_network +后端容器启动时会自动执行数据库迁移: -# 配置 webhook 密钥 -nano /opt/chicken-king/deploy/webhook/.env -# 填入:WEBHOOK_SECRET=你的64字符密钥 +- **空库**:自动导入 `backend/sql/production_clean_db.sql`(如不存在则回退 `schema.sql` + `seed_production_config.sql`) +- **已有库**:自动扫描 `backend/sql/NNN_*.sql` 并执行未记录的迁移 + +如需关闭自动迁移,可在 `.env` 中设置: -# 启动 webhook -cd /opt/chicken-king/deploy/webhook -docker compose up -d --build +``` +AUTO_MIGRATE=false ``` -### 4. 启动主项目 +如旧库没有 `schema_migrations` 表且已执行过部分迁移,可设置基线避免重复执行: -```bash -cd /opt/chicken-king -docker compose build --no-cache -docker compose up -d +``` +MIGRATION_BASELINE_VERSION=034 ``` -### 5. 初始化数据库 +### 5. 启动 Webhook 服务 ```bash -# 等待 MySQL 启动 -sleep 20 +# 创建 webhook 网络 +docker network create chicken-king_chicken_king_network -# 导入数据库(如有备份) -docker exec -i chicken_king_db mysql -uroot -ppassword chicken_king < /opt/chicken-king/chicken_king_dump.sql +# 配置 webhook 密钥 +nano /opt/chicken-king/deploy/webhook/.env +# 填入:WEBHOOK_SECRET=你的64字符密钥 +# 可选:WECHAT_PUSH_URL=微信推送地址 -# 或导入初始结构 -docker exec -i chicken_king_db mysql -uroot -ppassword chicken_king < /opt/chicken-king/backend/sql/schema.sql +# 启动 webhook +cd /opt/chicken-king/deploy/webhook +docker compose up -d --build ``` ### 6. 配置 GitHub Webhook -1. 访问 `https://github.com/deijing/ikun/settings/hooks` +1. 访问 `https://github.com/dejing/ikun/settings/hooks` 2. 点击 "Add webhook" 3. 配置: - - **Payload URL**: `https://pk.ikuncode.cc/webhook` + - **Payload URL**: `https:///webhook` - **Content type**: `application/json`(必须!) - - **Secret**: 与服务器 `.env` 中的 `WEBHOOK_SECRET` 一致 + - **Secret**: 与 `/opt/chicken-king/deploy/webhook/.env` 中的 `WEBHOOK_SECRET` 一致 - **Events**: Just the push event 4. 保存 @@ -183,19 +185,19 @@ docker exec -i chicken_king_db mysql -uroot -ppassword chicken_king < chicken_ki ``` /opt/chicken-king/ -├── .env # 生产环境配置(自动生成) -├── docker-compose.yml # 主项目容器编排 -├── chicken_king_dump.sql # 数据库备份 +├── .env # 生产环境配置(手动维护) +├── docker-compose.prod.yml # 生产环境编排(部署使用) +├── docker-compose.yml # 本地开发编排(可选) ├── backend/ # FastAPI 后端 ├── frontend/ # React 前端(生产模式用 nginx) -├── nginx/ +├── nginx/ # Nginx 反向代理 │ ├── nginx.prod.conf # Nginx 生产配置 │ ├── ssl/ # SSL 证书 │ └── logs/ # Nginx 日志 -└── deploy/ +└── deploy/ # Webhook 自动部署 └── webhook/ ├── app.py # Webhook Flask 服务 - ├── deploy.sh # 部署脚本(含生产配置生成) + ├── deploy.sh # 部署脚本(拉取/重建/迁移) ├── .env # Webhook 密钥 ├── Dockerfile ├── docker-compose.yml @@ -218,7 +220,7 @@ cd /opt/chicken-king && docker compose ps ### 查看日志 ```bash -# 部署日志 +# 部署日志(启用 Webhook 时) tail -f /opt/chicken-king/deploy/webhook/logs/deploy.log # 后端日志 @@ -235,10 +237,9 @@ tail -f /opt/chicken-king/nginx/logs/error.log ### 手动部署 ```bash +# 服务器:执行部署脚本(与 Webhook 流程一致) cd /opt/chicken-king -git pull -docker compose build --no-cache -docker compose up -d +bash deploy/webhook/deploy.sh ``` ### 重启单个服务 @@ -280,7 +281,7 @@ docker logs chicken_king_backend --tail 30 # 查看后端日志 docker compose up -d # 重启服务 ``` -### Webhook 返回 403 +### Webhook 返回 403(仅启用自动部署时) ```bash # 检查密钥 @@ -329,7 +330,7 @@ curl -v "http://localhost:8000/api/v1/auth/linuxdo/login" 2>&1 | grep "location: | 文件 | 说明 | |------|------| -| `deploy/webhook/deploy.sh` | 部署脚本,含生产配置自动生成 | +| `deploy/webhook/deploy.sh` | 部署脚本,拉取/重建/迁移 | | `deploy/webhook/app.py` | Webhook 接收服务 | | `docker-compose.yml` | 主项目容器编排 | | `nginx/nginx.prod.conf` | Nginx 反向代理配置 | diff --git a/docs/DEPLOY_PITFALLS.md b/docs/DEPLOY_PITFALLS.md new file mode 100644 index 0000000..d037ed3 --- /dev/null +++ b/docs/DEPLOY_PITFALLS.md @@ -0,0 +1,24 @@ +# 部署踩坑记录 + +> 目的:记录已踩过的问题与规避方式,避免重复翻车。 + +## 证书相关 +- Nginx 启动失败且容器不断重启,多数是证书路径不对。 +- 生产配置读取路径:`/opt/chicken-king/nginx/ssl/fullchain.pem`、`/opt/chicken-king/nginx/ssl/privkey.pem`。 +- 如果证书集中放在 `/opt/ssl`,需要先复制或软链到上述目录。 + +## Worker 回写 Token +- `WORKER_API_TOKEN` 是 Worker 回写状态接口的鉴权 Token,缺失会导致 Worker 启动后持续报错。 +- 修改 `.env` 后需要 **重建** 容器(不是简单 `docker restart`): +- `docker compose -f docker-compose.prod.yml up -d --force-recreate backend worker` + +## 初始库导入与迁移 +- 首次部署会导入 `backend/sql/production_clean_db.sql` 并**标记历史迁移已执行**。 +- 因此 `production_clean_db.sql` 必须包含最新结构字段,否则会出现运行时字段缺失(例如 `contests.visibility`)。 +- 修复方式: + - 更新 `production_clean_db.sql` 至最新结构;或 + - 手动补执行缺失迁移文件。 + +## 网络创建冲突 +- `docker-compose.prod.yml` 会创建 `chicken-king_chicken_king_network`。 +- 不要提前手动 `docker network create chicken-king_chicken_king_network`,否则会触发 label 冲突导致 `compose up` 失败。 diff --git "a/docs/\344\272\247\345\223\201\345\212\237\350\203\275\346\226\207\346\241\243.md" "b/docs/\344\272\247\345\223\201\345\212\237\350\203\275\346\226\207\346\241\243.md" index eaee74d..421ef4c 100644 --- "a/docs/\344\272\247\345\223\201\345\212\237\350\203\275\346\226\207\346\241\243.md" +++ "b/docs/\344\272\247\345\223\201\345\212\237\350\203\275\346\226\207\346\241\243.md" @@ -2,7 +2,7 @@ > ikuncode 开发者实战大赏平台 > -> 版本:v1.0 | 更新日期:2025-12-21 +> 版本:v1.2 | 更新日期:2025-12-27 --- @@ -18,6 +18,7 @@ 8. [API 设计规范](#8-api-设计规范) 9. [部署与运维](#9-部署与运维) 10. [业务流程图](#10-业务流程图) +11. [常见问题](#11-常见问题) --- @@ -75,7 +76,7 @@ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ Frontend │ │ Backend │ │ Webhook │ │ (React) │ │ (FastAPI) │ │ (Flask) │ -│ Port:5174 │ │ Port:8000 │ │ Port:9000 │ +│ Port:80 │ │ Port:8000 │ │ Port:9000 │ └───────────────┘ └───────┬───────┘ └───────────────┘ │ ┌─────────────────┼─────────────────┐ @@ -91,7 +92,7 @@ | 服务 | 端口 | 功能 | |------|------|------| | nginx | 80/443 | 反向代理、SSL、静态资源 | -| frontend | 5174 | React 前端应用 | +| frontend | 80 | React 前端应用 | | backend | 8000 | FastAPI 后端 API | | webhook | 9000 | GitHub Webhook 自动部署 | | mysql | 3306 | 主数据库 | @@ -149,22 +150,17 @@ | 方式 | 说明 | 优先级 | |------|------|--------| +| 本地账号 | 用户名/邮箱 + 密码 | 主要 | | Linux.do OAuth | 社区账号一键登录 | 主要 | -| GitHub OAuth | 开发者账号绑定 | 次要 | +| GitHub OAuth | 开发者账号绑定/登录 | 次要 | #### 4.1.2 认证流程 ``` -用户点击登录 +用户登录/注册 │ - ▼ -跳转 OAuth 授权页 - │ - ▼ -用户同意授权 - │ - ▼ -回调后端接口 + ├── 本地账号校验(用户名/邮箱 + 密码) + └── OAuth 授权(Linux.do / GitHub) │ ▼ 创建/更新用户 @@ -180,8 +176,21 @@ - **有效期**:7 天 - **刷新机制**:滑动刷新 +- **刷新接口**:`/auth/refresh` - **存储位置**:localStorage + Zustand +#### 4.1.4 账号安全与绑定 + +- 支持本地密码设置/修改(修改需旧密码) +- 支持邮件找回密码(重置链接有效期可配置) +- 支持 Linux.do / GitHub 账号绑定 +- 绑定冲突时提示合并确认(Linux.do) + +#### 4.1.5 风控与安全挑战 + +- 登录/注册/找回密码等接口内置限流 +- 触发式挑战:PoW / JS Challenge / 验证码(支持监控与强制模式) + --- ### 4.2 积分系统 @@ -279,6 +288,13 @@ UPCOMING ──→ SIGNUP ──→ SUBMISSION ──→ VOTING ──→ ENDED DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED ``` +#### 4.3.4 作品部署体系(Project) + +- 参赛者创建作品(Project),补充简介与仓库信息 +- 提交镜像 digest(Project Submission),进入部署队列 +- Worker 拉取镜像并执行健康检查,失败自动清理 +- 部署成功写入访问域名,作品入口页可直接访问 + --- ### 4.4 评审系统 @@ -298,6 +314,11 @@ DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED - 项目源码链接 - 其他评审员评分(匿名) +#### 4.4.3 评审榜与公示 + +- 评审榜按最终得分排序,3 人以上去掉最高最低取平均 +- 公示页展示 Top3 与完整榜单,支持查看详情 + --- ### 4.5 投票系统 @@ -336,6 +357,12 @@ DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED - 被打气者获得积分奖励 - 打气不影响比赛评分 +#### 4.6.3 作品互动(点赞/收藏) + +- 点赞/收藏仅针对已上线作品 +- 互动接口限流,防止刷榜 +- 支持点赞榜与收藏榜展示 + --- ### 4.7 活动中心 @@ -489,6 +516,7 @@ DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED #### 4.12.1 用户管理 - 查看用户列表 +- 编辑用户资料(用户名/昵称/邮箱) - 修改用户角色 - 禁用/启用账号 - 调整用户积分 @@ -688,6 +716,16 @@ submission_reviews -- 评审评分 votes -- 投票 ``` +#### 部署与评审 +```sql +projects -- 作品(部署体系) +project_submissions -- 部署提交 +project_review_assignments -- 评审分配 +project_reviews -- 评审评分 +project_likes -- 作品点赞 +project_favorites -- 作品收藏 +``` + #### 互动相关 ```sql cheers -- 打气记录 @@ -741,19 +779,27 @@ user_task_claims -- 领取记录 | 路由 | 页面 | 说明 | |------|------|------| | `/` | 首页 | 比赛介绍、快速入口 | -| `/login` | 登录页 | OAuth 登录 | +| `/login` | 登录页 | 本地登录 + OAuth | +| `/register` | 注册页 | 本地注册 | +| `/reset-password` | 重置密码 | 邮件重置 | +| `/account/security` | 账号安全 | 修改/设置密码、第三方绑定 | | `/role-guide` | 角色选择 | 新用户引导 | | `/activity` | 活动中心 | 所有小游戏 | | `/tasks` | 任务大厅 | 日/周任务 | | `/achievements` | 成就系统 | 成就进度 | | `/participants` | 选手列表 | 浏览选手 | -| `/contestant-center` | 参赛中心 | 我的报名 | +| `/my-project` | 参赛中心 | 我的作品 | | `/submit` | 作品提交 | 上传材料 | | `/submissions` | 作品展示 | 浏览作品 | +| `/projects/:projectId/access` | 作品访问入口 | 在线访问 | | `/prediction` | 竞猜市场 | 下注竞猜 | -| `/ranking` | 排行榜 | 票数排名 | +| `/ranking` | 排行榜 | 多维榜单(含评审榜) | +| `/ranking/review/:projectId` | 评审榜详情 | 单个作品评审数据 | +| `/announcement` | 公示页 | 评审榜公示 | | `/review-center` | 评审中心 | 评审专用 | -| `/admin` | 管理后台 | 管理专用 | +| `/admin/dashboard` | 管理后台 | 运营与管理 | +| `/admin/review` | 评审后台 | 报名审核 | +| `/admin/activity` | 活动配置 | 签到/抽奖/扭蛋/商城配置 | ### 7.2 组件架构 @@ -807,12 +853,14 @@ src/ | 模块 | 前缀 | 说明 | |------|------|------| -| 认证 | `/auth` | 登录、注册 | +| 认证 | `/auth` | 登录/注册/刷新/找回密码/账号绑定 | | 用户 | `/users` | 用户信息 | | 比赛 | `/contests` | 比赛管理 | | 报名 | `/registrations` | 报名管理 | -| 作品 | `/submissions` | 作品提交 | +| 作品 | `/submissions` | 旧作品提交体系 | +| 作品部署 | `/projects` | 作品部署与访问 | | 投票 | `/votes` | 投票功能 | +| 评审中心 | `/review-center` | 评审中心与评分 | | 积分 | `/points` | 积分系统 | | 抽奖 | `/lottery` | 抽奖功能 | | 扭蛋 | `/gacha` | 扭蛋功能 | @@ -845,6 +893,8 @@ Docker 重新构建 服务自动重启 ``` +> 详见 `docs/AUTO_DEPLOY.md`。 + ### 9.2 环境配置 **后端 `.env`**: @@ -853,7 +903,7 @@ DEBUG=false SECRET_KEY=your-secret-key DATABASE_URL=mysql+aiomysql://user:pass@host:3306/db REDIS_URL=redis://host:6379/0 -FRONTEND_URL=https://pk.ikuncode.cc +FRONTEND_URL=https://your-domain.com LINUX_DO_CLIENT_ID=xxx LINUX_DO_CLIENT_SECRET=xxx ``` @@ -863,6 +913,8 @@ LINUX_DO_CLIENT_SECRET=xxx VITE_API_URL=/api/v1 ``` +> 更多环境变量(如 SMTP、验证码、安全挑战、上传配额、Worker 等)见 `backend/.env.example`。 + ### 9.3 定时任务 | 任务 | 频率 | 功能 | @@ -881,7 +933,7 @@ VITE_API_URL=/api/v1 │ 用户旅程 │ ├─────────────────────────────────────────────────────────────┤ │ │ -│ 访问首页 ──→ OAuth登录 ──→ 角色选择 ──→ 进入平台 │ +│ 访问首页 ──→ 登录/注册(本地或 OAuth) ──→ 角色选择 ──→ 进入平台 │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ 每日行为 │ │ @@ -944,6 +996,19 @@ VITE_API_URL=/api/v1 --- +## 11. 常见问题 + +**Q: 点赞/收藏按钮不可用?** +A: 仅支持已上线作品,未上线的作品不开放互动。 + +**Q: 评审榜得分如何计算?** +A: 评分人数≥3 时去掉最高分与最低分后取平均,否则取平均分。 + +**Q: 公示页为空怎么办?** +A: 说明尚未产生评审结果或暂无上线作品,请先完成评审与发布。 + +--- + ## 附录 ### A. 枚举值参考 @@ -988,4 +1053,4 @@ VITE_API_URL=/api/v1 > 文档版本:v1.0 > -> 最后更新:2025-12-21 +> 最后更新:2025-12-24 diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 56269d3..3127089 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -37,7 +37,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html # 健康检查 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --quiet --tries=1 --spider http://localhost:80/health || exit 1 + CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/health || exit 1 # 暴露端口 EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 5bc8c2b..4849bda 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,6 +16,7 @@ events { http { include /etc/nginx/mime.types; default_type application/octet-stream; + charset utf-8; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 92fd9cb..2bfecb7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,8 @@ import NotFoundPage from './pages/NotFoundPage' // 懒加载页面 - 按需加载 const SubmissionsPage = lazy(() => import('./pages/SubmissionsPage')) const RankingPage = lazy(() => import('./pages/RankingPage')) +const AnnouncementPage = lazy(() => import('./pages/AnnouncementPage')) +const ReviewRankingDetailPage = lazy(() => import('./pages/ReviewRankingDetailPage')) const ParticipantsPage = lazy(() => import('./pages/ParticipantsPage')) const RegisterPage = lazy(() => import('./pages/RegisterPage')) const RoleGuidePage = lazy(() => import('./pages/RoleGuidePage')) @@ -29,6 +31,10 @@ const MyBetsPage = lazy(() => import('./pages/MyBetsPage')) const TasksPage = lazy(() => import('./pages/TasksPage')) const CodeChallengePage = lazy(() => import('./pages/CodeChallengePage')) const PointsHistoryPage = lazy(() => import('./pages/PointsHistoryPage')) +const ProjectAccessPage = lazy(() => import('./pages/ProjectAccessPage')) +const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage')) +const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage')) +const AccountSecurityPage = lazy(() => import('./pages/AccountSecurityPage')) // 加载中组件 function PageLoading() { @@ -62,6 +68,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -73,6 +81,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -80,6 +90,8 @@ function App() { } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/assets/avatar-default.svg b/frontend/src/assets/avatar-default.svg new file mode 100644 index 0000000..3be4862 --- /dev/null +++ b/frontend/src/assets/avatar-default.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/components/contest/AnnouncementBanner.jsx b/frontend/src/components/contest/AnnouncementBanner.jsx index f964f14..7d7d549 100644 --- a/frontend/src/components/contest/AnnouncementBanner.jsx +++ b/frontend/src/components/contest/AnnouncementBanner.jsx @@ -112,7 +112,7 @@ export default function AnnouncementBanner() { <> {/* 顶部横幅公告 - 未关闭的公告显示在这里 */} {currentBanner && bannerTypeConfig && ( -
+
diff --git a/frontend/src/components/contest/CTASection.jsx b/frontend/src/components/contest/CTASection.jsx index e928845..d70be56 100644 --- a/frontend/src/components/contest/CTASection.jsx +++ b/frontend/src/components/contest/CTASection.jsx @@ -8,9 +8,9 @@ import { RegistrationModal, PreparationGuideModal } from '../registration' /** * CTA 行动号召区组件 */ -export default function CTASection() { +export default function CTASection({ contestPhase, contestId }) { // 当前比赛 ID(后续可从 props 或 context 获取) - const contestId = 1 + const resolvedContestId = contestId ?? 1 // 认证状态 const token = useAuthStore((s) => s.token) @@ -32,17 +32,22 @@ export default function CTASection() { // 登录后检查报名状态(仅参赛者需要检查) useEffect(() => { if (!token || !isContestant) return - checkStatus(contestId).catch(() => {}) - }, [token, isContestant, checkStatus]) + checkStatus(resolvedContestId).catch(() => {}) + }, [token, isContestant, checkStatus, resolvedContestId]) // 是否可以编辑(参赛者角色、已报名且未撤回) const canEdit = token && isContestant && registration && status !== 'withdrawn' && status !== 'none' + const isRetireAction = !!contestPhase && contestPhase !== 'signup' && status === 'approved' + const withdrawLabel = isRetireAction ? '退赛' : '撤回报名' + const withdrawConfirmMessage = isRetireAction + ? '确定要退赛吗?退赛后将自动下线部署并删除运行容器,作品不再在公示/展示/投票中出现,且无法再报名。' + : '确定要撤回报名吗?撤回后将自动下线部署并删除运行容器,作品不再在公示/展示/投票中出现,可在报名期重新报名。' // 撤回报名 const handleWithdraw = async () => { - if (!window.confirm('确定要撤回报名吗?撤回后可以重新报名。')) return + if (!window.confirm(withdrawConfirmMessage)) return try { - await withdraw(contestId) + await withdraw(resolvedContestId) } catch { // 错误由 store 处理 } @@ -111,7 +116,7 @@ export default function CTASection() { className="px-6 py-4 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300 rounded-lg font-bold flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed" > - 撤回报名 + {withdrawLabel} )}
@@ -146,7 +151,7 @@ export default function CTASection() { {/* 报名弹窗 */} - + ) } diff --git a/frontend/src/components/contest/HeroSection.jsx b/frontend/src/components/contest/HeroSection.jsx index d048e13..f09661f 100644 --- a/frontend/src/components/contest/HeroSection.jsx +++ b/frontend/src/components/contest/HeroSection.jsx @@ -59,7 +59,7 @@ export default function HeroSection() { /** * 处理报名按钮点击 * 已登录且已报名:直接打开报名弹窗编辑 - * 已登录但未报名:打开事前准备引导 + * 已登录但未报名:打开赛前准备引导 * 未登录:滚动到报名区(CTA Section) */ const handleSignupClick = () => { diff --git a/frontend/src/components/contest/HotProjectsCarousel.jsx b/frontend/src/components/contest/HotProjectsCarousel.jsx index bcd702e..e95b4ee 100644 --- a/frontend/src/components/contest/HotProjectsCarousel.jsx +++ b/frontend/src/components/contest/HotProjectsCarousel.jsx @@ -15,6 +15,7 @@ import { Github, } from 'lucide-react' import api from '../../services/api' +import { resolveAvatarUrl } from '../../utils/avatar' // 提取技术栈标签 const getTechTags = (techStack) => { @@ -142,7 +143,7 @@ function ProjectCard({ project, rank, isActive }) {
{project.user?.username} @@ -257,19 +258,21 @@ function ProjectCard({ project, rank, isActive }) { ) } -export default function HotProjectsCarousel() { +export default function HotProjectsCarousel({ contestId }) { const [projects, setProjects] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) const [loading, setLoading] = useState(true) const [isPaused, setIsPaused] = useState(false) useEffect(() => { + if (!contestId) return const loadProjects = async () => { try { - const response = await api.get('/contests/1/cheer-leaderboard', { params: { limit: 5 } }) + setLoading(true) + const response = await api.get(`/contests/${contestId}/cheer-leaderboard`, { params: { limit: 5 } }) const items = response.items || [] if (items.length > 0) { - const regResponse = await api.get('/contests/1/registrations/public') + const regResponse = await api.get(`/contests/${contestId}/registrations/public`) const regMap = {} ;(regResponse.items || []).forEach(reg => { regMap[reg.id] = reg }) @@ -286,7 +289,7 @@ export default function HotProjectsCarousel() { } } loadProjects() - }, []) + }, [contestId]) useEffect(() => { if (projects.length <= 1 || isPaused) return diff --git a/frontend/src/components/contest/LiveFeedSection.jsx b/frontend/src/components/contest/LiveFeedSection.jsx index f79712a..3df2ffe 100644 --- a/frontend/src/components/contest/LiveFeedSection.jsx +++ b/frontend/src/components/contest/LiveFeedSection.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { Heart, Coffee, Zap, Pizza, Star, MessageCircle, Sparkles, RefreshCw, ChevronLeft, ChevronRight, GitCommit, Plus, Minus, Code2, Crown, Gift, Key } from 'lucide-react' import api from '../../services/api' import { cn } from '@/lib/utils' +import { resolveAvatarUrl } from '@/utils/avatar' /** * 打气类型配置 @@ -57,7 +58,7 @@ function CheerFeedItem({ item }) {
{/* 头像 */} {fromUser?.display_name @@ -108,7 +109,7 @@ function CommitFeedItem({ item }) {
{/* 头像 */} {user?.display_name @@ -173,7 +174,7 @@ function WinnerFeedItem({ item, index }) { {/* 头像带皇冠 */}
{item.display_name @@ -245,7 +246,7 @@ function TabButton({ active, onClick, icon: Icon, children, color }) { /** * 实时动态流组件 - 分页轮播版 + Tab 切换 */ -export default function LiveFeedSection() { +export default function LiveFeedSection({ contestId }) { const [activeTab, setActiveTab] = useState('cheers') // 'cheers' | 'commits' | 'winners' const [feeds, setFeeds] = useState([]) const [commits, setCommits] = useState([]) @@ -257,11 +258,12 @@ export default function LiveFeedSection() { // 加载应援动态 const loadCheers = async (isRefresh = false) => { + if (!contestId) return if (isRefresh) setRefreshing(true) else setLoading(true) try { - const response = await api.get('/contests/1/cheers/today', { + const response = await api.get(`/contests/${contestId}/cheers/today`, { params: { limit: ITEMS_PER_PAGE * TOTAL_PAGES } }) setFeeds(response.items || []) @@ -275,11 +277,12 @@ export default function LiveFeedSection() { // 加载提交动态 const loadCommits = async (isRefresh = false) => { + if (!contestId) return if (isRefresh) setRefreshing(true) else setLoading(true) try { - const response = await api.get('/contests/1/github-commits/recent', { + const response = await api.get(`/contests/${contestId}/github-commits/recent`, { params: { limit: ITEMS_PER_PAGE * TOTAL_PAGES } }) setCommits(response.items || []) @@ -311,6 +314,7 @@ export default function LiveFeedSection() { // 刷新当前 Tab 数据 const refreshData = useCallback((isRefresh = false) => { + if (!contestId) return if (activeTab === 'cheers') { loadCheers(isRefresh) } else if (activeTab === 'commits') { @@ -318,7 +322,7 @@ export default function LiveFeedSection() { } else { loadWinners(isRefresh) } - }, [activeTab]) + }, [activeTab, contestId]) // 初始加载和自动刷新 useEffect(() => { @@ -327,7 +331,7 @@ export default function LiveFeedSection() { // 每30秒自动刷新数据 const interval = setInterval(() => refreshData(true), 30000) return () => clearInterval(interval) - }, [activeTab]) + }, [activeTab, refreshData]) // Tab 切换时重置页码 useEffect(() => { diff --git a/frontend/src/components/contest/MarkdownSection.jsx b/frontend/src/components/contest/MarkdownSection.jsx new file mode 100644 index 0000000..ee8bf82 --- /dev/null +++ b/frontend/src/components/contest/MarkdownSection.jsx @@ -0,0 +1,22 @@ +/** + * Markdown 内容展示区(简易版) + */ +export default function MarkdownSection({ title, content, id }) { + const text = String(content || '').trim() + if (!text) return null + + return ( +
+
+
+

{title}

+
+
+
+            {text}
+          
+
+
+
+ ) +} diff --git a/frontend/src/components/contest/PrizesSection.jsx b/frontend/src/components/contest/PrizesSection.jsx index 9a31a3b..22335c7 100644 --- a/frontend/src/components/contest/PrizesSection.jsx +++ b/frontend/src/components/contest/PrizesSection.jsx @@ -1,27 +1,45 @@ -import { Trophy, Users } from 'lucide-react' +import { Trophy, Users, Gift, Ticket, Gem, Coins } from 'lucide-react' import { PRIZE_CONFIG } from './constants' +const DEFAULT_PARTICIPATION = { + title: '阳光普照奖(参与奖)', + description: '凡提交合格开源作品的选手', + reward: '8.5折充值优惠券', + extra: '平台专属"ikun开发者徽章"', +} + /** * 奖项设置区组件 */ -export default function PrizesSection() { +export default function PrizesSection({ templateConfig }) { + const prizeOverrides = templateConfig?.prizes || {} + const sectionTitle = templateConfig?.prize_section?.title || '奖项设置' + const sectionIntro = + templateConfig?.prize_section?.intro || '核心机制:只要你敢写且获奖,Token 消耗 ikuncode 全额买单!' + const championConfig = { ...PRIZE_CONFIG.champion, ...prizeOverrides.champion } + const secondConfig = { ...PRIZE_CONFIG.second, ...prizeOverrides.second } + const thirdConfig = { ...PRIZE_CONFIG.third, ...prizeOverrides.third } + const participationConfig = { ...DEFAULT_PARTICIPATION, ...prizeOverrides.participation } + const championButtonUrl = + championConfig.buttonUrl || championConfig.button_url || 'https://api.ikuncode.cc/' + return (

- 奖项设置 + {sectionTitle}

- 核心机制:只要你敢写且获奖,Token 消耗 ikuncode 全额买单! + {sectionIntro}

- - - + + +
{/* 阳光普照奖 */} @@ -31,15 +49,16 @@ export default function PrizesSection() {
-

- 阳光普照奖 (参与奖) +

+

-

凡提交合格开源作品的选手

+

{participationConfig.description}

-

8.5折充值优惠券

-

+ 平台专属"ikun开发者"徽章

+

{participationConfig.reward}

+

+ {participationConfig.extra}

@@ -50,7 +69,7 @@ export default function PrizesSection() { /** * 冠军卡片 */ -function ChampionCard({ config }) { +function ChampionCard({ config, buttonUrl }) { return (
@@ -67,7 +86,7 @@ function ChampionCard({ config }) {
  • - +
  • - +
    API 消耗 {config.apiReturn} @@ -85,7 +104,7 @@ function ChampionCard({ config }) {
  • - +
    至尊特权 {config.discount} 充值券 @@ -96,7 +115,7 @@ function ChampionCard({ config }) {
  • - +
    API 消耗 {config.apiReturn} @@ -141,7 +160,7 @@ function RunnerUpCard({ place, config }) {
  • - +
    {config.discount} 充值优惠券 × 1 diff --git a/frontend/src/components/contest/RulesSection.jsx b/frontend/src/components/contest/RulesSection.jsx index a7b2d53..501229f 100644 --- a/frontend/src/components/contest/RulesSection.jsx +++ b/frontend/src/components/contest/RulesSection.jsx @@ -4,7 +4,17 @@ import { RISK_RULES, REVIEW_WEIGHTS, PROCESS_STEPS } from './constants' /** * 风控规则与评审区组件 */ -export default function RulesSection() { +export default function RulesSection({ templateConfig }) { + const riskRules = templateConfig?.risk_rules?.length ? templateConfig.risk_rules : RISK_RULES + const baseReviewWeights = templateConfig?.review_weights?.length + ? templateConfig.review_weights + : REVIEW_WEIGHTS + const reviewWeights = baseReviewWeights.map((item, index) => ({ + ...REVIEW_WEIGHTS[index % REVIEW_WEIGHTS.length], + ...item, + })) + const processSteps = templateConfig?.process_steps?.length ? templateConfig.process_steps : PROCESS_STEPS + return (
    @@ -13,11 +23,11 @@ export default function RulesSection() {

    - 风控与返还规则(必读) + 风控与返还规则(必读)

      - {RISK_RULES.map((rule) => ( + {riskRules.map((rule) => (
    • {rule.label}: {rule.content} @@ -34,7 +44,7 @@ export default function RulesSection() { 评审机制
      - {REVIEW_WEIGHTS.map((item) => ( + {reviewWeights.map((item) => (
      {item.title} @@ -59,7 +69,7 @@ export default function RulesSection() { {/* 时间轴 - 确保圆点与竖线对齐 */}
      - {PROCESS_STEPS.map((step) => ( + {processSteps.map((step) => (
      {step.step} diff --git a/frontend/src/components/contest/constants.js b/frontend/src/components/contest/constants.js index b0ddfb3..b13474c 100644 --- a/frontend/src/components/contest/constants.js +++ b/frontend/src/components/contest/constants.js @@ -64,10 +64,22 @@ export const PRIZE_CONFIG = { * 风控规则 */ export const RISK_RULES = [ - { label: '消耗认定', content: '仅统计活动周期内,由参赛专用 Key 产生的真实有效调用。' }, - { label: '返还形式', content: '费用返还以平台余额形式发放。' }, - { label: '折扣券说明', content: '优惠券为一次性使用(建议大额充值时使用以最大化收益),有效期为发奖后30天内。' }, - { label: '严禁作弊', content: '后台部署了严格的流量分析系统,恶意刷量、空跑脚本将直接取消参赛及返还资格。' }, + { + label: '消耗认定', + content: '仅统计活动周期内,由参赛专用 Key 产生的真实有效调用。', + }, + { + label: '返还形式', + content: '费用返还以平台余额形式发放。', + }, + { + label: '折扣券说明', + content: '优惠券为一次性使用(建议大额充值时使用以最大化收益),有效期为发奖后 30 天内。', + }, + { + label: '严禁作弊', + content: '后台部署了严格的流量分析系统,恶意刷量、空跑脚本将直接取消参赛及返还资格。', + }, ] /** @@ -76,20 +88,25 @@ export const RISK_RULES = [ export const PROCESS_STEPS = [ { step: 1, - title: '准备 (Register)', - description: '注册 ikuncode,创建名为', + title: '准备 (Prepare)', + description: '登录 ikuncode,准备参赛项目,并创建名为', highlight: 'ikun-contest', suffix: '的专用 API Key。', }, { step: 2, - title: '开发 (Coding)', - description: '题材不限(插件/IDE工具/脚本)。需开源至 GitHub/Gitee 并真实调用 API。', + title: '报名 (Signup)', + description: '填写报名表单,提交项目简介、计划、技术栈与联系方式。', }, { step: 3, + title: '开发 (Build)', + description: '持续完善项目,开源至 GitHub/Gitee,并真实调用 ikuncode API。', + }, + { + step: 4, title: '提交 (Submit)', - description: '截止日前填写报名表单(项目地址、视频、账号信息)。', + description: '提交期上传作品材料与镜像部署,进入评审/投票/公示环节。', }, ] diff --git a/frontend/src/components/contestant/ContestantDashboard.jsx b/frontend/src/components/contestant/ContestantDashboard.jsx index ed5dccc..edc4fd5 100644 --- a/frontend/src/components/contestant/ContestantDashboard.jsx +++ b/frontend/src/components/contestant/ContestantDashboard.jsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import api from '@/services/api' import ParticipantDetailModal from '@/components/participant/ParticipantDetailModal' +import { useContestId } from '@/hooks/useContestId' import { CheckCircle2, Clock, @@ -335,12 +336,14 @@ export function ContestantStatusCard({ className, variant = 'default' }) { * 参赛者完整仪表盘 * 用于首页或独立页面 */ -export default function ContestantDashboard({ className }) { +export default function ContestantDashboard({ className, contestId: contestIdProp }) { const user = useAuthStore((s) => s.user) const registration = useRegistrationStore((s) => s.registration) const status = useRegistrationStore((s) => s.status) const checkStatus = useRegistrationStore((s) => s.checkStatus) const openModal = useRegistrationStore((s) => s.openModal) + const { contestId: fallbackContestId } = useContestId() + const contestId = contestIdProp ?? fallbackContestId const [githubStats, setGithubStats] = useState(null) const [quotaData, setQuotaData] = useState(null) @@ -349,12 +352,12 @@ export default function ContestantDashboard({ className }) { // 检查报名状态 useEffect(() => { - if (user) { - checkStatus(1).finally(() => setLoading(false)) + if (user?.role === 'contestant' && contestId) { + checkStatus(contestId).finally(() => setLoading(false)) } else { setLoading(false) } - }, [user, checkStatus]) + }, [user, checkStatus, contestId]) // 加载额外数据 useEffect(() => { diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index ce5f9fa..66040d7 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -39,7 +39,7 @@ export default function Layout() { }, [location.pathname, location.hash]) return ( -
      +
      {/* 全局公告横幅 */} diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index 5a396d8..32a456a 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -1,11 +1,17 @@ import { useState, useRef, useEffect } from 'react' import { Link, useLocation } from 'react-router-dom' -import { Menu, X, Sun, Moon, Monitor, LogOut, User, Shield, Crown, Sparkles, Zap, Coffee, Gavel, CheckCircle, Target, GitCommit, Flame, Edit3, ChevronRight, Clock, CheckCircle2, XCircle, Award, Gift, Coins, Settings, LayoutDashboard } from 'lucide-react' +import { Menu, X, Sun, Moon, Monitor, LogOut, User, Shield, Crown, Sparkles, Zap, Coffee, Gavel, CheckCircle, Target, GitCommit, Flame, Edit3, ChevronRight, Clock, CheckCircle2, XCircle, Award, Gift, Coins, Settings, LayoutDashboard, Upload } from 'lucide-react' import { useAuthStore } from '../../stores/authStore' import { useThemeStore } from '../../stores/themeStore' import { useRegistrationStore } from '../../stores/registrationStore' import { Badge } from '../ui/badge' import logo from '@/assets/logo.png' +import { resolveAvatarUrl } from '@/utils/avatar' +import { useToast } from '@/components/Toast' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui' +import { userApi } from '@/services' +import { IMAGE_ACCEPT, validateImageFile } from '@/utils/media' +import { useContestId } from '@/hooks/useContestId' const THEME_ICONS = { light: Sun, @@ -78,10 +84,13 @@ const ROLE_CONFIG = { }, } +const AVATAR_MAX_BYTES = 2 * 1024 * 1024 + const NAV_ITEMS = [ { path: '/', label: '首页' }, { path: '/submissions', label: '作品展示' }, { path: '/ranking', label: '排行榜' }, + { path: '/announcement', label: '公示', icon: Award }, { path: '/participants', label: '参赛选手' }, { path: '/activity', label: '疯狂娱乐城', icon: Gift, highlight: true }, ] @@ -116,19 +125,40 @@ const REGISTRATION_STATUS_CONFIG = { function UserDropdown({ user, logout }) { const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(null) + const avatarInputRef = useRef(null) + const [avatarUploading, setAvatarUploading] = useState(false) + const [profileOpen, setProfileOpen] = useState(false) + const [profileSaving, setProfileSaving] = useState(false) + const [profileForm, setProfileForm] = useState({ + username: user?.username || '', + display_name: user?.display_name || '', + email: user?.email || '', + }) + const toast = useToast() + const setUser = useAuthStore((s) => s.setUser) // 获取报名状态 const registration = useRegistrationStore((s) => s.registration) const regStatus = useRegistrationStore((s) => s.status) const openModal = useRegistrationStore((s) => s.openModal) const checkStatus = useRegistrationStore((s) => s.checkStatus) + const { contestId } = useContestId() // 首次加载时检查报名状态 useEffect(() => { - if (user && regStatus === 'unknown') { - checkStatus(1) + if (user?.role === 'contestant' && regStatus === 'unknown') { + checkStatus(contestId) } - }, [user, regStatus, checkStatus]) + }, [user, regStatus, checkStatus, contestId]) + + useEffect(() => { + if (!user) return + setProfileForm({ + username: user.username || '', + display_name: user.display_name || '', + email: user.email || '', + }) + }, [user]) // 点击外部关闭 useEffect(() => { @@ -148,6 +178,71 @@ function UserDropdown({ user, logout }) { const config = ROLE_CONFIG[roleKey] || ROLE_CONFIG.spectator const Icon = config.icon + const handleAvatarPick = () => { + if (avatarUploading) return + avatarInputRef.current?.click() + } + + const handleAvatarChange = async (event) => { + const file = event.target.files?.[0] || null + event.target.value = '' + if (!file) return + const error = validateImageFile(file, AVATAR_MAX_BYTES) + if (error) { + toast.error(error) + return + } + setAvatarUploading(true) + try { + const updated = await userApi.uploadAvatar(file) + setUser(updated) + toast.success('头像已更新') + } catch (err) { + const detail = err?.response?.data?.detail || '头像上传失败' + toast.error(detail) + } finally { + setAvatarUploading(false) + } + } + + const handleProfileSave = async () => { + if (!user) return + const username = profileForm.username.trim() + if (!username) { + toast.error('用户名不能为空') + return + } + const displayName = profileForm.display_name?.trim() || null + const emailValue = profileForm.email?.trim().toLowerCase() || null + const payload = {} + if (username !== user.username) { + payload.username = username + } + if ((user.display_name || null) !== displayName) { + payload.display_name = displayName + } + if ((user.email || null) !== emailValue) { + payload.email = emailValue + } + if (Object.keys(payload).length === 0) { + toast.warning('未检测到可更新内容') + setProfileOpen(false) + return + } + setProfileSaving(true) + try { + const updated = await userApi.updateMe(payload) + setUser(updated) + toast.success('资料已更新') + setProfileOpen(false) + } catch (err) { + const detail = err?.response?.data?.detail || '资料更新失败' + toast.error(detail) + } finally { + setProfileSaving(false) + } + } + return (
      + + PNG/JPG/WebP/GIF,≤2MB +
      {/* 信任等级进度 - 仅 Linux.do 用户显示 */} {user.linux_do_id && (
      @@ -416,6 +545,14 @@ function UserDropdown({ user, logout }) { {/* 底部按钮 */}
      + setIsOpen(false)} + className="w-full flex items-center justify-center space-x-2 px-4 py-3 text-sm font-bold text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] mb-2" + > + + 账号安全 +
      )} + + + + + 编辑资料 + +
      +
      + + setProfileForm((prev) => ({ ...prev, username: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + placeholder="请输入用户名" + /> +
      +
      + + setProfileForm((prev) => ({ ...prev, display_name: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + placeholder="可选,展示用昵称" + /> +
      +
      + + setProfileForm((prev) => ({ ...prev, email: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + placeholder="可选,用于找回密码" + /> +
      +
      + + + + +
      +
      ) } @@ -520,12 +714,20 @@ export default function Navbar() {
      ) : ( - - 登录 - +
      + + 登录 + + + 注册 + +
      )} @@ -638,6 +840,14 @@ export default function Navbar() { )} + setIsMenuOpen(false)} + className="flex items-center gap-2 px-3 py-2 rounded-md text-base font-medium text-slate-600 dark:text-slate-300" + > + + 账号安全 + ) : ( - setIsMenuOpen(false)} - className="block px-3 py-2 rounded-md text-base font-medium text-yellow-500 dark:text-yellow-400" - > - 登录 - + <> + setIsMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium text-yellow-500 dark:text-yellow-400" + > + 登录 + + setIsMenuOpen(false)} + className="block px-3 py-2 rounded-md text-base font-medium text-yellow-500 dark:text-yellow-400" + > + 注册 + + )}
      )} ) -} \ No newline at end of file +} diff --git a/frontend/src/components/participant/ParticipantDetailModal.jsx b/frontend/src/components/participant/ParticipantDetailModal.jsx index 2673596..c7e8244 100644 --- a/frontend/src/components/participant/ParticipantDetailModal.jsx +++ b/frontend/src/components/participant/ParticipantDetailModal.jsx @@ -13,6 +13,7 @@ import { useToast } from '@/components/Toast' import { cn } from '@/lib/utils' import api from '@/services/api' import { lotteryApi } from '@/services' +import { resolveAvatarUrl } from '@/utils/avatar' import { Github, GitCommit, @@ -692,7 +693,7 @@ export default function ParticipantDetailModal({ participant, open, onClose, ini
      {participant.user?.display_name} @@ -1000,7 +1001,7 @@ export default function ParticipantDetailModal({ participant, open, onClose, ini
      {user?.display_name} @@ -1067,7 +1068,7 @@ export default function ParticipantDetailModal({ participant, open, onClose, ini >
      {msg.user?.display_name} diff --git a/frontend/src/hooks/useContestId.js b/frontend/src/hooks/useContestId.js new file mode 100644 index 0000000..fee0439 --- /dev/null +++ b/frontend/src/hooks/useContestId.js @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import { contestApi } from '@/services' + +const DEFAULT_CONTEST_ID = Number(import.meta.env.VITE_CONTEST_ID ?? 1) + +/** + * 获取当前比赛 ID(失败时使用默认值) + */ +export function useContestId() { + const query = useQuery({ + queryKey: ['contest', 'current'], + queryFn: async () => { + try { + const res = await contestApi.getCurrent({ include_ended: true }) + return res?.id ?? DEFAULT_CONTEST_ID + } catch (error) { + console.warn('获取当前比赛失败,使用默认比赛ID', error) + return DEFAULT_CONTEST_ID + } + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + }) + + return { + contestId: query.data ?? DEFAULT_CONTEST_ID, + contestQuery: query, + } +} diff --git a/frontend/src/lib/apiBaseUrl.js b/frontend/src/lib/apiBaseUrl.js new file mode 100644 index 0000000..d8e7980 --- /dev/null +++ b/frontend/src/lib/apiBaseUrl.js @@ -0,0 +1,16 @@ +/** + * 统一处理 API 基地址,避免 HTTPS 页面触发 Mixed Content。 + */ +export function getApiBaseUrl() { + const raw = String(import.meta.env.VITE_API_URL || '/api/v1').trim() + if (typeof window === 'undefined') return raw + + if (raw.startsWith('https://')) return raw + if (raw.startsWith('http://')) { + if (window.location.protocol === 'https:') return raw.replace('http://', 'https://') + return raw + } + if (raw.startsWith('//')) return `${window.location.protocol}${raw}` + if (raw.startsWith('/')) return raw + return `/${raw}` +} diff --git a/frontend/src/pages/AccountSecurityPage.jsx b/frontend/src/pages/AccountSecurityPage.jsx new file mode 100644 index 0000000..1b2663f --- /dev/null +++ b/frontend/src/pages/AccountSecurityPage.jsx @@ -0,0 +1,297 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useAuthStore } from '../stores/authStore' +import { authApi } from '../services' +import { useToast } from '../components/Toast' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' + +function getErrorMessage(error) { + const detail = error?.response?.data?.detail + if (typeof detail === 'string' && detail) return detail + if (detail && typeof detail === 'object') { + if (typeof detail.message === 'string' && detail.message) return detail.message + } + if (typeof error?.message === 'string' && error.message) return error.message + return '操作失败,请稍后重试' +} + +export default function AccountSecurityPage() { + const toast = useToast() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const user = useAuthStore((s) => s.user) + const token = useAuthStore((s) => s.token) + const refreshUser = useAuthStore((s) => s.refreshUser) + + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [savingPassword, setSavingPassword] = useState(false) + const [mergeOpen, setMergeOpen] = useState(false) + const [mergeLoading, setMergeLoading] = useState(false) + + const [bindingLoading, setBindingLoading] = useState({ + linuxdo: false, + github: false, + }) + + useEffect(() => { + if (!token) { + navigate('/login?next=/account/security', { replace: true }) + } + }, [token, navigate]) + + useEffect(() => { + if (searchParams.get('merge') === 'linuxdo') { + setMergeOpen(true) + } + }, [searchParams]) + + const hasPassword = !!user?.has_password + + const handlePasswordSubmit = async (event) => { + event.preventDefault() + if (!newPassword) { + toast.warning('请输入新密码') + return + } + if (newPassword.length < 8) { + toast.warning('密码至少 8 位') + return + } + if (new TextEncoder().encode(newPassword).length > 72) { + toast.warning('密码长度过长') + return + } + if (newPassword !== confirmPassword) { + toast.warning('两次密码不一致') + return + } + if (hasPassword && !oldPassword) { + toast.warning('请输入旧密码') + return + } + setSavingPassword(true) + try { + if (hasPassword) { + await authApi.changePassword({ old_password: oldPassword, new_password: newPassword }) + } else { + await authApi.setPassword({ password: newPassword }) + } + setOldPassword('') + setNewPassword('') + setConfirmPassword('') + await refreshUser() + toast.success(hasPassword ? '密码已更新' : '密码已设置') + } catch (error) { + toast.error(getErrorMessage(error)) + } finally { + setSavingPassword(false) + } + } + + const handleBind = async (provider) => { + setBindingLoading((prev) => ({ ...prev, [provider]: true })) + try { + const payload = { next: '/account/security' } + const result = + provider === 'linuxdo' + ? await authApi.bindLinuxDo(payload) + : await authApi.bindGitHub(payload) + const authorizeUrl = result?.authorize_url + if (!authorizeUrl) { + throw new Error('绑定链接生成失败') + } + window.location.href = authorizeUrl + } catch (error) { + toast.error(getErrorMessage(error)) + setBindingLoading((prev) => ({ ...prev, [provider]: false })) + } + } + + const handleConfirmMerge = async () => { + setMergeLoading(true) + try { + await authApi.confirmLinuxDoMerge() + await refreshUser() + toast.success('已合并到当前账号') + setMergeOpen(false) + navigate('/account/security', { replace: true }) + } catch (error) { + toast.error(getErrorMessage(error)) + } finally { + setMergeLoading(false) + } + } + + const handleCancelMerge = async () => { + try { + await authApi.cancelLinuxDoMerge() + toast.info('已取消合并请求') + } catch (error) { + toast.error(getErrorMessage(error)) + } finally { + setMergeOpen(false) + navigate('/account/security', { replace: true }) + } + } + + const bindings = useMemo(() => ([ + { + key: 'linuxdo', + name: 'Linux.do', + bound: !!user?.linux_do_id, + detail: user?.linux_do_username || user?.linux_do_id, + }, + { + key: 'github', + name: 'GitHub', + bound: !!user?.github_id, + detail: user?.github_username || user?.github_id, + }, + ]), [user]) + + if (!token) { + return null + } + if (!user) { + return ( +
      + 加载中... +
      + ) + } + + return ( +
      +
      +

      账号安全

      +

      + 管理本地密码与第三方账号绑定 +

      +
      + +
      +
      +

      + {hasPassword ? '修改密码' : '设置本地密码'} +

      +
      + {hasPassword && ( +
      + + setOldPassword(event.target.value)} + className="w-full px-4 py-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-yellow-400" + placeholder="请输入旧密码" + autoComplete="current-password" + /> +
      + )} +
      + + setNewPassword(event.target.value)} + className="w-full px-4 py-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-yellow-400" + placeholder="至少 8 位" + autoComplete="new-password" + /> +
      +
      + + setConfirmPassword(event.target.value)} + className="w-full px-4 py-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-yellow-400" + placeholder="再次输入新密码" + autoComplete="new-password" + /> +
      + +
      +

      + 忘记密码可在登录页使用找回功能。 +

      +
      + +
      +

      + 第三方账号绑定 +

      +
      + {bindings.map((item) => ( +
      +
      +

      {item.name}

      +

      + {item.bound ? `已绑定${item.detail ? `:${item.detail}` : ''}` : '未绑定'} +

      +
      + +
      + ))} +
      +

      + 绑定过程中如检测到已有账号,将合并到当前账号。 +

      +
      +
      + + + + + 确认合并账号 + +
      +

      检测到该 Linux.do 账号已绑定到其他账号。

      +

      是否确认将该账号数据合并到当前账号?

      +
      + + + + +
      +
      +
      + ) +} diff --git a/frontend/src/pages/ActivityCenterPage.jsx b/frontend/src/pages/ActivityCenterPage.jsx index 9574feb..09fa586 100644 --- a/frontend/src/pages/ActivityCenterPage.jsx +++ b/frontend/src/pages/ActivityCenterPage.jsx @@ -1027,6 +1027,12 @@ export default function ActivityCenterPage() { // 兑换券刷新触发器 const [ticketRefreshTrigger, setTicketRefreshTrigger] = useState(0) + useEffect(() => { + if (!token) { + navigate('/login?next=/activity', { replace: true }) + } + }, [token, navigate]) + // 加载余额 const loadBalance = useCallback(async () => { setBalanceLoading(true) @@ -1104,7 +1110,6 @@ export default function ActivityCenterPage() { useEffect(() => { if (!token) { - navigate('/login') return } // 并行加载所有数据 @@ -1113,7 +1118,15 @@ export default function ActivityCenterPage() { loadLottery() loadMarkets() loadItems() - }, [token, navigate, loadBalance, loadSignin, loadLottery, loadMarkets, loadItems]) + }, [token, loadBalance, loadSignin, loadLottery, loadMarkets, loadItems]) + + if (!token) { + return ( +
      + 正在跳转登录... +
      + ) + } const handleSignin = async () => { if (signing || signinStatus?.signed_today) return diff --git a/frontend/src/pages/ActivityManagePage.jsx b/frontend/src/pages/ActivityManagePage.jsx index e5087a0..69b5874 100644 --- a/frontend/src/pages/ActivityManagePage.jsx +++ b/frontend/src/pages/ActivityManagePage.jsx @@ -44,6 +44,7 @@ import api from '../services/api' import { useAuthStore } from '../stores/authStore' import { useToast } from '../components/Toast' import { adminApi2 } from '../services' +import { resolveAvatarUrl } from '../utils/avatar' // Tab 组件 function Tab({ active, onClick, children, icon: Icon }) { @@ -1985,7 +1986,7 @@ function UserPointsPanel() { className="w-4 h-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500" /> {user.username} @@ -2146,7 +2147,7 @@ function UserPointsPanel() {
      {logUser.username} @@ -2790,7 +2791,7 @@ function SlotMachineManagePanel() {

      老虎机配置不存在

      -

      请先执行数据库迁移脚本 019_slot_machine_config.sql

      +

      请先执行数据库迁移脚本 039_slot_machine_config.sql

      ) } diff --git a/frontend/src/pages/AdminDashboardPage.jsx b/frontend/src/pages/AdminDashboardPage.jsx index 6c6ba6f..1b76e73 100644 --- a/frontend/src/pages/AdminDashboardPage.jsx +++ b/frontend/src/pages/AdminDashboardPage.jsx @@ -36,13 +36,17 @@ import { AlertTriangle as WarningIcon, CheckCircle, XOctagon, + ClipboardCheck, } from 'lucide-react' import ReactECharts from 'echarts-for-react' import * as echarts from 'echarts' import { useAuthStore } from '../stores/authStore' import { useToast } from '../components/Toast' import { useThemeStore } from '../stores/themeStore' -import { adminApi2, predictionApi } from '../services' +import { adminApi2, contestApi, predictionApi, projectApi } from '../services' +import { resolveAvatarUrl } from '../utils/avatar' +import { IMAGE_ACCEPT, validateImageFile } from '../utils/media' +import { PRIZE_CONFIG, RISK_RULES, REVIEW_WEIGHTS, PROCESS_STEPS } from '../components/contest/constants' // Tab 组件 - 现代简洁风格 function Tab({ active, onClick, children, icon: Icon }) { @@ -106,6 +110,255 @@ const PRIZE_TYPE_MAP = { NOTHING: '谢谢参与', } +const CONTEST_PHASE_LABELS = { + upcoming: '即将开始', + signup: '报名中', + submission: '提交中', + voting: '投票中', + ended: '已结束', +} + +const CONTEST_PHASE_OPTIONS = [ + { value: 'upcoming', label: '即将开始' }, + { value: 'signup', label: '报名中' }, + { value: 'submission', label: '提交中' }, + { value: 'voting', label: '投票中' }, + { value: 'ended', label: '已结束' }, +] + +const CONTEST_VISIBILITY_LABELS = { + draft: '草稿', + published: '已发布', + hidden: '已隐藏', +} + +const CONTEST_VISIBILITY_OPTIONS = [ + { value: 'published', label: '已发布' }, + { value: 'draft', label: '草稿' }, + { value: 'hidden', label: '已隐藏' }, +] + +const CONTEST_VISIBILITY_STYLES = { + draft: 'bg-slate-100 text-slate-600 dark:bg-slate-800/60 dark:text-slate-300', + published: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-200', + hidden: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-200', +} + +const CONTEST_FORM_DEFAULT = { + title: '', + description: '', + phase: 'upcoming', + visibility: 'published', + home_visible: false, + banner_url: '', + rules_md: '', + prizes_md: '', + review_rules_md: '', + faq_md: '', + template_prize_section_title: '', + template_prize_section_intro: '', + template_prize_champion_title: '', + template_prize_champion_subtitle: '', + template_prize_champion_count: '', + template_prize_champion_badge: '', + template_prize_champion_cash_prize: '', + template_prize_champion_api_return: '', + template_prize_champion_api_limit: '', + template_prize_champion_discount: '', + template_prize_champion_discount_note: '', + template_prize_champion_button_text: '', + template_prize_champion_button_url: '', + template_prize_second_title: '', + template_prize_second_subtitle: '', + template_prize_second_count: '', + template_prize_second_api_return: '', + template_prize_second_api_limit: '', + template_prize_second_discount: '', + template_prize_third_title: '', + template_prize_third_subtitle: '', + template_prize_third_count: '', + template_prize_third_api_return: '', + template_prize_third_api_limit: '', + template_prize_third_discount: '', + template_participation_title: '', + template_participation_description: '', + template_participation_reward: '', + template_participation_extra: '', + template_risk_rules: '', + template_review_weights: '', + template_process_steps: '', + signup_start: '', + signup_end: '', + submit_start: '', + submit_end: '', + vote_start: '', + vote_end: '', +} + +const MAX_BANNER_BYTES = 5 * 1024 * 1024 + +const DEFAULT_PRIZE_SECTION_TITLE = '奖项设置' +const DEFAULT_PRIZE_SECTION_INTRO = '核心机制:只要你敢写且获奖,Token 消耗 ikuncode 全额买单!' +const DEFAULT_PARTICIPATION = { + title: '阳光普照奖(参与奖)', + description: '凡提交合格开源作品的选手', + reward: '8.5折充值优惠券', + extra: '平台专属"ikun开发者徽章"', +} + +const formatRiskRules = (rules) => + rules.map((rule) => `${rule.label}|${rule.content}`).join('\n') + +const formatReviewWeights = (weights) => + weights.map((item) => `${item.title}|${item.weight}|${item.description}`).join('\n') + +const formatProcessSteps = (steps) => + steps + .map((step) => { + const parts = [step.step, step.title, step.description] + if (step.highlight) parts.push(step.highlight) + if (step.suffix) parts.push(step.suffix) + return parts.join('|') + }) + .join('\n') + +const DEFAULT_RISK_RULES_TEXT = formatRiskRules(RISK_RULES) +const DEFAULT_REVIEW_WEIGHTS_TEXT = formatReviewWeights(REVIEW_WEIGHTS) +const DEFAULT_PROCESS_STEPS_TEXT = formatProcessSteps(PROCESS_STEPS) + +const splitLines = (value) => + String(value ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + +const compactTextObject = (objectValue) => { + const entries = Object.entries(objectValue).filter(([, value]) => String(value ?? '').trim()) + return entries.length ? Object.fromEntries(entries) : null +} + +const parseRiskRulesText = (value) => { + const lines = splitLines(value) + if (!lines.length) return { items: [] } + const items = [] + for (let index = 0; index < lines.length; index += 1) { + const parts = lines[index].split('|').map((part) => part.trim()) + if (parts.length < 2) { + return { error: `风控规则第 ${index + 1} 行格式错误,请使用“标题|内容”` } + } + items.push({ label: parts[0], content: parts.slice(1).join('|').trim() }) + } + return { items } +} + +const parseReviewWeightsText = (value) => { + const lines = splitLines(value) + if (!lines.length) return { items: [] } + const items = [] + for (let index = 0; index < lines.length; index += 1) { + const parts = lines[index].split('|').map((part) => part.trim()) + if (parts.length < 3) { + return { error: `评审权重第 ${index + 1} 行格式错误,请使用“标题|权重|描述”` } + } + const weightText = parts[1].replace('%', '').trim() + const weightValue = Number(weightText) + if (!Number.isFinite(weightValue)) { + return { error: `评审权重第 ${index + 1} 行的权重不是数字` } + } + items.push({ + title: parts[0], + weight: weightValue, + description: parts.slice(2).join('|').trim(), + }) + } + return { items } +} + +const parseProcessStepsText = (value) => { + const lines = splitLines(value) + if (!lines.length) return { items: [] } + const items = [] + for (let index = 0; index < lines.length; index += 1) { + const parts = lines[index].split('|').map((part) => part.trim()) + if (parts.length < 3) { + return { error: `参赛流程第 ${index + 1} 行格式错误,请使用“步骤|标题|描述|高亮(可选)|后缀(可选)”` } + } + const stepNumber = Number(parts[0]) + const stepValue = Number.isFinite(stepNumber) ? stepNumber : parts[0] + const item = { + step: stepValue, + title: parts[1], + description: parts[2], + } + if (parts[3]) item.highlight = parts[3] + if (parts[4]) item.suffix = parts[4] + items.push(item) + } + return { items } +} + +const buildTemplateFormData = (templateConfig) => { + const prizeSection = templateConfig?.prize_section || {} + const prizes = templateConfig?.prizes || {} + const champion = prizes.champion || {} + const second = prizes.second || {} + const third = prizes.third || {} + const participation = prizes.participation || {} + + return { + template_prize_section_title: prizeSection.title || '', + template_prize_section_intro: prizeSection.intro || '', + template_prize_champion_title: champion.title || '', + template_prize_champion_subtitle: champion.subtitle || '', + template_prize_champion_count: champion.count || '', + template_prize_champion_badge: champion.badge || '', + template_prize_champion_cash_prize: champion.cashPrize || '', + template_prize_champion_api_return: champion.apiReturn || '', + template_prize_champion_api_limit: champion.apiLimit || '', + template_prize_champion_discount: champion.discount || '', + template_prize_champion_discount_note: champion.discountNote || '', + template_prize_champion_button_text: champion.buttonText || '', + template_prize_champion_button_url: champion.buttonUrl || champion.button_url || '', + template_prize_second_title: second.title || '', + template_prize_second_subtitle: second.subtitle || '', + template_prize_second_count: second.count || '', + template_prize_second_api_return: second.apiReturn || '', + template_prize_second_api_limit: second.apiLimit || '', + template_prize_second_discount: second.discount || '', + template_prize_third_title: third.title || '', + template_prize_third_subtitle: third.subtitle || '', + template_prize_third_count: third.count || '', + template_prize_third_api_return: third.apiReturn || '', + template_prize_third_api_limit: third.apiLimit || '', + template_prize_third_discount: third.discount || '', + template_participation_title: participation.title || '', + template_participation_description: participation.description || '', + template_participation_reward: participation.reward || '', + template_participation_extra: participation.extra || '', + template_risk_rules: templateConfig?.risk_rules?.length ? formatRiskRules(templateConfig.risk_rules) : '', + template_review_weights: templateConfig?.review_weights?.length + ? formatReviewWeights(templateConfig.review_weights) + : '', + template_process_steps: templateConfig?.process_steps?.length + ? formatProcessSteps(templateConfig.process_steps) + : '', + } +} + +const toContestDatetimeInput = (value) => { + if (!value) return '' + const text = typeof value === 'string' ? value : new Date(value).toISOString() + if (!text.includes('T')) return '' + return text.slice(0, 16) +} + +const formatContestDateTime = (value) => { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '—' + return date.toLocaleString('zh-CN') +} + // 仪表盘面板 function DashboardPanel() { const theme = useThemeStore((s) => s.theme) @@ -829,6 +1082,8 @@ function UsersPanel() { const [search, setSearch] = useState('') const [roleFilter, setRoleFilter] = useState('all') // 角色筛选 const [editingUser, setEditingUser] = useState(null) + const [editSaving, setEditSaving] = useState(false) + const [editForm, setEditForm] = useState({ username: '', display_name: '', email: '' }) const [roleDropdown, setRoleDropdown] = useState(null) const [pointsModal, setPointsModal] = useState(null) const [pointsAmount, setPointsAmount] = useState('') @@ -898,6 +1153,52 @@ function UsersPanel() { } } + const openEditUser = (user) => { + setEditingUser(user) + setEditForm({ + username: user.username || '', + display_name: user.display_name || '', + email: user.email || '', + }) + } + + const handleSaveUserEdit = async () => { + if (!editingUser) return + const username = editForm.username.trim() + if (!username) { + toast.error('用户名不能为空') + return + } + const displayName = editForm.display_name?.trim() || null + const emailValue = editForm.email?.trim().toLowerCase() || null + const payload = {} + if (username !== editingUser.username) { + payload.username = username + } + if ((editingUser.display_name || null) !== displayName) { + payload.display_name = displayName + } + if ((editingUser.email || null) !== emailValue) { + payload.email = emailValue + } + if (Object.keys(payload).length === 0) { + toast.warning('未检测到可更新内容') + setEditingUser(null) + return + } + setEditSaving(true) + try { + await adminApi2.updateUser(editingUser.id, payload) + toast.success('用户资料已更新') + setEditingUser(null) + loadUsers() + } catch (error) { + toast.error(error.response?.data?.detail || '更新失败') + } finally { + setEditSaving(false) + } + } + return (
      {/* 搜索栏和筛选 */} @@ -984,7 +1285,7 @@ function UsersPanel() {
      @@ -1044,6 +1345,13 @@ function UsersPanel() {
      +
      )} + + {editingUser && ( +
      +
      !editSaving && setEditingUser(null)} /> +
      +

      + 编辑用户 - {editingUser.display_name || editingUser.username} +

      +
      +
      + + setEditForm((prev) => ({ ...prev, username: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + /> +
      +
      + + setEditForm((prev) => ({ ...prev, display_name: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + /> +
      +
      + + setEditForm((prev) => ({ ...prev, email: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + /> +
      +
      +
      + + +
      +
      +
      + )} +
      + ) + } + + // 赛事管理面板 + function ContestManagementPanel() { + const toast = useToast() + const [contests, setContests] = useState([]) + const [loading, setLoading] = useState(true) + const [showEditor, setShowEditor] = useState(false) + const [saving, setSaving] = useState(false) + const [editingContest, setEditingContest] = useState(null) + const [formData, setFormData] = useState({ ...CONTEST_FORM_DEFAULT }) + const [bannerUploading, setBannerUploading] = useState(false) + const bannerInputRef = useRef(null) + + const loadContests = async () => { + setLoading(true) + try { + const data = await adminApi2.get('/contests') + setContests(data.items || []) + } catch (error) { + console.error('加载赛事失败:', error) + toast.error('加载赛事失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadContests() + }, []) + + const buildFormData = (contest) => ({ + title: contest?.title || '', + description: contest?.description || '', + phase: contest?.phase || 'upcoming', + visibility: contest?.visibility || 'published', + home_visible: contest?.home_visible ?? false, + banner_url: contest?.banner_url || '', + rules_md: contest?.rules_md || '', + prizes_md: contest?.prizes_md || '', + review_rules_md: contest?.review_rules_md || '', + faq_md: contest?.faq_md || '', + ...buildTemplateFormData(contest?.template_config), + signup_start: toContestDatetimeInput(contest?.signup_start), + signup_end: toContestDatetimeInput(contest?.signup_end), + submit_start: toContestDatetimeInput(contest?.submit_start), + submit_end: toContestDatetimeInput(contest?.submit_end), + vote_start: toContestDatetimeInput(contest?.vote_start), + vote_end: toContestDatetimeInput(contest?.vote_end), + }) + + const openEditor = (contest = null) => { + setEditingContest(contest) + setFormData(contest ? buildFormData(contest) : { ...CONTEST_FORM_DEFAULT }) + setShowEditor(true) + } + + const updateField = (key, value) => { + setFormData((prev) => ({ ...prev, [key]: value })) + } + + const normalizeText = (value) => { + const text = String(value ?? '').trim() + return text ? text : null + } + + const buildTemplateConfig = () => { + const riskRulesResult = parseRiskRulesText(formData.template_risk_rules) + if (riskRulesResult.error) return { error: riskRulesResult.error } + + const reviewWeightsResult = parseReviewWeightsText(formData.template_review_weights) + if (reviewWeightsResult.error) return { error: reviewWeightsResult.error } + + const processStepsResult = parseProcessStepsText(formData.template_process_steps) + if (processStepsResult.error) return { error: processStepsResult.error } + + const prizeSection = compactTextObject({ + title: formData.template_prize_section_title, + intro: formData.template_prize_section_intro, + }) + + const champion = compactTextObject({ + title: formData.template_prize_champion_title, + subtitle: formData.template_prize_champion_subtitle, + count: formData.template_prize_champion_count, + badge: formData.template_prize_champion_badge, + cashPrize: formData.template_prize_champion_cash_prize, + apiReturn: formData.template_prize_champion_api_return, + apiLimit: formData.template_prize_champion_api_limit, + discount: formData.template_prize_champion_discount, + discountNote: formData.template_prize_champion_discount_note, + buttonText: formData.template_prize_champion_button_text, + buttonUrl: formData.template_prize_champion_button_url, + }) + + const second = compactTextObject({ + title: formData.template_prize_second_title, + subtitle: formData.template_prize_second_subtitle, + count: formData.template_prize_second_count, + apiReturn: formData.template_prize_second_api_return, + apiLimit: formData.template_prize_second_api_limit, + discount: formData.template_prize_second_discount, + }) + + const third = compactTextObject({ + title: formData.template_prize_third_title, + subtitle: formData.template_prize_third_subtitle, + count: formData.template_prize_third_count, + apiReturn: formData.template_prize_third_api_return, + apiLimit: formData.template_prize_third_api_limit, + discount: formData.template_prize_third_discount, + }) + + const participation = compactTextObject({ + title: formData.template_participation_title, + description: formData.template_participation_description, + reward: formData.template_participation_reward, + extra: formData.template_participation_extra, + }) + + const prizes = {} + if (champion) prizes.champion = champion + if (second) prizes.second = second + if (third) prizes.third = third + if (participation) prizes.participation = participation + + const templateConfig = {} + if (prizeSection) templateConfig.prize_section = prizeSection + if (Object.keys(prizes).length) templateConfig.prizes = prizes + if (riskRulesResult.items.length) templateConfig.risk_rules = riskRulesResult.items + if (reviewWeightsResult.items.length) templateConfig.review_weights = reviewWeightsResult.items + if (processStepsResult.items.length) templateConfig.process_steps = processStepsResult.items + + return { + config: Object.keys(templateConfig).length ? templateConfig : null, + } + } + + const buildPayload = (templateConfig) => ({ + title: formData.title.trim(), + description: formData.description.trim() || null, + phase: formData.phase || null, + visibility: formData.visibility || null, + home_visible: formData.home_visible ?? false, + rules_md: normalizeText(formData.rules_md), + prizes_md: normalizeText(formData.prizes_md), + review_rules_md: normalizeText(formData.review_rules_md), + faq_md: normalizeText(formData.faq_md), + template_config: templateConfig, + signup_start: formData.signup_start || null, + signup_end: formData.signup_end || null, + submit_start: formData.submit_start || null, + submit_end: formData.submit_end || null, + vote_start: formData.vote_start || null, + vote_end: formData.vote_end || null, + }) + + const handleSave = async () => { + const title = formData.title.trim() + if (!title) { + toast.error('请输入赛事标题') + return + } + + const templateConfigResult = buildTemplateConfig() + if (templateConfigResult.error) { + toast.error(templateConfigResult.error) + return + } + + setSaving(true) + try { + const payload = buildPayload(templateConfigResult.config) + if (editingContest) { + await adminApi2.patch(`/contests/${editingContest.id}`, payload) + toast.success('赛事已更新') + } else { + await adminApi2.post('/contests', payload) + toast.success('赛事已创建') + } + setShowEditor(false) + loadContests() + } catch (error) { + console.error('保存赛事失败:', error) + toast.error('保存赛事失败') + } finally { + setSaving(false) + } + } + + const handleBannerPick = () => { + if (!editingContest?.id) { + toast.error('请先保存赛事后再上传 Banner') + return + } + if (bannerUploading) return + bannerInputRef.current?.click() + } + + const handleBannerChange = async (event) => { + const file = event.target.files?.[0] || null + event.target.value = '' + if (!file) return + if (!editingContest?.id) { + toast.error('请先保存赛事后再上传 Banner') + return + } + const error = validateImageFile(file, MAX_BANNER_BYTES) + if (error) { + toast.error(error) + return + } + setBannerUploading(true) + try { + const res = await contestApi.uploadBanner(editingContest.id, file) + setFormData((prev) => ({ ...prev, banner_url: res?.banner_url || prev.banner_url })) + toast.success('Banner 已上传') + await loadContests() + } catch (error) { + console.error('上传 Banner 失败:', error) + toast.error('上传 Banner 失败') + } finally { + setBannerUploading(false) + } + } + + const handleClearBanner = async () => { + if (!editingContest?.id) return + if (bannerUploading) return + setBannerUploading(true) + try { + await adminApi2.patch(`/contests/${editingContest.id}`, { banner_url: null }) + setFormData((prev) => ({ ...prev, banner_url: '' })) + toast.success('Banner 已清除') + await loadContests() + } catch (error) { + console.error('清除 Banner 失败:', error) + toast.error('清除 Banner 失败') + } finally { + setBannerUploading(false) + } + } + + const renderTimeRange = (start, end) => { + if (!start && !end) { + return '未配置' + } + return `${formatContestDateTime(start)} ~ ${formatContestDateTime(end)}` + } + + return ( +
      +
      +
      +
      + +
      +
      +

      赛事管理

      +

      创建与维护比赛阶段配置

      +
      +
      +
      + + +
      +
      + +
      + {loading ? ( +
      加载中...
      + ) : contests.length === 0 ? ( +
      + +

      暂无赛事

      +

      点击上方按钮创建第一场赛事

      +
      + ) : ( +
      + {contests.map((contest) => ( +
      +
      +
      +
      +

      + {contest.title} +

      + + {CONTEST_PHASE_LABELS[contest.phase] || contest.phase || '未知'} + + + {CONTEST_VISIBILITY_LABELS[contest.visibility] || contest.visibility || '未知'} + + {contest.home_visible && ( + + 首页展示 + + )} +
      + {contest.description && ( +

      + {contest.description} +

      + )} +
      + 赛事 ID:{contest.id} +
      +
      + +
      +
      +
      +
      报名期
      +
      {renderTimeRange(contest.signup_start, contest.signup_end)}
      +
      +
      +
      提交期
      +
      {renderTimeRange(contest.submit_start, contest.submit_end)}
      +
      +
      +
      投票期
      +
      {renderTimeRange(contest.vote_start, contest.vote_end)}
      +
      +
      +
      + ))} +
      + )} +
      + + {showEditor && ( +
      setShowEditor(false)} + > +
      +
      event.stopPropagation()} + > +
      +

      + {editingContest ? '编辑赛事' : '新建赛事'} +

      + +
      + +
      +
      +
      + + updateField('title', e.target.value)} + className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + placeholder="请输入赛事名称" + /> +
      +
      + + +
      +
      + + +
      +
      + +
      + updateField('home_visible', e.target.checked)} + className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" + /> + + 设为首页展示(自动取消其他比赛的首页展示) + +
      +
      +
      + +
      + +