diff --git a/backend/cmd/course/main.go b/backend/cmd/course/main.go new file mode 100644 index 0000000..141f10b --- /dev/null +++ b/backend/cmd/course/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/LoveLosita/smartflow/backend/bootstrap" + rootdao "github.com/LoveLosita/smartflow/backend/dao" + coursedao "github.com/LoveLosita/smartflow/backend/services/course/dao" + courserpc "github.com/LoveLosita/smartflow/backend/services/course/rpc" + coursesv "github.com/LoveLosita/smartflow/backend/services/course/sv" + llmservice "github.com/LoveLosita/smartflow/backend/services/llm" + "github.com/spf13/viper" +) + +func main() { + if err := bootstrap.LoadConfig(); err != nil { + log.Fatalf("failed to load config: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + db, err := coursedao.OpenDBFromConfig() + if err != nil { + log.Fatalf("failed to connect course database: %v", err) + } + + // 1. course 自有 DAO 只承载课程导入对 schedule 表的迁移期写入。 + // 2. scheduleRepo 用于复用既有冲突检查,后续若切 schedule RPC bridge 再替换这里。 + courseRepo := coursedao.NewCourseDAO(db) + scheduleRepo := rootdao.NewScheduleDAO(db) + courseImageClient := llmservice.NewArkResponsesClient( + os.Getenv("ARK_API_KEY"), + viper.GetString("agent.baseURL"), + viper.GetString("courseImport.visionModel"), + ) + svc := coursesv.NewCourseService( + courseRepo, + scheduleRepo, + courseImageClient, + coursesv.NewCourseImageParseConfig( + viper.GetInt64("courseImport.maxImageBytes"), + viper.GetInt("courseImport.maxTokens"), + ), + viper.GetString("courseImport.visionModel"), + ) + + server, listenOn, err := courserpc.NewServer(courserpc.ServerOptions{ + ListenOn: viper.GetString("course.rpc.listenOn"), + Timeout: viper.GetDuration("course.rpc.timeout"), + MaxImageBytes: viper.GetInt64("courseImport.maxImageBytes"), + Service: svc, + }) + if err != nil { + log.Fatalf("failed to build course zrpc server: %v", err) + } + defer server.Stop() + + go func() { + log.Printf("course zrpc service starting on %s", listenOn) + server.Start() + }() + + <-ctx.Done() + log.Println("course service stopping") +} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index b626cdc..54b2bfd 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -15,6 +15,7 @@ import ( "github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/gateway/api" gatewayactivescheduler "github.com/LoveLosita/smartflow/backend/gateway/client/activescheduler" + gatewaycourse "github.com/LoveLosita/smartflow/backend/gateway/client/course" gatewaynotification "github.com/LoveLosita/smartflow/backend/gateway/client/notification" gatewayschedule "github.com/LoveLosita/smartflow/backend/gateway/client/schedule" gatewaytask "github.com/LoveLosita/smartflow/backend/gateway/client/task" @@ -195,7 +196,6 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { agentCacheRepo := dao.NewAgentCache(rdb) _ = db.Use(middleware.NewGormCachePlugin(cacheRepo)) taskRepo := dao.NewTaskDAO(db) - courseRepo := dao.NewCourseDAO(db) taskClassRepo := dao.NewTaskClassDAO(db) scheduleRepo := dao.NewScheduleDAO(db) manager := dao.NewManager(db) @@ -248,6 +248,15 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { if err != nil { return nil, fmt.Errorf("failed to initialize task-class zrpc client: %w", err) } + courseClient, err := gatewaycourse.NewClient(gatewaycourse.ClientConfig{ + Endpoints: viper.GetStringSlice("course.rpc.endpoints"), + Target: viper.GetString("course.rpc.target"), + Timeout: viper.GetDuration("course.rpc.timeout"), + MaxImageBytes: viper.GetInt64("courseImport.maxImageBytes"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize course zrpc client: %w", err) + } activeSchedulerClient, err := gatewayactivescheduler.NewClient(gatewayactivescheduler.ClientConfig{ Endpoints: viper.GetStringSlice("activeScheduler.rpc.endpoints"), Target: viper.GetString("activeScheduler.rpc.target"), @@ -262,7 +271,6 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { taskOutboxPublisher := buildTaskOutboxPublisher(outboxRepo) taskSv := service.NewTaskService(taskRepo, cacheRepo, taskOutboxPublisher) taskSv.SetActiveScheduleDAO(manager.ActiveSchedule) - courseService := buildCourseService(llmService, courseRepo, scheduleRepo) scheduleService := service.NewScheduleService(scheduleRepo, taskClassRepo, manager, cacheRepo) agentService := service.NewAgentServiceWithSchedule( llmService, @@ -325,7 +333,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { return nil, err } agentService.SetActiveScheduleSessionRerunFunc(buildActiveScheduleSessionRerunFunc(manager.ActiveSchedule, activeScheduleGraphRunner, activeSchedulePreviewConfirm, activeScheduleFeedbackLocator)) - handlers := buildAPIHandlers(taskClient, taskClassClient, courseService, scheduleClient, agentService, memoryModule, activeSchedulerClient, notificationClient) + handlers := buildAPIHandlers(taskClient, taskClassClient, courseClient, scheduleClient, agentService, memoryModule, activeSchedulerClient, notificationClient) runtime := &appRuntime{ db: db, @@ -915,7 +923,7 @@ func buildQuickTaskQueryFunc(agentService *service.AgentService) func(ctx contex func buildAPIHandlers( taskClient ports.TaskCommandClient, taskClassClient ports.TaskClassCommandClient, - courseService *service.CourseService, + courseClient ports.CourseCommandClient, scheduleClient ports.ScheduleCommandClient, agentService *service.AgentService, memoryModule *memory.Module, @@ -925,7 +933,7 @@ func buildAPIHandlers( return &api.ApiHandlers{ TaskHandler: api.NewTaskHandler(taskClient), TaskClassHandler: api.NewTaskClassHandler(taskClassClient), - CourseHandler: api.NewCourseHandler(courseService), + CourseHandler: api.NewCourseHandler(courseClient), ScheduleHandler: api.NewScheduleAPI(scheduleClient), AgentHandler: api.NewAgentHandler(agentService), MemoryHandler: api.NewMemoryHandler(memoryModule), diff --git a/backend/config.example.yaml b/backend/config.example.yaml index e2ba76d..67c82bd 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -83,6 +83,14 @@ taskClass: - "127.0.0.1:9086" timeout: 6s +# 课程服务配置。 +course: + rpc: + listenOn: "0.0.0.0:9087" + endpoints: + - "127.0.0.1:9087" + timeout: 10s + # 主动调度服务配置。 activeScheduler: rpc: diff --git a/backend/gateway/api/course.go b/backend/gateway/api/course.go index 225f093..7c1d4be 100644 --- a/backend/gateway/api/course.go +++ b/backend/gateway/api/course.go @@ -2,57 +2,65 @@ package api import ( "context" + "encoding/json" "errors" "io" "log" "net/http" "time" - "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" - "github.com/LoveLosita/smartflow/backend/service" + coursecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/course" + "github.com/LoveLosita/smartflow/backend/shared/ports" "github.com/gin-gonic/gin" ) +const courseRequestTimeout = 10 * time.Second + type CourseHandler struct { - service *service.CourseService + client ports.CourseCommandClient } -func NewCourseHandler(service *service.CourseService) *CourseHandler { - return &CourseHandler{ - service: service, - } +func NewCourseHandler(client ports.CourseCommandClient) *CourseHandler { + return &CourseHandler{client: client} +} + +type courseImportConflict interface { + ConflictsJSON() json.RawMessage } func (sa *CourseHandler) CheckUserCourse(c *gin.Context) { - var req model.UserCheckCourseRequest + var req coursecontracts.UserCheckCourseRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, respond.WrongParamType) return } - if service.CheckSingleCourse(req) { - c.JSON(http.StatusOK, respond.Ok) + ctx, cancel := context.WithTimeout(c.Request.Context(), courseRequestTimeout) + defer cancel() + if err := sa.client.ValidateCourse(ctx, req); err != nil { + respond.DealWithError(c, err) return } - c.JSON(http.StatusBadRequest, respond.WrongCourseInfo) + c.JSON(http.StatusOK, respond.Ok) } func (sa *CourseHandler) AddUserCourses(c *gin.Context) { - var req model.UserImportCoursesRequest + var req coursecontracts.UserImportCoursesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, respond.WrongParamType) return } - userID := c.GetInt("user_id") - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + req.UserID = c.GetInt("user_id") + ctx, cancel := context.WithTimeout(c.Request.Context(), courseRequestTimeout) defer cancel() - conflicts, err := sa.service.AddUserCourses(ctx, req, userID) + _, err := sa.client.ImportCourses(ctx, req) if err != nil { - if errors.Is(err, respond.ScheduleConflict) { - c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflicts)) + var conflict courseImportConflict + if errors.As(err, &conflict) { + c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflict.ConflictsJSON())) return } respond.DealWithError(c, err) @@ -107,33 +115,34 @@ func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) { ctx, cancel := context.WithCancel(c.Request.Context()) defer cancel() - draft, err := sa.service.ParseCourseTableImage(ctx, model.CourseImageParseRequest{ + rawDraft, err := sa.client.ParseCourseTableImage(ctx, coursecontracts.CourseImageParseRequest{ Filename: fileHeader.Filename, MIMEType: fileHeader.Header.Get("Content-Type"), ImageBytes: imageBytes, }) if err != nil { + var resp respond.Response switch { - case errors.Is(err, service.ErrCourseImageParserUnavailable): + case errors.As(err, &resp) && resp.Status == "50003": log.Printf("[COURSE_PARSE][API] parser unavailable user=%d filename=%q", userID, fileHeader.Filename) - c.JSON(http.StatusServiceUnavailable, respond.Response{Status: "50003", Info: "course image parser is not configured"}) + c.JSON(http.StatusServiceUnavailable, resp) return - case errors.Is(err, service.ErrCourseImageTooLarge): + case errors.As(err, &resp) && resp.Status == "40064": log.Printf("[COURSE_PARSE][API] file too large user=%d filename=%q bytes=%d", userID, fileHeader.Filename, len(imageBytes)) - c.JSON(http.StatusBadRequest, respond.Response{Status: "40064", Info: "course image too large"}) + c.JSON(http.StatusBadRequest, resp) return - case errors.Is(err, service.ErrCourseImageUnsupportedMIME): + case errors.As(err, &resp) && resp.Status == "40065": log.Printf( "[COURSE_PARSE][API] unsupported mime user=%d filename=%q header_content_type=%q", userID, fileHeader.Filename, fileHeader.Header.Get("Content-Type"), ) - c.JSON(http.StatusBadRequest, respond.Response{Status: "40065", Info: "unsupported course image format"}) + c.JSON(http.StatusBadRequest, resp) return - case errors.Is(err, service.ErrCourseImageEmpty): + case errors.As(err, &resp) && resp.Status == "40066": log.Printf("[COURSE_PARSE][API] empty file user=%d filename=%q", userID, fileHeader.Filename) - c.JSON(http.StatusBadRequest, respond.Response{Status: "40066", Info: "course image is empty"}) + c.JSON(http.StatusBadRequest, resp) return default: log.Printf("[COURSE_PARSE][API] unexpected failure user=%d filename=%q err=%v", userID, fileHeader.Filename, err) @@ -142,6 +151,13 @@ func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) { } } + var draft coursecontracts.CourseImageParseResponse + if err := json.Unmarshal(rawDraft, &draft); err != nil { + log.Printf("[COURSE_PARSE][API] decode response failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err) + respond.DealWithError(c, err) + return + } + log.Printf( "[COURSE_PARSE][API] request success user=%d filename=%q draft_status=%s rows=%d warnings=%d", userID, diff --git a/backend/gateway/client/course/client.go b/backend/gateway/client/course/client.go new file mode 100644 index 0000000..843fe96 --- /dev/null +++ b/backend/gateway/client/course/client.go @@ -0,0 +1,162 @@ +package course + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + coursepb "github.com/LoveLosita/smartflow/backend/services/course/rpc/pb" + coursecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/course" + "github.com/zeromicro/go-zero/zrpc" + "google.golang.org/grpc" +) + +const ( + defaultEndpoint = "127.0.0.1:9087" + defaultTimeout = 10 * time.Second + defaultMaxRPCMessageSize = 8 * 1024 * 1024 + rpcMessageSizePadding = 1024 * 1024 +) + +type ClientConfig struct { + Endpoints []string + Target string + Timeout time.Duration + MaxImageBytes int64 +} + +// Client 是 gateway 访问 course zrpc 的最小适配层。 +// +// 职责边界: +// 1. 只负责跨进程 gRPC 调用和 JSON/bytes 透传,不触碰 DAO; +// 2. HTTP 入参仍由 gateway/api 做基础绑定,业务校验交给 course 服务; +// 3. import 冲突通过 CourseImportConflictError 返回,让 HTTP 层保留 409 + conflicts 数据。 +type Client struct { + rpc coursepb.CourseClient +} + +func NewClient(cfg ClientConfig) (*Client, error) { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + endpoints := normalizeEndpoints(cfg.Endpoints) + target := strings.TrimSpace(cfg.Target) + if len(endpoints) == 0 && target == "" { + endpoints = []string{defaultEndpoint} + } + + maxMessageSize := normalizeMaxRPCMessageSize(cfg.MaxImageBytes) + zclient, err := zrpc.NewClient(zrpc.RpcClientConf{ + Endpoints: endpoints, + Target: target, + NonBlock: true, + Timeout: int64(timeout / time.Millisecond), + }, zrpc.WithDialOption(grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(maxMessageSize), + grpc.MaxCallSendMsgSize(maxMessageSize), + ))) + if err != nil { + return nil, err + } + client := &Client{rpc: coursepb.NewCourseClient(zclient.Conn())} + if err := client.ping(timeout); err != nil { + return nil, err + } + return client, nil +} + +func (c *Client) ValidateCourse(ctx context.Context, req coursecontracts.UserCheckCourseRequest) error { + _, err := c.callJSON(ctx, c.rpc.ValidateCourse, req) + return responseFromRPCError(err) +} + +func (c *Client) ImportCourses(ctx context.Context, req coursecontracts.UserImportCoursesRequest) (json.RawMessage, error) { + resp, err := c.callJSON(ctx, c.rpc.ImportCourses, req) + raw, err := jsonFromResponse(resp, err) + if err != nil { + return nil, err + } + var result coursecontracts.ImportCoursesResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + if result.Conflict { + return nil, CourseImportConflictError{conflicts: result.Conflicts} + } + return raw, nil +} + +func (c *Client) ParseCourseTableImage(ctx context.Context, req coursecontracts.CourseImageParseRequest) (json.RawMessage, error) { + resp, err := c.rpc.ParseCourseImage(ctx, &coursepb.CourseImageRequest{ + Filename: req.Filename, + MimeType: req.MIMEType, + ImageBytes: req.ImageBytes, + }) + return jsonFromResponse(resp, err) +} + +func (c *Client) ensureReady() error { + if c == nil || c.rpc == nil { + return errors.New("course zrpc client is not initialized") + } + return nil +} + +func (c *Client) ping(timeout time.Duration) error { + if err := c.ensureReady(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, err := c.rpc.Ping(ctx, &coursepb.StatusResponse{}) + return responseFromRPCError(err) +} + +func (c *Client) callJSON(ctx context.Context, fn func(context.Context, *coursepb.JSONRequest, ...grpc.CallOption) (*coursepb.JSONResponse, error), payload any) (*coursepb.JSONResponse, error) { + if err := c.ensureReady(); err != nil { + return nil, err + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return fn(ctx, &coursepb.JSONRequest{PayloadJson: raw}) +} + +func jsonFromResponse(resp *coursepb.JSONResponse, rpcErr error) (json.RawMessage, error) { + if rpcErr != nil { + return nil, responseFromRPCError(rpcErr) + } + if resp == nil { + return nil, errors.New("course zrpc service returned empty JSON response") + } + if len(resp.DataJson) == 0 { + return json.RawMessage("null"), nil + } + return json.RawMessage(resp.DataJson), nil +} + +func normalizeEndpoints(values []string) []string { + endpoints := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + endpoints = append(endpoints, trimmed) + } + } + return endpoints +} + +func normalizeMaxRPCMessageSize(maxImageBytes int64) int { + if maxImageBytes <= 0 { + return defaultMaxRPCMessageSize + } + size := maxImageBytes + rpcMessageSizePadding + if size < defaultMaxRPCMessageSize { + return defaultMaxRPCMessageSize + } + return int(size) +} diff --git a/backend/gateway/client/course/errors.go b/backend/gateway/client/course/errors.go new file mode 100644 index 0000000..c70fede --- /dev/null +++ b/backend/gateway/client/course/errors.go @@ -0,0 +1,111 @@ +package course + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// CourseImportConflictError 表示课程导入和已有非课程日程冲突。 +// +// 职责边界: +// 1. 只在 gateway 边缘层用于恢复旧 HTTP 409 + conflicts 响应; +// 2. 不承载冲突计算逻辑,冲突详情由 course 服务生成; +// 3. ConflictsJSON 返回原始 JSON,避免 gateway 复制 schedule 冲突 DTO。 +type CourseImportConflictError struct { + conflicts json.RawMessage +} + +func (e CourseImportConflictError) Error() string { + return respond.ScheduleConflict.Info +} + +func (e CourseImportConflictError) ConflictsJSON() json.RawMessage { + if len(e.conflicts) == 0 { + return json.RawMessage("[]") + } + return e.conflicts +} + +// responseFromRPCError 负责把 course 的 gRPC 错误反解回项目内错误。 +func responseFromRPCError(err error) error { + if err == nil { + return nil + } + + st, ok := status.FromError(err) + if !ok { + return wrapRPCError(err) + } + if resp, ok := responseFromStatus(st); ok { + return resp + } + + switch st.Code() { + case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented: + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = "course zrpc service internal error" + } + return wrapRPCError(errors.New(msg)) + } + + msg := strings.TrimSpace(st.Message()) + if msg == "" { + msg = "course zrpc service rejected request" + } + return respond.Response{Status: grpcCodeToRespondStatus(st.Code()), Info: msg} +} + +func responseFromStatus(st *status.Status) (respond.Response, bool) { + if st == nil { + return respond.Response{}, false + } + for _, detail := range st.Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + statusValue := strings.TrimSpace(info.Reason) + if statusValue == "" { + statusValue = grpcCodeToRespondStatus(st.Code()) + } + message := strings.TrimSpace(st.Message()) + if message == "" && info.Metadata != nil { + message = strings.TrimSpace(info.Metadata["info"]) + } + if message == "" { + message = statusValue + } + return respond.Response{Status: statusValue, Info: message}, true + } + return respond.Response{}, false +} + +func grpcCodeToRespondStatus(code codes.Code) string { + switch code { + case codes.Unauthenticated: + return respond.ErrUnauthorized.Status + case codes.PermissionDenied: + return respond.ErrUnauthorized.Status + case codes.InvalidArgument: + return respond.WrongParamType.Status + case codes.Internal, codes.Unknown, codes.DataLoss: + return "500" + default: + return "400" + } +} + +func wrapRPCError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("调用 course zrpc 服务失败: %w", err) +} diff --git a/backend/services/course/dao/connect.go b/backend/services/course/dao/connect.go new file mode 100644 index 0000000..a3c85fe --- /dev/null +++ b/backend/services/course/dao/connect.go @@ -0,0 +1,52 @@ +package dao + +import ( + "fmt" + + "github.com/spf13/viper" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// OpenDBFromConfig 创建 course 服务自己的数据库句柄。 +// +// 职责边界: +// 1. course 当前没有独立课程写模型表,导入链路迁移期仍写 schedule_events / schedules; +// 2. 本函数不 AutoMigrate schedule 表,避免 course 进程越权管理 schedule schema; +// 3. 启动期只检查运行时依赖表是否存在,缺表时尽早失败。 +func OpenDBFromConfig() (*gorm.DB, error) { + host := viper.GetString("database.host") + port := viper.GetString("database.port") + user := viper.GetString("database.user") + password := viper.GetString("database.password") + dbname := viper.GetString("database.dbname") + + dsn := fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + user, password, host, port, dbname, + ) + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, err + } + if err = ensureRuntimeDependencyTables(db); err != nil { + return nil, err + } + return db, nil +} + +// ensureRuntimeDependencyTables 显式检查 course 导入链路迁移期仍直写的 schedule 表。 +// +// 说明: +// 1. schedule_events / schedules 属于 schedule 服务正式日程域; +// 2. 本轮保留 course 直写权限,用来维持课程导入两个表同事务写入; +// 3. 后续若改为 schedule RPC bridge,应先补课程导入幂等与冲突返回契约,再移除这里的依赖检查。 +func ensureRuntimeDependencyTables(db *gorm.DB) error { + for _, table := range []string{"schedule_events", "schedules"} { + if !db.Migrator().HasTable(table) { + return fmt.Errorf("course runtime dependency table missing: %s", table) + } + } + return nil +} diff --git a/backend/services/course/dao/course.go b/backend/services/course/dao/course.go new file mode 100644 index 0000000..30782b5 --- /dev/null +++ b/backend/services/course/dao/course.go @@ -0,0 +1,50 @@ +package dao + +import ( + "context" + + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" +) + +type CourseDAO struct { + db *gorm.DB +} + +// NewCourseDAO 创建ScheduleDAO实例 +func NewCourseDAO(db *gorm.DB) *CourseDAO { + return &CourseDAO{ + db: db, + } +} + +func (r *CourseDAO) WithTx(tx *gorm.DB) *CourseDAO { + return &CourseDAO{db: tx} +} + +func (r *CourseDAO) AddUserCoursesIntoSchedule(ctx context.Context, courses []model.Schedule) error { + if err := r.db.WithContext(ctx).Create(&courses).Error; err != nil { + return err + } + return nil +} + +func (r *CourseDAO) AddUserCoursesIntoScheduleEvents(ctx context.Context, events []model.ScheduleEvent) ([]int, error) { + if err := r.db.WithContext(ctx).Create(&events).Error; err != nil { + return nil, err + } + ids := make([]int, 0, len(events)) + for i := range events { + ids = append(ids, events[i].ID) + } + return ids, nil +} + +// Transaction 在同一个数据库事务中执行传入的函数,供 service 层复用(自动提交/回滚) +// 规则:fn 返回 nil \-\> 提交;fn 返回 error 或发生 panic \-\> 回滚 +// 说明:gorm\.\(\\\*DB\)\.Transaction 会在 fn 返回 error 时回滚,并在发生 panic 时自动回滚后继续向上抛出 panic +func (r *CourseDAO) Transaction(fn func(txDAO *CourseDAO) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return fn(NewCourseDAO(tx)) + }) +} diff --git a/backend/services/course/rpc/course.proto b/backend/services/course/rpc/course.proto new file mode 100644 index 0000000..c145ec2 --- /dev/null +++ b/backend/services/course/rpc/course.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package smartflow.course; + +option go_package = "github.com/LoveLosita/smartflow/backend/services/course/rpc/pb"; + +service Course { + rpc Ping(StatusResponse) returns (StatusResponse); + rpc ValidateCourse(JSONRequest) returns (JSONResponse); + rpc ImportCourses(JSONRequest) returns (JSONResponse); + rpc ParseCourseImage(CourseImageRequest) returns (JSONResponse); +} + +message JSONRequest { + bytes payload_json = 1; +} + +message JSONResponse { + bytes data_json = 1; +} + +message CourseImageRequest { + string filename = 1; + string mime_type = 2; + bytes image_bytes = 3; +} + +message StatusResponse { +} diff --git a/backend/services/course/rpc/errors.go b/backend/services/course/rpc/errors.go new file mode 100644 index 0000000..65a85f5 --- /dev/null +++ b/backend/services/course/rpc/errors.go @@ -0,0 +1,84 @@ +package rpc + +import ( + "errors" + "log" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + coursesv "github.com/LoveLosita/smartflow/backend/services/course/sv" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const courseErrorDomain = "smartflow.course" + +// grpcErrorFromServiceError 负责把 course 内部错误转换为 gRPC status。 +// +// 职责边界: +// 1. respond.Response 保留项目内部 status/info,供 gateway 反解; +// 2. 图片解析哨兵错误转换为历史 HTTP 兼容错误码; +// 3. 未分类错误只暴露通用内部错误,详细信息留在服务日志。 +func grpcErrorFromServiceError(err error) error { + if err == nil { + return nil + } + var resp respond.Response + if errors.As(err, &resp) { + return grpcErrorFromResponse(resp) + } + switch { + case errors.Is(err, coursesv.ErrCourseImageParserUnavailable): + return grpcErrorFromResponse(respond.Response{Status: "50003", Info: "course image parser is not configured"}) + case errors.Is(err, coursesv.ErrCourseImageTooLarge): + return grpcErrorFromResponse(respond.Response{Status: "40064", Info: "course image too large"}) + case errors.Is(err, coursesv.ErrCourseImageUnsupportedMIME): + return grpcErrorFromResponse(respond.Response{Status: "40065", Info: "unsupported course image format"}) + case errors.Is(err, coursesv.ErrCourseImageEmpty): + return grpcErrorFromResponse(respond.Response{Status: "40066", Info: "course image is empty"}) + } + log.Printf("course rpc internal error: %v", err) + return status.Error(codes.Internal, "course service internal error") +} + +func grpcErrorFromResponse(resp respond.Response) error { + code := grpcCodeFromRespondStatus(resp.Status) + message := strings.TrimSpace(resp.Info) + if message == "" { + message = strings.TrimSpace(resp.Status) + } + st := status.New(code, message) + detail := &errdetails.ErrorInfo{ + Domain: courseErrorDomain, + Reason: resp.Status, + Metadata: map[string]string{ + "info": resp.Info, + }, + } + withDetails, err := st.WithDetails(detail) + if err != nil { + return st.Err() + } + return withDetails.Err() +} + +func grpcCodeFromRespondStatus(statusValue string) codes.Code { + switch strings.TrimSpace(statusValue) { + case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status, + respond.ErrUnauthorized.Status, respond.WrongTokenType.Status, respond.UserLoggedOut.Status: + return codes.Unauthenticated + case respond.CourseNotBelongToUser.Status: + return codes.PermissionDenied + case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status, + respond.WrongUserID.Status, respond.WrongCourseID.Status, respond.WrongCourseInfo.Status, + respond.InsertCourseTwice.Status, respond.ScheduleConflict.Status, respond.InvalidSectionNumber.Status, + respond.InvalidWeekOrDayOfWeek.Status, respond.InvalidSectionRange.Status, + respond.TimeOutOfRangeOfThisSemester.Status: + return codes.InvalidArgument + } + if strings.HasPrefix(strings.TrimSpace(statusValue), "5") { + return codes.Internal + } + return codes.InvalidArgument +} diff --git a/backend/services/course/rpc/handler.go b/backend/services/course/rpc/handler.go new file mode 100644 index 0000000..f628d89 --- /dev/null +++ b/backend/services/course/rpc/handler.go @@ -0,0 +1,141 @@ +package rpc + +import ( + "context" + "encoding/json" + "errors" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/LoveLosita/smartflow/backend/services/course/rpc/pb" + coursesv "github.com/LoveLosita/smartflow/backend/services/course/sv" + coursecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/course" +) + +type Handler struct { + pb.UnimplementedCourseServer + svc *coursesv.CourseService +} + +func NewHandler(svc *coursesv.CourseService) *Handler { + return &Handler{svc: svc} +} + +// Ping 供调用方在启动期确认 course zrpc 已可用。 +func (h *Handler) Ping(ctx context.Context, req *pb.StatusResponse) (*pb.StatusResponse, error) { + if err := h.ensureReady(req); err != nil { + return nil, err + } + return &pb.StatusResponse{}, nil +} + +func (h *Handler) ValidateCourse(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) { + if err := h.ensureReady(req); err != nil { + return nil, err + } + var contractReq coursecontracts.UserCheckCourseRequest + if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil { + return nil, grpcErrorFromServiceError(respond.WrongParamType) + } + if !coursesv.CheckSingleCourse(toModelCheckCourseRequest(contractReq)) { + return nil, grpcErrorFromServiceError(respond.WrongCourseInfo) + } + return jsonResponse(nil, nil) +} + +func (h *Handler) ImportCourses(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) { + if err := h.ensureReady(req); err != nil { + return nil, err + } + var contractReq coursecontracts.UserImportCoursesRequest + if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil { + return nil, grpcErrorFromServiceError(respond.WrongParamType) + } + conflicts, err := h.svc.AddUserCourses(ctx, toModelImportCoursesRequest(contractReq), contractReq.UserID) + if errors.Is(err, respond.ScheduleConflict) { + rawConflicts, marshalErr := json.Marshal(conflicts) + if marshalErr != nil { + return nil, grpcErrorFromServiceError(marshalErr) + } + return jsonResponse(coursecontracts.ImportCoursesResult{ + Conflict: true, + Conflicts: rawConflicts, + }, nil) + } + return jsonResponse(coursecontracts.ImportCoursesResult{Conflict: false}, err) +} + +func (h *Handler) ParseCourseImage(ctx context.Context, req *pb.CourseImageRequest) (*pb.JSONResponse, error) { + if err := h.ensureReady(req); err != nil { + return nil, err + } + draft, err := h.svc.ParseCourseTableImage(ctx, model.CourseImageParseRequest{ + Filename: req.Filename, + MIMEType: req.MimeType, + ImageBytes: req.ImageBytes, + }) + return jsonResponse(draft, err) +} + +func (h *Handler) ensureReady(req any) error { + if h == nil || h.svc == nil { + return grpcErrorFromServiceError(errors.New("course service dependency not initialized")) + } + if req == nil { + return grpcErrorFromServiceError(respond.MissingParam) + } + return nil +} + +func jsonResponse(value any, err error) (*pb.JSONResponse, error) { + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + raw, err := json.Marshal(value) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.JSONResponse{DataJson: raw}, nil +} + +func toModelImportCoursesRequest(req coursecontracts.UserImportCoursesRequest) model.UserImportCoursesRequest { + courses := make([]model.UserCheckCourseRequest, 0, len(req.Courses)) + for _, course := range req.Courses { + courses = append(courses, toModelCheckCourseRequest(course)) + } + return model.UserImportCoursesRequest{Courses: courses} +} + +func toModelCheckCourseRequest(req coursecontracts.UserCheckCourseRequest) model.UserCheckCourseRequest { + arrangements := make([]struct { + StartWeek int `json:"start_week"` + EndWeek int `json:"end_week"` + DayOfWeek int `json:"day_of_week"` + StartSection int `json:"start_section"` + EndSection int `json:"end_section"` + WeekType string `json:"week_type"` + }, 0, len(req.Arrangements)) + for _, arrangement := range req.Arrangements { + arrangements = append(arrangements, struct { + StartWeek int `json:"start_week"` + EndWeek int `json:"end_week"` + DayOfWeek int `json:"day_of_week"` + StartSection int `json:"start_section"` + EndSection int `json:"end_section"` + WeekType string `json:"week_type"` + }{ + StartWeek: arrangement.StartWeek, + EndWeek: arrangement.EndWeek, + DayOfWeek: arrangement.DayOfWeek, + StartSection: arrangement.StartSection, + EndSection: arrangement.EndSection, + WeekType: arrangement.WeekType, + }) + } + return model.UserCheckCourseRequest{ + CourseName: req.CourseName, + Location: req.Location, + IsAllowTasks: req.IsAllowTasks, + Arrangements: arrangements, + } +} diff --git a/backend/services/course/rpc/pb/course.pb.go b/backend/services/course/rpc/pb/course.pb.go new file mode 100644 index 0000000..f012dae --- /dev/null +++ b/backend/services/course/rpc/pb/course.pb.go @@ -0,0 +1,52 @@ +package pb + +import proto "github.com/golang/protobuf/proto" + +var _ = proto.Marshal + +const _ = proto.ProtoPackageIsVersion3 + +type JSONRequest struct { + PayloadJson []byte `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *JSONRequest) Reset() { *m = JSONRequest{} } +func (m *JSONRequest) String() string { return proto.CompactTextString(m) } +func (*JSONRequest) ProtoMessage() {} + +type JSONResponse struct { + DataJson []byte `protobuf:"bytes,1,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *JSONResponse) Reset() { *m = JSONResponse{} } +func (m *JSONResponse) String() string { return proto.CompactTextString(m) } +func (*JSONResponse) ProtoMessage() {} + +type CourseImageRequest struct { + Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` + MimeType string `protobuf:"bytes,2,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"` + ImageBytes []byte `protobuf:"bytes,3,opt,name=image_bytes,json=imageBytes,proto3" json:"image_bytes,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CourseImageRequest) Reset() { *m = CourseImageRequest{} } +func (m *CourseImageRequest) String() string { return proto.CompactTextString(m) } +func (*CourseImageRequest) ProtoMessage() {} + +type StatusResponse struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *StatusResponse) Reset() { *m = StatusResponse{} } +func (m *StatusResponse) String() string { return proto.CompactTextString(m) } +func (*StatusResponse) ProtoMessage() {} diff --git a/backend/services/course/rpc/pb/course_grpc.pb.go b/backend/services/course/rpc/pb/course_grpc.pb.go new file mode 100644 index 0000000..6273557 --- /dev/null +++ b/backend/services/course/rpc/pb/course_grpc.pb.go @@ -0,0 +1,141 @@ +package pb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +const ( + Course_Ping_FullMethodName = "/smartflow.course.Course/Ping" + Course_ValidateCourse_FullMethodName = "/smartflow.course.Course/ValidateCourse" + Course_ImportCourses_FullMethodName = "/smartflow.course.Course/ImportCourses" + Course_ParseCourseImage_FullMethodName = "/smartflow.course.Course/ParseCourseImage" +) + +type CourseClient interface { + Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error) + ValidateCourse(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) + ImportCourses(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) + ParseCourseImage(ctx context.Context, in *CourseImageRequest, opts ...grpc.CallOption) (*JSONResponse, error) +} + +type courseClient struct { + cc grpc.ClientConnInterface +} + +func NewCourseClient(cc grpc.ClientConnInterface) CourseClient { + return &courseClient{cc} +} + +func (c *courseClient) Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error) { + out := new(StatusResponse) + err := c.cc.Invoke(ctx, Course_Ping_FullMethodName, in, out, opts...) + return out, err +} + +func (c *courseClient) ValidateCourse(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) { + out := new(JSONResponse) + err := c.cc.Invoke(ctx, Course_ValidateCourse_FullMethodName, in, out, opts...) + return out, err +} + +func (c *courseClient) ImportCourses(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) { + out := new(JSONResponse) + err := c.cc.Invoke(ctx, Course_ImportCourses_FullMethodName, in, out, opts...) + return out, err +} + +func (c *courseClient) ParseCourseImage(ctx context.Context, in *CourseImageRequest, opts ...grpc.CallOption) (*JSONResponse, error) { + out := new(JSONResponse) + err := c.cc.Invoke(ctx, Course_ParseCourseImage_FullMethodName, in, out, opts...) + return out, err +} + +type CourseServer interface { + Ping(context.Context, *StatusResponse) (*StatusResponse, error) + ValidateCourse(context.Context, *JSONRequest) (*JSONResponse, error) + ImportCourses(context.Context, *JSONRequest) (*JSONResponse, error) + ParseCourseImage(context.Context, *CourseImageRequest) (*JSONResponse, error) +} + +type UnimplementedCourseServer struct{} + +func (UnimplementedCourseServer) Ping(context.Context, *StatusResponse) (*StatusResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedCourseServer) ValidateCourse(context.Context, *JSONRequest) (*JSONResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ValidateCourse not implemented") +} +func (UnimplementedCourseServer) ImportCourses(context.Context, *JSONRequest) (*JSONResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ImportCourses not implemented") +} +func (UnimplementedCourseServer) ParseCourseImage(context.Context, *CourseImageRequest) (*JSONResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ParseCourseImage not implemented") +} + +func RegisterCourseServer(s grpc.ServiceRegistrar, srv CourseServer) { + s.RegisterService(&Course_ServiceDesc, srv) +} + +func _Course_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatusResponse) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CourseServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Course_Ping_FullMethodName} + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CourseServer).Ping(ctx, req.(*StatusResponse)) + } + return interceptor(ctx, in, info, handler) +} + +func _Course_JSON_Handler(fullMethod string, invoke func(CourseServer, context.Context, *JSONRequest) (*JSONResponse, error)) grpc.MethodHandler { + return func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(JSONRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return invoke(srv.(CourseServer), ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: fullMethod} + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return invoke(srv.(CourseServer), ctx, req.(*JSONRequest)) + } + return interceptor(ctx, in, info, handler) + } +} + +func _Course_ParseCourseImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CourseImageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CourseServer).ParseCourseImage(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Course_ParseCourseImage_FullMethodName} + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CourseServer).ParseCourseImage(ctx, req.(*CourseImageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var Course_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "smartflow.course.Course", + HandlerType: (*CourseServer)(nil), + Methods: []grpc.MethodDesc{ + {MethodName: "Ping", Handler: _Course_Ping_Handler}, + {MethodName: "ValidateCourse", Handler: _Course_JSON_Handler(Course_ValidateCourse_FullMethodName, CourseServer.ValidateCourse)}, + {MethodName: "ImportCourses", Handler: _Course_JSON_Handler(Course_ImportCourses_FullMethodName, CourseServer.ImportCourses)}, + {MethodName: "ParseCourseImage", Handler: _Course_ParseCourseImage_Handler}, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "course.proto", +} diff --git a/backend/services/course/rpc/server.go b/backend/services/course/rpc/server.go new file mode 100644 index 0000000..cef6450 --- /dev/null +++ b/backend/services/course/rpc/server.go @@ -0,0 +1,77 @@ +package rpc + +import ( + "errors" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/services/course/rpc/pb" + coursesv "github.com/LoveLosita/smartflow/backend/services/course/sv" + "github.com/zeromicro/go-zero/core/service" + "github.com/zeromicro/go-zero/zrpc" + "google.golang.org/grpc" +) + +const ( + defaultListenOn = "0.0.0.0:9087" + defaultTimeout = 10 * time.Second + defaultMaxRPCMessageSize = 8 * 1024 * 1024 + rpcMessageSizePadding = 1024 * 1024 +) + +type ServerOptions struct { + ListenOn string + Timeout time.Duration + MaxImageBytes int64 + Service *coursesv.CourseService +} + +// NewServer 创建 course zrpc 服务端。 +// +// 职责边界: +// 1. 只负责 zrpc server 配置与 gRPC handler 注册; +// 2. 不创建数据库、模型客户端或业务服务,它们由 cmd/course 管理; +// 3. 图片解析走 bytes 请求,需按 maxImageBytes 抬高 gRPC 消息上限。 +func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) { + if opts.Service == nil { + return nil, "", errors.New("course service dependency not initialized") + } + + listenOn := strings.TrimSpace(opts.ListenOn) + if listenOn == "" { + listenOn = defaultListenOn + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + + server, err := zrpc.NewServer(zrpc.RpcServerConf{ + ServiceConf: service.ServiceConf{ + Name: "course.rpc", + Mode: service.DevMode, + }, + ListenOn: listenOn, + Timeout: int64(timeout / time.Millisecond), + }, func(grpcServer *grpc.Server) { + pb.RegisterCourseServer(grpcServer, NewHandler(opts.Service)) + }) + if err != nil { + return nil, "", err + } + + maxMessageSize := normalizeMaxRPCMessageSize(opts.MaxImageBytes) + server.AddOptions(grpc.MaxRecvMsgSize(maxMessageSize), grpc.MaxSendMsgSize(maxMessageSize)) + return server, listenOn, nil +} + +func normalizeMaxRPCMessageSize(maxImageBytes int64) int { + if maxImageBytes <= 0 { + return defaultMaxRPCMessageSize + } + size := maxImageBytes + rpcMessageSizePadding + if size < defaultMaxRPCMessageSize { + return defaultMaxRPCMessageSize + } + return int(size) +} diff --git a/backend/services/course/sv/course.go b/backend/services/course/sv/course.go new file mode 100644 index 0000000..679dc55 --- /dev/null +++ b/backend/services/course/sv/course.go @@ -0,0 +1,165 @@ +package sv + +import ( + "context" + "strings" + + "github.com/LoveLosita/smartflow/backend/conv" + rootdao "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" + coursedao "github.com/LoveLosita/smartflow/backend/services/course/dao" + llmservice "github.com/LoveLosita/smartflow/backend/services/llm" +) + +type CourseService struct { + // 伸出手:准备接住 DAO + courseDAO *coursedao.CourseDAO + scheduleDAO *rootdao.ScheduleDAO + courseImageResponsesClient *llmservice.ArkResponsesClient + courseImageConfig CourseImageParseConfig + courseImageModel string +} + +// NewCourseService 创建 CourseService 实例 +func NewCourseService( + courseDAO *coursedao.CourseDAO, + scheduleDAO *rootdao.ScheduleDAO, + courseImageResponsesClient *llmservice.ArkResponsesClient, + courseImageConfig CourseImageParseConfig, + courseImageModel string, +) *CourseService { + return &CourseService{ + courseDAO: courseDAO, + scheduleDAO: scheduleDAO, + courseImageResponsesClient: courseImageResponsesClient, + courseImageConfig: courseImageConfig, + courseImageModel: strings.TrimSpace(courseImageModel), + } +} + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + // 兼容常见 MySQL / PostgreSQL / SQLite 的报错关键字 + // 也可以进一步精确到你的索引名 idx_user_slot_atomic + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "duplicate entry") || + strings.Contains(msg, "unique constraint") || + strings.Contains(msg, "unique violation") || + strings.Contains(msg, "duplicate key") { + return true + } + return false +} + +func CheckSingleCourse(req model.UserCheckCourseRequest) bool { + for _, arrangement := range req.Arrangements { + if arrangement.StartWeek > arrangement.EndWeek || + arrangement.DayOfWeek < 1 || arrangement.DayOfWeek > 7 || + arrangement.StartSection < 1 || arrangement.EndSection < arrangement.StartSection || + arrangement.EndSection > 12 || arrangement.StartWeek < 1 || arrangement.EndWeek > 24 { + return false + } + } + return true +} + +// AddUserCourses 添加用户课程表 +func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImportCoursesRequest, userID int) ([]model.ScheduleConflictDetail, error) { + //1.先校验参数是否正确 + for _, course := range req.Courses { + result := CheckSingleCourse(course) + if !result { + return nil, respond.WrongCourseInfo + } + } + //2.将前端传来的课程信息转换为 Schedule 和 ScheduleEvent 切片 + var finalSchedules []model.Schedule + var finalScheduleEvents []model.ScheduleEvent + var pos []int + for _, course := range req.Courses { + // 避免取 range 迭代变量字段地址导致指针复用问题 + location := course.Location + for _, arrangement := range course.Arrangements { + weekType := arrangement.WeekType + for week := arrangement.StartWeek; week <= arrangement.EndWeek; week++ { + if weekType == "odd" && week%2 == 0 { + continue + } + if weekType == "even" && week%2 != 0 { + continue + } + //2.转换为 Schedule_event 切片 + st, ed, err := conv.RelativeTimeToRealTime(week, arrangement.DayOfWeek, arrangement.StartSection, arrangement.EndSection) + if err != nil { + return nil, err + } + scheduleEvent := model.ScheduleEvent{ + UserID: userID, + Name: course.CourseName, + Location: &location, + Type: "course", + RelID: nil, + CanBeEmbedded: course.IsAllowTasks, + StartTime: st, + EndTime: ed, + } + finalScheduleEvents = append(finalScheduleEvents, scheduleEvent) + //3.转换为 Schedule 切片 + for section := arrangement.StartSection; section <= arrangement.EndSection; section++ { + schedule := model.Schedule{ + Week: week, + DayOfWeek: arrangement.DayOfWeek, + Section: section, + Status: "normal", + UserID: userID, + EventID: 0, + } + finalSchedules = append(finalSchedules, schedule) + pos = append(pos, len(finalScheduleEvents)-1) + } + } + } + } + //3.先检测是否重复插入了课程(同一周、同一天、同一节已有课程) + exists, err := ss.scheduleDAO.CheckScheduleConflict(ctx, finalSchedules) + if err != nil { + return nil, err + } + if exists { + return nil, respond.InsertCourseTwice + } + //4.再检查是否和某些非课程的日程冲突(同一周、同一天、同一节已有非课程日程),并给出具体的冲突信息 + conflicts, err := ss.scheduleDAO.GetNonCourseScheduleConflicts(ctx, finalSchedules) + if err != nil { + return nil, err + } + if len(conflicts) > 0 { + ret := conv.SchedulesToScheduleConflictDetail(conflicts) + return ret, respond.ScheduleConflict + } + //5.事务:插入两个表要么都成功,要么都回滚 + err = ss.courseDAO.Transaction(func(txDAO *coursedao.CourseDAO) error { + ids, err := txDAO.AddUserCoursesIntoScheduleEvents(ctx, finalScheduleEvents) + if err != nil { + return err + } + // 将生成的 ScheduleEvent ID 赋值给对应的 Schedule 的 EventID 字段 + for i := range finalSchedules { + finalSchedules[i].EventID = ids[pos[i]] + } + if err := txDAO.AddUserCoursesIntoSchedule(ctx, finalSchedules); err != nil { + return err + } + return nil + }) + if err != nil { + if isUniqueViolation(err) { + return nil, respond.InsertCourseTwice + } + return nil, err + } + return nil, nil +} diff --git a/backend/services/course/sv/course_parse.go b/backend/services/course/sv/course_parse.go new file mode 100644 index 0000000..5dda0db --- /dev/null +++ b/backend/services/course/sv/course_parse.go @@ -0,0 +1,295 @@ +package sv + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/LoveLosita/smartflow/backend/model" +) + +const ( + defaultCourseImageMaxBytes = 5 * 1024 * 1024 + defaultCourseImageMaxTokens = 16384 + maxCourseImageDraftRows = 256 + courseImageParseTemperature = 0.1 +) + +var ( + ErrCourseImageParserUnavailable = errors.New("course image parser is not configured") + ErrCourseImageTooLarge = errors.New("course image is too large") + ErrCourseImageUnsupportedMIME = errors.New("course image mime type is not supported") + ErrCourseImageEmpty = errors.New("course image is empty") +) + +type CourseImageParseConfig struct { + MaxImageBytes int64 + MaxTokens int +} + +func NewCourseImageParseConfig(maxImageBytes int64, maxTokens int) CourseImageParseConfig { + if maxImageBytes <= 0 { + maxImageBytes = defaultCourseImageMaxBytes + } + if maxTokens <= 0 { + maxTokens = defaultCourseImageMaxTokens + } + return CourseImageParseConfig{ + MaxImageBytes: maxImageBytes, + MaxTokens: maxTokens, + } +} + +func normalizeCourseImageParseRequest(req model.CourseImageParseRequest, cfg CourseImageParseConfig) (*model.CourseImageParseRequest, error) { + req.Filename = strings.TrimSpace(req.Filename) + req.MIMEType = strings.TrimSpace(strings.ToLower(req.MIMEType)) + if len(req.ImageBytes) == 0 { + return nil, ErrCourseImageEmpty + } + if int64(len(req.ImageBytes)) > cfg.MaxImageBytes { + return nil, ErrCourseImageTooLarge + } + + detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(req.ImageBytes))) + if req.MIMEType == "" || req.MIMEType == "application/octet-stream" { + req.MIMEType = detected + } + if !isSupportedCourseImageMIME(req.MIMEType) { + if isSupportedCourseImageMIME(detected) { + req.MIMEType = detected + } else { + return nil, ErrCourseImageUnsupportedMIME + } + } + + if req.Filename == "" { + req.Filename = "course-table" + } + return &req, nil +} + +func isSupportedCourseImageMIME(mimeType string) bool { + switch strings.TrimSpace(strings.ToLower(mimeType)) { + case "image/jpeg", "image/png", "image/webp": + return true + default: + return false + } +} + +func normalizeCourseImageParseResponse(resp *model.CourseImageParseResponse) (*model.CourseImageParseResponse, error) { + if resp == nil { + return nil, errors.New("course image parse response is nil") + } + + resp.DraftStatus = model.CourseImageParseDraftStatus(strings.ToLower(strings.TrimSpace(string(resp.DraftStatus)))) + resp.Message = strings.TrimSpace(resp.Message) + resp.Warnings = normalizeWarningList(resp.Warnings) + resp.Rows = normalizeCourseImageParseRows(resp.Rows, &resp.Warnings) + + switch resp.DraftStatus { + case model.CourseImageParseDraftStatusSuccess: + if len(resp.Rows) == 0 { + return nil, errors.New("course image parse response has no rows in success status") + } + for idx := range resp.Rows { + if err := validateCourseImageParseRow(&resp.Rows[idx], true); err != nil { + return nil, fmt.Errorf("course image parse success row %d invalid: %w", idx+1, err) + } + } + case model.CourseImageParseDraftStatusPartial: + if len(resp.Rows) == 0 { + return nil, errors.New("course image parse response has no rows in partial status") + } + for idx := range resp.Rows { + if err := validateCourseImageParseRow(&resp.Rows[idx], false); err != nil { + return nil, fmt.Errorf("course image parse partial row %d invalid: %w", idx+1, err) + } + } + case model.CourseImageParseDraftStatusReject: + resp.Rows = make([]model.CourseImageParseRow, 0) + default: + return nil, fmt.Errorf("unsupported draft_status: %s", resp.DraftStatus) + } + + if resp.Message == "" { + resp.Message = defaultCourseImageParseMessage(resp.DraftStatus, len(resp.Rows)) + } + return resp, nil +} + +func normalizeCourseImageParseRows(rows []model.CourseImageParseRow, warnings *[]string) []model.CourseImageParseRow { + if len(rows) == 0 { + return make([]model.CourseImageParseRow, 0) + } + if len(rows) > maxCourseImageDraftRows { + rows = rows[:maxCourseImageDraftRows] + appendUniqueWarning(warnings, "识别结果行数超过上限,后端已截断为 256 行,请重点核对。") + } + + normalized := make([]model.CourseImageParseRow, 0, len(rows)) + for idx := range rows { + row := rows[idx] + row.RowID = strings.TrimSpace(row.RowID) + if row.RowID == "" { + row.RowID = fmt.Sprintf("row_%03d", idx+1) + } + row.CourseName = strings.TrimSpace(row.CourseName) + row.Location = strings.TrimSpace(row.Location) + row.WeekType = normalizeCourseImageWeekType(row.WeekType) + row.RawText = strings.TrimSpace(row.RawText) + row.RowWarnings = normalizeWarningList(row.RowWarnings) + normalizeOptionalPositiveInt(&row.StartWeek) + normalizeOptionalPositiveInt(&row.EndWeek) + normalizeOptionalPositiveInt(&row.DayOfWeek) + normalizeOptionalPositiveInt(&row.StartSection) + normalizeOptionalPositiveInt(&row.EndSection) + if row.Confidence < 0 { + row.Confidence = 0 + } + if row.Confidence > 1 { + row.Confidence = 1 + } + if row.CourseName == "" && + row.StartWeek == nil && + row.EndWeek == nil && + row.DayOfWeek == nil && + row.StartSection == nil && + row.EndSection == nil && + row.RawText == "" { + appendUniqueWarning(warnings, fmt.Sprintf("存在空白草稿行,后端已自动忽略:%s", row.RowID)) + continue + } + normalized = append(normalized, row) + } + + return normalized +} + +func validateCourseImageParseRow(row *model.CourseImageParseRow, strict bool) error { + if row == nil { + return errors.New("row is nil") + } + if strict && row.CourseName == "" { + return errors.New("course_name is empty") + } + if strict && row.WeekType == "" { + return errors.New("week_type is empty") + } + if row.WeekType != "" && row.WeekType != "all" && row.WeekType != "odd" && row.WeekType != "even" { + return fmt.Errorf("week_type is invalid: %s", row.WeekType) + } + + if err := validateOptionalCourseIntPair(row.StartWeek, row.EndWeek, 1, 24, "week", strict); err != nil { + return err + } + if err := validateOptionalCourseIntPair(row.StartSection, row.EndSection, 1, 12, "section", strict); err != nil { + return err + } + if strict && row.DayOfWeek == nil { + return errors.New("day_of_week is empty") + } + if row.DayOfWeek != nil && (*row.DayOfWeek < 1 || *row.DayOfWeek > 7) { + return fmt.Errorf("day_of_week out of range: %d", *row.DayOfWeek) + } + return nil +} + +func validateOptionalCourseIntPair(start *int, end *int, min int, max int, field string, strict bool) error { + if strict { + if start == nil || end == nil { + return fmt.Errorf("%s range is incomplete", field) + } + } + if start == nil && end == nil { + return nil + } + if start == nil || end == nil { + return fmt.Errorf("%s range is incomplete", field) + } + if *start < min || *start > max { + return fmt.Errorf("%s start out of range: %d", field, *start) + } + if *end < min || *end > max { + return fmt.Errorf("%s end out of range: %d", field, *end) + } + if *start > *end { + return fmt.Errorf("%s start is greater than end: %d > %d", field, *start, *end) + } + return nil +} + +func normalizeOptionalPositiveInt(target **int) { + if target == nil || *target == nil { + return + } + if **target <= 0 { + *target = nil + } +} + +func normalizeCourseImageWeekType(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "", "unknown", "null": + return "" + case "all", "every", "weekly", "each week", "每周", "全周", "全部": + return "all" + case "odd", "single", "单", "单周": + return "odd" + case "even", "double", "双", "双周": + return "even" + default: + return normalized + } +} + +func normalizeWarningList(items []string) []string { + if len(items) == 0 { + return make([]string, 0) + } + seen := make(map[string]struct{}, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} + +func appendUniqueWarning(target *[]string, warningText string) { + if target == nil { + return + } + trimmed := strings.TrimSpace(warningText) + if trimmed == "" { + return + } + for _, existing := range *target { + if strings.TrimSpace(existing) == trimmed { + return + } + } + *target = append(*target, trimmed) +} + +func defaultCourseImageParseMessage(status model.CourseImageParseDraftStatus, rowCount int) string { + switch status { + case model.CourseImageParseDraftStatusSuccess: + return fmt.Sprintf("已识别 %d 条课程安排,请重点核对周次、星期和节次。", rowCount) + case model.CourseImageParseDraftStatusPartial: + return fmt.Sprintf("已识别 %d 条课程安排,但仍存在不确定字段,请结合 warning 逐项核对。", rowCount) + case model.CourseImageParseDraftStatusReject: + return "图片信息不足,建议重新上传完整、清晰、包含表头和节次栏的总课表截图。" + default: + return "课程表图片识别已完成,请人工核对后再导入。" + } +} diff --git a/backend/services/course/sv/course_parse_ark.go b/backend/services/course/sv/course_parse_ark.go new file mode 100644 index 0000000..93a0154 --- /dev/null +++ b/backend/services/course/sv/course_parse_ark.go @@ -0,0 +1,228 @@ +package sv + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + llmservice "github.com/LoveLosita/smartflow/backend/services/llm" +) + +// ParseCourseTableImage 使用 Ark SDK Responses 解析课程表图片。 +func (ss *CourseService) ParseCourseTableImage(ctx context.Context, req model.CourseImageParseRequest) (*model.CourseImageParseResponse, error) { + if ss == nil || ss.courseImageResponsesClient == nil { + modelName := "" + if ss != nil { + modelName = ss.courseImageModel + } + log.Printf( + "[COURSE_PARSE][SERVICE] parser unavailable model_name=%q filename=%q mime=%q bytes=%d", + modelName, + req.Filename, + req.MIMEType, + len(req.ImageBytes), + ) + return nil, ErrCourseImageParserUnavailable + } + + normalizedReq, err := normalizeCourseImageParseRequest(req, ss.courseImageConfig) + if err != nil { + log.Printf( + "[COURSE_PARSE][SERVICE] request normalization failed filename=%q mime=%q bytes=%d err=%v", + req.Filename, + req.MIMEType, + len(req.ImageBytes), + err, + ) + return nil, err + } + + log.Printf( + "[COURSE_PARSE][SERVICE] normalized request model_name=%q filename=%q mime=%q bytes=%d max_bytes=%d", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + len(normalizedReq.ImageBytes), + ss.courseImageConfig.MaxImageBytes, + ) + + messages, base64Chars, promptChars := buildCourseImageParseResponsesMessages(normalizedReq) + startAt := time.Now() + log.Printf( + "[COURSE_PARSE][SERVICE] model invoke start model_name=%q filename=%q mime=%q message_count=%d base64_chars=%d prompt_chars=%d payload_chars_estimate=%d thinking=%s temperature=%.2f max_output_tokens=%d text_format=%s", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + len(messages), + base64Chars, + promptChars, + base64Chars+promptChars+len(strings.TrimSpace(courseImageParseSystemPrompt)), + llmservice.ThinkingModeDisabled, + courseImageParseTemperature, + ss.courseImageConfig.MaxTokens, + "json_object", + ) + + // 1. 课程表图片识别输出体量大,显式透传 max_output_tokens,避免被默认值截断。 + // 2. text_format 固定为 json_object,降低输出混入解释文本导致解析失败的概率。 + // 3. thinking 显式关闭,优先保证课程导入链路稳定性。 + draft, rawResult, err := llmservice.GenerateArkResponsesJSON[model.CourseImageParseResponse](ctx, ss.courseImageResponsesClient, messages, llmservice.ArkResponsesOptions{ + Temperature: courseImageParseTemperature, + MaxOutputTokens: ss.courseImageConfig.MaxTokens, + Thinking: llmservice.ThinkingModeDisabled, + TextFormat: "json_object", + }) + if err != nil { + rawText := "" + rawChars := 0 + status := "" + incompleteReason := "" + errorCode := "" + errorMessage := "" + inputTokens := int64(0) + outputTokens := int64(0) + totalTokens := int64(0) + if rawResult != nil { + rawText = strings.TrimSpace(rawResult.Text) + rawChars = len(rawText) + status = strings.TrimSpace(rawResult.Status) + incompleteReason = strings.TrimSpace(rawResult.IncompleteReason) + errorCode = strings.TrimSpace(rawResult.ErrorCode) + errorMessage = strings.TrimSpace(rawResult.ErrorMessage) + if rawResult.Usage != nil { + inputTokens = rawResult.Usage.InputTokens + outputTokens = rawResult.Usage.OutputTokens + totalTokens = rawResult.Usage.TotalTokens + } + } + log.Printf( + "[COURSE_PARSE][SERVICE] model invoke failed model_name=%q filename=%q mime=%q cost_ms=%d err=%v status=%q incomplete_reason=%q error_code=%q error_message=%q input_tokens=%d output_tokens=%d total_tokens=%d raw_chars=%d raw_full=\n%s", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + time.Since(startAt).Milliseconds(), + err, + status, + incompleteReason, + errorCode, + errorMessage, + inputTokens, + outputTokens, + totalTokens, + rawChars, + rawText, + ) + if isCourseImageOutputTruncated(rawResult) { + return nil, fmt.Errorf( + "课程表识别输出疑似被 max_output_tokens 截断:status=%s incomplete_reason=%s output_tokens=%d max_output_tokens=%d", + status, + incompleteReason, + outputTokens, + ss.courseImageConfig.MaxTokens, + ) + } + return nil, err + } + + rawText := "" + rawChars := 0 + status := "" + incompleteReason := "" + errorCode := "" + errorMessage := "" + inputTokens := int64(0) + outputTokens := int64(0) + totalTokens := int64(0) + if rawResult != nil { + rawText = strings.TrimSpace(rawResult.Text) + rawChars = len(rawText) + status = strings.TrimSpace(rawResult.Status) + incompleteReason = strings.TrimSpace(rawResult.IncompleteReason) + errorCode = strings.TrimSpace(rawResult.ErrorCode) + errorMessage = strings.TrimSpace(rawResult.ErrorMessage) + if rawResult.Usage != nil { + inputTokens = rawResult.Usage.InputTokens + outputTokens = rawResult.Usage.OutputTokens + totalTokens = rawResult.Usage.TotalTokens + } + } + log.Printf( + "[COURSE_PARSE][SERVICE] model invoke success model_name=%q filename=%q mime=%q cost_ms=%d status=%q incomplete_reason=%q error_code=%q error_message=%q input_tokens=%d output_tokens=%d total_tokens=%d raw_chars=%d raw_full=\n%s", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + time.Since(startAt).Milliseconds(), + status, + incompleteReason, + errorCode, + errorMessage, + inputTokens, + outputTokens, + totalTokens, + rawChars, + rawText, + ) + + normalizedDraft, err := normalizeCourseImageParseResponse(draft) + if err != nil { + log.Printf( + "[COURSE_PARSE][SERVICE] draft normalization failed model_name=%q filename=%q err=%v draft_status=%v row_count=%d", + ss.courseImageModel, + normalizedReq.Filename, + err, + draft.DraftStatus, + len(draft.Rows), + ) + return nil, err + } + + log.Printf( + "[COURSE_PARSE][SERVICE] draft normalization success model_name=%q filename=%q draft_status=%s rows=%d warnings=%d", + ss.courseImageModel, + normalizedReq.Filename, + normalizedDraft.DraftStatus, + len(normalizedDraft.Rows), + len(normalizedDraft.Warnings), + ) + + return normalizedDraft, nil +} + +func buildCourseImageParseResponsesMessages(req *model.CourseImageParseRequest) ([]llmservice.ArkResponsesMessage, int, int) { + userPrompt := fmt.Sprintf(courseImageParseUserPromptTemplate, req.Filename, req.MIMEType) + base64Data := base64.StdEncoding.EncodeToString(req.ImageBytes) + imageDataURL := fmt.Sprintf("data:%s;base64,%s", req.MIMEType, base64Data) + + messages := []llmservice.ArkResponsesMessage{ + { + Role: "system", + Text: strings.TrimSpace(courseImageParseSystemPrompt), + }, + { + Role: "user", + Text: strings.TrimSpace(userPrompt), + ImageURL: imageDataURL, + ImageDetail: "high", + }, + } + return messages, len(base64Data), len(strings.TrimSpace(userPrompt)) +} + +func isCourseImageOutputTruncated(rawResult *llmservice.ArkResponsesResult) bool { + if rawResult == nil { + return false + } + + reason := strings.ToLower(strings.TrimSpace(rawResult.IncompleteReason)) + if strings.Contains(reason, "max_output_tokens") || + strings.Contains(reason, "max_tokens") || + strings.Contains(reason, "length") { + return true + } + + return strings.EqualFold(strings.TrimSpace(rawResult.Status), "incomplete") && reason == "" +} diff --git a/backend/services/course/sv/course_parse_prompt.go b/backend/services/course/sv/course_parse_prompt.go new file mode 100644 index 0000000..19cd712 --- /dev/null +++ b/backend/services/course/sv/course_parse_prompt.go @@ -0,0 +1,59 @@ +package sv + +const courseImageParseSystemPrompt = ` +你是 SmartFlow 的“总课表图片识别器”。你的唯一任务是读取用户上传的总课表图片,输出结构化 JSON 草稿,供前端人工核对后再导入系统。 + +必须遵守以下规则: +1. 只能输出一个 JSON 对象,禁止输出 Markdown、代码块、解释文字或额外前后缀。 +2. 顶层 JSON 结构必须是: +{ + "draft_status": "success | partial | reject", + "message": "字符串", + "warnings": ["字符串"], + "rows": [ + { + "row_id": "字符串,可为空", + "course_name": "字符串", + "location": "字符串", + "is_allow_tasks": false, + "start_week": 1, + "end_week": 16, + "day_of_week": 1, + "start_section": 1, + "end_section": 2, + "week_type": "all | odd | even", + "confidence": 0.92, + "raw_text": "原图中对应的近似文本", + "row_warnings": ["字符串"] + } + ] +} +3. rows 中一行只表达一个“课程安排片段”,不要把同一门课的多个时间段强行合并成一行。 +4. is_allow_tasks 无法从课表图片稳定识别时,一律返回 false,不要自行猜测。 +5. 若图片完整且大部分字段明确,可返回 success。 +6. 若图片可识别出部分行,但存在裁切、模糊、遮挡、单双周不清晰、节次/周次不确定等问题,返回 partial。 +7. 若图片严重不完整、分辨率过低、主体不是课表、无法可靠识别,返回 reject,同时 rows 置为空数组。 +8. 不要编造信息。看不清的数值字段请返回 null,并在 row_warnings 或 warnings 中明确说明原因。 +9. week_type 只能是: + - all:每周/未标注单双周 + - odd:单周 + - even:双周 +10. day_of_week 使用 1-7 表示周一到周日。 +11. start_section/end_section 使用原子节次编号,例如 1-2 节应输出 start_section=1, end_section=2。 +12. confidence 取 0 到 1 之间的小数;不确定时可以偏保守。 +13. 如果 rows 不为空,优先保证“周次、星期、节次”准确,地点可为空字符串。 +14. 当图片信息不足时,应明确拒绝或降级为 partial,而不是强行补全。 +15. 填写json中course_name时,严格按照截图的课程名称来。例如,有的课可能既有本体,又有实验课,这算是两门不同的课。 +16. 周信息是可能出现中断的,例如一节课可能是第1周和第6-12周,这是正常的课程安排,请不要擅自更改。 +` + +const courseImageParseUserPromptTemplate = ` +请识别这张总课表图片,并严格按照约定 JSON 输出草稿。 + +补充约束: +1. 文件名:%s +2. MIME 类型:%s +3. 这是一张供学生核对的“导入草稿”,不是最终真值;不确定就留空或写 warning。 +4. 如果图片右侧、底部、表头、周次栏、节次栏有缺失,请优先返回 partial 或 reject。 +5. rows 里尽量保留 raw_text,方便前端逐行回显核对。 +` diff --git a/backend/shared/contracts/course/types.go b/backend/shared/contracts/course/types.go new file mode 100644 index 0000000..fbe9bac --- /dev/null +++ b/backend/shared/contracts/course/types.go @@ -0,0 +1,66 @@ +package course + +import "encoding/json" + +// CourseArrangement 是课程导入中单个上课时间片的跨进程契约。 +type CourseArrangement struct { + StartWeek int `json:"start_week"` + EndWeek int `json:"end_week"` + DayOfWeek int `json:"day_of_week"` + StartSection int `json:"start_section"` + EndSection int `json:"end_section"` + WeekType string `json:"week_type"` +} + +// UserCheckCourseRequest 是 course validate / import 共用的课程输入契约。 +// +// 职责边界: +// 1. 只描述 HTTP 与 course 服务之间稳定传递的字段; +// 2. 不承载课程冲突检测、时间换算或 schedule 写入逻辑; +// 3. UserID 由 gateway 在 import 场景补齐,不信任前端传入。 +type UserCheckCourseRequest struct { + CourseName string `json:"course_name"` + Location string `json:"location"` + IsAllowTasks bool `json:"is_allow_tasks"` + Arrangements []CourseArrangement `json:"arrangements"` +} + +type UserImportCoursesRequest struct { + UserID int `json:"user_id"` + Courses []UserCheckCourseRequest `json:"courses"` +} + +// ImportCoursesResult 用来保留旧 HTTP 在冲突时返回 conflicts 数据的语义。 +type ImportCoursesResult struct { + Conflict bool `json:"conflict"` + Conflicts json.RawMessage `json:"conflicts,omitempty"` +} + +type CourseImageParseRequest struct { + Filename string `json:"filename"` + MIMEType string `json:"mime_type"` + ImageBytes []byte `json:"image_bytes"` +} + +type CourseImageParseRow struct { + RowID string `json:"row_id"` + CourseName string `json:"course_name"` + Location string `json:"location"` + IsAllowTasks bool `json:"is_allow_tasks"` + StartWeek *int `json:"start_week"` + EndWeek *int `json:"end_week"` + DayOfWeek *int `json:"day_of_week"` + StartSection *int `json:"start_section"` + EndSection *int `json:"end_section"` + WeekType string `json:"week_type"` + Confidence float64 `json:"confidence"` + RawText string `json:"raw_text"` + RowWarnings []string `json:"row_warnings"` +} + +type CourseImageParseResponse struct { + DraftStatus string `json:"draft_status"` + Message string `json:"message"` + Warnings []string `json:"warnings"` + Rows []CourseImageParseRow `json:"rows"` +} diff --git a/backend/shared/ports/course.go b/backend/shared/ports/course.go new file mode 100644 index 0000000..9dff1e2 --- /dev/null +++ b/backend/shared/ports/course.go @@ -0,0 +1,20 @@ +package ports + +import ( + "context" + "encoding/json" + + coursecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/course" +) + +// CourseCommandClient 是 gateway 调用 course 服务的最小能力集合。 +// +// 职责边界: +// 1. 只覆盖当前 `/api/v1/course/*` HTTP 门面需要的能力; +// 2. 不暴露 course DAO,也不暴露迁移期直写 schedule 表的实现细节; +// 3. import / parse 的复杂响应以 JSON 透传,避免 gateway 复制业务模型。 +type CourseCommandClient interface { + ValidateCourse(ctx context.Context, req coursecontracts.UserCheckCourseRequest) error + ImportCourses(ctx context.Context, req coursecontracts.UserImportCoursesRequest) (json.RawMessage, error) + ParseCourseTableImage(ctx context.Context, req coursecontracts.CourseImageParseRequest) (json.RawMessage, error) +} diff --git a/docs/backend/微服务四步迁移与第二阶段并行开发计划.md b/docs/backend/微服务四步迁移与第二阶段并行开发计划.md index 61de35b..d887f28 100644 --- a/docs/backend/微服务四步迁移与第二阶段并行开发计划.md +++ b/docs/backend/微服务四步迁移与第二阶段并行开发计划.md @@ -442,10 +442,16 @@ flowchart LR 8. 第三刀 `task-class` 已完成 HTTP 所有权切流:新增 `cmd/task-class`、`services/task_class/{dao,rpc,sv}`、`gateway/client/taskclass`、`shared/contracts/taskclass` 和 `shared/ports` task-class port。 9. gateway 的 `/api/v1/task-class/*` HTTP 门面已切到 task-class zrpc client;gateway 只负责鉴权、参数绑定、短超时和响应透传,不再直接调用 `backend/service.TaskClassService`。 10. task-class 本轮按主人拍板保留迁移期直写 `schedule_events` / `schedules` 权限,不走 schedule RPC bridge,以保留 `insert into schedule` / `apply batch into schedule` 与 task item 状态更新的本地事务语义;`cmd/task-class` 只 AutoMigrate `task_classes` / `task_items`,启动时显式检查 schedule 依赖表是否存在。 -11. 旧实现仍保留:`backend/service/schedule.go`、`backend/dao/schedule.go`、`backend/service/task.go`、`backend/dao/task.go`、`backend/service/task-class.go`、`backend/dao/task-class.go`、active-scheduler 旧 Gorm apply adapter 暂时保留,用于 agent 迁移期、单体残留路径和回退。 -12. 当前切流点:HTTP schedule 流量进入 `cmd/schedule`;HTTP task 流量进入 `cmd/task`;HTTP task-class 流量进入 `cmd/task-class`;active-scheduler 读取 task/schedule facts 与正式写日程均走 RPC;course / agent 内部仍存在直接 DAO 调用,后续按域继续切。 +11. 旧实现仍保留:`backend/service/schedule.go`、`backend/dao/schedule.go`、`backend/service/task.go`、`backend/dao/task.go`、`backend/service/task-class.go`、`backend/dao/task-class.go`、`backend/service/course*.go`、`backend/dao/course.go`、active-scheduler 旧 Gorm apply adapter 暂时保留,用于 agent 迁移期、单体残留路径和回退。 +12. 当前切流点:HTTP schedule 流量进入 `cmd/schedule`;HTTP task 流量进入 `cmd/task`;HTTP task-class 流量进入 `cmd/task-class`;active-scheduler 读取 task/schedule facts 与正式写日程均走 RPC;agent 内部仍存在直接 DAO 调用,后续按 agent/memory 阶段继续收。 13. 当前残留跨域 DB 依赖:task-class 迁移期仍直接写 `schedule_events` / `schedules`;task 服务迁移期仍 best-effort 写 `active_schedule_jobs`;active-scheduler 仍直接写 agent 会话 / timeline 和 notification outbox 相关表;agent 本地 task 查询、task-class upsert 和 schedule provider 仍保留 DAO 适配。 14. 已完成验证:`go test ./...` 通过;避让默认端口启动完整本地服务组(HTTP `18080`,zrpc `19081-19086`)后,task-class add / list / get / insert-into-schedule / delete-item / delete-class smoke 通过,并用 `docker exec` 核对 task-class 与 schedule 相关表无残留。 +15. 第四刀 `course` 已完成 HTTP 所有权切流:新增 `cmd/course`、`services/course/{dao,rpc,sv}`、`gateway/client/course`、`shared/contracts/course` 和 `shared/ports` course port。 +16. gateway 的 `/api/v1/course/*` HTTP 门面已切到 course zrpc client;gateway 只负责鉴权、限流、幂等、multipart 文件读取、短超时和响应透传,不再直接调用 `backend/service.CourseService`。 +17. course 本轮保留迁移期直写 `schedule_events` / `schedules` 权限,不走 schedule RPC bridge,以保留课程导入两个表同事务写入和冲突返回语义;`cmd/course` 不 AutoMigrate schedule 表,启动时显式检查依赖表是否存在。 +18. 当前切流点更新:HTTP schedule / task / task-class / course 流量均进入各自独立 zrpc 服务;active-scheduler 读取 task/schedule facts 与正式写日程均走 RPC;agent 内部仍存在 task、task-class、schedule DAO 适配,后续按 agent/memory 阶段继续收。 +19. 当前残留跨域 DB 依赖更新:task-class 与 course 迁移期仍直接写 `schedule_events` / `schedules`;task 服务迁移期仍 best-effort 写 `active_schedule_jobs`;active-scheduler 仍直接写 agent 会话 / timeline 和 notification outbox 相关表;agent 本地 task 查询、task-class upsert 和 schedule provider 仍保留 DAO 适配。 +20. 已完成验证:`go test ./...` 通过;避让默认端口启动完整本地服务组(HTTP `18180`,zrpc `19181-19187`)后,course validate / import smoke 通过,并用 `docker exec` 核对课程导入写入 `schedule_events=1`、`schedules=2`。 目标: @@ -456,7 +462,7 @@ flowchart LR 这一步要做的事: -1. `schedule`、`task`、`task-class` 已先后独立;下一轮优先评估 `course`,再看 agent 残留 DAO 适配。 +1. `schedule`、`task`、`task-class`、`course` 已先后独立;下一轮优先评估 agent 残留 DAO 适配和 memory/agent 阶段切分。 2. 每个领域只维护自己的写模型。 3. 通过事件或明确 RPC 契约通信。 4. 继续保持并行迁移,旧实现和新实现可以短期并存。 @@ -899,7 +905,7 @@ graph TD 6. 阶段 4 `active-scheduler` 已完成首轮收口;后续不要再把它当成“未拆服务”,除非是在补契约测试或继续替换跨域 DB 访问。 7. `shared` 只保留跨进程契约和少量跨服务底座,不承载业务逻辑、DAO、模型或状态机。 8. 如果后续要改目录,必须先回答“这个文件属于哪一个典型用例”,回答不清楚就先别动结构。 -9. 当前文档已经可以作为切对话基线;后续代理默认按本文件推进。现阶段的迁移基线入口是 `backend/cmd/api`、`backend/cmd/worker`、`backend/cmd/all`,它们只是当前仓库的启动壳,不是终态。`backend/cmd/userauth` 是阶段 2 的独立服务入口,`backend/cmd/notification` 是阶段 3 的独立服务入口,`backend/cmd/active-scheduler` 是阶段 4 的独立服务入口,`backend/cmd/schedule`、`backend/cmd/task`、`backend/cmd/task-class` 是阶段 5 已落地的独立服务入口。终态仍然是“一个服务一个独立 `main.go`”,只在出现新的契约风险、边界变化或业务语义变化时再重新讨论架构。 +9. 当前文档已经可以作为切对话基线;后续代理默认按本文件推进。现阶段的迁移基线入口是 `backend/cmd/api`、`backend/cmd/worker`、`backend/cmd/all`,它们只是当前仓库的启动壳,不是终态。`backend/cmd/userauth` 是阶段 2 的独立服务入口,`backend/cmd/notification` 是阶段 3 的独立服务入口,`backend/cmd/active-scheduler` 是阶段 4 的独立服务入口,`backend/cmd/schedule`、`backend/cmd/task`、`backend/cmd/task-class`、`backend/cmd/course` 是阶段 5 已落地的独立服务入口。终态仍然是“一个服务一个独立 `main.go`”,只在出现新的契约风险、边界变化或业务语义变化时再重新讨论架构。 ### 6.10 启动方式与进程模型 @@ -910,7 +916,7 @@ graph TD 5. 如果某些服务需要联动启动,应通过脚本、Makefile、docker compose 或开发编排器去启动多个二进制,而不是把进程边界打穿。 6. 带 worker 的服务可以继续保留多入口角色,例如 `api` / `worker` / `all`,但它们仍然是同一服务的不同可执行角色,不是把多个服务硬塞进一个进程。 7. MySQL / Redis 容器的启动归 `docker compose` 或运维层;Go 服务只负责在自己的进程里建立连接、做自己的 AutoMigrate 和连通性检查。 -8. 阶段 5 后,旧 `cmd/start.go` / `cmd/all` 只是 gateway 和迁移期组合壳;本地完整 smoke 必须额外启动 `cmd/userauth`、`cmd/notification`、`cmd/active-scheduler`、`cmd/schedule`、`cmd/task` 和 `cmd/task-class`。如果同机已有另一条线占用默认端口,应复制临时配置,把 HTTP / zrpc 端口整体平移后再启动服务。 +8. 阶段 5 后,旧 `cmd/start.go` / `cmd/all` 只是 gateway 和迁移期组合壳;本地完整 smoke 必须额外启动 `cmd/userauth`、`cmd/notification`、`cmd/active-scheduler`、`cmd/schedule`、`cmd/task`、`cmd/task-class` 和 `cmd/course`。如果同机已有另一条线占用默认端口,应复制临时配置,把 HTTP / zrpc 端口整体平移后再启动服务。 ### 6.11 测试自动化与 smoke 权限边界 @@ -1094,14 +1100,14 @@ graph TD 阶段 5 当前基线: -1. `backend/cmd/schedule/main.go` 是 schedule 独立进程入口,`backend/cmd/task/main.go` 是 task 独立进程入口,`backend/cmd/task-class/main.go` 是 task-class 独立进程入口,三者各自初始化 DB / Redis / zrpc server 和所需服务内资源。 -2. `backend/services/schedule` 拥有正式日程领域核心,`backend/services/task` 拥有任务池读写、完成/撤销、紧急性平移和 task outbox handler,`backend/services/task_class` 拥有任务类与任务块维护、批量排入日程等核心逻辑。 -3. `backend/gateway/api` 继续作为 HTTP 门面统一目录,`backend/gateway/client/schedule`、`backend/gateway/client/task` 与 `backend/gateway/client/taskclass` 作为 gateway 侧 zrpc client。 -4. `backend/shared/contracts/schedule`、`backend/shared/contracts/task`、`backend/shared/contracts/taskclass` 和 `backend/shared/ports` 只承载跨进程契约与端口接口,不放 DAO、model 或业务状态机。 +1. `backend/cmd/schedule/main.go` 是 schedule 独立进程入口,`backend/cmd/task/main.go` 是 task 独立进程入口,`backend/cmd/task-class/main.go` 是 task-class 独立进程入口,`backend/cmd/course/main.go` 是 course 独立进程入口,四者各自初始化 DB / Redis / zrpc server 和所需服务内资源。 +2. `backend/services/schedule` 拥有正式日程领域核心,`backend/services/task` 拥有任务池读写、完成/撤销、紧急性平移和 task outbox handler,`backend/services/task_class` 拥有任务类与任务块维护、批量排入日程等核心逻辑,`backend/services/course` 拥有课程校验、课程导入和课表图片解析逻辑。 +3. `backend/gateway/api` 继续作为 HTTP 门面统一目录,`backend/gateway/client/schedule`、`backend/gateway/client/task`、`backend/gateway/client/taskclass` 与 `backend/gateway/client/course` 作为 gateway 侧 zrpc client。 +4. `backend/shared/contracts/schedule`、`backend/shared/contracts/task`、`backend/shared/contracts/taskclass`、`backend/shared/contracts/course` 和 `backend/shared/ports` 只承载跨进程契约与端口接口,不放 DAO、model 或业务状态机。 5. active-scheduler 的 schedule facts / feedback / confirm apply 已走 schedule RPC,task facts / due job scanner 已走 task RPC;启动依赖检查不再要求 `schedule_events`、`schedules`、`task_classes`、`task_items` 或 `tasks`。 6. `task.urgency.promote.requested` 的消费边界已迁入 `cmd/task`;单体 outbox worker 不再启动 task service bus,只保留 Agent 残留路径的 publish-only 写入能力,避免迁移期重复 relay / consume。 -7. task-class 迁移期仍直接写 `schedule_events` / `schedules`,用于保留 `insert/apply` 与 item 状态更新的本地事务语义;服务启动只检查这些 schedule 依赖表,不 AutoMigrate schedule 表。 -8. 本阶段残留:task 服务仍 best-effort 写 `active_schedule_jobs`;agent 本地 task 查询、quick task 创建、task-class upsert、schedule provider 和 course 仍存在直接 DAO 调用;active-scheduler 旧 Gorm apply adapter 保留为迁移期残留,不作为新流量主路径。 +7. task-class 迁移期仍直接写 `schedule_events` / `schedules`,用于保留 `insert/apply` 与 item 状态更新的本地事务语义;course 迁移期仍直接写 `schedule_events` / `schedules`,用于保留课程导入两个表同事务写入;两个服务启动都只检查 schedule 依赖表,不 AutoMigrate schedule 表。 +8. 本阶段残留:task 服务仍 best-effort 写 `active_schedule_jobs`;agent 本地 task 查询、quick task 创建、task-class upsert 和 schedule provider 仍存在直接 DAO 调用;active-scheduler 旧 Gorm apply adapter 保留为迁移期残留,不作为新流量主路径。 ---