Files
smartmate/backend/gateway/api/course.go
Losita 3b6fca44a6 Version: 0.9.77.dev.260505
后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
2026-05-05 23:25:07 +08:00

171 lines
5.0 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
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 {
client ports.CourseCommandClient
}
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 coursecontracts.UserCheckCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
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.StatusOK, respond.Ok)
}
func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
var req coursecontracts.UserImportCoursesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.UserID = c.GetInt("user_id")
ctx, cancel := context.WithTimeout(c.Request.Context(), courseRequestTimeout)
defer cancel()
_, err := sa.client.ImportCourses(ctx, req)
if err != nil {
var conflict courseImportConflict
if errors.As(err, &conflict) {
c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflict.ConflictsJSON()))
return
}
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}
func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) {
userID := c.GetInt("user_id")
fileHeader, err := c.FormFile("image")
if err != nil {
log.Printf("[COURSE_PARSE][API] missing file user=%d path=%s err=%v", userID, c.FullPath(), err)
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
log.Printf(
"[COURSE_PARSE][API] request start user=%d path=%s filename=%q header_content_type=%q size=%d",
userID,
c.FullPath(),
fileHeader.Filename,
fileHeader.Header.Get("Content-Type"),
fileHeader.Size,
)
file, err := fileHeader.Open()
if err != nil {
log.Printf("[COURSE_PARSE][API] open file failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err)
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
defer file.Close()
imageBytes, err := io.ReadAll(file)
if err != nil {
log.Printf("[COURSE_PARSE][API] read file failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err)
respond.DealWithError(c, err)
return
}
log.Printf(
"[COURSE_PARSE][API] file loaded user=%d filename=%q bytes=%d",
userID,
fileHeader.Filename,
len(imageBytes),
)
// 课表图片识别当前不再额外叠加服务端 45 秒超时,避免长耗时多模态请求被本层提前打断。
// 这里只跟随客户端请求上下文,便于先观察真实上游耗时与失败位置。
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
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.As(err, &resp) && resp.Status == "50003":
log.Printf("[COURSE_PARSE][API] parser unavailable user=%d filename=%q", userID, fileHeader.Filename)
c.JSON(http.StatusServiceUnavailable, resp)
return
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, resp)
return
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, resp)
return
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, resp)
return
default:
log.Printf("[COURSE_PARSE][API] unexpected failure user=%d filename=%q err=%v", userID, fileHeader.Filename, err)
respond.DealWithError(c, err)
return
}
}
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,
fileHeader.Filename,
draft.DraftStatus,
len(draft.Rows),
len(draft.Warnings),
)
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, draft))
}