From d8266ad9cf5cf9c54db1f217ea7e2af1b65809e9 Mon Sep 17 00:00:00 2001 From: miizeus Date: Thu, 25 Dec 2025 00:56:23 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=8F=90=E4=BA=A4/=E5=B1=95=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E9=83=A8=E7=BD=B2=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production.example | 5 + .gitignore | 10 + .spec-workflow/templates/design-template.md | 96 ++ .spec-workflow/templates/product-template.md | 51 + .../templates/requirements-template.md | 50 + .../templates/structure-template.md | 145 +++ .spec-workflow/templates/tasks-template.md | 139 ++ .spec-workflow/templates/tech-template.md | 99 ++ .spec-workflow/user-templates/README.md | 64 + DEPLOY.md | 12 + backend/.env.example | 24 + backend/app/api/v1/__init__.py | 5 +- backend/app/api/v1/endpoints/contest.py | 452 ++++++- backend/app/api/v1/endpoints/project.py | 1117 +++++++++++++++++ .../api/v1/endpoints/project_review_center.py | 435 +++++++ backend/app/api/v1/endpoints/submission.py | 71 +- backend/app/core/config.py | 26 + backend/app/core/rate_limit.py | 3 + backend/app/core/redis.py | 19 + backend/app/models/__init__.py | 14 + backend/app/models/project.py | 63 + backend/app/models/project_favorite.py | 33 + backend/app/models/project_like.py | 33 + backend/app/models/project_review.py | 33 + .../app/models/project_review_assignment.py | 21 + backend/app/models/project_submission.py | 69 + backend/app/models/submission.py | 6 +- backend/app/schemas/project.py | 235 ++++ backend/app/schemas/submission.py | 2 +- backend/app/services/project_domain.py | 11 + backend/app/worker.py | 401 ++++++ backend/requirements.txt | 1 + backend/sql/026_project_deployments.sql | 98 ++ .../sql/027_project_review_assignments.sql | 37 + backend/sql/028_project_reviews.sql | 39 + backend/sql/029_project_interactions.sql | 61 + backend/sql/init/00_schema.sql | 158 +++ backend/sql/production_clean_db.sql | 216 ++++ backend/sql/production_initial_db.sql | 216 ++++ backend/sql/schema.sql | 164 +++ docker-compose.prod.yml | 65 +- docker-compose.yml | 52 + docs/AUTO_DEPLOY.md | 3 + ...37\350\203\275\346\226\207\346\241\243.md" | 57 +- frontend/Dockerfile.prod | 2 +- frontend/src/App.jsx | 6 + frontend/src/components/layout/Navbar.jsx | 3 +- frontend/src/lib/apiBaseUrl.js | 16 + frontend/src/pages/AdminDashboardPage.jsx | 292 ++++- frontend/src/pages/AnnouncementPage.jsx | 213 ++++ frontend/src/pages/ContestantCenterPage.jsx | 57 +- frontend/src/pages/LoginPage.jsx | 9 +- frontend/src/pages/ProjectAccessPage.jsx | 113 ++ frontend/src/pages/RankingPage.jsx | 80 +- frontend/src/pages/ReviewCenterPage.jsx | 62 +- .../src/pages/ReviewRankingDetailPage.jsx | 219 ++++ frontend/src/pages/SubmissionsPage.jsx | 311 ++++- frontend/src/pages/SubmitPage.jsx | 727 +++++++++-- frontend/src/pages/index.js | 2 + frontend/src/services/api.js | 13 +- frontend/src/services/index.js | 43 +- frontend/src/stores/authStore.js | 22 +- nginx/nginx.prod.conf | 173 ++- 63 files changed, 7048 insertions(+), 226 deletions(-) create mode 100644 .spec-workflow/templates/design-template.md create mode 100644 .spec-workflow/templates/product-template.md create mode 100644 .spec-workflow/templates/requirements-template.md create mode 100644 .spec-workflow/templates/structure-template.md create mode 100644 .spec-workflow/templates/tasks-template.md create mode 100644 .spec-workflow/templates/tech-template.md create mode 100644 .spec-workflow/user-templates/README.md create mode 100644 backend/app/api/v1/endpoints/project.py create mode 100644 backend/app/api/v1/endpoints/project_review_center.py create mode 100644 backend/app/core/redis.py create mode 100644 backend/app/models/project.py create mode 100644 backend/app/models/project_favorite.py create mode 100644 backend/app/models/project_like.py create mode 100644 backend/app/models/project_review.py create mode 100644 backend/app/models/project_review_assignment.py create mode 100644 backend/app/models/project_submission.py create mode 100644 backend/app/schemas/project.py create mode 100644 backend/app/services/project_domain.py create mode 100644 backend/app/worker.py create mode 100644 backend/sql/026_project_deployments.sql create mode 100644 backend/sql/027_project_review_assignments.sql create mode 100644 backend/sql/028_project_reviews.sql create mode 100644 backend/sql/029_project_interactions.sql create mode 100644 frontend/src/lib/apiBaseUrl.js create mode 100644 frontend/src/pages/AnnouncementPage.jsx create mode 100644 frontend/src/pages/ProjectAccessPage.jsx create mode 100644 frontend/src/pages/ReviewRankingDetailPage.jsx diff --git a/.env.production.example b/.env.production.example index 40ded4f..160bb38 100644 --- a/.env.production.example +++ b/.env.production.example @@ -15,6 +15,11 @@ SECRET_KEY=your_64_char_random_secret_key_here # 前端 URL FRONTEND_URL=https://pk.ikuncode.cc +# 作品访问域名后缀(生成 project-{submission_id}.your-domain.com) +PROJECT_DOMAIN_SUFFIX=your-domain.com +# 作品访问域名模板(默认即可) +PROJECT_DOMAIN_TEMPLATE=project-{submission_id}.{suffix} + # Linux.do OAuth LINUX_DO_CLIENT_ID=your_client_id LINUX_DO_CLIENT_SECRET=your_client_secret diff --git a/.gitignore b/.gitignore index 7944607..1e86e86 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,16 @@ venv/ .env.prod *.env +# 本地临时与密钥 +.ssh/ +.tmp/ +.sync.tar.gz + +# 本地文档 +docs/进度与待办.md +待办清单.md +readme2.md + # Build dist/ build/ diff --git a/.spec-workflow/templates/design-template.md b/.spec-workflow/templates/design-template.md new file mode 100644 index 0000000..1295d7b --- /dev/null +++ b/.spec-workflow/templates/design-template.md @@ -0,0 +1,96 @@ +# Design Document + +## Overview + +[High-level description of the feature and its place in the overall system] + +## Steering Document Alignment + +### Technical Standards (tech.md) +[How the design follows documented technical patterns and standards] + +### Project Structure (structure.md) +[How the implementation will follow project organization conventions] + +## Code Reuse Analysis +[What existing code will be leveraged, extended, or integrated with this feature] + +### Existing Components to Leverage +- **[Component/Utility Name]**: [How it will be used] +- **[Service/Helper Name]**: [How it will be extended] + +### Integration Points +- **[Existing System/API]**: [How the new feature will integrate] +- **[Database/Storage]**: [How data will connect to existing schemas] + +## Architecture + +[Describe the overall architecture and design patterns used] + +### Modular Design Principles +- **Single File Responsibility**: Each file should handle one specific concern or domain +- **Component Isolation**: Create small, focused components rather than large monolithic files +- **Service Layer Separation**: Separate data access, business logic, and presentation layers +- **Utility Modularity**: Break utilities into focused, single-purpose modules + +```mermaid +graph TD + A[Component A] --> B[Component B] + B --> C[Component C] +``` + +## Components and Interfaces + +### Component 1 +- **Purpose:** [What this component does] +- **Interfaces:** [Public methods/APIs] +- **Dependencies:** [What it depends on] +- **Reuses:** [Existing components/utilities it builds upon] + +### Component 2 +- **Purpose:** [What this component does] +- **Interfaces:** [Public methods/APIs] +- **Dependencies:** [What it depends on] +- **Reuses:** [Existing components/utilities it builds upon] + +## Data Models + +### Model 1 +``` +[Define the structure of Model1 in your language] +- id: [unique identifier type] +- name: [string/text type] +- [Additional properties as needed] +``` + +### Model 2 +``` +[Define the structure of Model2 in your language] +- id: [unique identifier type] +- [Additional properties as needed] +``` + +## Error Handling + +### Error Scenarios +1. **Scenario 1:** [Description] + - **Handling:** [How to handle] + - **User Impact:** [What user sees] + +2. **Scenario 2:** [Description] + - **Handling:** [How to handle] + - **User Impact:** [What user sees] + +## Testing Strategy + +### Unit Testing +- [Unit testing approach] +- [Key components to test] + +### Integration Testing +- [Integration testing approach] +- [Key flows to test] + +### End-to-End Testing +- [E2E testing approach] +- [User scenarios to test] diff --git a/.spec-workflow/templates/product-template.md b/.spec-workflow/templates/product-template.md new file mode 100644 index 0000000..82e60de --- /dev/null +++ b/.spec-workflow/templates/product-template.md @@ -0,0 +1,51 @@ +# Product Overview + +## Product Purpose +[Describe the core purpose of this product/project. What problem does it solve?] + +## Target Users +[Who are the primary users of this product? What are their needs and pain points?] + +## Key Features +[List the main features that deliver value to users] + +1. **Feature 1**: [Description] +2. **Feature 2**: [Description] +3. **Feature 3**: [Description] + +## Business Objectives +[What are the business goals this product aims to achieve?] + +- [Objective 1] +- [Objective 2] +- [Objective 3] + +## Success Metrics +[How will we measure the success of this product?] + +- [Metric 1]: [Target] +- [Metric 2]: [Target] +- [Metric 3]: [Target] + +## Product Principles +[Core principles that guide product decisions] + +1. **[Principle 1]**: [Explanation] +2. **[Principle 2]**: [Explanation] +3. **[Principle 3]**: [Explanation] + +## Monitoring & Visibility (if applicable) +[How do users track progress and monitor the system?] + +- **Dashboard Type**: [e.g., Web-based, CLI, Desktop app] +- **Real-time Updates**: [e.g., WebSocket, polling, push notifications] +- **Key Metrics Displayed**: [What information is most important to surface] +- **Sharing Capabilities**: [e.g., read-only links, exports, reports] + +## Future Vision +[Where do we see this product evolving in the future?] + +### Potential Enhancements +- **Remote Access**: [e.g., Tunnel features for sharing dashboards with stakeholders] +- **Analytics**: [e.g., Historical trends, performance metrics] +- **Collaboration**: [e.g., Multi-user support, commenting] diff --git a/.spec-workflow/templates/requirements-template.md b/.spec-workflow/templates/requirements-template.md new file mode 100644 index 0000000..1c80ca0 --- /dev/null +++ b/.spec-workflow/templates/requirements-template.md @@ -0,0 +1,50 @@ +# Requirements Document + +## Introduction + +[Provide a brief overview of the feature, its purpose, and its value to users] + +## Alignment with Product Vision + +[Explain how this feature supports the goals outlined in product.md] + +## Requirements + +### Requirement 1 + +**User Story:** As a [role], I want [feature], so that [benefit] + +#### Acceptance Criteria + +1. WHEN [event] THEN [system] SHALL [response] +2. IF [precondition] THEN [system] SHALL [response] +3. WHEN [event] AND [condition] THEN [system] SHALL [response] + +### Requirement 2 + +**User Story:** As a [role], I want [feature], so that [benefit] + +#### Acceptance Criteria + +1. WHEN [event] THEN [system] SHALL [response] +2. IF [precondition] THEN [system] SHALL [response] + +## Non-Functional Requirements + +### Code Architecture and Modularity +- **Single Responsibility Principle**: Each file should have a single, well-defined purpose +- **Modular Design**: Components, utilities, and services should be isolated and reusable +- **Dependency Management**: Minimize interdependencies between modules +- **Clear Interfaces**: Define clean contracts between components and layers + +### Performance +- [Performance requirements] + +### Security +- [Security requirements] + +### Reliability +- [Reliability requirements] + +### Usability +- [Usability requirements] diff --git a/.spec-workflow/templates/structure-template.md b/.spec-workflow/templates/structure-template.md new file mode 100644 index 0000000..1ab1fbc --- /dev/null +++ b/.spec-workflow/templates/structure-template.md @@ -0,0 +1,145 @@ +# Project Structure + +## Directory Organization + +``` +[Define your project's directory structure. Examples below - adapt to your project type] + +Example for a library/package: +project-root/ +├── src/ # Source code +├── tests/ # Test files +├── docs/ # Documentation +├── examples/ # Usage examples +└── [build/dist/out] # Build output + +Example for an application: +project-root/ +├── [src/app/lib] # Main source code +├── [assets/resources] # Static resources +├── [config/settings] # Configuration +├── [scripts/tools] # Build/utility scripts +└── [tests/spec] # Test files + +Common patterns: +- Group by feature/module +- Group by layer (UI, business logic, data) +- Group by type (models, controllers, views) +- Flat structure for simple projects +``` + +## Naming Conventions + +### Files +- **Components/Modules**: [e.g., `PascalCase`, `snake_case`, `kebab-case`] +- **Services/Handlers**: [e.g., `UserService`, `user_service`, `user-service`] +- **Utilities/Helpers**: [e.g., `dateUtils`, `date_utils`, `date-utils`] +- **Tests**: [e.g., `[filename]_test`, `[filename].test`, `[filename]Test`] + +### Code +- **Classes/Types**: [e.g., `PascalCase`, `CamelCase`, `snake_case`] +- **Functions/Methods**: [e.g., `camelCase`, `snake_case`, `PascalCase`] +- **Constants**: [e.g., `UPPER_SNAKE_CASE`, `SCREAMING_CASE`, `PascalCase`] +- **Variables**: [e.g., `camelCase`, `snake_case`, `lowercase`] + +## Import Patterns + +### Import Order +1. External dependencies +2. Internal modules +3. Relative imports +4. Style imports + +### Module/Package Organization +``` +[Describe your project's import/include patterns] +Examples: +- Absolute imports from project root +- Relative imports within modules +- Package/namespace organization +- Dependency management approach +``` + +## Code Structure Patterns + +[Define common patterns for organizing code within files. Below are examples - choose what applies to your project] + +### Module/Class Organization +``` +Example patterns: +1. Imports/includes/dependencies +2. Constants and configuration +3. Type/interface definitions +4. Main implementation +5. Helper/utility functions +6. Exports/public API +``` + +### Function/Method Organization +``` +Example patterns: +- Input validation first +- Core logic in the middle +- Error handling throughout +- Clear return points +``` + +### File Organization Principles +``` +Choose what works for your project: +- One class/module per file +- Related functionality grouped together +- Public API at the top/bottom +- Implementation details hidden +``` + +## Code Organization Principles + +1. **Single Responsibility**: Each file should have one clear purpose +2. **Modularity**: Code should be organized into reusable modules +3. **Testability**: Structure code to be easily testable +4. **Consistency**: Follow patterns established in the codebase + +## Module Boundaries +[Define how different parts of your project interact and maintain separation of concerns] + +Examples of boundary patterns: +- **Core vs Plugins**: Core functionality vs extensible plugins +- **Public API vs Internal**: What's exposed vs implementation details +- **Platform-specific vs Cross-platform**: OS-specific code isolation +- **Stable vs Experimental**: Production code vs experimental features +- **Dependencies direction**: Which modules can depend on which + +## Code Size Guidelines +[Define your project's guidelines for file and function sizes] + +Suggested guidelines: +- **File size**: [Define maximum lines per file] +- **Function/Method size**: [Define maximum lines per function] +- **Class/Module complexity**: [Define complexity limits] +- **Nesting depth**: [Maximum nesting levels] + +## Dashboard/Monitoring Structure (if applicable) +[How dashboard or monitoring components are organized] + +### Example Structure: +``` +src/ +└── dashboard/ # Self-contained dashboard subsystem + ├── server/ # Backend server components + ├── client/ # Frontend assets + ├── shared/ # Shared types/utilities + └── public/ # Static assets +``` + +### Separation of Concerns +- Dashboard isolated from core business logic +- Own CLI entry point for independent operation +- Minimal dependencies on main application +- Can be disabled without affecting core functionality + +## Documentation Standards +- All public APIs must have documentation +- Complex logic should include inline comments +- README files for major modules +- Follow language-specific documentation conventions diff --git a/.spec-workflow/templates/tasks-template.md b/.spec-workflow/templates/tasks-template.md new file mode 100644 index 0000000..be461de --- /dev/null +++ b/.spec-workflow/templates/tasks-template.md @@ -0,0 +1,139 @@ +# Tasks Document + +- [ ] 1. Create core interfaces in src/types/feature.ts + - File: src/types/feature.ts + - Define TypeScript interfaces for feature data structures + - Extend existing base interfaces from base.ts + - Purpose: Establish type safety for feature implementation + - _Leverage: src/types/base.ts_ + - _Requirements: 1.1_ + - _Prompt: Role: TypeScript Developer specializing in type systems and interfaces | Task: Create comprehensive TypeScript interfaces for the feature data structures following requirements 1.1, extending existing base interfaces from src/types/base.ts | Restrictions: Do not modify existing base interfaces, maintain backward compatibility, follow project naming conventions | Success: All interfaces compile without errors, proper inheritance from base types, full type coverage for feature requirements_ + +- [ ] 2. Create base model class in src/models/FeatureModel.ts + - File: src/models/FeatureModel.ts + - Implement base model extending BaseModel class + - Add validation methods using existing validation utilities + - Purpose: Provide data layer foundation for feature + - _Leverage: src/models/BaseModel.ts, src/utils/validation.ts_ + - _Requirements: 2.1_ + - _Prompt: Role: Backend Developer with expertise in Node.js and data modeling | Task: Create a base model class extending BaseModel and implementing validation following requirement 2.1, leveraging existing patterns from src/models/BaseModel.ts and src/utils/validation.ts | Restrictions: Must follow existing model patterns, do not bypass validation utilities, maintain consistent error handling | Success: Model extends BaseModel correctly, validation methods implemented and tested, follows project architecture patterns_ + +- [ ] 3. Add specific model methods to FeatureModel.ts + - File: src/models/FeatureModel.ts (continue from task 2) + - Implement create, update, delete methods + - Add relationship handling for foreign keys + - Purpose: Complete model functionality for CRUD operations + - _Leverage: src/models/BaseModel.ts_ + - _Requirements: 2.2, 2.3_ + - _Prompt: Role: Backend Developer with expertise in ORM and database operations | Task: Implement CRUD methods and relationship handling in FeatureModel.ts following requirements 2.2 and 2.3, extending patterns from src/models/BaseModel.ts | Restrictions: Must maintain transaction integrity, follow existing relationship patterns, do not duplicate base model functionality | Success: All CRUD operations work correctly, relationships are properly handled, database operations are atomic and efficient_ + +- [ ] 4. Create model unit tests in tests/models/FeatureModel.test.ts + - File: tests/models/FeatureModel.test.ts + - Write tests for model validation and CRUD methods + - Use existing test utilities and fixtures + - Purpose: Ensure model reliability and catch regressions + - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ + - _Requirements: 2.1, 2.2_ + - _Prompt: Role: QA Engineer with expertise in unit testing and Jest/Mocha frameworks | Task: Create comprehensive unit tests for FeatureModel validation and CRUD methods covering requirements 2.1 and 2.2, using existing test utilities from tests/helpers/testUtils.ts and fixtures from tests/fixtures/data.ts | Restrictions: Must test both success and failure scenarios, do not test external dependencies directly, maintain test isolation | Success: All model methods are tested with good coverage, edge cases covered, tests run independently and consistently_ + +- [ ] 5. Create service interface in src/services/IFeatureService.ts + - File: src/services/IFeatureService.ts + - Define service contract with method signatures + - Extend base service interface patterns + - Purpose: Establish service layer contract for dependency injection + - _Leverage: src/services/IBaseService.ts_ + - _Requirements: 3.1_ + - _Prompt: Role: Software Architect specializing in service-oriented architecture and TypeScript interfaces | Task: Design service interface contract following requirement 3.1, extending base service patterns from src/services/IBaseService.ts for dependency injection | Restrictions: Must maintain interface segregation principle, do not expose internal implementation details, ensure contract compatibility with DI container | Success: Interface is well-defined with clear method signatures, extends base service appropriately, supports all required service operations_ + +- [ ] 6. Implement feature service in src/services/FeatureService.ts + - File: src/services/FeatureService.ts + - Create concrete service implementation using FeatureModel + - Add error handling with existing error utilities + - Purpose: Provide business logic layer for feature operations + - _Leverage: src/services/BaseService.ts, src/utils/errorHandler.ts, src/models/FeatureModel.ts_ + - _Requirements: 3.2_ + - _Prompt: Role: Backend Developer with expertise in service layer architecture and business logic | Task: Implement concrete FeatureService following requirement 3.2, using FeatureModel and extending BaseService patterns with proper error handling from src/utils/errorHandler.ts | Restrictions: Must implement interface contract exactly, do not bypass model validation, maintain separation of concerns from data layer | Success: Service implements all interface methods correctly, robust error handling implemented, business logic is well-encapsulated and testable_ + +- [ ] 7. Add service dependency injection in src/utils/di.ts + - File: src/utils/di.ts (modify existing) + - Register FeatureService in dependency injection container + - Configure service lifetime and dependencies + - Purpose: Enable service injection throughout application + - _Leverage: existing DI configuration in src/utils/di.ts_ + - _Requirements: 3.1_ + - _Prompt: Role: DevOps Engineer with expertise in dependency injection and IoC containers | Task: Register FeatureService in DI container following requirement 3.1, configuring appropriate lifetime and dependencies using existing patterns from src/utils/di.ts | Restrictions: Must follow existing DI container patterns, do not create circular dependencies, maintain service resolution efficiency | Success: FeatureService is properly registered and resolvable, dependencies are correctly configured, service lifetime is appropriate for use case_ + +- [ ] 8. Create service unit tests in tests/services/FeatureService.test.ts + - File: tests/services/FeatureService.test.ts + - Write tests for service methods with mocked dependencies + - Test error handling scenarios + - Purpose: Ensure service reliability and proper error handling + - _Leverage: tests/helpers/testUtils.ts, tests/mocks/modelMocks.ts_ + - _Requirements: 3.2, 3.3_ + - _Prompt: Role: QA Engineer with expertise in service testing and mocking frameworks | Task: Create comprehensive unit tests for FeatureService methods covering requirements 3.2 and 3.3, using mocked dependencies from tests/mocks/modelMocks.ts and test utilities | Restrictions: Must mock all external dependencies, test business logic in isolation, do not test framework code | Success: All service methods tested with proper mocking, error scenarios covered, tests verify business logic correctness and error handling_ + +- [ ] 4. Create API endpoints + - Design API structure + - _Leverage: src/api/baseApi.ts, src/utils/apiUtils.ts_ + - _Requirements: 4.0_ + - _Prompt: Role: API Architect specializing in RESTful design and Express.js | Task: Design comprehensive API structure following requirement 4.0, leveraging existing patterns from src/api/baseApi.ts and utilities from src/utils/apiUtils.ts | Restrictions: Must follow REST conventions, maintain API versioning compatibility, do not expose internal data structures directly | Success: API structure is well-designed and documented, follows existing patterns, supports all required operations with proper HTTP methods and status codes_ + +- [ ] 4.1 Set up routing and middleware + - Configure application routes + - Add authentication middleware + - Set up error handling middleware + - _Leverage: src/middleware/auth.ts, src/middleware/errorHandler.ts_ + - _Requirements: 4.1_ + - _Prompt: Role: Backend Developer with expertise in Express.js middleware and routing | Task: Configure application routes and middleware following requirement 4.1, integrating authentication from src/middleware/auth.ts and error handling from src/middleware/errorHandler.ts | Restrictions: Must maintain middleware order, do not bypass security middleware, ensure proper error propagation | Success: Routes are properly configured with correct middleware chain, authentication works correctly, errors are handled gracefully throughout the request lifecycle_ + +- [ ] 4.2 Implement CRUD endpoints + - Create API endpoints + - Add request validation + - Write API integration tests + - _Leverage: src/controllers/BaseController.ts, src/utils/validation.ts_ + - _Requirements: 4.2, 4.3_ + - _Prompt: Role: Full-stack Developer with expertise in API development and validation | Task: Implement CRUD endpoints following requirements 4.2 and 4.3, extending BaseController patterns and using validation utilities from src/utils/validation.ts | Restrictions: Must validate all inputs, follow existing controller patterns, ensure proper HTTP status codes and responses | Success: All CRUD operations work correctly, request validation prevents invalid data, integration tests pass and cover all endpoints_ + +- [ ] 5. Add frontend components + - Plan component architecture + - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ + - _Requirements: 5.0_ + - _Prompt: Role: Frontend Architect with expertise in React component design and architecture | Task: Plan comprehensive component architecture following requirement 5.0, leveraging base patterns from src/components/BaseComponent.tsx and theme system from src/styles/theme.ts | Restrictions: Must follow existing component patterns, maintain design system consistency, ensure component reusability | Success: Architecture is well-planned and documented, components are properly organized, follows existing patterns and theme system_ + +- [ ] 5.1 Create base UI components + - Set up component structure + - Implement reusable components + - Add styling and theming + - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ + - _Requirements: 5.1_ + - _Prompt: Role: Frontend Developer specializing in React and component architecture | Task: Create reusable UI components following requirement 5.1, extending BaseComponent patterns and using existing theme system from src/styles/theme.ts | Restrictions: Must use existing theme variables, follow component composition patterns, ensure accessibility compliance | Success: Components are reusable and properly themed, follow existing architecture, accessible and responsive_ + +- [ ] 5.2 Implement feature-specific components + - Create feature components + - Add state management + - Connect to API endpoints + - _Leverage: src/hooks/useApi.ts, src/components/BaseComponent.tsx_ + - _Requirements: 5.2, 5.3_ + - _Prompt: Role: React Developer with expertise in state management and API integration | Task: Implement feature-specific components following requirements 5.2 and 5.3, using API hooks from src/hooks/useApi.ts and extending BaseComponent patterns | Restrictions: Must use existing state management patterns, handle loading and error states properly, maintain component performance | Success: Components are fully functional with proper state management, API integration works smoothly, user experience is responsive and intuitive_ + +- [ ] 6. Integration and testing + - Plan integration approach + - _Leverage: src/utils/integrationUtils.ts, tests/helpers/testUtils.ts_ + - _Requirements: 6.0_ + - _Prompt: Role: Integration Engineer with expertise in system integration and testing strategies | Task: Plan comprehensive integration approach following requirement 6.0, leveraging integration utilities from src/utils/integrationUtils.ts and test helpers | Restrictions: Must consider all system components, ensure proper test coverage, maintain integration test reliability | Success: Integration plan is comprehensive and feasible, all system components work together correctly, integration points are well-tested_ + +- [ ] 6.1 Write end-to-end tests + - Set up E2E testing framework + - Write user journey tests + - Add test automation + - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ + - _Requirements: All_ + - _Prompt: Role: QA Automation Engineer with expertise in E2E testing and test frameworks like Cypress or Playwright | Task: Implement comprehensive end-to-end tests covering all requirements, setting up testing framework and user journey tests using test utilities and fixtures | Restrictions: Must test real user workflows, ensure tests are maintainable and reliable, do not test implementation details | Success: E2E tests cover all critical user journeys, tests run reliably in CI/CD pipeline, user experience is validated from end-to-end_ + +- [ ] 6.2 Final integration and cleanup + - Integrate all components + - Fix any integration issues + - Clean up code and documentation + - _Leverage: src/utils/cleanup.ts, docs/templates/_ + - _Requirements: All_ + - _Prompt: Role: Senior Developer with expertise in code quality and system integration | Task: Complete final integration of all components and perform comprehensive cleanup covering all requirements, using cleanup utilities and documentation templates | Restrictions: Must not break existing functionality, ensure code quality standards are met, maintain documentation consistency | Success: All components are fully integrated and working together, code is clean and well-documented, system meets all requirements and quality standards_ diff --git a/.spec-workflow/templates/tech-template.md b/.spec-workflow/templates/tech-template.md new file mode 100644 index 0000000..57cd538 --- /dev/null +++ b/.spec-workflow/templates/tech-template.md @@ -0,0 +1,99 @@ +# Technology Stack + +## Project Type +[Describe what kind of project this is: web application, CLI tool, desktop application, mobile app, library, API service, embedded system, game, etc.] + +## Core Technologies + +### Primary Language(s) +- **Language**: [e.g., Python 3.11, Go 1.21, TypeScript, Rust, C++] +- **Runtime/Compiler**: [if applicable] +- **Language-specific tools**: [package managers, build tools, etc.] + +### Key Dependencies/Libraries +[List the main libraries and frameworks your project depends on] +- **[Library/Framework name]**: [Purpose and version] +- **[Library/Framework name]**: [Purpose and version] + +### Application Architecture +[Describe how your application is structured - this could be MVC, event-driven, plugin-based, client-server, standalone, microservices, monolithic, etc.] + +### Data Storage (if applicable) +- **Primary storage**: [e.g., PostgreSQL, files, in-memory, cloud storage] +- **Caching**: [e.g., Redis, in-memory, disk cache] +- **Data formats**: [e.g., JSON, Protocol Buffers, XML, binary] + +### External Integrations (if applicable) +- **APIs**: [External services you integrate with] +- **Protocols**: [e.g., HTTP/REST, gRPC, WebSocket, TCP/IP] +- **Authentication**: [e.g., OAuth, API keys, certificates] + +### Monitoring & Dashboard Technologies (if applicable) +- **Dashboard Framework**: [e.g., React, Vue, vanilla JS, terminal UI] +- **Real-time Communication**: [e.g., WebSocket, Server-Sent Events, polling] +- **Visualization Libraries**: [e.g., Chart.js, D3, terminal graphs] +- **State Management**: [e.g., Redux, Vuex, file system as source of truth] + +## Development Environment + +### Build & Development Tools +- **Build System**: [e.g., Make, CMake, Gradle, npm scripts, cargo] +- **Package Management**: [e.g., pip, npm, cargo, go mod, apt, brew] +- **Development workflow**: [e.g., hot reload, watch mode, REPL] + +### Code Quality Tools +- **Static Analysis**: [Tools for code quality and correctness] +- **Formatting**: [Code style enforcement tools] +- **Testing Framework**: [Unit, integration, and/or end-to-end testing tools] +- **Documentation**: [Documentation generation tools] + +### Version Control & Collaboration +- **VCS**: [e.g., Git, Mercurial, SVN] +- **Branching Strategy**: [e.g., Git Flow, GitHub Flow, trunk-based] +- **Code Review Process**: [How code reviews are conducted] + +### Dashboard Development (if applicable) +- **Live Reload**: [e.g., Hot module replacement, file watchers] +- **Port Management**: [e.g., Dynamic allocation, configurable ports] +- **Multi-Instance Support**: [e.g., Running multiple dashboards simultaneously] + +## Deployment & Distribution (if applicable) +- **Target Platform(s)**: [Where/how the project runs: cloud, on-premise, desktop, mobile, embedded] +- **Distribution Method**: [How users get your software: download, package manager, app store, SaaS] +- **Installation Requirements**: [Prerequisites, system requirements] +- **Update Mechanism**: [How updates are delivered] + +## Technical Requirements & Constraints + +### Performance Requirements +- [e.g., response time, throughput, memory usage, startup time] +- [Specific benchmarks or targets] + +### Compatibility Requirements +- **Platform Support**: [Operating systems, architectures, versions] +- **Dependency Versions**: [Minimum/maximum versions of dependencies] +- **Standards Compliance**: [Industry standards, protocols, specifications] + +### Security & Compliance +- **Security Requirements**: [Authentication, encryption, data protection] +- **Compliance Standards**: [GDPR, HIPAA, SOC2, etc. if applicable] +- **Threat Model**: [Key security considerations] + +### Scalability & Reliability +- **Expected Load**: [Users, requests, data volume] +- **Availability Requirements**: [Uptime targets, disaster recovery] +- **Growth Projections**: [How the system needs to scale] + +## Technical Decisions & Rationale +[Document key architectural and technology choices] + +### Decision Log +1. **[Technology/Pattern Choice]**: [Why this was chosen, alternatives considered] +2. **[Architecture Decision]**: [Rationale, trade-offs accepted] +3. **[Tool/Library Selection]**: [Reasoning, evaluation criteria] + +## Known Limitations +[Document any technical debt, limitations, or areas for improvement] + +- [Limitation 1]: [Impact and potential future solutions] +- [Limitation 2]: [Why it exists and when it might be addressed] diff --git a/.spec-workflow/user-templates/README.md b/.spec-workflow/user-templates/README.md new file mode 100644 index 0000000..ad36a48 --- /dev/null +++ b/.spec-workflow/user-templates/README.md @@ -0,0 +1,64 @@ +# User Templates + +This directory allows you to create custom templates that override the default Spec Workflow templates. + +## How to Use Custom Templates + +1. **Create your custom template file** in this directory with the exact same name as the default template you want to override: + - `requirements-template.md` - Override requirements document template + - `design-template.md` - Override design document template + - `tasks-template.md` - Override tasks document template + - `product-template.md` - Override product steering template + - `tech-template.md` - Override tech steering template + - `structure-template.md` - Override structure steering template + +2. **Template Loading Priority**: + - The system first checks this `user-templates/` directory + - If a matching template is found here, it will be used + - Otherwise, the default template from `templates/` will be used + +## Example Custom Template + +To create a custom requirements template: + +1. Create a file named `requirements-template.md` in this directory +2. Add your custom structure, for example: + +```markdown +# Requirements Document + +## Executive Summary +[Your custom section] + +## Business Requirements +[Your custom structure] + +## Technical Requirements +[Your custom fields] + +## Custom Sections +[Add any sections specific to your workflow] +``` + +## Template Variables + +Templates can include placeholders that will be replaced when documents are created: +- `{{projectName}}` - The name of your project +- `{{featureName}}` - The name of the feature being specified +- `{{date}}` - The current date +- `{{author}}` - The document author + +## Best Practices + +1. **Start from defaults**: Copy a default template from `../templates/` as a starting point +2. **Keep structure consistent**: Maintain similar section headers for tool compatibility +3. **Document changes**: Add comments explaining why sections were added/modified +4. **Version control**: Track your custom templates in version control +5. **Test thoroughly**: Ensure custom templates work with the spec workflow tools + +## Notes + +- Custom templates are project-specific and not included in the package distribution +- The `templates/` directory contains the default templates which are updated with each version +- Your custom templates in this directory are preserved during updates +- If a custom template has errors, the system will fall back to the default template diff --git a/DEPLOY.md b/DEPLOY.md index 9998d3a..f45b6f5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -61,6 +61,18 @@ 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 数据库迁移(重要) + +- 新增互动表迁移:`backend/sql/029_project_interactions.sql` +- 若使用 `production_clean_db.sql` 或 `production_initial_db.sql` 初始化,无需额外执行 +- 旧库升级时需手动执行该迁移脚本 + +### 3.6 部署前检查清单 + +- `.env.production` 已更新必要配置 +- Nginx 证书与域名解析已准备 +- 数据库迁移脚本已执行或初始化脚本已替换 + ### 4. 配置域名和 SSL #### 方式一:使用 Let's Encrypt(推荐) diff --git a/backend/.env.example b/backend/.env.example index 6156b8c..56741d0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,30 @@ REDIS_URL=redis://localhost:6379/0 # 前端地址(OAuth 回跳用) FRONTEND_URL=http://localhost:5173 +# 作品部署提交流程配置 +PROJECT_SUBMISSION_COOLDOWN_SECONDS=600 +PROJECT_SUBMISSION_DAILY_LIMIT=10 +WORKER_API_TOKEN=change-me +WORKER_API_BASE_URL=http://127.0.0.1:8000 +WORKER_QUEUE_KEY=project_submissions:queue +WORKER_QUEUE_BLOCK_SECONDS=5 +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 +WORKER_DOCKER_HOST=unix:///var/run/docker.sock +WORKER_DEPLOY_NETWORK= +WORKER_PROJECT_PORT=8080 +WORKER_CONTAINER_MEMORY_LIMIT=1g +WORKER_CONTAINER_CPU_LIMIT=1.0 +WORKER_CONTAINER_PIDS_LIMIT=256 +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 LINUX_DO_CLIENT_ID=your-client-id LINUX_DO_CLIENT_SECRET=your-client-secret diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 51ebac2..8c97373 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 ) router = APIRouter() @@ -15,6 +16,7 @@ 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(vote.router, prefix="/votes", tags=["投票"]) router.include_router(registration.router, tags=["报名"]) router.include_router(github.router, tags=["GitHub统计"]) @@ -26,6 +28,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/contest.py b/backend/app/api/v1/endpoints/contest.py index 858ddf2..120b966 100644 --- a/backend/app/api/v1/endpoints/contest.py +++ b/backend/app/api/v1/endpoints/contest.py @@ -3,15 +3,26 @@ 提供比赛信息的查询功能。 """ +from datetime import datetime +from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, ConfigDict -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from app.api.v1.endpoints.submission import get_current_user from app.core.database import get_db from app.models.contest import Contest, ContestPhase +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.user import User +from app.schemas.review_center import ReviewStatsResponse +from app.schemas.submission import UserBrief router = APIRouter() @@ -32,6 +43,202 @@ class ContestListResponse(BaseModel): total: int +class ContestCreateRequest(BaseModel): + """创建比赛请求体""" + title: str + description: Optional[str] = None + phase: Optional[ContestPhase] = 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 + + +class ContestUpdateRequest(BaseModel): + """更新比赛请求体""" + title: Optional[str] = None + description: Optional[str] = None + phase: Optional[ContestPhase] = 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 + + +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 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, + ) + + +async def build_project_ranking_items( + db: AsyncSession, + contest_id: int, +) -> list[ProjectRankingItem]: + """构建比赛作品排行榜列表""" + project_result = await db.execute( + select(Project) + .options(selectinload(Project.user)) + .where( + Project.contest_id == contest_id, + Project.status.in_({ProjectStatus.SUBMITTED.value, ProjectStatus.ONLINE.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)): """获取比赛列表""" @@ -59,8 +266,12 @@ async def get_contest(contest_id: int, db: AsyncSession = Depends(get_db)): 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}/ranking", response_model=ContestRankingResponse, summary="获取排行榜") +async def get_ranking( + contest_id: int, + limit: int = 50, + db: AsyncSession = Depends(get_db), +): """获取排行榜""" # 验证比赛存在 result = await db.execute(select(Contest).where(Contest.id == contest_id)) @@ -72,5 +283,236 @@ async def get_ranking(contest_id: int, db: AsyncSession = Depends(get_db)): detail="比赛不存在" ) - # TODO: 实现排行榜逻辑 - return {"message": f"比赛 {contest_id} 排行榜", "items": []} + 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), +): + """获取排行榜详情""" + 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="比赛不存在" + ) + + 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), +): + """获取作品点赞/收藏排行榜""" + 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="比赛不存在" + ) + + 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) + + contest = Contest( + title=payload.title, + description=payload.description, + phase=(payload.phase.value if payload.phase else ContestPhase.UPCOMING.value), + 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, + ) + 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) + for field, value in update_data.items(): + if isinstance(value, ContestPhase): + value = value.value + setattr(contest, field, value) + + await db.commit() + await db.refresh(contest) + 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 + await db.commit() + await db.refresh(contest) + return ContestResponse.model_validate(contest) diff --git a/backend/app/api/v1/endpoints/project.py b/backend/app/api/v1/endpoints/project.py new file mode 100644 index 0000000..689b224 --- /dev/null +++ b/backend/app/api/v1/endpoints/project.py @@ -0,0 +1,1117 @@ +""" +作品与部署提交相关 API +""" +from datetime import datetime, time +import logging +from typing import Optional, Tuple + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status, Request +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 ( + get_current_user, + get_optional_user, + require_approved_registration, +) +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.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__) + +ALLOWED_REGISTRIES = {"ghcr.io", "docker.io"} + + +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 + + +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) + + 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="该比赛已存在作品,请使用更新接口" + ) + + 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=payload.cover_image_url, + screenshot_urls=payload.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.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) + 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}/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 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: + 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 + + 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: + submission.status = ProjectSubmissionStatus.FAILED.value + submission.status_message = payload.message or "管理员下架" + submission.error_code = "admin_offline" + submission.failed_at = now + append_status_history( + submission=submission, + status_value=ProjectSubmissionStatus.FAILED.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="提交记录不存在") + + 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="提交记录不存在") + + now = datetime.utcnow() + submission.status = ProjectSubmissionStatus.FAILED.value + submission.status_message = payload.message or "管理员强制停止运行" + submission.error_code = "admin_stop" + submission.failed_at = now + append_status_history( + submission=submission, + status_value=ProjectSubmissionStatus.FAILED.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..9d9adf7 --- /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.api.v1.endpoints.submission 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/submission.py b/backend/app/api/v1/endpoints/submission.py index 2859cd1..434fc99 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 @@ -71,8 +72,8 @@ # 配置常量 # ============================================================================ -# 上传根目录 -UPLOAD_ROOT = Path("uploads") / "submissions" +# 上传根目录(默认放在容器内可写目录) +UPLOAD_ROOT = Path(os.getenv("UPLOAD_ROOT", "/app/app/uploads")) / "submissions" # 各类型附件的最大文件大小(字节) MAX_SIZE_BY_TYPE: dict[str, int] = { @@ -297,6 +298,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 +372,7 @@ async def validate_submission_materials( submission: Submission, ) -> SubmissionValidateResponse: """ - 校验作品提交的5种必填材料 + 校验作品提交的必填材料(演示视频可选) Returns: SubmissionValidateResponse: 校验结果 @@ -403,13 +417,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 +483,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 +491,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", @@ -1008,7 +1023,7 @@ async def delete_attachment( "/{submission_id}/validate", response_model=SubmissionValidateResponse, summary="校验作品材料", - description="校验作品的5种必填材料是否完整。", + description="校验作品的必填材料是否完整(演示视频可选)。", ) async def validate_submission( submission_id: int, @@ -1066,6 +1081,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/core/config.py b/backend/app/core/config.py index 6ee287f..936d0fd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -78,6 +78,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 = "1g" # 作品容器内存上限 + WORKER_CONTAINER_CPU_LIMIT: float = 1.0 # 作品容器 CPU 上限(核数) + WORKER_CONTAINER_PIDS_LIMIT: int = 256 # 作品容器进程数上限 + 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/rate_limit.py b/backend/app/core/rate_limit.py index f0d4715..cf5241d 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -83,3 +83,6 @@ class RateLimits: # 管理员操作 - 每分钟最多 60 次 ADMIN = "60/minute" + + # 点赞/收藏 - 每分钟最多 30 次 + INTERACTION = "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/models/__init__.py b/backend/app/models/__init__.py index 8ced340..5245c3f 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 ( @@ -35,6 +41,14 @@ "Registration", "RegistrationStatus", "Vote", + "Project", + "ProjectStatus", + "ProjectSubmission", + "ProjectSubmissionStatus", + "ProjectReviewAssignment", + "ProjectReview", + "ProjectLike", + "ProjectFavorite", "GitHubStats", "GitHubSyncLog", "Cheer", 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..e54227a --- /dev/null +++ b/backend/app/models/project_submission.py @@ -0,0 +1,69 @@ +""" +作品部署提交模型 +""" +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" # 失败 + + +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', + 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/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..d3612fd --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,235 @@ +""" +作品与部署提交相关 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") + 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/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/worker.py b/backend/app/worker.py new file mode 100644 index 0000000..c6dbec4 --- /dev/null +++ b/backend/app/worker.py @@ -0,0 +1,401 @@ +""" +作品部署 Worker + +功能: +- 消费 Redis 队列中的提交 +- 拉取镜像并启动容器 +- 驱动提交状态流转并回写日志 +""" +from __future__ import annotations + +import asyncio +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 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 _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 + try: + submission_id = int(raw_value) + except (TypeError, ValueError): + logger.warning("无效的提交ID: %s", raw_value) + continue + + await _process_submission(client, 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..9a247bd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,3 +33,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/sql/026_project_deployments.sql b/backend/sql/026_project_deployments.sql new file mode 100644 index 0000000..963a251 --- /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') + 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/init/00_schema.sql b/backend/sql/init/00_schema.sql index a7ec1cb..ab1d467 100644 --- a/backend/sql/init/00_schema.sql +++ b/backend/sql/init/00_schema.sql @@ -138,6 +138,164 @@ CREATE TABLE IF NOT EXISTS `submissions` ( 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') + 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='作品部署提交表'; + +-- ============================================================================ +-- 作品评审分配表 +-- ============================================================================ +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='作品收藏表'; + -- ============================================================================ -- 投票表 -- ============================================================================ diff --git a/backend/sql/production_clean_db.sql b/backend/sql/production_clean_db.sql index aff164b..a8dbdd4 100644 --- a/backend/sql/production_clean_db.sql +++ b/backend/sql/production_clean_db.sql @@ -2053,6 +2053,222 @@ 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') 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/production_initial_db.sql b/backend/sql/production_initial_db.sql index ce98b9a..7b52f92 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') 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..9fda4a2 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`; @@ -148,6 +154,164 @@ CREATE TABLE `submissions` ( 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') + 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='作品部署提交表'; + +-- ============================================================================ +-- 作品评审分配表 +-- ============================================================================ +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='作品收藏表'; + -- ============================================================================ -- 投票表 -- ============================================================================ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7bb0213..bf576db 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -74,6 +74,9 @@ services: - DATABASE_URL=mysql+aiomysql://${MYSQL_USER:-chicken}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-chicken_king} - REDIS_URL=redis://redis:6379/0 - FRONTEND_URL=${FRONTEND_URL} + - WORKER_API_TOKEN=${WORKER_API_TOKEN} + - PROJECT_DOMAIN_SUFFIX=${PROJECT_DOMAIN_SUFFIX:-local} + - PROJECT_DOMAIN_TEMPLATE=${PROJECT_DOMAIN_TEMPLATE:-project-{submission_id}.{suffix}} - LINUX_DO_CLIENT_ID=${LINUX_DO_CLIENT_ID} - LINUX_DO_CLIENT_SECRET=${LINUX_DO_CLIENT_SECRET} - LINUX_DO_REDIRECT_URI=${LINUX_DO_REDIRECT_URI} @@ -91,6 +94,49 @@ services: expose: - "8000" + # ========================================================================== + # Worker 服务(部署流程) + # ========================================================================== + worker: + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: chicken_king_worker + restart: unless-stopped + command: ["python", "-m", "app.worker"] + environment: + - DEBUG=false + - SECRET_KEY=${SECRET_KEY} + - DATABASE_URL=mysql+aiomysql://${MYSQL_USER:-chicken}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-chicken_king} + - REDIS_URL=redis://redis:6379/0 + - WORKER_API_TOKEN=${WORKER_API_TOKEN} + - WORKER_API_BASE_URL=http://backend:8000 + - WORKER_HEALTHCHECK_ENABLED=${WORKER_HEALTHCHECK_ENABLED:-false} + - WORKER_DOCKER_HOST=${WORKER_DOCKER_HOST:-unix:///var/run/docker.sock} + - WORKER_DEPLOY_NETWORK=${WORKER_DEPLOY_NETWORK:-} + - WORKER_PROJECT_PORT=${WORKER_PROJECT_PORT:-8080} + - WORKER_CONTAINER_MEMORY_LIMIT=${WORKER_CONTAINER_MEMORY_LIMIT:-1g} + - WORKER_CONTAINER_CPU_LIMIT=${WORKER_CONTAINER_CPU_LIMIT:-1.0} + - WORKER_CONTAINER_PIDS_LIMIT=${WORKER_CONTAINER_PIDS_LIMIT:-256} + - WORKER_CONTAINER_LOG_MAX_SIZE=${WORKER_CONTAINER_LOG_MAX_SIZE:-10m} + - WORKER_CONTAINER_LOG_MAX_FILE=${WORKER_CONTAINER_LOG_MAX_FILE:-3} + - PROJECT_DOMAIN_SUFFIX=${PROJECT_DOMAIN_SUFFIX:-local} + - PROJECT_DOMAIN_TEMPLATE=${PROJECT_DOMAIN_TEMPLATE:-project-{submission_id}.{suffix}} + 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 +153,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 +186,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 diff --git a/docker-compose.yml b/docker-compose.yml index f18f8d9..0f8ab35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,9 @@ services: - 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} + - WORKER_API_TOKEN=${WORKER_API_TOKEN:-dev-worker-token} + - PROJECT_DOMAIN_SUFFIX=${PROJECT_DOMAIN_SUFFIX:-local} + - PROJECT_DOMAIN_TEMPLATE=${PROJECT_DOMAIN_TEMPLATE:-project-{submission_id}.{suffix}} - LINUX_DO_CLIENT_ID=${LINUX_DO_CLIENT_ID:-} - LINUX_DO_CLIENT_SECRET=${LINUX_DO_CLIENT_SECRET:-} - LINUX_DO_REDIRECT_URI=${LINUX_DO_REDIRECT_URI:-} @@ -58,6 +61,42 @@ services: - ./backend:/app restart: unless-stopped # 自动重启 + # Worker 服务(部署流程) + worker: + build: + context: ./backend + dockerfile: Dockerfile + container_name: chicken_king_worker + command: ["python", "-m", "app.worker"] + environment: + - DATABASE_URL=mysql+aiomysql://root:${MYSQL_ROOT_PASSWORD:-password}@db:3306/${MYSQL_DATABASE:-chicken_king} + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production} + - WORKER_API_TOKEN=${WORKER_API_TOKEN:-dev-worker-token} + - WORKER_API_BASE_URL=http://backend:8000 + - WORKER_HEALTHCHECK_ENABLED=${WORKER_HEALTHCHECK_ENABLED:-false} + - WORKER_DOCKER_HOST=${WORKER_DOCKER_HOST:-unix:///var/run/docker.sock} + - WORKER_DEPLOY_NETWORK=${WORKER_DEPLOY_NETWORK:-} + - WORKER_PROJECT_PORT=${WORKER_PROJECT_PORT:-8080} + - WORKER_CONTAINER_MEMORY_LIMIT=${WORKER_CONTAINER_MEMORY_LIMIT:-1g} + - WORKER_CONTAINER_CPU_LIMIT=${WORKER_CONTAINER_CPU_LIMIT:-1.0} + - WORKER_CONTAINER_PIDS_LIMIT=${WORKER_CONTAINER_PIDS_LIMIT:-256} + - WORKER_CONTAINER_LOG_MAX_SIZE=${WORKER_CONTAINER_LOG_MAX_SIZE:-10m} + - WORKER_CONTAINER_LOG_MAX_FILE=${WORKER_CONTAINER_LOG_MAX_FILE:-3} + - PROJECT_DOMAIN_SUFFIX=${PROJECT_DOMAIN_SUFFIX:-local} + - PROJECT_DOMAIN_TEMPLATE=${PROJECT_DOMAIN_TEMPLATE:-project-{submission_id}.{suffix}} + 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 +141,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 +167,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..82ba1aa 100644 --- a/docs/AUTO_DEPLOY.md +++ b/docs/AUTO_DEPLOY.md @@ -114,6 +114,9 @@ docker exec -i chicken_king_db mysql -uroot -ppassword chicken_king < /opt/chick # 或导入初始结构 docker exec -i chicken_king_db mysql -uroot -ppassword chicken_king < /opt/chicken-king/backend/sql/schema.sql + +# 若为旧库升级,需执行互动表迁移 +docker exec -i chicken_king_db mysql -uroot -ppassword chicken_king < /opt/chicken-king/backend/sql/029_project_interactions.sql ``` ### 6. 配置 GitHub Webhook 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..b47cb5b 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.1 | 更新日期:2025-12-24 --- @@ -18,6 +18,7 @@ 8. [API 设计规范](#8-api-设计规范) 9. [部署与运维](#9-部署与运维) 10. [业务流程图](#10-业务流程图) +11. [常见问题](#11-常见问题) --- @@ -279,6 +280,13 @@ UPCOMING ──→ SIGNUP ──→ SUBMISSION ──→ VOTING ──→ ENDED DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED ``` +#### 4.3.4 作品部署体系(Project) + +- 参赛者创建作品(Project),补充简介与仓库信息 +- 提交镜像 digest(Project Submission),进入部署队列 +- Worker 拉取镜像并执行健康检查,失败自动清理 +- 部署成功写入访问域名,作品入口页可直接访问 + --- ### 4.4 评审系统 @@ -298,6 +306,11 @@ DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED - 项目源码链接 - 其他评审员评分(匿名) +#### 4.4.3 评审榜与公示 + +- 评审榜按最终得分排序,3 人以上去掉最高最低取平均 +- 公示页展示 Top3 与完整榜单,支持查看详情 + --- ### 4.5 投票系统 @@ -336,6 +349,12 @@ DRAFT → VALIDATING → SUBMITTED → APPROVED/REJECTED - 被打气者获得积分奖励 - 打气不影响比赛评分 +#### 4.6.3 作品互动(点赞/收藏) + +- 点赞/收藏仅针对已上线作品 +- 互动接口限流,防止刷榜 +- 支持点赞榜与收藏榜展示 + --- ### 4.7 活动中心 @@ -688,6 +707,16 @@ submission_reviews -- 评审评分 votes -- 投票 ``` +#### 部署与评审 +```sql +projects -- 作品(部署体系) +project_submissions -- 部署提交 +project_review_assignments -- 评审分配 +project_reviews -- 评审评分 +project_likes -- 作品点赞 +project_favorites -- 作品收藏 +``` + #### 互动相关 ```sql cheers -- 打气记录 @@ -747,11 +776,14 @@ user_task_claims -- 领取记录 | `/tasks` | 任务大厅 | 日/周任务 | | `/achievements` | 成就系统 | 成就进度 | | `/participants` | 选手列表 | 浏览选手 | -| `/contestant-center` | 参赛中心 | 我的报名 | +| `/my-project` | 参赛中心 | 我的作品 | | `/submit` | 作品提交 | 上传材料 | | `/submissions` | 作品展示 | 浏览作品 | +| `/projects/:projectId/access` | 作品访问入口 | 在线访问 | | `/prediction` | 竞猜市场 | 下注竞猜 | -| `/ranking` | 排行榜 | 票数排名 | +| `/ranking` | 排行榜 | 多维榜单(含评审榜) | +| `/ranking/review/:projectId` | 评审榜详情 | 单个作品评审数据 | +| `/announcement` | 公示页 | 评审榜公示 | | `/review-center` | 评审中心 | 评审专用 | | `/admin` | 管理后台 | 管理专用 | @@ -811,8 +843,10 @@ src/ | 用户 | `/users` | 用户信息 | | 比赛 | `/contests` | 比赛管理 | | 报名 | `/registrations` | 报名管理 | -| 作品 | `/submissions` | 作品提交 | +| 作品 | `/submissions` | 旧作品提交体系 | +| 作品部署 | `/projects` | 作品部署与访问 | | 投票 | `/votes` | 投票功能 | +| 评审中心 | `/review-center` | 评审中心与评分 | | 积分 | `/points` | 积分系统 | | 抽奖 | `/lottery` | 抽奖功能 | | 扭蛋 | `/gacha` | 扭蛋功能 | @@ -944,6 +978,19 @@ VITE_API_URL=/api/v1 --- +## 11. 常见问题 + +**Q: 点赞/收藏按钮不可用?** +A: 仅支持已上线作品,未上线的作品不开放互动。 + +**Q: 评审榜得分如何计算?** +A: 评分人数≥3 时去掉最高分与最低分后取平均,否则取平均分。 + +**Q: 公示页为空怎么办?** +A: 说明尚未产生评审结果或暂无上线作品,请先完成评审与发布。 + +--- + ## 附录 ### A. 枚举值参考 @@ -988,4 +1035,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/src/App.jsx b/frontend/src/App.jsx index 92fd9cb..41c24a0 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,7 @@ 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')) // 加载中组件 function PageLoading() { @@ -62,6 +65,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -73,6 +78,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index 5a396d8..3a952d8 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -82,6 +82,7 @@ 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 }, ] @@ -663,4 +664,4 @@ export default function Navbar() { )} ) -} \ No newline at end of file +} 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/AdminDashboardPage.jsx b/frontend/src/pages/AdminDashboardPage.jsx index 6c6ba6f..f5e8f35 100644 --- a/frontend/src/pages/AdminDashboardPage.jsx +++ b/frontend/src/pages/AdminDashboardPage.jsx @@ -36,6 +36,7 @@ import { AlertTriangle as WarningIcon, CheckCircle, XOctagon, + ClipboardCheck, } from 'lucide-react' import ReactECharts from 'echarts-for-react' import * as echarts from 'echarts' @@ -1130,6 +1131,293 @@ function UsersPanel() { ) } +// 作品评审分配面板 +function ProjectReviewAssignPanel() { + const toast = useToast() + const [projectIdInput, setProjectIdInput] = useState('') + const [project, setProject] = useState(null) + const [projectLoading, setProjectLoading] = useState(false) + const [assignments, setAssignments] = useState([]) + const [assignmentLoading, setAssignmentLoading] = useState(false) + const [reviewers, setReviewers] = useState([]) + const [reviewerSearch, setReviewerSearch] = useState('') + const [reviewerLoading, setReviewerLoading] = useState(false) + const [assigningId, setAssigningId] = useState(null) + const [removingId, setRemovingId] = useState(null) + + const assignedIds = new Set(assignments.map((item) => item.reviewer_id)) + + const loadProject = async (projectId) => { + setProjectLoading(true) + try { + const data = await adminApi2.getProject(projectId) + setProject(data) + } catch (error) { + setProject(null) + toast.error(error.response?.data?.detail || '加载作品失败') + } finally { + setProjectLoading(false) + } + } + + const loadAssignments = async (projectId) => { + if (!projectId) { + setAssignments([]) + return + } + setAssignmentLoading(true) + try { + const data = await adminApi2.getProjectReviewers(projectId) + setAssignments(data.items || []) + } catch (error) { + setAssignments([]) + toast.error(error.response?.data?.detail || '加载评审分配失败') + } finally { + setAssignmentLoading(false) + } + } + + const handleLoadProject = async () => { + const id = parseInt(projectIdInput, 10) + if (!id) { + toast.error('请输入有效的作品 ID') + return + } + await Promise.all([loadProject(id), loadAssignments(id)]) + } + + const loadReviewers = async () => { + setReviewerLoading(true) + try { + const params = { role: 'reviewer', limit: 50 } + if (reviewerSearch.trim()) { + params.search = reviewerSearch.trim() + } + const data = await adminApi2.getUsers(params) + setReviewers(data.items || []) + } catch (error) { + setReviewers([]) + toast.error('加载评审员失败') + } finally { + setReviewerLoading(false) + } + } + + useEffect(() => { + loadReviewers() + }, []) + + const handleAssign = async (reviewerId) => { + if (!project?.id) { + toast.error('请先加载作品') + return + } + setAssigningId(reviewerId) + try { + await adminApi2.assignProjectReviewers(project.id, [reviewerId]) + toast.success('分配成功') + await loadAssignments(project.id) + } catch (error) { + toast.error(error.response?.data?.detail || '分配失败') + } finally { + setAssigningId(null) + } + } + + const handleRemove = async (reviewerId) => { + if (!project?.id) { + toast.error('请先加载作品') + return + } + setRemovingId(reviewerId) + try { + await adminApi2.removeProjectReviewer(project.id, reviewerId) + toast.success('已移除评审员') + await loadAssignments(project.id) + } catch (error) { + toast.error(error.response?.data?.detail || '移除失败') + } finally { + setRemovingId(null) + } + } + + const ownerName = project?.owner?.display_name || project?.owner?.username || (project?.user_id ? `用户#${project.user_id}` : '-') + + return ( +
+
+
+
+

作品评审分配

+

+ 先加载作品,再为评审员分配权限 +

+
+
+ setProjectIdInput(e.target.value)} + placeholder="作品 ID" + className="w-32 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + /> + +
+
+ + {project && ( +
+
+
作品标题
+
{project.title || '-'}
+
+
+
作者
+
{ownerName}
+
+
+
作品状态
+
{project.status}
+
+
+
当前提交
+
{project.current_submission_id || '-'}
+
+
+ )} +
+ +
+
+

已分配评审员

+ +
+ + {!project ? ( +
请先加载作品
+ ) : assignmentLoading ? ( +
+ + 加载中... +
+ ) : assignments.length === 0 ? ( +
暂无分配记录
+ ) : ( +
+ {assignments.map((item) => ( +
+
+
+ {item.reviewer?.display_name || item.reviewer?.username || `评审员#${item.reviewer_id}`} +
+
ID: {item.reviewer_id}
+
+ +
+ ))} +
+ )} +
+ +
+
+

评审员列表

+
+ setReviewerSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && loadReviewers()} + placeholder="搜索用户名/昵称" + className="w-56 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800" + /> + +
+
+ +
+ + + + + + + + + + {reviewerLoading ? ( + + + + ) : reviewers.length === 0 ? ( + + + + ) : ( + reviewers.map((user) => { + const assigned = assignedIds.has(user.id) + return ( + + + + + + ) + }) + )} + +
ID评审员操作
+ + 加载中... +
暂无评审员
{user.id} +
+ {user.display_name || user.username} +
+
@{user.username}
+
+ +
+
+
+
+ ) +} + // 签到配置面板 function SigninConfigPanel() { const toast = useToast() @@ -5371,7 +5659,7 @@ function AnnouncementPanel() { } // 有效的 Tab 列表 -const VALID_TABS = ['dashboard', 'users', 'signin', 'lottery', 'activity', 'apikeys', 'apimonitor', 'prediction', 'logs', 'announcements'] +const VALID_TABS = ['dashboard', 'users', 'review-assign', 'signin', 'lottery', 'activity', 'apikeys', 'apimonitor', 'prediction', 'logs', 'announcements'] // 主页面 export default function AdminDashboardPage() { @@ -5443,6 +5731,7 @@ export default function AdminDashboardPage() {
handleTabChange('dashboard')} icon={BarChart3}>仪表盘 handleTabChange('users')} icon={Users}>用户 + handleTabChange('review-assign')} icon={ClipboardCheck}>评审分配 handleTabChange('signin')} icon={Calendar}>签到 handleTabChange('lottery')} icon={Gift}>抽奖 handleTabChange('activity')} icon={Zap}>活动 @@ -5458,6 +5747,7 @@ export default function AdminDashboardPage() {
{activeTab === 'dashboard' && } {activeTab === 'users' && } + {activeTab === 'review-assign' && } {activeTab === 'signin' && } {activeTab === 'lottery' && } {activeTab === 'activity' && } diff --git a/frontend/src/pages/AnnouncementPage.jsx b/frontend/src/pages/AnnouncementPage.jsx new file mode 100644 index 0000000..44da71b --- /dev/null +++ b/frontend/src/pages/AnnouncementPage.jsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { Award, Trophy, Star } from 'lucide-react' +import { contestApi } from '../services' +import { cn } from '@/lib/utils' + +const CONTEST_ID = 1 + +const PHASE_LABELS = { + upcoming: '即将开始', + signup: '报名中', + submission: '提交中', + voting: '投票中', + ended: '已结束', +} + +const MEDAL_CONFIG = { + 1: { + label: '冠军', + badge: 'bg-amber-500/15 text-amber-700 dark:text-amber-300', + ring: 'border-amber-500/40', + icon: 'text-amber-500', + }, + 2: { + label: '亚军', + badge: 'bg-slate-500/15 text-slate-600 dark:text-slate-300', + ring: 'border-slate-400/40', + icon: 'text-slate-400', + }, + 3: { + label: '季军', + badge: 'bg-orange-500/15 text-orange-600 dark:text-orange-300', + ring: 'border-orange-400/40', + icon: 'text-orange-400', + }, +} + +function formatScore(value) { + if (value == null) return '-' + const num = Number(value) + if (Number.isNaN(num)) return '-' + return num.toFixed(2) +} + +export default function AnnouncementPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [contest, setContest] = useState(null) + const [ranking, setRanking] = useState({ items: [], total: 0 }) + + const loadData = async () => { + setLoading(true) + setError('') + try { + const [contestRes, rankingRes] = await Promise.all([ + contestApi.get(CONTEST_ID), + contestApi.getRanking(CONTEST_ID, { limit: 50 }), + ]) + setContest(contestRes) + setRanking(rankingRes || { items: [], total: 0 }) + } catch (err) { + setError(err?.response?.data?.detail || '加载公示信息失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) + + const top3 = useMemo(() => ranking.items.slice(0, 3), [ranking.items]) + + return ( +
+
+
+
+
+
+ + 赛事公示 +
+

+ {contest?.title || '赛事公示'} +

+

+ {contest?.description || '暂无赛事说明'} +

+
+ + + 查看评审榜 + +
+ +
+
+
+ 当前阶段:{PHASE_LABELS[contest?.phase] || '未知'} +
+
+ 共 {ranking.total || ranking.items.length} 个作品参与评审榜 +
+
+
+ + {loading ? ( +
+
+
+

加载公示信息中...

+
+
+ ) : error ? ( +
+

加载失败

+

{error}

+ +
+ ) : ranking.items.length === 0 ? ( +
+

暂无公示数据

+

评审结果尚未产生

+
+ ) : ( + <> +
+ {top3.map((item, index) => { + const rank = index + 1 + const config = MEDAL_CONFIG[rank] + return ( +
+
+ + #{rank} {config?.label || '上榜'} + + +
+

+ {item.title || '未命名作品'} +

+

+ 作者:{item.user?.display_name || item.user?.username || '匿名选手'} +

+
+ 评审得分 + + {formatScore(item.stats?.final_score)} + +
+
+ 评分人数 {item.stats?.review_count ?? 0} +
+
+ ) + })} +
+ +
+
+ + 完整榜单 +
+
+ {ranking.items.map((item) => ( +
+
+
+ #{item.rank} +
+
+
+ {item.title || '未命名作品'} +
+
+ 作者:{item.user?.display_name || item.user?.username || '匿名选手'} +
+
+
+
+ 评审得分 {formatScore(item.stats?.final_score)} + 评分人数 {item.stats?.review_count ?? 0} +
+
+ ))} +
+
+ + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/ContestantCenterPage.jsx b/frontend/src/pages/ContestantCenterPage.jsx index 84de441..a49351d 100644 --- a/frontend/src/pages/ContestantCenterPage.jsx +++ b/frontend/src/pages/ContestantCenterPage.jsx @@ -101,6 +101,18 @@ const STATUS_CONFIG = { }, } +const CONTEST_ID = 1 + +/** + * 作品状态展示文案 + */ +const PROJECT_STATUS_LABELS = { + draft: '草稿', + submitted: '已提交', + online: '已上线', + offline: '已下线', +} + /** * 格式化日期为 YYYY-MM-DD */ @@ -547,6 +559,7 @@ export default function ContestantCenterPage() { const [copied, setCopied] = useState(false) const [exportingPDF, setExportingPDF] = useState(false) const [showDetailModal, setShowDetailModal] = useState(false) + const [projectInfo, setProjectInfo] = useState(null) // 为展示页 modal 构建完整的参赛者数据 const participantData = useMemo(() => { @@ -590,7 +603,7 @@ export default function ContestantCenterPage() { navigate('/login') return } - checkStatus(1).finally(() => setLoading(false)) + checkStatus(CONTEST_ID).finally(() => setLoading(false)) }, [hydrated, token, checkStatus, navigate]) useEffect(() => { @@ -627,6 +640,26 @@ export default function ContestantCenterPage() { loadData() }, [registration?.id, registration?.repo_url]) + useEffect(() => { + if (!token || !registration?.id) return + let active = true + + const loadProject = async () => { + try { + const res = await api.get('/projects', { params: { contest_id: CONTEST_ID, mine: true } }) + const project = res?.items?.[0] || null + if (active) setProjectInfo(project) + } catch { + if (active) setProjectInfo(null) + } + } + + loadProject() + return () => { + active = false + } + }, [token, registration?.id]) + const handleRefresh = async () => { if (!registration?.id) return setRefreshing(true) @@ -854,6 +887,7 @@ export default function ContestantCenterPage() { } const planProgress = useMemo(() => parsePlanProgress(registration?.plan), [registration?.plan]) + const projectStatusLabel = projectInfo?.status ? (PROJECT_STATUS_LABELS[projectInfo.status] || '未知') : '-' const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.submitted const StatusIcon = statusConfig.icon @@ -1274,6 +1308,23 @@ export default function ContestantCenterPage() { 提交作品 + {projectInfo?.id ? ( + + + + ) : ( + + )}
@@ -1291,6 +1342,10 @@ export default function ContestantCenterPage() { {statusConfig.label}
+
+ 作品状态 + {projectStatusLabel} +
报名时间 diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index c6932cf..facfa19 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { useAuthStore } from '../stores/authStore' import { trackLogin } from '../utils/analytics' +import { getApiBaseUrl } from '../lib/apiBaseUrl' import logo from '@/assets/logo.png' /** @@ -31,13 +32,7 @@ export default function LoginPage() { const apiBaseUrl = useMemo(() => { // OAuth 登录需要完整 URL,不能用相对路径 - // 生产环境使用当前域名 + /api/v1 - const envUrl = import.meta.env.VITE_API_URL - if (envUrl && envUrl.startsWith('http')) { - return envUrl - } - // 相对路径或未设置时,使用当前域名 - return `${window.location.origin}/api/v1` + return getApiBaseUrl() }, []) // 从 URL 参数获取登录后跳转目标 diff --git a/frontend/src/pages/ProjectAccessPage.jsx b/frontend/src/pages/ProjectAccessPage.jsx new file mode 100644 index 0000000..d72716d --- /dev/null +++ b/frontend/src/pages/ProjectAccessPage.jsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { projectApi } from '../services' + +function buildAccessUrl(domain) { + if (!domain) return '' + if (domain.startsWith('http://') || domain.startsWith('https://')) return domain + return `//${domain}` +} + +export default function ProjectAccessPage() { + const { projectId } = useParams() + const [loading, setLoading] = useState(true) + const [data, setData] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let active = true + setLoading(true) + setError('') + + projectApi + .getAccess(projectId) + .then((res) => { + if (!active) return + setData(res) + }) + .catch((err) => { + if (!active) return + const message = err?.response?.data?.detail || '获取作品访问入口失败' + setError(message) + }) + .finally(() => { + if (!active) return + setLoading(false) + }) + + return () => { + active = false + } + }, [projectId]) + + if (loading) { + return ( +
+
+
+

加载中...

+
+
+ ) + } + + if (error) { + return ( +
+
+

访问失败

+

{error}

+ + 返回首页 + +
+
+ ) + } + + const status = data?.status || 'unknown' + const domain = data?.domain || '' + const accessUrl = buildAccessUrl(domain) + const isOnline = status === 'online' + + return ( +
+
+

作品访问入口

+

{data?.message || '作品状态已更新'}

+ +
+
作品 ID:{data?.project_id}
+
当前状态:{status}
+ {domain ?
访问域名:{domain}
: null} +
+ +
+ {isOnline && accessUrl ? ( + + 打开作品 + + ) : ( + + 暂未上线 + + )} + + 返回首页 + +
+
+
+ ) +} diff --git a/frontend/src/pages/RankingPage.jsx b/frontend/src/pages/RankingPage.jsx index c8fe426..2e69ec3 100644 --- a/frontend/src/pages/RankingPage.jsx +++ b/frontend/src/pages/RankingPage.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useSearchParams } from 'react-router-dom' -import { Crown, Trophy, Heart, Coffee, Zap, Pizza, Star, GitCommit, Flame, Sparkles, ThumbsUp, Code2, Clock, Target } from 'lucide-react' +import { Link, useSearchParams } from 'react-router-dom' +import { Crown, Trophy, Heart, Coffee, Zap, Pizza, Star, GitCommit, Flame, Sparkles, ThumbsUp, Code2, Clock, Target, Bookmark } from 'lucide-react' import api from '../services/api' import { cn } from '@/lib/utils' @@ -8,10 +8,13 @@ const CONTEST_ID = 1 const TABS = [ { key: 'cheer', label: '人气排行榜', icon: Heart }, + { key: 'like', label: '点赞榜', icon: ThumbsUp }, + { key: 'favorite', label: '收藏榜', icon: Bookmark }, { key: 'github', label: '代码提交排行榜', icon: GitCommit }, { key: 'quota', label: '额度消耗排行榜', icon: Flame }, { key: 'lucky', label: '欧皇榜', icon: Sparkles }, { key: 'heat', label: '热力榜', icon: ThumbsUp }, + { key: 'review', label: '评审榜', icon: Star }, { key: 'puzzle', label: '码神挑战榜', icon: Code2 }, ] @@ -109,6 +112,12 @@ function TopCard({ entry, rank, mode }) { if (mode === 'cheer') { metricValue = entry?.stats?.total ?? 0 metricLabel = '总打气' + } else if (mode === 'like') { + metricValue = entry?.count ?? 0 + metricLabel = '点赞数' + } else if (mode === 'favorite') { + metricValue = entry?.count ?? 0 + metricLabel = '收藏数' } else if (mode === 'quota') { metricValue = entry?.quota?.used != null ? `$${entry.quota.used.toFixed(2)}` : '-' metricLabel = '已消耗' @@ -121,6 +130,9 @@ function TopCard({ entry, rank, mode }) { metricValue = entry?.heat_value ?? 0 metricLabel = '热力值' showTitle = false // 热力榜显示用户,不显示项目名 + } else if (mode === 'review') { + metricValue = entry?.stats?.final_score != null ? Number(entry.stats.final_score).toFixed(2) : '-' + metricLabel = '评审得分' } else if (mode === 'puzzle') { metricValue = `${entry?.total_solved ?? 0}/42` metricLabel = '通关进度' @@ -162,6 +174,8 @@ function TopCard({ entry, rank, mode }) { ) : mode === 'heat' ? ( + ) : mode === 'review' ? ( + ) : mode === 'puzzle' ? ( ) : ( @@ -269,6 +283,12 @@ function Row({ entry, mode }) { if (mode === 'cheer') { value = entry?.stats?.total ?? 0 metricLabel = '总打气' + } else if (mode === 'like') { + value = entry?.count ?? 0 + metricLabel = '点赞数' + } else if (mode === 'favorite') { + value = entry?.count ?? 0 + metricLabel = '收藏数' } else if (mode === 'quota') { value = entry?.quota?.used != null ? `$${entry.quota.used.toFixed(2)}` : '-' metricLabel = '已消耗' @@ -281,6 +301,10 @@ function Row({ entry, mode }) { value = entry?.heat_value ?? 0 metricLabel = '热力值' showTitle = false + } else if (mode === 'review') { + value = entry?.stats?.final_score != null ? Number(entry.stats.final_score).toFixed(2) : '-' + metricLabel = '评审得分' + subValue = entry?.stats?.review_count != null ? `评分人数 ${entry.stats.review_count}` : null } else if (mode === 'puzzle') { value = `${entry?.total_solved ?? 0}/42` metricLabel = '通关进度' @@ -290,7 +314,23 @@ function Row({ entry, mode }) { metricLabel = '提交数' } - const MetricIcon = mode === 'cheer' ? Heart : mode === 'quota' ? Flame : mode === 'lucky' ? Sparkles : mode === 'heat' ? Flame : mode === 'puzzle' ? Target : GitCommit + const MetricIcon = mode === 'cheer' + ? Heart + : mode === 'like' + ? ThumbsUp + : mode === 'favorite' + ? Bookmark + : mode === 'quota' + ? Flame + : mode === 'lucky' + ? Sparkles + : mode === 'heat' + ? Flame + : mode === 'review' + ? Star + : mode === 'puzzle' + ? Target + : GitCommit // 欧皇榜、热力榜、码神挑战榜使用 entry 上的用户信息 const isUserMode = mode === 'lucky' || mode === 'heat' || mode === 'puzzle' @@ -384,6 +424,19 @@ function Row({ entry, mode }) {
)} + {mode === 'review' && subValue && ( +
+ {subValue} +
+ )} + {mode === 'review' && entry?.project_id && ( + + 查看详情 + + )} ) @@ -403,10 +456,13 @@ export default function RankingPage() { const title = useMemo(() => { if (activeTab === 'cheer') return '人气排行榜' + if (activeTab === 'like') return '点赞榜' + if (activeTab === 'favorite') return '收藏榜' if (activeTab === 'github') return '代码提交排行榜' if (activeTab === 'quota') return '额度消耗排行榜' if (activeTab === 'lucky') return '欧皇榜' if (activeTab === 'heat') return '热力榜' + if (activeTab === 'review') return '评审得分榜' if (activeTab === 'puzzle') return '码神挑战榜' return '排行榜' }, [activeTab]) @@ -421,6 +477,10 @@ export default function RankingPage() { let res if (tab === 'cheer') { res = await api.get(`/contests/${CONTEST_ID}/cheer-leaderboard`, { params: { limit: 50 } }) + } else if (tab === 'like') { + res = await api.get(`/contests/${CONTEST_ID}/interaction-leaderboard`, { params: { type: 'like', limit: 50 } }) + } else if (tab === 'favorite') { + res = await api.get(`/contests/${CONTEST_ID}/interaction-leaderboard`, { params: { type: 'favorite', limit: 50 } }) } else if (tab === 'github') { res = await api.get(`/contests/${CONTEST_ID}/github-leaderboard`, { params: { leaderboard_type: 'commits', limit: 50 }, @@ -431,6 +491,8 @@ export default function RankingPage() { res = await api.get('/lottery/leaderboard', { params: { limit: 50 } }) } else if (tab === 'heat') { res = await api.get('/votes/leaderboard', { params: { contest_id: CONTEST_ID, limit: 50 } }) + } else if (tab === 'review') { + res = await api.get(`/contests/${CONTEST_ID}/ranking`, { params: { limit: 50 } }) } else if (tab === 'puzzle') { res = await api.get('/puzzle/leaderboard', { params: { limit: 50 } }) } @@ -534,12 +596,18 @@ export default function RankingPage() {
{activeTab === 'cheer' ? '基于总打气数' + : activeTab === 'like' + ? '基于作品点赞数' + : activeTab === 'favorite' + ? '基于作品收藏数' : activeTab === 'quota' ? `基于已消耗额度${data.failed_queries ? `(${data.failed_queries} 条查询失败)` : ''}` : activeTab === 'lucky' ? '基于稀有奖品中奖次数' : activeTab === 'heat' ? '基于用户打赏消耗的道具积分' + : activeTab === 'review' + ? '基于评审得分(3 人以上去掉最高最低取平均)' : activeTab === 'puzzle' ? '基于通关关卡数,同关卡数按用时排序' : '基于累计提交数'} @@ -549,7 +617,11 @@ export default function RankingPage() { {/* Full List */}
{items.map((entry) => ( - + ))}
diff --git a/frontend/src/pages/ReviewCenterPage.jsx b/frontend/src/pages/ReviewCenterPage.jsx index 089f66a..23d9556 100644 --- a/frontend/src/pages/ReviewCenterPage.jsx +++ b/frontend/src/pages/ReviewCenterPage.jsx @@ -30,12 +30,18 @@ import { Badge } from '../components/ui/badge' * 3. 筛选已评分/未评分作品 * 4. 查看评分统计 */ +function buildAccessUrl(domain) { + if (!domain) return '' + if (domain.startsWith('http://') || domain.startsWith('https://')) return domain + return `//${domain}` +} + export default function ReviewCenterPage() { const navigate = useNavigate() const user = useAuthStore((s) => s.user) // 状态 - const [submissions, setSubmissions] = useState([]) + const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) const [filter, setFilter] = useState('all') // all, yes, no const [stats, setStats] = useState(null) @@ -68,11 +74,14 @@ export default function ReviewCenterPage() { const loadData = async () => { setLoading(true) try { - const [submissionsData, statsData] = await Promise.all([ - reviewCenterApi.getSubmissions({ scored: filter !== 'all' ? filter : undefined }), + const [projectsData, statsData] = await Promise.all([ + reviewCenterApi.getSubmissions({ + scored: filter !== 'all' ? filter : undefined, + page_size: 100, + }), reviewCenterApi.getStats(), ]) - setSubmissions(submissionsData.items || []) + setProjects(projectsData.items || []) setStats(statsData) } catch (error) { console.error('加载数据失败:', error) @@ -233,16 +242,18 @@ export default function ReviewCenterPage() {
- ) : submissions.length === 0 ? ( + ) : projects.length === 0 ? (
{filter === 'no' ? '没有未评分的作品' : filter === 'yes' ? '还没有评分记录' : '暂无待审作品'}
) : (
- {submissions.map((sub) => { + {projects.map((sub) => { const isExpanded = expandedId === sub.id const isScoring = scoringId === sub.id const hasReview = !!sub.my_review + const ownerName = sub.owner?.display_name || sub.owner?.username || '匿名' + const accessUrl = buildAccessUrl(sub.domain) return (
{sub.contestant?.display_name}

{sub.title}

- {sub.contestant?.display_name || sub.contestant?.username} · + {ownerName} · - {sub.submitted_at ? new Date(sub.submitted_at).toLocaleString() : '未提交'} + {sub.created_at ? new Date(sub.created_at).toLocaleString() : '时间未知'}

{sub.description && ( @@ -318,15 +329,28 @@ export default function ReviewCenterPage() {
{/* 项目链接 */}
- - - 查看代码仓库 - + {sub.repo_url && ( + + + 查看代码仓库 + + )} + {accessUrl && ( + + + 访问作品 + + )} {sub.demo_url && ( { + let active = true + setLoading(true) + setError('') + setDetail(null) + setAccess(null) + + contestApi + .getRankingDetail(CONTEST_ID, projectId) + .then(async (res) => { + if (!active) return + setDetail(res) + if (res?.status === 'online') { + try { + const accessRes = await projectApi.getAccess(projectId) + if (!active) return + setAccess(accessRes) + } catch { + if (!active) return + setAccess(null) + } + } + }) + .catch((err) => { + if (!active) return + setError(err?.response?.data?.detail || '加载详情失败') + }) + .finally(() => { + if (!active) return + setLoading(false) + }) + + return () => { + active = false + } + }, [projectId]) + + const accessUrl = useMemo(() => buildAccessUrl(access?.domain || ''), [access?.domain]) + + if (loading) { + return ( +
+
+
+

加载中...

+
+
+ ) + } + + if (error) { + return ( +
+
+

详情加载失败

+

{error}

+ + 返回评审榜 + +
+
+ ) + } + + const stats = detail?.stats || {} + const ownerName = detail?.user?.display_name || detail?.user?.username || '匿名选手' + + return ( +
+ ) +} diff --git a/frontend/src/pages/SubmissionsPage.jsx b/frontend/src/pages/SubmissionsPage.jsx index 381b42c..b4fcab9 100644 --- a/frontend/src/pages/SubmissionsPage.jsx +++ b/frontend/src/pages/SubmissionsPage.jsx @@ -1,12 +1,315 @@ -/** +/** * 作品展示页 */ +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { ExternalLink, Github, Eye, Search, User2, ThumbsUp, Bookmark, FileText } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useToast } from '@/components/Toast' +import { cn } from '@/lib/utils' +import { projectApi } from '../services' +import { useAuthStore } from '../stores/authStore' + +const CONTEST_ID = 1 + +const STATUS_CONFIG = { + online: { label: '已上线', variant: 'success' }, + submitted: { label: '提交中', variant: 'warning' }, + draft: { label: '草稿', variant: 'secondary' }, + offline: { label: '已下线', variant: 'secondary' }, +} + +function normalizeUrl(url) { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://')) return url + if (url.startsWith('//')) return url + return `https://${url}` +} + +function resolveCoverUrl(project) { + if (project?.cover_image_url) return project.cover_image_url + const screenshots = project?.screenshot_urls || [] + return screenshots.length > 0 ? screenshots[0] : '' +} + export default function SubmissionsPage() { + const toast = useToast() + const navigate = useNavigate() + const user = useAuthStore((s) => s.user) + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [searchQuery, setSearchQuery] = useState('') + const [actioning, setActioning] = useState({}) + + const fetchProjects = async () => { + try { + setLoading(true) + setError('') + const res = await projectApi.list({ contest_id: CONTEST_ID }) + setProjects(res?.items || []) + } catch (err) { + setError(err?.response?.data?.detail || '加载作品失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchProjects() + }, []) + + const updateProjectInteraction = (projectId, data) => { + setProjects((prev) => + prev.map((project) => { + if (project.id !== projectId) return project + return { + ...project, + like_count: data?.like_count ?? project.like_count ?? 0, + favorite_count: data?.favorite_count ?? project.favorite_count ?? 0, + liked: data?.liked ?? project.liked ?? false, + favorited: data?.favorited ?? project.favorited ?? false, + } + }) + ) + } + + const setActioningFlag = (projectId, key, value) => { + setActioning((prev) => ({ + ...prev, + [projectId]: { + ...(prev[projectId] || {}), + [key]: value, + }, + })) + } + + const ensureLogin = () => { + if (user) return true + toast.warning('请先登录后再操作') + navigate('/login') + return false + } + + const toggleLike = async (project) => { + if (!ensureLogin()) return + if (actioning[project.id]?.like) return + setActioningFlag(project.id, 'like', true) + try { + const res = project.liked + ? await projectApi.unlike(project.id) + : await projectApi.like(project.id) + updateProjectInteraction(project.id, res) + } catch (err) { + toast.error(err?.response?.data?.detail || '点赞操作失败') + } finally { + setActioningFlag(project.id, 'like', false) + } + } + + const toggleFavorite = async (project) => { + if (!ensureLogin()) return + if (actioning[project.id]?.favorite) return + setActioningFlag(project.id, 'favorite', true) + try { + const res = project.favorited + ? await projectApi.unfavorite(project.id) + : await projectApi.favorite(project.id) + updateProjectInteraction(project.id, res) + } catch (err) { + toast.error(err?.response?.data?.detail || '收藏操作失败') + } finally { + setActioningFlag(project.id, 'favorite', false) + } + } + + const filteredProjects = useMemo(() => { + const keyword = searchQuery.trim().toLowerCase() + if (!keyword) return projects + return projects.filter((project) => { + const fields = [ + project.title, + project.summary, + project.description, + project.owner?.display_name, + project.owner?.username, + ] + return fields.some((text) => String(text || '').toLowerCase().includes(keyword)) + }) + }, [projects, searchQuery]) + return ( -
+
-

作品展示

-

作品列表功能开发中...

+
+
+

作品展示

+

共 {projects.length} 个作品

+
+
+
+ + setSearchQuery(e.target.value)} + placeholder="搜索作品或作者" + className="pl-9 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500" + /> +
+
+
+ + {loading ? ( +
+
+
+

加载中...

+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : filteredProjects.length === 0 ? ( +
+

暂无符合条件的作品

+
+ ) : ( +
+ {filteredProjects.map((project) => { + const status = STATUS_CONFIG[project.status] || STATUS_CONFIG.offline + const ownerName = project.owner?.display_name || project.owner?.username || '匿名作者' + const coverUrl = resolveCoverUrl(project) + return ( +
+
+ {coverUrl ? ( + {`${project.title + ) : ( + 暂无封面 + )} +
+
+

{project.title}

+ {status.label} +
+

+ {project.summary || project.description || '暂无简介'} +

+ +
+ + {ownerName} +
+ +
+ {project.repo_url && ( + + + 开源仓库 + + )} + {project.demo_url && ( + + + 演示链接 + + )} + {project.readme_url && ( + + + README + + )} +
+ +
+ + +
+ +
+ + + +
+ ID {project.id} +
+
+
+ ) + })} +
+ )}
) diff --git a/frontend/src/pages/SubmitPage.jsx b/frontend/src/pages/SubmitPage.jsx index 068ee16..61775b5 100644 --- a/frontend/src/pages/SubmitPage.jsx +++ b/frontend/src/pages/SubmitPage.jsx @@ -1,9 +1,9 @@ /** * 提交作品页 * - * 支持5种必填材料: + * 支持5种材料(演示视频可选): * 1. 项目源码 - GitHub/Gitee URL - * 2. 演示视频 - MP4/AVI, 3-5分钟 + * 2. 演示视频(可选) - MP4/AVI, 3-5分钟 * 3. 项目文档 - Markdown格式 * 4. API调用证明 - 截图 + 日志文件 * 5. 参赛报名表 - 关联已有报名记录 @@ -25,7 +25,7 @@ import { Upload, Video, } from 'lucide-react' -import { submissionApi } from '@/services' +import { projectApi, submissionApi } from '@/services' import { useAuthStore } from '@/stores/authStore' import { useRegistrationStore } from '@/stores/registrationStore' import { useToast } from '@/components/Toast' @@ -52,6 +52,13 @@ const ACCEPTS = { [ATTACHMENT_TYPES.API_LOG]: 'text/plain,application/json,application/zip,application/x-zip-compressed,application/gzip,.txt,.log,.json,.zip,.gz', } +const PROJECT_STATUS_LABELS = { + draft: '草稿', + submitted: '已提交', + online: '已上线', + offline: '已下线', +} + /** 格式化字节大小 */ function formatBytes(bytes) { const b = Number(bytes || 0) @@ -107,6 +114,39 @@ function validateRepoUrl(url) { return { ok: true, message: '' } } +/** 构建作品访问链接 */ +function buildAccessUrl(domain) { + if (!domain) return '' + if (domain.startsWith('http://') || domain.startsWith('https://')) return domain + return `//${domain}` +} + +/** 解析截图链接 */ +function parseScreenshotUrls(value) { + return String(value || '') + .split(/[\n,,;;]+/) + .map((item) => item.trim()) + .filter(Boolean) +} + +/** 验证镜像引用 */ +function validateImageRef(value) { + const v = String(value || '').trim() + if (!v) return { ok: false, message: '请输入镜像引用' } + if (!v.includes('@sha256:')) return { ok: false, message: '镜像必须包含 @sha256 digest' } + const [ref, digest] = v.split('@', 2) + if (!ref || !digest) return { ok: false, message: '镜像引用格式不正确' } + const registry = ref.split('/')[0] + if (!registry) return { ok: false, message: '镜像引用格式不正确' } + if (!['ghcr.io', 'docker.io'].includes(registry)) { + return { ok: false, message: '仅支持 ghcr.io 或 docker.io' } + } + if (!digest.startsWith('sha256:') || digest.length !== 71) { + return { ok: false, message: '镜像 digest 格式不正确' } + } + return { ok: true, message: '', value: v } +} + /** 获取指定类型的第一个附件 */ function getFirstAttachment(submission, type) { const list = submission?.attachments || [] @@ -216,6 +256,25 @@ function StatusBadge({ status }) { ) } +/** 部署状态徽章 */ +function DeployStatusBadge({ status }) { + const map = { + created: { label: '已创建', className: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' }, + queued: { label: '排队中', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' }, + pulling: { label: '拉取镜像', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' }, + deploying: { label: '部署中', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' }, + healthchecking: { label: '健康检查', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' }, + online: { label: '已上线', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300' }, + failed: { label: '失败', className: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300' }, + } + const item = map[status] || { label: '未知', className: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' } + return ( + + {item.label} + + ) +} + /** 进度条 */ function ProgressBar({ value }) { const v = Math.max(0, Math.min(100, Number(value || 0))) @@ -446,6 +505,7 @@ export default function SubmitPage() { const [finalizeOpen, setFinalizeOpen] = useState(false) const [finalizeSubmissionId, setFinalizeSubmissionId] = useState(null) const [validateResult, setValidateResult] = useState(null) + const [imageRef, setImageRef] = useState('') const [uploadState, setUploadState] = useState(() => ({ [ATTACHMENT_TYPES.DEMO_VIDEO]: { uploading: false, progress: 0 }, @@ -458,11 +518,16 @@ export default function SubmitPage() { description: '', repo_url: '', demo_url: '', + readme_url: '', + cover_image_url: '', + screenshot_urls_text: '', project_doc_md: '', }) const initForSubmissionIdRef = useRef(null) + const initProjectMetaRef = useRef(false) const createDraftPromiseRef = useRef(null) + const initImageRefDone = useRef(false) // 检查报名状态 useEffect(() => { @@ -482,9 +547,46 @@ export default function SubmitPage() { refetchOnWindowFocus: false, }) + // 获取我的作品 + const projectQuery = useQuery({ + queryKey: ['project', 'mine', CONTEST_ID], + enabled: !!token, + queryFn: async () => { + const res = await projectApi.list({ contest_id: CONTEST_ID, mine: true }) + return res?.items?.[0] || null + }, + staleTime: 1000 * 15, + refetchOnWindowFocus: false, + }) + const submission = mineQuery.data + const project = projectQuery.data const isEditable = !submission || isEditableStatus(submission.status) + const projectSubmissionsQuery = useQuery({ + queryKey: ['project', 'submissions', project?.id], + enabled: !!token && !!project?.id, + queryFn: async () => { + const res = await projectApi.listSubmissions(project.id) + return res?.items || [] + }, + staleTime: 1000 * 10, + refetchOnWindowFocus: false, + }) + + const projectAccessQuery = useQuery({ + queryKey: ['project', 'access', project?.id], + enabled: !!token && !!project?.id, + queryFn: async () => projectApi.getAccess(project.id), + staleTime: 1000 * 10, + refetchOnWindowFocus: false, + }) + + const latestProjectSubmission = projectSubmissionsQuery.data?.[0] || null + const projectError = projectQuery.error ? getErrorMessage(projectQuery.error) : '' + const projectSubmissionError = projectSubmissionsQuery.error ? getErrorMessage(projectSubmissionsQuery.error) : '' + const projectAccessError = projectAccessQuery.error ? getErrorMessage(projectAccessQuery.error) : '' + // 初始化表单数据 useEffect(() => { const id = submission?.id || null @@ -494,16 +596,48 @@ export default function SubmitPage() { } if (initForSubmissionIdRef.current === id) return initForSubmissionIdRef.current = id - setForm({ + setForm((prev) => ({ + ...prev, title: submission?.title || '', description: submission?.description || '', repo_url: submission?.repo_url || '', demo_url: submission?.demo_url || '', project_doc_md: submission?.project_doc_md || '', - }) + })) setValidateResult(submission?.validation_summary || null) }, [submission?.id]) + useEffect(() => { + if (!project?.id) { + initImageRefDone.current = false + setImageRef('') + initProjectMetaRef.current = false + return + } + if (initImageRefDone.current) return + if (latestProjectSubmission?.image_ref) { + setImageRef(latestProjectSubmission.image_ref) + initImageRefDone.current = true + } + }, [project?.id, latestProjectSubmission?.id]) + + useEffect(() => { + if (!project?.id) { + initProjectMetaRef.current = false + return + } + if (initProjectMetaRef.current) return + setForm((prev) => ({ + ...prev, + readme_url: project?.readme_url || '', + cover_image_url: project?.cover_image_url || '', + screenshot_urls_text: Array.isArray(project?.screenshot_urls) + ? project.screenshot_urls.join('\n') + : '', + })) + initProjectMetaRef.current = true + }, [project?.id]) + // Mutations const createDraftMutation = useMutation({ mutationFn: (payload) => submissionApi.create(payload), @@ -530,6 +664,59 @@ export default function SubmitPage() { }, }) + const syncProjectMutation = useMutation({ + mutationFn: async () => { + const title = String(form.title || '').trim() + if (title.length < 2) { + throw new Error('作品标题至少 2 个字符') + } + const repoCheck = form.repo_url ? validateRepoUrl(form.repo_url) : { ok: true, message: '' } + if (!repoCheck.ok) { + throw new Error(repoCheck.message) + } + const screenshotUrls = parseScreenshotUrls(form.screenshot_urls_text) + const payload = { + contest_id: CONTEST_ID, + title, + summary: String(form.description || '').trim() || null, + description: null, + repo_url: form.repo_url?.trim() || null, + demo_url: form.demo_url?.trim() || null, + readme_url: form.readme_url?.trim() || null, + cover_image_url: form.cover_image_url?.trim() || null, + screenshot_urls: screenshotUrls.length > 0 ? screenshotUrls : null, + } + if (!project?.id) return projectApi.create(payload) + const { contest_id, ...updatePayload } = payload + return projectApi.update(project.id, updatePayload) + }, + onSuccess: async () => { + toast.success(project?.id ? '作品信息已更新' : '作品已创建') + await queryClient.invalidateQueries({ queryKey: ['project', 'mine', CONTEST_ID] }) + }, + onError: (e) => { + toast.error(getErrorMessage(e)) + }, + }) + + const submitImageMutation = useMutation({ + mutationFn: async () => { + if (!project?.id) throw new Error('请先创建作品信息') + const check = validateImageRef(imageRef) + if (!check.ok) throw new Error(check.message) + return projectApi.submitImage(project.id, { image_ref: check.value }) + }, + onSuccess: async () => { + toast.success('镜像已提交,正在部署') + await queryClient.invalidateQueries({ queryKey: ['project', 'submissions', project?.id] }) + await queryClient.invalidateQueries({ queryKey: ['project', 'access', project?.id] }) + await queryClient.invalidateQueries({ queryKey: ['project', 'mine', CONTEST_ID] }) + }, + onError: (e) => { + toast.error(getErrorMessage(e)) + }, + }) + const deleteAttachmentMutation = useMutation({ mutationFn: ({ submissionId, attachmentId }) => submissionApi.deleteAttachment(submissionId, attachmentId), onSuccess: async () => { @@ -552,23 +739,49 @@ export default function SubmitPage() { return { ok: true, message: '' } }, [token, registrationStatus, registration]) + const canFinalize = useMemo(() => { + if (!token) return { ok: false, message: '请先登录后提交作品' } + if (registrationStatus === 'withdrawn') return { ok: false, message: '报名已撤回,无法提交作品' } + if (registrationStatus === 'none') return { ok: false, message: '请先完成比赛报名' } + if (!registration) return { ok: false, message: '请先完成比赛报名' } + if (registrationStatus !== 'approved') return { ok: false, message: '报名未审核通过,暂不可最终提交' } + return { ok: true, message: '' } + }, [token, registrationStatus, registration]) + + const canDeploy = useMemo(() => { + if (!token) return { ok: false, message: '请先登录后提交镜像' } + if (registrationStatus === 'withdrawn') return { ok: false, message: '报名已撤回,无法提交镜像' } + if (registrationStatus === 'none') return { ok: false, message: '请先完成比赛报名' } + if (!registration) return { ok: false, message: '请先完成比赛报名' } + if (registrationStatus !== 'approved') return { ok: false, message: '报名未审核通过,暂不可提交镜像部署' } + return { ok: true, message: '' } + }, [token, registrationStatus, registration]) + // 本地完整性检查 const localChecklist = useMemo(() => { const trimmedTitle = String(form.title || '').trim() const repoOk = validateRepoUrl(form.repo_url).ok const docOk = !!String(form.project_doc_md || '').trim() const regOk = canUseSubmission.ok && (submission?.registration_id || registration?.id) - const demoOk = !!demoVideo?.is_uploaded + const demoOk = !demoVideo || !!demoVideo?.is_uploaded const apiOk = !!apiScreenshot?.is_uploaded && !!apiLog?.is_uploaded const titleOk = trimmedTitle.length >= 2 - const allOk = repoOk && docOk && regOk && demoOk && apiOk && titleOk + const allOk = repoOk && docOk && regOk && apiOk && titleOk return { allOk, repoOk, docOk, regOk, demoOk, apiOk, titleOk } }, [form.repo_url, form.project_doc_md, form.title, canUseSubmission.ok, submission?.registration_id, registration?.id, demoVideo?.is_uploaded, apiScreenshot?.is_uploaded, apiLog?.is_uploaded]) + const imageRefCheck = useMemo(() => { + const v = String(imageRef || '').trim() + if (!v) return { ok: false, message: '' } + return validateImageRef(v) + }, [imageRef]) + const saving = createDraftMutation.isPending || updateDraftMutation.isPending const validating = validateMutation.isPending const finalizing = finalizeMutation.isPending + const syncingProject = syncProjectMutation.isPending + const submittingImage = submitImageMutation.isPending // 确保有草稿(防止并发创建) const ensureDraft = async () => { @@ -748,6 +961,10 @@ export default function SubmitPage() { // 开始最终提交 const startFinalize = async () => { try { + if (!canFinalize.ok) { + toast.warning(canFinalize.message) + return + } const draft = await ensureDraft() const res = await validateMutation.mutateAsync(draft.id) setValidateResult(res) @@ -766,6 +983,10 @@ export default function SubmitPage() { const confirmFinalize = async () => { try { if (!finalizeSubmissionId) return + if (!canFinalize.ok) { + toast.warning(canFinalize.message) + return + } await finalizeMutation.mutateAsync(finalizeSubmissionId) setFinalizeOpen(false) setFinalizeSubmissionId(null) @@ -777,6 +998,38 @@ export default function SubmitPage() { } } + const syncProjectInfo = async () => { + if (!token) { + toast.warning('请先登录后同步作品信息') + return + } + if (!canUseSubmission.ok) { + toast.warning(canUseSubmission.message) + return + } + try { + await syncProjectMutation.mutateAsync() + } catch { + // 错误由 onError 提示 + } + } + + const submitImageRef = async () => { + if (!token) { + toast.warning('请先登录后提交镜像') + return + } + if (!canDeploy.ok) { + toast.warning(canDeploy.message) + return + } + try { + await submitImageMutation.mutateAsync() + } catch { + // 错误由 onError 提示 + } + } + return (
@@ -828,7 +1081,7 @@ export default function SubmitPage() { +
+ )} + + {project && ( + <> +
+ + setImageRef(e.target.value)} + placeholder="ghcr.io/owner/repo@sha256:..." + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" + /> + {!imageRefCheck.ok && imageRefCheck.message && ( +

{imageRefCheck.message}

+ )} +

+ 示例:ghcr.io/xxx/yyy@sha256:xxxxxxxx(必须是 digest,禁止仅 tag) +

+
+ +
+ + + {project?.id && ( + + 查看访问页 + + )} +
+ +
+
+ 部署状态 + {latestProjectSubmission?.status ? ( + + ) : ( + 暂无记录 + )} +
+ {projectSubmissionError && ( +
{projectSubmissionError}
+ )} +
+
最近提交时间:{formatDateTime(latestProjectSubmission?.submitted_at)}
+ {latestProjectSubmission?.status_message && ( +
状态说明:{latestProjectSubmission.status_message}
+ )} + {latestProjectSubmission?.error_code && ( +
错误码:{latestProjectSubmission.error_code}
+ )} +
+ {projectAccessError && ( +
{projectAccessError}
+ )} +
+ {projectAccessQuery.data?.domain ? ( + + + 打开作品 + + ) : ( + 暂无访问链接 + )} + +
+
+ + )} + + )} +
+
+ + {/* 材料完整性检查 */} +
+
+

材料完整性

+

+ 提交前请确保必填材料齐全(演示视频可选) +

+
+
+ + + + + + + +
+
+
+ {localChecklist.allOk ? ( + <> + + 本地检查:已齐全 + + ) : ( + <> + + 本地检查:未齐全 + + )} +
+

+ 建议点击「材料检查」触发服务端校验,避免遗漏。 +

+
+
+ + {validateResult && ( +
+
+ 服务端校验结果 + + {validateResult?.ok ? '通过' : '未通过'} + +
+ {Array.isArray(validateResult?.errors) && validateResult.errors.length > 0 ? ( +
+ {validateResult.errors.slice(0, 6).map((e, idx) => ( +
+ {e.field}:{e.message} +
+ ))} + {validateResult.errors.length > 6 && ( +

+ 还有 {validateResult.errors.length - 6} 项未展示 +

+ )} +
+ ) : ( +

未发现问题

+ )} +
+ )} + +
+ + + +
+
+
+
+ {/* 左侧表单 */} -
+
{/* 1. 项目源码 */}
@@ -978,13 +1520,68 @@ export default function SubmitPage() { className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-none" />
+ +
+
+ + setForm((s) => ({ ...s, cover_image_url: e.target.value }))} + placeholder="https://... 或 //..." + disabled={!isEditable} + className="w-full h-10 px-3 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" + /> +

+ 建议 16:9 比例,支持图床/仓库原图链接 +

+
+
+ + setForm((s) => ({ ...s, readme_url: e.target.value }))} + placeholder="https://raw.githubusercontent.com/.../README.md" + disabled={!isEditable} + className="w-full h-10 px-3 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" + /> +

+ 用于作品展示页的 README 入口 +

+
+
+ +
+ +