diff --git a/cmd/server/wire.go b/cmd/server/wire.go index a63a51b..749b0c4 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -40,6 +40,8 @@ func ProvideRouter( voteHandler *handler.VoteHandler, channelHandler *handler.ChannelHandler, scheduleHandler *handler.ScheduleHandler, + gradeHandler *handler.GradeHandler, + examHandler *handler.ExamHandler, roleHandler *handler.RoleHandler, adminUserHandler *handler.AdminUserHandler, adminPostHandler *handler.AdminPostHandler, @@ -78,6 +80,8 @@ func ProvideRouter( VoteHandler: voteHandler, ChannelHandler: channelHandler, ScheduleHandler: scheduleHandler, + GradeHandler: gradeHandler, + ExamHandler: examHandler, RoleHandler: roleHandler, AdminUserHandler: adminUserHandler, AdminPostHandler: adminPostHandler, diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index ff5bc72..ac31d25 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -105,6 +105,12 @@ func InitializeApp() (*App, error) { taskManager := wire.ProvideTaskManager(runnerHub, logger) scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger) scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService) + gradeRepository := repository.NewGradeRepository(db) + gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger) + gradeHandler := handler.NewGradeHandler(gradeSyncService, gradeRepository) + examRepository := repository.NewExamRepository(db) + examSyncService := wire.ProvideExamSyncService(taskManager, examRepository, logger) + examHandler := handler.NewExamHandler(examSyncService, examRepository) roleRepository := wire.ProvideRoleRepository(db) enforcer := wire.ProvideCasbinEnforcer(config, db) casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config) @@ -147,7 +153,7 @@ func InitializeApp() (*App, error) { tradeService := wire.ProvideTradeService(tradeRepository) tradeHandler := handler.NewTradeHandler(tradeService) wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userRepository) - router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler) + router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler) hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client) server := wire.ProvideGRPCServer(config, logger, runnerHub) runnerRegistry := wire.ProvideRunnerRegistry(config, client) @@ -175,6 +181,8 @@ func ProvideRouter( voteHandler *handler.VoteHandler, channelHandler *handler.ChannelHandler, scheduleHandler *handler.ScheduleHandler, + gradeHandler *handler.GradeHandler, + examHandler *handler.ExamHandler, roleHandler *handler.RoleHandler, adminUserHandler *handler.AdminUserHandler, adminPostHandler *handler.AdminPostHandler, @@ -213,6 +221,8 @@ func ProvideRouter( VoteHandler: voteHandler, ChannelHandler: channelHandler, ScheduleHandler: scheduleHandler, + GradeHandler: gradeHandler, + ExamHandler: examHandler, RoleHandler: roleHandler, AdminUserHandler: adminUserHandler, AdminPostHandler: adminPostHandler, diff --git a/internal/cache/conversation_cache.go b/internal/cache/conversation_cache.go index 31e71d7..bfd6999 100644 --- a/internal/cache/conversation_cache.go +++ b/internal/cache/conversation_cache.go @@ -670,31 +670,51 @@ func (c *ConversationCache) IncrementUnread(ctx context.Context, userID, convID return nil } -// ClearUnread 清零用户在某会话的未读数 +// ClearUnread 清零用户在某会话的未读数(原子操作,防止与 IncrementUnread 竞态) func (c *ConversationCache) ClearUnread(ctx context.Context, userID, convID string) error { hashKey := UnreadHashKey(userID) totalKey := UnreadTotalKey(userID) - // 读取当前未读数 - currentStr, err := c.cache.HGet(ctx, hashKey, convID) + redisCache, ok := c.cache.(*RedisCache) + if !ok { + // miniredis/内存模式:保持原逻辑(非原子,但无并发竞态风险) + currentStr, err := c.cache.HGet(ctx, hashKey, convID) + if err != nil { + currentStr = "0" + } + current := int64(0) + if v, parseErr := fmt.Sscanf(currentStr, "%d", ¤t); parseErr != nil || v != 1 { + current = 0 + } + if err := c.cache.HSet(ctx, hashKey, convID, "0"); err != nil { + return fmt.Errorf("hset clear unread failed: %w", err) + } + if current > 0 { + c.cache.IncrBySeq(ctx, totalKey, -current) + } + return nil + } + + rdb := redisCache.GetRedisClient().GetClient() + nHashKey := normalizeKey(hashKey) + nTotalKey := normalizeKey(totalKey) + + script := redis.NewScript(` + local current = tonumber(redis.call('HGET', KEYS[1], ARGV[1])) + if current == nil or current <= 0 then + return 0 + end + redis.call('HSET', KEYS[1], ARGV[1], '0') + local newTotal = tonumber(redis.call('INCRBY', KEYS[2], -current)) + if newTotal < 0 then + redis.call('SET', KEYS[2], '0') + end + return current + `) + _, err := script.Run(ctx, rdb, []string{nHashKey, nTotalKey}, convID).Result() if err != nil { - currentStr = "0" + return fmt.Errorf("lua clear unread failed: %w", err) } - current := int64(0) - if v, parseErr := fmt.Sscanf(currentStr, "%d", ¤t); parseErr != nil || v != 1 { - current = 0 - } - - // 清零该会话未读数 - if err := c.cache.HSet(ctx, hashKey, convID, "0"); err != nil { - return fmt.Errorf("hset clear unread failed: %w", err) - } - - // 减少总未读数 - if current > 0 { - c.cache.IncrBySeq(ctx, totalKey, -current) - } - return nil } diff --git a/internal/handler/exam_handler.go b/internal/handler/exam_handler.go new file mode 100644 index 0000000..6bc20e3 --- /dev/null +++ b/internal/handler/exam_handler.go @@ -0,0 +1,78 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "with_you/internal/pkg/response" + "with_you/internal/repository" + "with_you/internal/service" +) + +type ExamHandler struct { + examSyncService service.ExamSyncService + examRepo repository.ExamRepository +} + +func NewExamHandler(examSyncService service.ExamSyncService, examRepo repository.ExamRepository) *ExamHandler { + return &ExamHandler{ + examSyncService: examSyncService, + examRepo: examRepo, + } +} + +type syncExamsRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Semester string `json:"semester"` +} + +func (h *ExamHandler) SyncExams(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + var req syncExamsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request: "+err.Error()) + return + } + + result, err := h.examSyncService.SyncUserExams(c.Request.Context(), userID, &service.SyncExamsRequest{ + Username: req.Username, + Password: req.Password, + Semester: req.Semester, + }) + if err != nil { + response.HandleError(c, err, "failed to sync exams") + return + } + + if !result.Success { + response.Success(c, result) + return + } + response.SuccessWithMessage(c, result.Message, result) +} + +func (h *ExamHandler) ListExams(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + semester := c.Query("semester") + if semester == "" { + response.BadRequest(c, "semester is required") + return + } + + exams, err := h.examRepo.ListByUserAndSemester(userID, semester) + if err != nil { + response.HandleError(c, err, "failed to list exams") + return + } + + response.Success(c, gin.H{"exams": exams}) +} diff --git a/internal/handler/grade_handler.go b/internal/handler/grade_handler.go new file mode 100644 index 0000000..badaee1 --- /dev/null +++ b/internal/handler/grade_handler.go @@ -0,0 +1,83 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "with_you/internal/pkg/response" + "with_you/internal/repository" + "with_you/internal/service" +) + +type GradeHandler struct { + gradeSyncService service.GradeSyncService + gradeRepo repository.GradeRepository +} + +func NewGradeHandler(gradeSyncService service.GradeSyncService, gradeRepo repository.GradeRepository) *GradeHandler { + return &GradeHandler{ + gradeSyncService: gradeSyncService, + gradeRepo: gradeRepo, + } +} + +type syncGradesRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Semester string `json:"semester"` +} + +func (h *GradeHandler) SyncGrades(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + var req syncGradesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request: "+err.Error()) + return + } + + result, err := h.gradeSyncService.SyncUserGrades(c.Request.Context(), userID, &service.SyncGradesRequest{ + Username: req.Username, + Password: req.Password, + Semester: req.Semester, + }) + if err != nil { + response.HandleError(c, err, "failed to sync grades") + return + } + + if !result.Success { + response.Success(c, result) + return + } + response.SuccessWithMessage(c, result.Message, result) +} + +func (h *GradeHandler) ListGrades(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + semester := c.Query("semester") + if semester == "" { + response.BadRequest(c, "semester is required") + return + } + + grades, err := h.gradeRepo.ListByUserAndSemester(userID, semester) + if err != nil { + response.HandleError(c, err, "failed to list grades") + return + } + + gpaSummary, _ := h.gradeRepo.GetGpaSummary(userID, semester) + + response.Success(c, gin.H{ + "grades": grades, + "gpa_summary": gpaSummary, + }) +} diff --git a/internal/model/exam.go b/internal/model/exam.go new file mode 100644 index 0000000..45566b4 --- /dev/null +++ b/internal/model/exam.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Exam struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + Semester string `json:"semester" gorm:"type:varchar(30);not null"` + CourseName string `json:"course_name" gorm:"type:varchar(120);not null"` + ExamType string `json:"exam_type" gorm:"type:varchar(30)"` + Date string `json:"date" gorm:"type:varchar(20)"` + Time string `json:"time" gorm:"type:varchar(30)"` + Week string `json:"week" gorm:"type:varchar(20)"` + Weekday string `json:"weekday" gorm:"type:varchar(10)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (e *Exam) BeforeCreate(tx *gorm.DB) error { + SetUUIDIfEmpty(&e.ID) + return nil +} + +func (Exam) TableName() string { return "exams" } diff --git a/internal/model/grade.go b/internal/model/grade.go new file mode 100644 index 0000000..5b5dcd3 --- /dev/null +++ b/internal/model/grade.go @@ -0,0 +1,51 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Grade struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + Term string `json:"term" gorm:"type:varchar(30);not null"` + CourseCode string `json:"course_code" gorm:"type:varchar(30)"` + CourseName string `json:"course_name" gorm:"type:varchar(120);not null"` + Nature string `json:"nature" gorm:"type:varchar(30)"` + Category string `json:"category" gorm:"type:varchar(30)"` + Credit string `json:"credit" gorm:"type:varchar(10)"` + Score string `json:"score" gorm:"type:varchar(20)"` + GradePoint string `json:"grade_point" gorm:"type:varchar(10)"` + IsExam string `json:"is_exam" gorm:"type:varchar(10)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (g *Grade) BeforeCreate(tx *gorm.DB) error { + SetUUIDIfEmpty(&g.ID) + return nil +} + +func (Grade) TableName() string { return "grades" } + +type GpaSummary struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + Semester string `json:"semester" gorm:"type:varchar(30);not null"` + AverageGpa string `json:"average_gpa" gorm:"type:varchar(20)"` + Rank string `json:"rank" gorm:"type:varchar(30)"` + ExamTotalGpa string `json:"exam_total_gpa" gorm:"type:varchar(20)"` + ExamTotalCredits string `json:"exam_total_credits" gorm:"type:varchar(20)"` + FailCredits string `json:"fail_credits" gorm:"type:varchar(20)"` + Semesters string `json:"semesters" gorm:"type:varchar(20)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (g *GpaSummary) BeforeCreate(tx *gorm.DB) error { + SetUUIDIfEmpty(&g.ID) + return nil +} + +func (GpaSummary) TableName() string { return "gpa_summaries" } diff --git a/internal/model/init.go b/internal/model/init.go index 753fc0c..895161a 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -154,6 +154,11 @@ func autoMigrate(db *gorm.DB) error { // 课表 &ScheduleCourse{}, + // 成绩与考试 + &Grade{}, + &GpaSummary{}, + &Exam{}, + // 用户活跃相关 &UserActiveLog{}, &UserActivityStat{}, diff --git a/internal/repository/exam_repo.go b/internal/repository/exam_repo.go new file mode 100644 index 0000000..4e88165 --- /dev/null +++ b/internal/repository/exam_repo.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + + "with_you/internal/model" + + "gorm.io/gorm" +) + +type ExamRepository interface { + ListByUserAndSemester(userID, semester string) ([]*model.Exam, error) + DeleteByUserAndSemester(ctx context.Context, userID, semester string) error + BatchCreate(ctx context.Context, exams []*model.Exam) error +} + +type examRepository struct { + db *gorm.DB +} + +func NewExamRepository(db *gorm.DB) ExamRepository { + return &examRepository{db: db} +} + +func (r *examRepository) ListByUserAndSemester(userID, semester string) ([]*model.Exam, error) { + var exams []*model.Exam + err := r.db.Where("user_id = ? AND semester = ?", userID, semester). + Order("created_at ASC"). + Find(&exams).Error + return exams, err +} + +func (r *examRepository) DeleteByUserAndSemester(ctx context.Context, userID, semester string) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND semester = ?", userID, semester). + Delete(&model.Exam{}).Error +} + +func (r *examRepository) BatchCreate(ctx context.Context, exams []*model.Exam) error { + return r.db.WithContext(ctx).CreateInBatches(exams, 100).Error +} diff --git a/internal/repository/grade_repo.go b/internal/repository/grade_repo.go new file mode 100644 index 0000000..9484f7b --- /dev/null +++ b/internal/repository/grade_repo.go @@ -0,0 +1,56 @@ +package repository + +import ( + "context" + + "with_you/internal/model" + + "gorm.io/gorm" +) + +type GradeRepository interface { + ListByUserAndSemester(userID, semester string) ([]*model.Grade, error) + DeleteByUserAndSemester(ctx context.Context, userID, semester string) error + BatchCreate(ctx context.Context, grades []*model.Grade) error + GetGpaSummary(userID, semester string) (*model.GpaSummary, error) + SaveGpaSummary(ctx context.Context, summary *model.GpaSummary) error +} + +type gradeRepository struct { + db *gorm.DB +} + +func NewGradeRepository(db *gorm.DB) GradeRepository { + return &gradeRepository{db: db} +} + +func (r *gradeRepository) ListByUserAndSemester(userID, semester string) ([]*model.Grade, error) { + var grades []*model.Grade + err := r.db.Where("user_id = ? AND term = ?", userID, semester). + Order("created_at ASC"). + Find(&grades).Error + return grades, err +} + +func (r *gradeRepository) DeleteByUserAndSemester(ctx context.Context, userID, semester string) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND term = ?", userID, semester). + Delete(&model.Grade{}).Error +} + +func (r *gradeRepository) BatchCreate(ctx context.Context, grades []*model.Grade) error { + return r.db.WithContext(ctx).CreateInBatches(grades, 100).Error +} + +func (r *gradeRepository) GetGpaSummary(userID, semester string) (*model.GpaSummary, error) { + var summary model.GpaSummary + err := r.db.Where("user_id = ? AND semester = ?", userID, semester).First(&summary).Error + if err != nil { + return nil, err + } + return &summary, nil +} + +func (r *gradeRepository) SaveGpaSummary(ctx context.Context, summary *model.GpaSummary) error { + return r.db.WithContext(ctx).Save(summary).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index dae8d47..4421c3c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -26,6 +26,8 @@ type RouterDeps struct { VoteHandler *handler.VoteHandler ChannelHandler *handler.ChannelHandler ScheduleHandler *handler.ScheduleHandler + GradeHandler *handler.GradeHandler + ExamHandler *handler.ExamHandler RoleHandler *handler.RoleHandler AdminUserHandler *handler.AdminUserHandler AdminPostHandler *handler.AdminPostHandler @@ -67,6 +69,8 @@ type Router struct { voteHandler *handler.VoteHandler channelHandler *handler.ChannelHandler scheduleHandler *handler.ScheduleHandler + gradeHandler *handler.GradeHandler + examHandler *handler.ExamHandler roleHandler *handler.RoleHandler adminUserHandler *handler.AdminUserHandler adminPostHandler *handler.AdminPostHandler @@ -111,6 +115,8 @@ func New(deps RouterDeps) *Router { voteHandler: deps.VoteHandler, channelHandler: deps.ChannelHandler, scheduleHandler: deps.ScheduleHandler, + gradeHandler: deps.GradeHandler, + examHandler: deps.ExamHandler, roleHandler: deps.RoleHandler, adminUserHandler: deps.AdminUserHandler, adminPostHandler: deps.AdminPostHandler, @@ -346,6 +352,26 @@ func (r *Router) setupRoutes() { } } + // 成绩路由 + if r.gradeHandler != nil { + grades := v1.Group("/grades") + grades.Use(authMiddleware) + { + grades.GET("", r.gradeHandler.ListGrades) + grades.POST("/sync", r.gradeHandler.SyncGrades) + } + } + + // 考试安排路由 + if r.examHandler != nil { + exams := v1.Group("/exams") + exams.Use(authMiddleware) + { + exams.GET("", r.examHandler.ListExams) + exams.POST("/sync", r.examHandler.SyncExams) + } + } + // 学习资料路由(公开) if r.materialHandler != nil { materials := v1.Group("/materials") diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index eb21c44..4500b21 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -387,6 +387,36 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv // 获取会话中的参与者并发送消息 participants, err := s.getParticipants(ctx, conversationID) if err == nil { + // 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息 + if s.conversationCache != nil { + for _, p := range participants { + if p.UserID == senderID { + continue + } + if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil { + zap.L().Warn("increment unread from redis hash failed", + zap.String("userID", p.UserID), + zap.String("convID", conversationID), + zap.Error(incrErr), + ) + } + } + + keys := make([]string, 0, len(participants)*2) + for _, p := range participants { + keys = append(keys, + cache.UnreadDetailKey(p.UserID, conversationID), + cache.UnreadConversationKey(p.UserID), + ) + } + if len(keys) > 0 { + s.cache.DeleteBatch(keys) + } + for _, p := range participants { + s.conversationCache.InvalidateConversationList(p.UserID) + } + } + targetIDs := make([]string, 0, len(participants)) for _, p := range participants { if conv.Type == model.ConversationTypePrivate && p.UserID == senderID { @@ -422,36 +452,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv } } - if s.conversationCache != nil { - // 使用 Redis Hash 预计算未读数 - for _, p := range participants { - if p.UserID == senderID { - continue - } - if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil { - zap.L().Warn("increment unread from redis hash failed", - zap.String("userID", p.UserID), - zap.String("convID", conversationID), - zap.Error(incrErr), - ) - } - } - - keys := make([]string, 0, len(participants)*2) - for _, p := range participants { - keys = append(keys, - cache.UnreadDetailKey(p.UserID, conversationID), - cache.UnreadConversationKey(p.UserID), - ) - } - if len(keys) > 0 { - s.cache.DeleteBatch(keys) - } - for _, p := range participants { - s.conversationCache.InvalidateConversationList(p.UserID) - } - } - if s.pushSvc != nil && len(participants) > 0 { sender := ChatMessageSender{ID: senderID} if senderUser, sErr := s.userRepo.GetByID(senderID); sErr == nil { @@ -695,7 +695,8 @@ func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) maxSeqs[conv.ID] = maxSeq } } - if len(maxSeqs) > 0 { + // 只有全部会话的 msg_seq 都命中时才用算术结果,避免部分缺失导致低估 + if len(maxSeqs) == len(convIDs) { if total, err := s.conversationCache.ComputeAllUnreadCount(ctx, userID, convIDs, maxSeqs); err == nil { return total, nil } diff --git a/internal/service/exam_sync_service.go b/internal/service/exam_sync_service.go new file mode 100644 index 0000000..b60c882 --- /dev/null +++ b/internal/service/exam_sync_service.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "with_you/internal/grpc/runner" + "with_you/internal/model" + "with_you/internal/repository" + pb "with_you/proto/runner" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type SyncExamsRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Semester string `json:"semester"` +} + +type SyncExamsResult struct { + Success bool `json:"success"` + Message string `json:"message"` + SyncedCount int `json:"synced_count"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type ExamSyncService interface { + SyncUserExams(ctx context.Context, userID string, req *SyncExamsRequest) (*SyncExamsResult, error) +} + +type examSyncService struct { + taskManager *runner.TaskManager + examRepo repository.ExamRepository + logger *zap.Logger +} + +func NewExamSyncService( + taskManager *runner.TaskManager, + examRepo repository.ExamRepository, + logger *zap.Logger, +) ExamSyncService { + return &examSyncService{ + taskManager: taskManager, + examRepo: examRepo, + logger: logger, + } +} + +func (s *examSyncService) SyncUserExams(ctx context.Context, userID string, req *SyncExamsRequest) (*SyncExamsResult, error) { + payload := &pb.GetExamsPayload{ + Username: req.Username, + Password: req.Password, + Semester: req.Semester, + } + + taskResult, err := s.taskManager.SubmitTaskSync(ctx, pb.TaskType_TASK_TYPE_GET_EXAMS, payload) + if err != nil { + s.logger.Error("failed to submit exams sync task", + zap.String("user_id", userID), + zap.Error(err)) + return nil, fmt.Errorf("failed to submit task: %w", err) + } + + if taskResult.Status != pb.TaskStatus_TASK_STATUS_SUCCESS { + return &SyncExamsResult{ + Success: false, + Message: "考试安排获取失败", + ErrorMessage: taskResult.ErrorMessage, + }, nil + } + + var resultData pb.ExamsResultData + if err := json.Unmarshal(taskResult.Data, &resultData); err != nil { + s.logger.Error("failed to parse exams result", + zap.String("user_id", userID), + zap.Error(err)) + return nil, fmt.Errorf("failed to parse result: %w", err) + } + + exams := s.convertExams(userID, &resultData) + if err := s.saveExams(ctx, userID, resultData.Semester, exams); err != nil { + s.logger.Error("failed to save exams", + zap.String("user_id", userID), + zap.Error(err)) + return nil, fmt.Errorf("failed to save exams: %w", err) + } + + s.logger.Info("exams sync completed", + zap.String("user_id", userID), + zap.Int("exam_count", len(exams))) + + return &SyncExamsResult{ + Success: true, + Message: "考试安排同步成功", + SyncedCount: len(exams), + }, nil +} + +func (s *examSyncService) convertExams(userID string, data *pb.ExamsResultData) []*model.Exam { + exams := make([]*model.Exam, 0, len(data.Exams)) + for _, e := range data.Exams { + exams = append(exams, &model.Exam{ + ID: uuid.New().String(), + UserID: userID, + Semester: data.Semester, + CourseName: e.CourseName, + ExamType: e.ExamType, + Date: e.Date, + Time: e.Time, + Week: e.Week, + Weekday: e.Weekday, + }) + } + return exams +} + +func (s *examSyncService) saveExams(ctx context.Context, userID, semester string, exams []*model.Exam) error { + if err := s.examRepo.DeleteByUserAndSemester(ctx, userID, semester); err != nil { + return fmt.Errorf("failed to delete old exams: %w", err) + } + if len(exams) == 0 { + return nil + } + if err := s.examRepo.BatchCreate(ctx, exams); err != nil { + return fmt.Errorf("failed to create exams: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/service/grade_sync_service.go b/internal/service/grade_sync_service.go new file mode 100644 index 0000000..bcff11c --- /dev/null +++ b/internal/service/grade_sync_service.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "with_you/internal/grpc/runner" + "with_you/internal/model" + "with_you/internal/repository" + pb "with_you/proto/runner" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type SyncGradesRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Semester string `json:"semester"` +} + +type SyncGradesResult struct { + Success bool `json:"success"` + Message string `json:"message"` + SyncedCount int `json:"synced_count"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type GradeSyncService interface { + SyncUserGrades(ctx context.Context, userID string, req *SyncGradesRequest) (*SyncGradesResult, error) +} + +type gradeSyncService struct { + taskManager *runner.TaskManager + gradeRepo repository.GradeRepository + logger *zap.Logger +} + +func NewGradeSyncService( + taskManager *runner.TaskManager, + gradeRepo repository.GradeRepository, + logger *zap.Logger, +) GradeSyncService { + return &gradeSyncService{ + taskManager: taskManager, + gradeRepo: gradeRepo, + logger: logger, + } +} + +func (s *gradeSyncService) SyncUserGrades(ctx context.Context, userID string, req *SyncGradesRequest) (*SyncGradesResult, error) { + payload := &pb.GetGradesPayload{ + Username: req.Username, + Password: req.Password, + Semester: req.Semester, + } + + taskResult, err := s.taskManager.SubmitTaskSync(ctx, pb.TaskType_TASK_TYPE_GET_GRADES, payload) + if err != nil { + s.logger.Error("failed to submit grades sync task", + zap.String("user_id", userID), + zap.Error(err)) + return nil, fmt.Errorf("failed to submit task: %w", err) + } + + if taskResult.Status != pb.TaskStatus_TASK_STATUS_SUCCESS { + return &SyncGradesResult{ + Success: false, + Message: "成绩获取失败", + ErrorMessage: taskResult.ErrorMessage, + }, nil + } + + var resultData pb.GradesResultData + if err := json.Unmarshal(taskResult.Data, &resultData); err != nil { + s.logger.Error("failed to parse grades result", + zap.String("user_id", userID), + zap.Error(err)) + return nil, fmt.Errorf("failed to parse result: %w", err) + } + + grades := s.convertGrades(userID, &resultData) + if err := s.saveGrades(ctx, userID, resultData.Semester, grades); err != nil { + s.logger.Error("failed to save grades", + zap.String("user_id", userID), + zap.Error(err)) + return nil, fmt.Errorf("failed to save grades: %w", err) + } + + if resultData.GpaSummary != nil { + summary := s.convertGpaSummary(userID, resultData.Semester, resultData.GpaSummary) + if err := s.gradeRepo.SaveGpaSummary(ctx, summary); err != nil { + s.logger.Warn("failed to save gpa summary", + zap.String("user_id", userID), + zap.Error(err)) + } + } + + s.logger.Info("grades sync completed", + zap.String("user_id", userID), + zap.Int("grade_count", len(grades))) + + return &SyncGradesResult{ + Success: true, + Message: "成绩同步成功", + SyncedCount: len(grades), + }, nil +} + +func (s *gradeSyncService) convertGrades(userID string, data *pb.GradesResultData) []*model.Grade { + grades := make([]*model.Grade, 0, len(data.Grades)) + for _, g := range data.Grades { + grades = append(grades, &model.Grade{ + ID: uuid.New().String(), + UserID: userID, + Term: g.Term, + CourseCode: g.CourseCode, + CourseName: g.CourseName, + Nature: g.Nature, + Category: g.Category, + Credit: g.Credit, + Score: g.Score, + GradePoint: g.GradePoint, + IsExam: g.IsExam, + }) + } + return grades +} + +func (s *gradeSyncService) convertGpaSummary(userID, semester string, gpa *pb.GpaSummary) *model.GpaSummary { + return &model.GpaSummary{ + ID: uuid.New().String(), + UserID: userID, + Semester: semester, + AverageGpa: gpa.AverageGpa, + Rank: gpa.Rank, + ExamTotalGpa: gpa.ExamTotalGpa, + ExamTotalCredits: gpa.ExamTotalCredits, + FailCredits: gpa.FailCredits, + Semesters: gpa.Semesters, + } +} + +func (s *gradeSyncService) saveGrades(ctx context.Context, userID, semester string, grades []*model.Grade) error { + if err := s.gradeRepo.DeleteByUserAndSemester(ctx, userID, semester); err != nil { + return fmt.Errorf("failed to delete old grades: %w", err) + } + if len(grades) == 0 { + return nil + } + if err := s.gradeRepo.BatchCreate(ctx, grades); err != nil { + return fmt.Errorf("failed to create grades: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/wire/handler.go b/internal/wire/handler.go index 87815c2..27e6ff6 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -35,6 +35,8 @@ var HandlerSet = wire.NewSet( handler.NewAdminProfileAuditHandler, handler.NewPostHandler, handler.NewTradeHandler, + handler.NewGradeHandler, + handler.NewExamHandler, // 需要特殊处理的 Handler ProvideUserHandler, diff --git a/internal/wire/repository.go b/internal/wire/repository.go index 79f000b..0bcdfbe 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -24,6 +24,8 @@ var RepositorySet = wire.NewSet( repository.NewVoteRepository, repository.NewChannelRepository, repository.NewScheduleRepository, + repository.NewGradeRepository, + repository.NewExamRepository, repository.NewMaterialSubjectRepository, repository.NewMaterialFileRepository, repository.NewCallRepository, diff --git a/internal/wire/service.go b/internal/wire/service.go index 9e17b7b..eecc4f2 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -46,6 +46,8 @@ var ServiceSet = wire.NewSet( ProvideChatService, ProvideScheduleService, ProvideScheduleSyncService, + ProvideGradeSyncService, + ProvideExamSyncService, ProvideGroupService, ProvideUploadService, ProvideUserActivityService, @@ -230,6 +232,24 @@ func ProvideScheduleSyncService( return service.NewScheduleSyncService(taskManager, scheduleRepo, logger) } +// ProvideGradeSyncService 提供成绩同步服务 +func ProvideGradeSyncService( + taskManager *runner.TaskManager, + gradeRepo repository.GradeRepository, + logger *zap.Logger, +) service.GradeSyncService { + return service.NewGradeSyncService(taskManager, gradeRepo, logger) +} + +// ProvideExamSyncService 提供考试安排同步服务 +func ProvideExamSyncService( + taskManager *runner.TaskManager, + examRepo repository.ExamRepository, + logger *zap.Logger, +) service.ExamSyncService { + return service.NewExamSyncService(taskManager, examRepo, logger) +} + // ProvideGroupService 提供群组服务 func ProvideGroupService( groupRepo repository.GroupRepository, diff --git a/proto/runner/runner.pb.go b/proto/runner/runner.pb.go index b9e40ce..53e154a 100644 --- a/proto/runner/runner.pb.go +++ b/proto/runner/runner.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.1 +// protoc v7.34.1 // source: proto/runner/runner.proto package runner @@ -1209,19 +1209,105 @@ func (x *ScheduleTime) GetWeeks() []int32 { return nil } +// GpaSummary 学分绩汇总信息 +type GpaSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + AverageGpa string `protobuf:"bytes,1,opt,name=average_gpa,json=averageGpa,proto3" json:"average_gpa,omitempty"` // 平均学分绩 + Rank string `protobuf:"bytes,2,opt,name=rank,proto3" json:"rank,omitempty"` // 专业排名 + ExamTotalGpa string `protobuf:"bytes,3,opt,name=exam_total_gpa,json=examTotalGpa,proto3" json:"exam_total_gpa,omitempty"` // 考试课学分绩 + ExamTotalCredits string `protobuf:"bytes,4,opt,name=exam_total_credits,json=examTotalCredits,proto3" json:"exam_total_credits,omitempty"` // 考试课学分数 + FailCredits string `protobuf:"bytes,5,opt,name=fail_credits,json=failCredits,proto3" json:"fail_credits,omitempty"` // 考查课不及格学分 + Semesters string `protobuf:"bytes,6,opt,name=semesters,proto3" json:"semesters,omitempty"` // 计算学期数 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GpaSummary) Reset() { + *x = GpaSummary{} + mi := &file_proto_runner_runner_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GpaSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GpaSummary) ProtoMessage() {} + +func (x *GpaSummary) ProtoReflect() protoreflect.Message { + mi := &file_proto_runner_runner_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GpaSummary.ProtoReflect.Descriptor instead. +func (*GpaSummary) Descriptor() ([]byte, []int) { + return file_proto_runner_runner_proto_rawDescGZIP(), []int{15} +} + +func (x *GpaSummary) GetAverageGpa() string { + if x != nil { + return x.AverageGpa + } + return "" +} + +func (x *GpaSummary) GetRank() string { + if x != nil { + return x.Rank + } + return "" +} + +func (x *GpaSummary) GetExamTotalGpa() string { + if x != nil { + return x.ExamTotalGpa + } + return "" +} + +func (x *GpaSummary) GetExamTotalCredits() string { + if x != nil { + return x.ExamTotalCredits + } + return "" +} + +func (x *GpaSummary) GetFailCredits() string { + if x != nil { + return x.FailCredits + } + return "" +} + +func (x *GpaSummary) GetSemesters() string { + if x != nil { + return x.Semesters + } + return "" +} + // GradesResultData 成绩结果数据 type GradesResultData struct { state protoimpl.MessageState `protogen:"open.v1"` - Semester string `protobuf:"bytes,1,opt,name=semester,proto3" json:"semester,omitempty"` // 学期 - StudentId string `protobuf:"bytes,2,opt,name=student_id,json=studentId,proto3" json:"student_id,omitempty"` // 学号 - Grades []*Grade `protobuf:"bytes,3,rep,name=grades,proto3" json:"grades,omitempty"` // 成绩列表 + Semester string `protobuf:"bytes,1,opt,name=semester,proto3" json:"semester,omitempty"` // 学期 + StudentId string `protobuf:"bytes,2,opt,name=student_id,json=studentId,proto3" json:"student_id,omitempty"` // 学号 + Grades []*Grade `protobuf:"bytes,3,rep,name=grades,proto3" json:"grades,omitempty"` // 成绩列表 + GpaSummary *GpaSummary `protobuf:"bytes,4,opt,name=gpa_summary,json=gpaSummary,proto3" json:"gpa_summary,omitempty"` // 学分绩汇总 (可选) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GradesResultData) Reset() { *x = GradesResultData{} - mi := &file_proto_runner_runner_proto_msgTypes[15] + mi := &file_proto_runner_runner_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1233,7 +1319,7 @@ func (x *GradesResultData) String() string { func (*GradesResultData) ProtoMessage() {} func (x *GradesResultData) ProtoReflect() protoreflect.Message { - mi := &file_proto_runner_runner_proto_msgTypes[15] + mi := &file_proto_runner_runner_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1246,7 +1332,7 @@ func (x *GradesResultData) ProtoReflect() protoreflect.Message { // Deprecated: Use GradesResultData.ProtoReflect.Descriptor instead. func (*GradesResultData) Descriptor() ([]byte, []int) { - return file_proto_runner_runner_proto_rawDescGZIP(), []int{15} + return file_proto_runner_runner_proto_rawDescGZIP(), []int{16} } func (x *GradesResultData) GetSemester() string { @@ -1270,21 +1356,32 @@ func (x *GradesResultData) GetGrades() []*Grade { return nil } +func (x *GradesResultData) GetGpaSummary() *GpaSummary { + if x != nil { + return x.GpaSummary + } + return nil +} + // Grade 成绩信息 type Grade struct { state protoimpl.MessageState `protogen:"open.v1"` - CourseCode string `protobuf:"bytes,1,opt,name=course_code,json=courseCode,proto3" json:"course_code,omitempty"` // 课程代码 - CourseName string `protobuf:"bytes,2,opt,name=course_name,json=courseName,proto3" json:"course_name,omitempty"` // 课程名称 - Credit float32 `protobuf:"fixed32,3,opt,name=credit,proto3" json:"credit,omitempty"` // 学分 - Grade string `protobuf:"bytes,4,opt,name=grade,proto3" json:"grade,omitempty"` // 成绩 (可能是等级或分数) - GradePoint float32 `protobuf:"fixed32,5,opt,name=grade_point,json=gradePoint,proto3" json:"grade_point,omitempty"` // 绩点 + Term string `protobuf:"bytes,1,opt,name=term,proto3" json:"term,omitempty"` // 学期 + CourseCode string `protobuf:"bytes,2,opt,name=course_code,json=courseCode,proto3" json:"course_code,omitempty"` // 课程代码 + CourseName string `protobuf:"bytes,3,opt,name=course_name,json=courseName,proto3" json:"course_name,omitempty"` // 课程名称 + Nature string `protobuf:"bytes,4,opt,name=nature,proto3" json:"nature,omitempty"` // 课程性质 + Category string `protobuf:"bytes,5,opt,name=category,proto3" json:"category,omitempty"` // 课程类别 + Credit string `protobuf:"bytes,6,opt,name=credit,proto3" json:"credit,omitempty"` // 学分 + Score string `protobuf:"bytes,7,opt,name=score,proto3" json:"score,omitempty"` // 成绩 (可能是等级或分数) + GradePoint string `protobuf:"bytes,8,opt,name=grade_point,json=gradePoint,proto3" json:"grade_point,omitempty"` // 绩点 + IsExam string `protobuf:"bytes,9,opt,name=is_exam,json=isExam,proto3" json:"is_exam,omitempty"` // 是否考试课 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Grade) Reset() { *x = Grade{} - mi := &file_proto_runner_runner_proto_msgTypes[16] + mi := &file_proto_runner_runner_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1296,7 +1393,7 @@ func (x *Grade) String() string { func (*Grade) ProtoMessage() {} func (x *Grade) ProtoReflect() protoreflect.Message { - mi := &file_proto_runner_runner_proto_msgTypes[16] + mi := &file_proto_runner_runner_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1309,7 +1406,14 @@ func (x *Grade) ProtoReflect() protoreflect.Message { // Deprecated: Use Grade.ProtoReflect.Descriptor instead. func (*Grade) Descriptor() ([]byte, []int) { - return file_proto_runner_runner_proto_rawDescGZIP(), []int{16} + return file_proto_runner_runner_proto_rawDescGZIP(), []int{17} +} + +func (x *Grade) GetTerm() string { + if x != nil { + return x.Term + } + return "" } func (x *Grade) GetCourseCode() string { @@ -1326,25 +1430,46 @@ func (x *Grade) GetCourseName() string { return "" } -func (x *Grade) GetCredit() float32 { +func (x *Grade) GetNature() string { if x != nil { - return x.Credit - } - return 0 -} - -func (x *Grade) GetGrade() string { - if x != nil { - return x.Grade + return x.Nature } return "" } -func (x *Grade) GetGradePoint() float32 { +func (x *Grade) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *Grade) GetCredit() string { + if x != nil { + return x.Credit + } + return "" +} + +func (x *Grade) GetScore() string { + if x != nil { + return x.Score + } + return "" +} + +func (x *Grade) GetGradePoint() string { if x != nil { return x.GradePoint } - return 0 + return "" +} + +func (x *Grade) GetIsExam() string { + if x != nil { + return x.IsExam + } + return "" } // ExamsResultData 考试安排结果数据 @@ -1359,7 +1484,7 @@ type ExamsResultData struct { func (x *ExamsResultData) Reset() { *x = ExamsResultData{} - mi := &file_proto_runner_runner_proto_msgTypes[17] + mi := &file_proto_runner_runner_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1371,7 +1496,7 @@ func (x *ExamsResultData) String() string { func (*ExamsResultData) ProtoMessage() {} func (x *ExamsResultData) ProtoReflect() protoreflect.Message { - mi := &file_proto_runner_runner_proto_msgTypes[17] + mi := &file_proto_runner_runner_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1384,7 +1509,7 @@ func (x *ExamsResultData) ProtoReflect() protoreflect.Message { // Deprecated: Use ExamsResultData.ProtoReflect.Descriptor instead. func (*ExamsResultData) Descriptor() ([]byte, []int) { - return file_proto_runner_runner_proto_rawDescGZIP(), []int{17} + return file_proto_runner_runner_proto_rawDescGZIP(), []int{18} } func (x *ExamsResultData) GetSemester() string { @@ -1411,20 +1536,19 @@ func (x *ExamsResultData) GetExams() []*Exam { // Exam 考试信息 type Exam struct { state protoimpl.MessageState `protogen:"open.v1"` - CourseCode string `protobuf:"bytes,1,opt,name=course_code,json=courseCode,proto3" json:"course_code,omitempty"` // 课程代码 - CourseName string `protobuf:"bytes,2,opt,name=course_name,json=courseName,proto3" json:"course_name,omitempty"` // 课程名称 - ExamType string `protobuf:"bytes,3,opt,name=exam_type,json=examType,proto3" json:"exam_type,omitempty"` // 考试类型 (期末/期中/补考) - Date string `protobuf:"bytes,4,opt,name=date,proto3" json:"date,omitempty"` // 考试日期 (YYYY-MM-DD) - Time string `protobuf:"bytes,5,opt,name=time,proto3" json:"time,omitempty"` // 考试时间 (HH:MM-HH:MM) - Room string `protobuf:"bytes,6,opt,name=room,proto3" json:"room,omitempty"` // 考场 - Seat string `protobuf:"bytes,7,opt,name=seat,proto3" json:"seat,omitempty"` // 座位号 + CourseName string `protobuf:"bytes,1,opt,name=course_name,json=courseName,proto3" json:"course_name,omitempty"` // 课程名称 + ExamType string `protobuf:"bytes,2,opt,name=exam_type,json=examType,proto3" json:"exam_type,omitempty"` // 考试类型 (期末/期中/补考) + Date string `protobuf:"bytes,3,opt,name=date,proto3" json:"date,omitempty"` // 考试日期 + Time string `protobuf:"bytes,4,opt,name=time,proto3" json:"time,omitempty"` // 考试时间 + Week string `protobuf:"bytes,5,opt,name=week,proto3" json:"week,omitempty"` // 考试周次 + Weekday string `protobuf:"bytes,6,opt,name=weekday,proto3" json:"weekday,omitempty"` // 星期几 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Exam) Reset() { *x = Exam{} - mi := &file_proto_runner_runner_proto_msgTypes[18] + mi := &file_proto_runner_runner_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1436,7 +1560,7 @@ func (x *Exam) String() string { func (*Exam) ProtoMessage() {} func (x *Exam) ProtoReflect() protoreflect.Message { - mi := &file_proto_runner_runner_proto_msgTypes[18] + mi := &file_proto_runner_runner_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1449,14 +1573,7 @@ func (x *Exam) ProtoReflect() protoreflect.Message { // Deprecated: Use Exam.ProtoReflect.Descriptor instead. func (*Exam) Descriptor() ([]byte, []int) { - return file_proto_runner_runner_proto_rawDescGZIP(), []int{18} -} - -func (x *Exam) GetCourseCode() string { - if x != nil { - return x.CourseCode - } - return "" + return file_proto_runner_runner_proto_rawDescGZIP(), []int{19} } func (x *Exam) GetCourseName() string { @@ -1487,16 +1604,16 @@ func (x *Exam) GetTime() string { return "" } -func (x *Exam) GetRoom() string { +func (x *Exam) GetWeek() string { if x != nil { - return x.Room + return x.Week } return "" } -func (x *Exam) GetSeat() string { +func (x *Exam) GetWeekday() string { if x != nil { - return x.Seat + return x.Weekday } return "" } @@ -1517,7 +1634,7 @@ type UserInfoResultData struct { func (x *UserInfoResultData) Reset() { *x = UserInfoResultData{} - mi := &file_proto_runner_runner_proto_msgTypes[19] + mi := &file_proto_runner_runner_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1529,7 +1646,7 @@ func (x *UserInfoResultData) String() string { func (*UserInfoResultData) ProtoMessage() {} func (x *UserInfoResultData) ProtoReflect() protoreflect.Message { - mi := &file_proto_runner_runner_proto_msgTypes[19] + mi := &file_proto_runner_runner_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1542,7 +1659,7 @@ func (x *UserInfoResultData) ProtoReflect() protoreflect.Message { // Deprecated: Use UserInfoResultData.ProtoReflect.Descriptor instead. func (*UserInfoResultData) Descriptor() ([]byte, []int) { - return file_proto_runner_runner_proto_rawDescGZIP(), []int{19} + return file_proto_runner_runner_proto_rawDescGZIP(), []int{20} } func (x *UserInfoResultData) GetStudentId() string { @@ -1680,36 +1797,49 @@ const file_proto_runner_runner_proto_rawDesc = "" + "\fScheduleTime\x12\x10\n" + "\x03day\x18\x01 \x01(\tR\x03day\x12\x1a\n" + "\bsections\x18\x02 \x03(\x05R\bsections\x12\x14\n" + - "\x05weeks\x18\x03 \x03(\x05R\x05weeks\"t\n" + + "\x05weeks\x18\x03 \x03(\x05R\x05weeks\"\xd6\x01\n" + + "\n" + + "GpaSummary\x12\x1f\n" + + "\vaverage_gpa\x18\x01 \x01(\tR\n" + + "averageGpa\x12\x12\n" + + "\x04rank\x18\x02 \x01(\tR\x04rank\x12$\n" + + "\x0eexam_total_gpa\x18\x03 \x01(\tR\fexamTotalGpa\x12,\n" + + "\x12exam_total_credits\x18\x04 \x01(\tR\x10examTotalCredits\x12!\n" + + "\ffail_credits\x18\x05 \x01(\tR\vfailCredits\x12\x1c\n" + + "\tsemesters\x18\x06 \x01(\tR\tsemesters\"\xa9\x01\n" + "\x10GradesResultData\x12\x1a\n" + "\bsemester\x18\x01 \x01(\tR\bsemester\x12\x1d\n" + "\n" + "student_id\x18\x02 \x01(\tR\tstudentId\x12%\n" + - "\x06grades\x18\x03 \x03(\v2\r.runner.GradeR\x06grades\"\x98\x01\n" + - "\x05Grade\x12\x1f\n" + - "\vcourse_code\x18\x01 \x01(\tR\n" + + "\x06grades\x18\x03 \x03(\v2\r.runner.GradeR\x06grades\x123\n" + + "\vgpa_summary\x18\x04 \x01(\v2\x12.runner.GpaSummaryR\n" + + "gpaSummary\"\xf9\x01\n" + + "\x05Grade\x12\x12\n" + + "\x04term\x18\x01 \x01(\tR\x04term\x12\x1f\n" + + "\vcourse_code\x18\x02 \x01(\tR\n" + "courseCode\x12\x1f\n" + - "\vcourse_name\x18\x02 \x01(\tR\n" + + "\vcourse_name\x18\x03 \x01(\tR\n" + "courseName\x12\x16\n" + - "\x06credit\x18\x03 \x01(\x02R\x06credit\x12\x14\n" + - "\x05grade\x18\x04 \x01(\tR\x05grade\x12\x1f\n" + - "\vgrade_point\x18\x05 \x01(\x02R\n" + - "gradePoint\"p\n" + + "\x06nature\x18\x04 \x01(\tR\x06nature\x12\x1a\n" + + "\bcategory\x18\x05 \x01(\tR\bcategory\x12\x16\n" + + "\x06credit\x18\x06 \x01(\tR\x06credit\x12\x14\n" + + "\x05score\x18\a \x01(\tR\x05score\x12\x1f\n" + + "\vgrade_point\x18\b \x01(\tR\n" + + "gradePoint\x12\x17\n" + + "\ais_exam\x18\t \x01(\tR\x06isExam\"p\n" + "\x0fExamsResultData\x12\x1a\n" + "\bsemester\x18\x01 \x01(\tR\bsemester\x12\x1d\n" + "\n" + "student_id\x18\x02 \x01(\tR\tstudentId\x12\"\n" + - "\x05exams\x18\x03 \x03(\v2\f.runner.ExamR\x05exams\"\xb5\x01\n" + + "\x05exams\x18\x03 \x03(\v2\f.runner.ExamR\x05exams\"\x9a\x01\n" + "\x04Exam\x12\x1f\n" + - "\vcourse_code\x18\x01 \x01(\tR\n" + - "courseCode\x12\x1f\n" + - "\vcourse_name\x18\x02 \x01(\tR\n" + + "\vcourse_name\x18\x01 \x01(\tR\n" + "courseName\x12\x1b\n" + - "\texam_type\x18\x03 \x01(\tR\bexamType\x12\x12\n" + - "\x04date\x18\x04 \x01(\tR\x04date\x12\x12\n" + - "\x04time\x18\x05 \x01(\tR\x04time\x12\x12\n" + - "\x04room\x18\x06 \x01(\tR\x04room\x12\x12\n" + - "\x04seat\x18\a \x01(\tR\x04seat\"\xc4\x01\n" + + "\texam_type\x18\x02 \x01(\tR\bexamType\x12\x12\n" + + "\x04date\x18\x03 \x01(\tR\x04date\x12\x12\n" + + "\x04time\x18\x04 \x01(\tR\x04time\x12\x12\n" + + "\x04week\x18\x05 \x01(\tR\x04week\x12\x18\n" + + "\aweekday\x18\x06 \x01(\tR\aweekday\"\xc4\x01\n" + "\x12UserInfoResultData\x12\x1d\n" + "\n" + "student_id\x18\x01 \x01(\tR\tstudentId\x12\x12\n" + @@ -1735,7 +1865,7 @@ const file_proto_runner_runner_proto_rawDesc = "" + "\x13TASK_STATUS_TIMEOUT\x10\x03\x12\x19\n" + "\x15TASK_STATUS_CANCELLED\x10\x042H\n" + "\tRunnerHub\x12;\n" + - "\aConnect\x12\x15.runner.StreamMessage\x1a\x15.runner.StreamMessage(\x010\x01B Z\x1ecarrot_bbs/proto/runner;runnerb\x06proto3" + "\aConnect\x12\x15.runner.StreamMessage\x1a\x15.runner.StreamMessage(\x010\x01B(Z&schedule_converter/proto/runner;runnerb\x06proto3" var ( file_proto_runner_runner_proto_rawDescOnce sync.Once @@ -1750,7 +1880,7 @@ func file_proto_runner_runner_proto_rawDescGZIP() []byte { } var file_proto_runner_runner_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proto_runner_runner_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_proto_runner_runner_proto_msgTypes = make([]protoimpl.MessageInfo, 22) var file_proto_runner_runner_proto_goTypes = []any{ (TaskType)(0), // 0: runner.TaskType (TaskStatus)(0), // 1: runner.TaskStatus @@ -1769,12 +1899,13 @@ var file_proto_runner_runner_proto_goTypes = []any{ (*ScheduleResultData)(nil), // 14: runner.ScheduleResultData (*Course)(nil), // 15: runner.Course (*ScheduleTime)(nil), // 16: runner.ScheduleTime - (*GradesResultData)(nil), // 17: runner.GradesResultData - (*Grade)(nil), // 18: runner.Grade - (*ExamsResultData)(nil), // 19: runner.ExamsResultData - (*Exam)(nil), // 20: runner.Exam - (*UserInfoResultData)(nil), // 21: runner.UserInfoResultData - nil, // 22: runner.RegisterRequest.CapabilitiesEntry + (*GpaSummary)(nil), // 17: runner.GpaSummary + (*GradesResultData)(nil), // 18: runner.GradesResultData + (*Grade)(nil), // 19: runner.Grade + (*ExamsResultData)(nil), // 20: runner.ExamsResultData + (*Exam)(nil), // 21: runner.Exam + (*UserInfoResultData)(nil), // 22: runner.UserInfoResultData + nil, // 23: runner.RegisterRequest.CapabilitiesEntry } var file_proto_runner_runner_proto_depIdxs = []int32{ 3, // 0: runner.StreamMessage.register:type_name -> runner.RegisterRequest @@ -1784,20 +1915,21 @@ var file_proto_runner_runner_proto_depIdxs = []int32{ 7, // 4: runner.StreamMessage.task:type_name -> runner.Task 9, // 5: runner.StreamMessage.result:type_name -> runner.TaskResult 8, // 6: runner.StreamMessage.task_cancel:type_name -> runner.TaskCancel - 22, // 7: runner.RegisterRequest.capabilities:type_name -> runner.RegisterRequest.CapabilitiesEntry + 23, // 7: runner.RegisterRequest.capabilities:type_name -> runner.RegisterRequest.CapabilitiesEntry 0, // 8: runner.Task.type:type_name -> runner.TaskType 1, // 9: runner.TaskResult.status:type_name -> runner.TaskStatus 15, // 10: runner.ScheduleResultData.courses:type_name -> runner.Course 16, // 11: runner.Course.schedule:type_name -> runner.ScheduleTime - 18, // 12: runner.GradesResultData.grades:type_name -> runner.Grade - 20, // 13: runner.ExamsResultData.exams:type_name -> runner.Exam - 2, // 14: runner.RunnerHub.Connect:input_type -> runner.StreamMessage - 2, // 15: runner.RunnerHub.Connect:output_type -> runner.StreamMessage - 15, // [15:16] is the sub-list for method output_type - 14, // [14:15] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 19, // 12: runner.GradesResultData.grades:type_name -> runner.Grade + 17, // 13: runner.GradesResultData.gpa_summary:type_name -> runner.GpaSummary + 21, // 14: runner.ExamsResultData.exams:type_name -> runner.Exam + 2, // 15: runner.RunnerHub.Connect:input_type -> runner.StreamMessage + 2, // 16: runner.RunnerHub.Connect:output_type -> runner.StreamMessage + 16, // [16:17] is the sub-list for method output_type + 15, // [15:16] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_proto_runner_runner_proto_init() } @@ -1820,7 +1952,7 @@ func file_proto_runner_runner_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_runner_runner_proto_rawDesc), len(file_proto_runner_runner_proto_rawDesc)), NumEnums: 2, - NumMessages: 21, + NumMessages: 22, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/runner/runner.proto b/proto/runner/runner.proto index fed7a23..415d6d7 100644 --- a/proto/runner/runner.proto +++ b/proto/runner/runner.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package runner; -option go_package = "carrot_bbs/proto/runner;runner"; +option go_package = "schedule_converter/proto/runner;runner"; // ============================================================ // 服务定义 @@ -178,20 +178,35 @@ message ScheduleTime { repeated int32 weeks = 3; // 周次,如 [1, 2, 3, 4, 5] } +// GpaSummary 学分绩汇总信息 +message GpaSummary { + string average_gpa = 1; // 平均学分绩 + string rank = 2; // 专业排名 + string exam_total_gpa = 3; // 考试课学分绩 + string exam_total_credits = 4; // 考试课学分数 + string fail_credits = 5; // 考查课不及格学分 + string semesters = 6; // 计算学期数 +} + // GradesResultData 成绩结果数据 message GradesResultData { string semester = 1; // 学期 string student_id = 2; // 学号 repeated Grade grades = 3; // 成绩列表 + GpaSummary gpa_summary = 4; // 学分绩汇总 (可选) } // Grade 成绩信息 message Grade { - string course_code = 1; // 课程代码 - string course_name = 2; // 课程名称 - float credit = 3; // 学分 - string grade = 4; // 成绩 (可能是等级或分数) - float grade_point = 5; // 绩点 + string term = 1; // 学期 + string course_code = 2; // 课程代码 + string course_name = 3; // 课程名称 + string nature = 4; // 课程性质 + string category = 5; // 课程类别 + string credit = 6; // 学分 + string score = 7; // 成绩 (可能是等级或分数) + string grade_point = 8; // 绩点 + string is_exam = 9; // 是否考试课 } // ExamsResultData 考试安排结果数据 @@ -203,13 +218,12 @@ message ExamsResultData { // Exam 考试信息 message Exam { - string course_code = 1; // 课程代码 - string course_name = 2; // 课程名称 - string exam_type = 3; // 考试类型 (期末/期中/补考) - string date = 4; // 考试日期 (YYYY-MM-DD) - string time = 5; // 考试时间 (HH:MM-HH:MM) - string room = 6; // 考场 - string seat = 7; // 座位号 + string course_name = 1; // 课程名称 + string exam_type = 2; // 考试类型 (期末/期中/补考) + string date = 3; // 考试日期 + string time = 4; // 考试时间 + string week = 5; // 考试周次 + string weekday = 6; // 星期几 } // UserInfoResultData 用户信息结果数据 diff --git a/proto/runner/runner_grpc.pb.go b/proto/runner/runner_grpc.pb.go index c9a00a2..3adf82f 100644 --- a/proto/runner/runner_grpc.pb.go +++ b/proto/runner/runner_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v6.33.1 +// - protoc-gen-go-grpc v1.6.1 +// - protoc v7.34.1 // source: proto/runner/runner.proto package runner @@ -15,8 +15,8 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 const ( RunnerHub_Connect_FullMethodName = "/runner.RunnerHub/Connect" @@ -25,10 +25,13 @@ const ( // RunnerHubClient is the client API for RunnerHub service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// RunnerHub 服务 - 由 Backend 服务器实现 +// Runner 作为客户端主动连接,建立双向流通信 type RunnerHubClient interface { // Connect 建立双向流连接 // Runner 通过此连接注册、接收任务、返回结果 - Connect(ctx context.Context, opts ...grpc.CallOption) (RunnerHub_ConnectClient, error) + Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[StreamMessage, StreamMessage], error) } type runnerHubClient struct { @@ -39,55 +42,44 @@ func NewRunnerHubClient(cc grpc.ClientConnInterface) RunnerHubClient { return &runnerHubClient{cc} } -func (c *runnerHubClient) Connect(ctx context.Context, opts ...grpc.CallOption) (RunnerHub_ConnectClient, error) { - stream, err := c.cc.NewStream(ctx, &RunnerHub_ServiceDesc.Streams[0], RunnerHub_Connect_FullMethodName, opts...) +func (c *runnerHubClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[StreamMessage, StreamMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &RunnerHub_ServiceDesc.Streams[0], RunnerHub_Connect_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &runnerHubConnectClient{stream} + x := &grpc.GenericClientStream[StreamMessage, StreamMessage]{ClientStream: stream} return x, nil } -type RunnerHub_ConnectClient interface { - Send(*StreamMessage) error - Recv() (*StreamMessage, error) - grpc.ClientStream -} - -type runnerHubConnectClient struct { - grpc.ClientStream -} - -func (x *runnerHubConnectClient) Send(m *StreamMessage) error { - return x.ClientStream.SendMsg(m) -} - -func (x *runnerHubConnectClient) Recv() (*StreamMessage, error) { - m := new(StreamMessage) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type RunnerHub_ConnectClient = grpc.BidiStreamingClient[StreamMessage, StreamMessage] // RunnerHubServer is the server API for RunnerHub service. // All implementations must embed UnimplementedRunnerHubServer -// for forward compatibility +// for forward compatibility. +// +// RunnerHub 服务 - 由 Backend 服务器实现 +// Runner 作为客户端主动连接,建立双向流通信 type RunnerHubServer interface { // Connect 建立双向流连接 // Runner 通过此连接注册、接收任务、返回结果 - Connect(RunnerHub_ConnectServer) error + Connect(grpc.BidiStreamingServer[StreamMessage, StreamMessage]) error mustEmbedUnimplementedRunnerHubServer() } -// UnimplementedRunnerHubServer must be embedded to have forward compatible implementations. -type UnimplementedRunnerHubServer struct { -} +// UnimplementedRunnerHubServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedRunnerHubServer struct{} -func (UnimplementedRunnerHubServer) Connect(RunnerHub_ConnectServer) error { - return status.Errorf(codes.Unimplemented, "method Connect not implemented") +func (UnimplementedRunnerHubServer) Connect(grpc.BidiStreamingServer[StreamMessage, StreamMessage]) error { + return status.Error(codes.Unimplemented, "method Connect not implemented") } func (UnimplementedRunnerHubServer) mustEmbedUnimplementedRunnerHubServer() {} +func (UnimplementedRunnerHubServer) testEmbeddedByValue() {} // UnsafeRunnerHubServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to RunnerHubServer will @@ -97,34 +89,22 @@ type UnsafeRunnerHubServer interface { } func RegisterRunnerHubServer(s grpc.ServiceRegistrar, srv RunnerHubServer) { + // If the following call panics, it indicates UnimplementedRunnerHubServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&RunnerHub_ServiceDesc, srv) } func _RunnerHub_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(RunnerHubServer).Connect(&runnerHubConnectServer{stream}) + return srv.(RunnerHubServer).Connect(&grpc.GenericServerStream[StreamMessage, StreamMessage]{ServerStream: stream}) } -type RunnerHub_ConnectServer interface { - Send(*StreamMessage) error - Recv() (*StreamMessage, error) - grpc.ServerStream -} - -type runnerHubConnectServer struct { - grpc.ServerStream -} - -func (x *runnerHubConnectServer) Send(m *StreamMessage) error { - return x.ServerStream.SendMsg(m) -} - -func (x *runnerHubConnectServer) Recv() (*StreamMessage, error) { - m := new(StreamMessage) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type RunnerHub_ConnectServer = grpc.BidiStreamingServer[StreamMessage, StreamMessage] // RunnerHub_ServiceDesc is the grpc.ServiceDesc for RunnerHub service. // It's only intended for direct use with grpc.RegisterService,