diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 1601b86c2e0f..f29f9d580e0d 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,141 +1,27 @@ -name: Publish Docker image (Multi-arch) - +name: Build and Push Docker Image on: push: - tags: - - '*' - - '!nightly*' - workflow_dispatch: - inputs: - tag: - description: 'Tag name to build (e.g., v0.10.8-alpha.3)' - required: true - type: string - + branches: [ "main" ] +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - build_single_arch: - name: Build & push (${{ matrix.arch }}) - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-latest - - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - outputs: - tag: ${{ steps.version.outputs.tag }} - + build: + runs-on: ubuntu-latest permissions: - packages: write contents: read - id-token: write - + packages: write steps: - - name: Check out - uses: actions/checkout@v4 - with: - fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }} - ref: ${{ github.event.inputs.tag || github.ref }} - - - name: Resolve tag & write VERSION - id: version - run: | - if [ -n "${{ github.event.inputs.tag }}" ]; then - TAG="${{ github.event.inputs.tag }}" - if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error::Tag '$TAG' does not exist" - exit 1 - fi - else - TAG=${GITHUB_REF#refs/tags/} - fi - echo "TAG=${TAG}" >> $GITHUB_ENV - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "${TAG}" > VERSION - echo "Building tag: ${TAG} for ${{ matrix.arch }}" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub + - uses: actions/checkout@v4 + - name: Log in to Container Registry uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata (labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: calciumion/new-api - - - name: Build & push - id: build + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push uses: docker/build-push-action@v6 with: context: . - platforms: ${{ matrix.platform }} push: true - tags: | - calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }} - calciumion/new-api:latest-${{ matrix.arch }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: mode=max - sbom: true - - - name: Install cosign - uses: sigstore/cosign-installer@v3 - - - name: Sign image with cosign - run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }} - - - name: Image summary - run: | - echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY - echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - create_manifests: - name: Create multi-arch manifests - needs: [build_single_arch] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - - steps: - - name: Set version - run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & push manifest (version) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:${TAG} \ - calciumion/new-api:${TAG}-amd64 \ - calciumion/new-api:${TAG}-arm64 - - - name: Create & push manifest (latest) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:latest \ - calciumion/new-api:latest-amd64 \ - calciumion/new-api:latest-arm64 - - - name: Manifest summary - run: | - echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000000..61f148050a1c --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,23 @@ +name: Build and Push Docker Image +on: + push: + branches: [ "main" ] +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/wangyaodujing123/new-api:latest diff --git a/controller/assessment.go b/controller/assessment.go new file mode 100644 index 000000000000..13f33469d594 --- /dev/null +++ b/controller/assessment.go @@ -0,0 +1,415 @@ +package controller + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const assessmentScreenshotDir = "data/assessment_screenshots" + +func init() { + os.MkdirAll(assessmentScreenshotDir, 0755) +} + +func getAssessmentScreenshotPath(filename string) string { + return filepath.Join(assessmentScreenshotDir, filename) +} + +func GetAssessmentScreenshot(c *gin.Context) { + filename := c.Param("filename") + if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid filename"}) + return + } + filePath := getAssessmentScreenshotPath(filename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "file not found"}) + return + } + c.File(filePath) +} + +func UploadAssessmentScreenshot(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + common.ApiErrorMsg(c, "请选择文件") + return + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".gif" && ext != ".webp" { + common.ApiErrorMsg(c, "仅支持 png/jpg/jpeg/gif/webp 格式") + return + } + + if header.Size > 10*1024*1024 { + common.ApiErrorMsg(c, "文件大小不能超过10MB") + return + } + + filename := uuid.New().String() + ext + outPath := getAssessmentScreenshotPath(filename) + outFile, err := os.Create(outPath) + if err != nil { + common.ApiErrorMsg(c, "文件保存失败") + return + } + defer outFile.Close() + + _, err = io.Copy(outFile, file) + if err != nil { + common.ApiErrorMsg(c, "文件保存失败") + return + } + + common.ApiSuccess(c, gin.H{"filename": filename}) +} + +func CreateAssessment(c *gin.Context) { + var req dto.CreateAssessmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if req.Title == "" { + common.ApiErrorMsg(c, "标题不能为空") + return + } + + status := model.AssessmentStatusPending + if req.Status != nil { + status = *req.Status + } + maxScore := 100 + if req.MaxScore != nil { + maxScore = *req.MaxScore + } + + assessment := model.Assessment{ + Title: req.Title, + Description: req.Description, + StartTime: req.StartTime, + EndTime: req.EndTime, + Status: status, + MaxScore: maxScore, + CreatedBy: c.GetInt("id"), + } + if err := assessment.Insert(); err != nil { + common.ApiErrorMsg(c, "创建失败:"+err.Error()) + return + } + common.ApiSuccess(c, assessment) +} + +func UpdateAssessment(c *gin.Context) { + var req dto.UpdateAssessmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + + existing, err := model.GetAssessmentByID(req.Id) + if err != nil { + common.ApiErrorMsg(c, "考核不存在") + return + } + + existing.Title = req.Title + existing.Description = req.Description + existing.StartTime = req.StartTime + existing.EndTime = req.EndTime + if req.Status != nil { + existing.Status = *req.Status + } + if req.MaxScore != nil { + existing.MaxScore = *req.MaxScore + } + + if err := existing.Update(); err != nil { + common.ApiErrorMsg(c, "更新失败:"+err.Error()) + return + } + common.ApiSuccess(c, existing) +} + +func DeleteAssessment(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if err := model.DeleteAssessmentByID(id); err != nil { + common.ApiErrorMsg(c, "删除失败:"+err.Error()) + return + } + common.ApiSuccess(c, nil) +} + +func GetAllAssessments(c *gin.Context) { + list, err := model.GetAllAssessments() + if err != nil { + common.ApiErrorMsg(c, "查询失败:"+err.Error()) + return + } + if list == nil { + list = []model.Assessment{} + } + common.ApiSuccess(c, list) +} + +func GetActiveAssessmentsForUser(c *gin.Context) { + model.UpdateAssessmentStatus() + list, err := model.GetActiveAssessments() + if err != nil { + common.ApiErrorMsg(c, "查询失败:"+err.Error()) + return + } + if list == nil { + list = []model.Assessment{} + } + + userId := c.GetInt("id") + type AssessmentWithSubmission struct { + Id int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + AssessmentStatus int `json:"status"` + MaxScore int `json:"max_score"` + CreatedBy int `json:"created_by"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Submitted bool `json:"submitted"` + Score *float64 `json:"score"` + SubmissionStatus int `json:"submission_status"` + } + + result := make([]AssessmentWithSubmission, len(list)) + for i, a := range list { + result[i].Id = a.Id + result[i].Title = a.Title + result[i].Description = a.Description + result[i].StartTime = a.StartTime + result[i].EndTime = a.EndTime + result[i].AssessmentStatus = a.Status + result[i].MaxScore = a.MaxScore + result[i].CreatedBy = a.CreatedBy + result[i].CreatedAt = a.CreatedAt + result[i].UpdatedAt = a.UpdatedAt + if sub, err := model.GetUserSubmissionByAssessment(userId, a.Id); err == nil { + result[i].Submitted = true + result[i].Score = sub.Score + result[i].SubmissionStatus = sub.Status + } + } + common.ApiSuccess(c, result) +} + +func SubmitAssessment(c *gin.Context) { + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + common.ApiErrorMsg(c, "请求解析失败") + return + } + + assessmentId, err := strconv.Atoi(c.PostForm("assessment_id")) + if err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + content := c.PostForm("content") + userId := c.GetInt("id") + + model.UpdateAssessmentStatus() + assessment, err := model.GetAssessmentByID(assessmentId) + if err != nil { + common.ApiErrorMsg(c, "考核不存在") + return + } + if assessment.Status != model.AssessmentStatusActive { + common.ApiErrorMsg(c, "考核不在进行中") + return + } + + existing, err := model.GetUserSubmissionByAssessment(userId, assessmentId) + if err == nil && existing != nil { + common.ApiErrorMsg(c, "已提交过该考核") + return + } + + form := c.Request.MultipartForm + var screenshots []string + files := form.File["screenshots"] + for _, fh := range files { + ext := strings.ToLower(filepath.Ext(fh.Filename)) + if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".gif" && ext != ".webp" { + common.ApiErrorMsg(c, "仅支持 png/jpg/jpeg/gif/webp 格式") + return + } + if fh.Size > 10*1024*1024 { + common.ApiErrorMsg(c, "单张图片不能超过10MB") + return + } + + file, err := fh.Open() + if err != nil { + continue + } + + filename := uuid.New().String() + ext + outPath := getAssessmentScreenshotPath(filename) + outFile, err := os.Create(outPath) + if err != nil { + file.Close() + continue + } + + io.Copy(outFile, file) + outFile.Close() + file.Close() + + screenshots = append(screenshots, filename) + } + + submission := model.AssessmentSubmission{ + AssessmentId: assessmentId, + UserId: userId, + Content: content, + Screenshots: model.ScreenshotsJSON(screenshots), + Status: model.SubmissionStatusPending, + } + if len(screenshots) == 0 { + submission.Screenshots = model.ScreenshotsJSON{} + } + if err := submission.Insert(); err != nil { + common.ApiErrorMsg(c, "提交失败:"+err.Error()) + return + } + common.ApiSuccess(c, submission) +} + +func GetMySubmissions(c *gin.Context) { + userId := c.GetInt("id") + submissions, err := model.GetUserSubmissions(userId) + if err != nil { + common.ApiErrorMsg(c, "查询失败:"+err.Error()) + return + } + if submissions == nil { + submissions = []model.AssessmentSubmission{} + } + + type SubmissionWithAssessment struct { + model.AssessmentSubmission + AssessmentTitle string `json:"assessment_title"` + } + + result := make([]SubmissionWithAssessment, len(submissions)) + for i, s := range submissions { + result[i].AssessmentSubmission = s + if a, err := model.GetAssessmentByID(s.AssessmentId); err == nil { + result[i].AssessmentTitle = a.Title + } + } + common.ApiSuccess(c, result) +} + +func GetAssessmentSubmissionsAdmin(c *gin.Context) { + assessmentId, err := strconv.Atoi(c.Param("assessment_id")) + if err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + + submissions, err := model.GetSubmissionsByAssessment(assessmentId) + if err != nil { + common.ApiErrorMsg(c, "查询失败:"+err.Error()) + return + } + if submissions == nil { + submissions = []model.AssessmentSubmission{} + } + + type SubmissionWithUser struct { + model.AssessmentSubmission + Username string `json:"username"` + Email string `json:"email"` + } + + result := make([]SubmissionWithUser, len(submissions)) + for i, s := range submissions { + result[i].AssessmentSubmission = s + if user, err := model.GetUserById(s.UserId, false); err == nil { + result[i].Username = user.Username + result[i].Email = user.Email + } + } + common.ApiSuccess(c, result) +} + +func ReviewSubmission(c *gin.Context) { + var req dto.ReviewSubmissionRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + + submission, err := model.GetSubmissionByID(req.Id) + if err != nil { + common.ApiErrorMsg(c, "提交记录不存在") + return + } + + if err := model.ReviewSubmission(req.Id, req.Status, req.Score, req.Comment, c.GetInt("id")); err != nil { + common.ApiErrorMsg(c, "评审失败:"+err.Error()) + return + } + + assessment, _ := model.GetAssessmentByID(submission.AssessmentId) + title := "" + if assessment != nil { + title = assessment.Title + } + go service.NotifyAssessmentReview(submission.UserId, title, req.Status, req.Score, req.Comment) + + common.ApiSuccess(c, nil) +} + +func GetAssessmentStats(c *gin.Context) { + assessmentId, err := strconv.Atoi(c.Param("assessment_id")) + if err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + + stats, err := model.GetAssessmentSubmissionStats(assessmentId) + if err != nil { + common.ApiErrorMsg(c, "查询失败:"+err.Error()) + return + } + common.ApiSuccess(c, stats) +} + +func GetMyAssessmentStats(c *gin.Context) { + userId := c.GetInt("id") + stats, err := model.GetUserAssessmentStats(userId) + if err != nil { + common.ApiErrorMsg(c, "查询失败:"+err.Error()) + return + } + common.ApiSuccess(c, stats) +} + diff --git a/dto/assessment.go b/dto/assessment.go new file mode 100644 index 000000000000..6bb0c843164d --- /dev/null +++ b/dto/assessment.go @@ -0,0 +1,32 @@ +package dto + +type CreateAssessmentRequest struct { + Title string `json:"title"` + Description string `json:"description"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + Status *int `json:"status"` + MaxScore *int `json:"max_score"` +} + +type UpdateAssessmentRequest struct { + Id int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + Status *int `json:"status"` + MaxScore *int `json:"max_score"` +} + +type SubmitAssessmentRequest struct { + AssessmentId int `json:"assessment_id"` + Content string `json:"content"` +} + +type ReviewSubmissionRequest struct { + Id int `json:"id"` + Status int `json:"status"` + Score float64 `json:"score"` + Comment string `json:"comment"` +} diff --git a/model/assessment.go b/model/assessment.go new file mode 100644 index 000000000000..b21670358e39 --- /dev/null +++ b/model/assessment.go @@ -0,0 +1,115 @@ +package model + +import ( + "math" + "time" +) + +const ( + AssessmentStatusPending = 0 + AssessmentStatusActive = 1 + AssessmentStatusClosed = 2 +) + +type Assessment struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Title string `json:"title" gorm:"size:255;not null"` + Description string `json:"description" gorm:"type:text"` + StartTime int64 `json:"start_time" gorm:"bigint;not null"` + EndTime int64 `json:"end_time" gorm:"bigint;not null"` + Status int `json:"status" gorm:"default:0;not null"` + MaxScore int `json:"max_score" gorm:"default:100;not null"` + CreatedBy int `json:"created_by" gorm:"not null"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +func (Assessment) TableName() string { + return "assessments" +} + +func (a *Assessment) Insert() error { + now := time.Now().Unix() + a.CreatedAt = now + a.UpdatedAt = now + return DB.Create(a).Error +} + +func (a *Assessment) Update() error { + a.UpdatedAt = time.Now().Unix() + return DB.Save(a).Error +} + +func DeleteAssessmentByID(id int) error { + return DB.Delete(&Assessment{}, id).Error +} + +func GetAssessmentByID(id int) (*Assessment, error) { + var a Assessment + err := DB.First(&a, id).Error + if err != nil { + return nil, err + } + return &a, nil +} + +func GetAllAssessments() ([]Assessment, error) { + var list []Assessment + err := DB.Order("created_at DESC").Find(&list).Error + return list, err +} + +func GetActiveAssessments() ([]Assessment, error) { + now := time.Now().Unix() + var list []Assessment + err := DB.Where("status = ? AND start_time <= ? AND end_time >= ?", + AssessmentStatusActive, now, now). + Order("created_at DESC").Find(&list).Error + return list, err +} + +func UpdateAssessmentStatus() { + now := time.Now().Unix() + DB.Model(&Assessment{}).Where("status = ? AND start_time <= ? AND end_time >= ?", + AssessmentStatusPending, now, now). + Update("status", AssessmentStatusActive) + DB.Model(&Assessment{}).Where("status = ? AND end_time < ?", + AssessmentStatusActive, now). + Update("status", AssessmentStatusClosed) +} + +func HasAssessmentActive() (bool, error) { + now := time.Now().Unix() + var count int64 + err := DB.Model(&Assessment{}). + Where("status = ? AND start_time <= ? AND end_time >= ?", + AssessmentStatusActive, now, now). + Count(&count).Error + return count > 0, err +} + +func GetUserAssessmentStats(userId int) (map[string]interface{}, error) { + var total int64 + var passed int64 + var totalScore float64 + DB.Model(&AssessmentSubmission{}). + Where("user_id = ?", userId).Count(&total) + DB.Model(&AssessmentSubmission{}). + Where("user_id = ? AND status = ?", userId, SubmissionStatusPassed).Count(&passed) + row := DB.Model(&AssessmentSubmission{}). + Where("user_id = ?", userId). + Select("COALESCE(SUM(score), 0)").Row() + if err := row.Scan(&totalScore); err != nil { + return nil, err + } + + var avgScore float64 + if total > 0 { + avgScore = totalScore / float64(total) + } + return map[string]interface{}{ + "total_submissions": total, + "passed": passed, + "average_score": math.Round(avgScore*100) / 100, + }, nil +} diff --git a/model/assessment_submission.go b/model/assessment_submission.go new file mode 100644 index 000000000000..145a69c52e44 --- /dev/null +++ b/model/assessment_submission.go @@ -0,0 +1,151 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "math" + "time" +) + +const ( + SubmissionStatusPending = 0 + SubmissionStatusPassed = 1 + SubmissionStatusFailed = 2 +) + +type ScreenshotsJSON []string + +func (s ScreenshotsJSON) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return json.Marshal(s) +} + +func (s *ScreenshotsJSON) Scan(value interface{}) error { + switch v := value.(type) { + case nil: + *s = nil + return nil + case []byte: + return json.Unmarshal(v, s) + case string: + return json.Unmarshal([]byte(v), s) + default: + b, err := json.Marshal(v) + if err != nil { + return err + } + return json.Unmarshal(b, s) + } +} + +func (s ScreenshotsJSON) MarshalJSON() ([]byte, error) { + if s == nil { + return []byte("null"), nil + } + return json.Marshal([]string(s)) +} + +type AssessmentSubmission struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + AssessmentId int `json:"assessment_id" gorm:"index;not null"` + UserId int `json:"user_id" gorm:"index;not null"` + Content string `json:"content" gorm:"type:text"` + Screenshots ScreenshotsJSON `json:"screenshots" gorm:"type:text"` + Status int `json:"status" gorm:"default:0;not null"` + Score *float64 `json:"score" gorm:"default:null"` + Comment string `json:"comment" gorm:"type:text"` + ReviewedBy int `json:"reviewed_by" gorm:"default:0"` + SubmittedAt int64 `json:"submitted_at" gorm:"bigint"` + ReviewedAt int64 `json:"reviewed_at" gorm:"bigint"` +} + +func (AssessmentSubmission) TableName() string { + return "assessment_submissions" +} + +func (s *AssessmentSubmission) Insert() error { + s.SubmittedAt = time.Now().Unix() + return DB.Create(s).Error +} + +func (s *AssessmentSubmission) Update() error { + return DB.Save(s).Error +} + +func GetSubmissionByID(id int) (*AssessmentSubmission, error) { + var s AssessmentSubmission + err := DB.First(&s, id).Error + if err != nil { + return nil, err + } + return &s, nil +} + +func GetUserSubmissions(userId int) ([]AssessmentSubmission, error) { + var list []AssessmentSubmission + err := DB.Where("user_id = ?", userId). + Order("submitted_at DESC").Find(&list).Error + return list, err +} + +func GetSubmissionsByAssessment(assessmentId int) ([]AssessmentSubmission, error) { + var list []AssessmentSubmission + err := DB.Where("assessment_id = ?", assessmentId). + Order("submitted_at DESC").Find(&list).Error + return list, err +} + +func GetUserSubmissionByAssessment(userId, assessmentId int) (*AssessmentSubmission, error) { + var s AssessmentSubmission + err := DB.Where("user_id = ? AND assessment_id = ?", userId, assessmentId). + First(&s).Error + if err != nil { + return nil, err + } + return &s, nil +} + +func ReviewSubmission(id int, status int, score float64, comment string, reviewedBy int) error { + now := time.Now().Unix() + return DB.Model(&AssessmentSubmission{}).Where("id = ?", id).Updates(map[string]interface{}{ + "status": status, + "score": score, + "comment": comment, + "reviewed_by": reviewedBy, + "reviewed_at": now, + }).Error +} + +func GetAssessmentSubmissionStats(assessmentId int) (map[string]interface{}, error) { + var total int64 + var pending int64 + var passed int64 + var failed int64 + var totalScore float64 + + DB.Model(&AssessmentSubmission{}). + Where("assessment_id = ?", assessmentId).Count(&total) + DB.Model(&AssessmentSubmission{}). + Where("assessment_id = ? AND status = ?", assessmentId, SubmissionStatusPending).Count(&pending) + DB.Model(&AssessmentSubmission{}). + Where("assessment_id = ? AND status = ?", assessmentId, SubmissionStatusPassed).Count(&passed) + DB.Model(&AssessmentSubmission{}). + Where("assessment_id = ? AND status = ?", assessmentId, SubmissionStatusFailed).Count(&failed) + + row := DB.Model(&AssessmentSubmission{}). + Where("assessment_id = ? AND status != ?", assessmentId, SubmissionStatusPending). + Select("COALESCE(AVG(score), 0)").Row() + if err := row.Scan(&totalScore); err != nil { + return nil, err + } + + return map[string]interface{}{ + "total": total, + "pending": pending, + "passed": passed, + "failed": failed, + "average_score": math.Round(totalScore*100) / 100, + }, nil +} diff --git a/model/main.go b/model/main.go index f37cb667cd43..9a8819e8161b 100644 --- a/model/main.go +++ b/model/main.go @@ -280,6 +280,8 @@ func migrateDB() error { &SubscriptionPreConsumeRecord{}, &CustomOAuthProvider{}, &UserOAuthBinding{}, + &Assessment{}, + &AssessmentSubmission{}, ) if err != nil { return err @@ -328,6 +330,8 @@ func migrateDBFast() error { {&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"}, {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, + {&Assessment{}, "Assessment"}, + {&AssessmentSubmission{}, "AssessmentSubmission"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/router/api-router.go b/router/api-router.go index 83f5e4ae9d92..e5709514a36d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -25,6 +25,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) + apiRouter.GET("/assessment/screenshot/:filename", controller.GetAssessmentScreenshot) apiRouter.GET("/user-agreement", controller.GetUserAgreement) apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy) apiRouter.GET("/about", controller.GetAbout) @@ -379,5 +380,27 @@ func SetApiRouter(router *gin.Engine) { deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment) deploymentsRoute.DELETE("/:id", controller.DeleteDeployment) } + + // Assessment (AI code review) + assessmentUserRoute := apiRouter.Group("/assessment") + assessmentUserRoute.Use(middleware.UserAuth()) + { + assessmentUserRoute.GET("/active", controller.GetActiveAssessmentsForUser) + assessmentUserRoute.GET("/my", controller.GetMySubmissions) + assessmentUserRoute.GET("/my/stats", controller.GetMyAssessmentStats) + assessmentUserRoute.POST("/submit", controller.SubmitAssessment) + assessmentUserRoute.POST("/upload", controller.UploadAssessmentScreenshot) + } + assessmentAdminRoute := apiRouter.Group("/assessment/admin") + assessmentAdminRoute.Use(middleware.AdminAuth()) + { + assessmentAdminRoute.GET("/", controller.GetAllAssessments) + assessmentAdminRoute.POST("/", controller.CreateAssessment) + assessmentAdminRoute.PUT("/", controller.UpdateAssessment) + assessmentAdminRoute.DELETE("/:id", controller.DeleteAssessment) + assessmentAdminRoute.GET("/submissions/:assessment_id", controller.GetAssessmentSubmissionsAdmin) + assessmentAdminRoute.POST("/review", controller.ReviewSubmission) + assessmentAdminRoute.GET("/stats/:assessment_id", controller.GetAssessmentStats) + } } } diff --git a/service/assessment.go b/service/assessment.go new file mode 100644 index 000000000000..be250bfb0b49 --- /dev/null +++ b/service/assessment.go @@ -0,0 +1,39 @@ +package service + +import ( + "fmt" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" +) + +const ( + NotifyTypeAssessmentReview = "assessment_review" +) + +func NotifyAssessmentReview(userId int, assessmentTitle string, status int, score float64, comment string) { + user, err := model.GetUserById(userId, false) + if err != nil { + return + } + + var resultText string + switch status { + case model.SubmissionStatusPassed: + resultText = "通过" + case model.SubmissionStatusFailed: + resultText = "未通过" + default: + resultText = "已评审" + } + + title := "考核评审结果通知" + content := fmt.Sprintf("您在考核「%s」中的提交已被评审,结果:%s,得分:%.1f分", assessmentTitle, resultText, score) + if comment != "" { + content += fmt.Sprintf("\n评语:%s", comment) + } + + notification := dto.NewNotify(NotifyTypeAssessmentReview, title, content, nil) + userSetting := user.GetSetting() + _ = NotifyUser(userId, user.Email, userSetting, notification) +} diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx index a5d1ebc00b32..2b7901b7365b 100644 --- a/web/classic/src/App.jsx +++ b/web/classic/src/App.jsx @@ -45,6 +45,7 @@ import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; +import Assessment from './pages/Assessment'; import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; import Setup from './pages/Setup'; @@ -315,6 +316,16 @@ function App() { } /> + + } key={location.pathname}> + + + + } + /> {} }) => { @@ -105,6 +106,11 @@ const SiderBar = ({ onNavigate = () => {} }) => { className: localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', }, + { + text: t('AI Code Review Assessment'), + itemKey: 'assessment', + to: '/assessment', + }, ]; // 根据配置过滤项目 diff --git a/web/classic/src/helpers/render.jsx b/web/classic/src/helpers/render.jsx index 46c95b236831..fe84b9302276 100644 --- a/web/classic/src/helpers/render.jsx +++ b/web/classic/src/helpers/render.jsx @@ -71,6 +71,7 @@ import { BarChart3, Image as ImageIcon, CheckSquare, + ClipboardCheck, CreditCard, Layers, Gift, @@ -135,6 +136,8 @@ export function getLucideIcon(key, selected = false) { return ; case 'task': return ; + case 'assessment': + return ; case 'topup': return ; case 'channel': diff --git a/web/classic/src/hooks/common/useSidebar.js b/web/classic/src/hooks/common/useSidebar.js index cd74ada20280..481a5965cd4c 100644 --- a/web/classic/src/hooks/common/useSidebar.js +++ b/web/classic/src/hooks/common/useSidebar.js @@ -38,6 +38,7 @@ export const DEFAULT_ADMIN_CONFIG = { log: true, midjourney: true, task: true, + assessment: true, }, personal: { enabled: true, diff --git a/web/classic/src/pages/Assessment/index.jsx b/web/classic/src/pages/Assessment/index.jsx new file mode 100644 index 000000000000..9bff74dff63e --- /dev/null +++ b/web/classic/src/pages/Assessment/index.jsx @@ -0,0 +1,468 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; +import { API, isAdmin, showSuccess, showError } from '../../helpers'; +import { + Card, + Button, + Input, + Textarea, + Modal, + Select, + Tag, + Typography, + Spin, + Empty, + Tabs, + TabPane, +} from '@douyinfe/semi-ui'; + +const SUBMISSION_STATUS = { 0: 'Pending Review', 1: 'Passed', 2: 'Failed' }; +const ASSESS_STATUS = { 0: 'Not Started', 1: 'In Progress', 2: 'Ended' }; + +const useData = (key, fetcher, deps = []) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetcher(); + setData(res?.data || res); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }, deps); + useEffect(() => { load(); }, [load]); + return { data, loading, reload: load }; +}; + +const Assessment = () => { + const { t } = useTranslation(); + const admin = isAdmin(); + + if (admin) return ; + return ; +}; + +const UserView = ({ t }) => ( +
+ {t('AI Code Review Assessment')} + + + + + + + + + + + +
+); + +const ActiveAssessments = ({ t }) => { + const { data, loading, reload } = useData('active', () => + API.get('/api/assessment/active').then(r => r.data) + ); + const [submitTarget, setSubmitTarget] = useState(null); + const [content, setContent] = useState(''); + const [files, setFiles] = useState([]); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!submitTarget) return; + setSubmitting(true); + try { + const fd = new FormData(); + fd.append('assessment_id', String(submitTarget.id)); + fd.append('content', content); + for (const f of files) fd.append('screenshots', f); + const res = await API.post('/api/assessment/submit', fd); + if (res.data.success) { + showSuccess(t('Submitted successfully')); + setSubmitTarget(null); + setContent(''); + setFiles([]); + reload(); + } + } catch { + showError(t('Submission failed')); + } finally { + setSubmitting(false); + } + }; + + if (loading) return ; + const items = data?.data ?? []; + if (items.length === 0) return ; + + return ( + <> + {items.map((item) => ( + +
+ {item.title} + + {t(item.submitted ? SUBMISSION_STATUS[item.submission_status] || 'Unknown' : 'Not Submitted')} + +
+ + {dayjs.unix(item.start_time).format('YYYY-MM-DD HH:mm')} ~ {dayjs.unix(item.end_time).format('YYYY-MM-DD HH:mm')} + +
+ {item.description} + {item.submitted && item.score != null && ( + {t('Score')}: {item.score} + )} + {!item.submitted && ( + + )} +
+
+ ))} + + setSubmitTarget(null)} footer={null}> +
+
+ {t('Description')} +