feat(runner): implement empty classroom sync functionality
Some checks failed
Build Backend / build (push) Successful in 2m22s
Build Backend / build-docker (push) Failing after 3m3s

Add support for fetching and synchronizing empty classroom data. This includes new protobuf definitions for task payloads and results, database models, repository, service, and HTTP handlers.

- Add `TASK_TYPE_GET_EMPTY_CLASSROOM` to `TaskType` enum
- Implement `EmptyClassroom` model and auto-migration
- Add `EmptyClassroomHandler` with `GET` and `POST /sync` routes
- Implement `EmptyClassroomSyncService` and `EmptyClassroomRepository`
- Update dependency injection with wire sets
This commit is contained in:
2026-05-31 21:29:45 +08:00
parent 2084473fb5
commit 9356cb6876
13 changed files with 3007 additions and 48 deletions

View File

@@ -0,0 +1,87 @@
package handler
import (
"github.com/gin-gonic/gin"
"with_you/internal/pkg/response"
"with_you/internal/repository"
"with_you/internal/service"
)
type EmptyClassroomHandler struct {
classroomSyncService service.EmptyClassroomSyncService
classroomRepo repository.EmptyClassroomRepository
}
func NewEmptyClassroomHandler(
classroomSyncService service.EmptyClassroomSyncService,
classroomRepo repository.EmptyClassroomRepository,
) *EmptyClassroomHandler {
return &EmptyClassroomHandler{
classroomSyncService: classroomSyncService,
classroomRepo: classroomRepo,
}
}
type syncEmptyClassroomsRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Semester string `json:"semester"`
WeekStart string `json:"week_start"`
WeekEnd string `json:"week_end"`
CampusCode string `json:"campus_code"`
}
func (h *EmptyClassroomHandler) SyncEmptyClassrooms(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
var req syncEmptyClassroomsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
result, err := h.classroomSyncService.SyncUserEmptyClassrooms(c.Request.Context(), userID, &service.SyncEmptyClassroomsRequest{
Username: req.Username,
Password: req.Password,
Semester: req.Semester,
WeekStart: req.WeekStart,
WeekEnd: req.WeekEnd,
CampusCode: req.CampusCode,
})
if err != nil {
response.HandleError(c, err, "failed to sync empty classrooms")
return
}
if !result.Success {
response.Success(c, result)
return
}
response.SuccessWithMessage(c, result.Message, result)
}
func (h *EmptyClassroomHandler) ListEmptyClassrooms(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
}
classrooms, err := h.classroomRepo.ListByUserAndSemester(userID, semester)
if err != nil {
response.HandleError(c, err, "failed to list empty classrooms")
return
}
response.Success(c, gin.H{"classrooms": classrooms})
}

View File

@@ -0,0 +1,26 @@
package model
import (
"time"
"gorm.io/gorm"
)
type EmptyClassroom 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"`
Classroom string `json:"classroom" gorm:"type:varchar(60);not null"`
Weekday string `json:"weekday" gorm:"type:varchar(100)"`
Periods string `json:"periods" gorm:"type:text"`
Weeks string `json:"weeks" gorm:"type:varchar(30)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (e *EmptyClassroom) BeforeCreate(tx *gorm.DB) error {
SetUUIDIfEmpty(&e.ID)
return nil
}
func (EmptyClassroom) TableName() string { return "empty_classrooms" }

View File

@@ -193,6 +193,7 @@ func autoMigrate(db *gorm.DB) error {
&Grade{},
&GpaSummary{},
&Exam{},
&EmptyClassroom{},
// 用户活跃相关
&UserActiveLog{},

View File

@@ -0,0 +1,41 @@
package repository
import (
"context"
"with_you/internal/model"
"gorm.io/gorm"
)
type EmptyClassroomRepository interface {
ListByUserAndSemester(userID, semester string) ([]*model.EmptyClassroom, error)
DeleteByUserAndSemester(ctx context.Context, userID, semester string) error
BatchCreate(ctx context.Context, classrooms []*model.EmptyClassroom) error
}
type emptyClassroomRepository struct {
db *gorm.DB
}
func NewEmptyClassroomRepository(db *gorm.DB) EmptyClassroomRepository {
return &emptyClassroomRepository{db: db}
}
func (r *emptyClassroomRepository) ListByUserAndSemester(userID, semester string) ([]*model.EmptyClassroom, error) {
var classrooms []*model.EmptyClassroom
err := r.db.Where("user_id = ? AND semester = ?", userID, semester).
Order("classroom ASC, weekday ASC").
Find(&classrooms).Error
return classrooms, err
}
func (r *emptyClassroomRepository) DeleteByUserAndSemester(ctx context.Context, userID, semester string) error {
return r.db.WithContext(ctx).
Where("user_id = ? AND semester = ?", userID, semester).
Delete(&model.EmptyClassroom{}).Error
}
func (r *emptyClassroomRepository) BatchCreate(ctx context.Context, classrooms []*model.EmptyClassroom) error {
return r.db.WithContext(ctx).CreateInBatches(classrooms, 100).Error
}

View File

@@ -28,6 +28,7 @@ type RouterDeps struct {
ScheduleHandler *handler.ScheduleHandler
GradeHandler *handler.GradeHandler
ExamHandler *handler.ExamHandler
EmptyClassroomHandler *handler.EmptyClassroomHandler
RoleHandler *handler.RoleHandler
AdminUserHandler *handler.AdminUserHandler
AdminPostHandler *handler.AdminPostHandler
@@ -71,6 +72,7 @@ type Router struct {
scheduleHandler *handler.ScheduleHandler
gradeHandler *handler.GradeHandler
examHandler *handler.ExamHandler
emptyClassroomHandler *handler.EmptyClassroomHandler
roleHandler *handler.RoleHandler
adminUserHandler *handler.AdminUserHandler
adminPostHandler *handler.AdminPostHandler
@@ -117,6 +119,7 @@ func New(deps RouterDeps) *Router {
scheduleHandler: deps.ScheduleHandler,
gradeHandler: deps.GradeHandler,
examHandler: deps.ExamHandler,
emptyClassroomHandler: deps.EmptyClassroomHandler,
roleHandler: deps.RoleHandler,
adminUserHandler: deps.AdminUserHandler,
adminPostHandler: deps.AdminPostHandler,
@@ -372,6 +375,16 @@ func (r *Router) setupRoutes() {
}
}
// 空教室路由
if r.emptyClassroomHandler != nil {
classrooms := v1.Group("/classrooms")
classrooms.Use(authMiddleware)
{
classrooms.GET("", r.emptyClassroomHandler.ListEmptyClassrooms)
classrooms.POST("/sync", r.emptyClassroomHandler.SyncEmptyClassrooms)
}
}
// 学习资料路由(公开)
if r.materialHandler != nil {
materials := v1.Group("/materials")

View File

@@ -0,0 +1,135 @@
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 SyncEmptyClassroomsRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Semester string `json:"semester"`
WeekStart string `json:"week_start"`
WeekEnd string `json:"week_end"`
CampusCode string `json:"campus_code"`
}
type SyncEmptyClassroomsResult struct {
Success bool `json:"success"`
Message string `json:"message"`
SyncedCount int `json:"synced_count"`
ErrorMessage string `json:"error_message,omitempty"`
}
type EmptyClassroomSyncService interface {
SyncUserEmptyClassrooms(ctx context.Context, userID string, req *SyncEmptyClassroomsRequest) (*SyncEmptyClassroomsResult, error)
}
type emptyClassroomSyncService struct {
taskManager *runner.TaskManager
classroomRepo repository.EmptyClassroomRepository
logger *zap.Logger
}
func NewEmptyClassroomSyncService(
taskManager *runner.TaskManager,
classroomRepo repository.EmptyClassroomRepository,
logger *zap.Logger,
) EmptyClassroomSyncService {
return &emptyClassroomSyncService{
taskManager: taskManager,
classroomRepo: classroomRepo,
logger: logger,
}
}
func (s *emptyClassroomSyncService) SyncUserEmptyClassrooms(ctx context.Context, userID string, req *SyncEmptyClassroomsRequest) (*SyncEmptyClassroomsResult, error) {
payload := &pb.GetEmptyClassroomPayload{
Username: req.Username,
Password: req.Password,
Semester: req.Semester,
WeekStart: req.WeekStart,
WeekEnd: req.WeekEnd,
CampusCode: req.CampusCode,
}
taskResult, err := s.taskManager.SubmitTaskSync(ctx, pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM, payload)
if err != nil {
s.logger.Error("failed to submit empty classroom 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 &SyncEmptyClassroomsResult{
Success: false,
Message: "空教室查询失败",
ErrorMessage: taskResult.ErrorMessage,
}, nil
}
var resultData pb.EmptyClassroomResultData
if err := json.Unmarshal(taskResult.Data, &resultData); err != nil {
s.logger.Error("failed to parse empty classroom result",
zap.String("user_id", userID),
zap.Error(err))
return nil, fmt.Errorf("failed to parse result: %w", err)
}
classrooms := s.convertClassrooms(userID, &resultData)
if err := s.saveClassrooms(ctx, userID, resultData.Semester, classrooms); err != nil {
s.logger.Error("failed to save empty classrooms",
zap.String("user_id", userID),
zap.Error(err))
return nil, fmt.Errorf("failed to save classrooms: %w", err)
}
s.logger.Info("empty classrooms sync completed",
zap.String("user_id", userID),
zap.Int("classroom_count", len(classrooms)))
return &SyncEmptyClassroomsResult{
Success: true,
Message: "空教室查询成功",
SyncedCount: len(classrooms),
}, nil
}
func (s *emptyClassroomSyncService) convertClassrooms(userID string, data *pb.EmptyClassroomResultData) []*model.EmptyClassroom {
classrooms := make([]*model.EmptyClassroom, 0, len(data.Classrooms))
for _, c := range data.Classrooms {
classrooms = append(classrooms, &model.EmptyClassroom{
ID: uuid.New().String(),
UserID: userID,
Semester: data.Semester,
Classroom: c.Classroom,
Weekday: c.Weekday,
Periods: c.Periods,
Weeks: c.Weeks,
})
}
return classrooms
}
func (s *emptyClassroomSyncService) saveClassrooms(ctx context.Context, userID, semester string, classrooms []*model.EmptyClassroom) error {
if err := s.classroomRepo.DeleteByUserAndSemester(ctx, userID, semester); err != nil {
return fmt.Errorf("failed to delete old classrooms: %w", err)
}
if len(classrooms) == 0 {
return nil
}
if err := s.classroomRepo.BatchCreate(ctx, classrooms); err != nil {
return fmt.Errorf("failed to create classrooms: %w", err)
}
return nil
}

View File

@@ -37,6 +37,7 @@ var HandlerSet = wire.NewSet(
handler.NewTradeHandler,
handler.NewGradeHandler,
handler.NewExamHandler,
handler.NewEmptyClassroomHandler,
// 需要特殊处理的 Handler
ProvideUserHandler,

View File

@@ -26,6 +26,7 @@ var RepositorySet = wire.NewSet(
repository.NewScheduleRepository,
repository.NewGradeRepository,
repository.NewExamRepository,
repository.NewEmptyClassroomRepository,
repository.NewMaterialSubjectRepository,
repository.NewMaterialFileRepository,
repository.NewCallRepository,

View File

@@ -48,6 +48,7 @@ var ServiceSet = wire.NewSet(
ProvideScheduleSyncService,
ProvideGradeSyncService,
ProvideExamSyncService,
ProvideEmptyClassroomSyncService,
ProvideGroupService,
ProvideUploadService,
ProvideUserActivityService,
@@ -263,6 +264,15 @@ func ProvideExamSyncService(
return service.NewExamSyncService(taskManager, examRepo, logger)
}
// ProvideEmptyClassroomSyncService 提供空教室同步服务
func ProvideEmptyClassroomSyncService(
taskManager *runner.TaskManager,
classroomRepo repository.EmptyClassroomRepository,
logger *zap.Logger,
) service.EmptyClassroomSyncService {
return service.NewEmptyClassroomSyncService(taskManager, classroomRepo, logger)
}
// ProvideGroupService 提供群组服务
func ProvideGroupService(
groupRepo repository.GroupRepository,