Version: 0.9.42.dev.260424
后端: 1. 新增课表图片识别接口,支持上传截图后返回“可编辑草稿”(success / partial / reject),并补齐大图、空图、格式不支持、识别能力未配置等错误分支。 2. 课表识别服务接入多模态 Responses 链路,完善图片请求归一化与安全校验(大小、MIME、内容探测),并对识别结果做结构化清洗、强/弱约束校验、告警去重与默认文案兜底。 3. 新增 Ark Responses 统一客户端抽象,支持文本+图片输入、JSON对象输出、usage统计透传与不完整输出识别;同时补齐模型返回 finish_reason 透传,便于定位截断问题。 4. 启动阶段增加课表识图模型与参数注入(模型名、最大图片字节、最大输出token),并将配置示例收敛为“仅保留当前代码实际读取项”。 前端: 5. 课表中心新增“导入课表”完整闭环:上传图片识别、草稿编辑校对、正式导入落库;并新增对应 API 与类型定义。 6. 导入弹窗支持识别中止、全局告警与行级告警展示、低置信度提示、行内编辑、手动新增、删除、拖拽排序、本地校验与提交前二次确认。 7. 正式导入前将草稿按“课程名+地点+是否允许嵌入”聚合为导入结构,并统一携带幂等键请求头,降低重复提交风险。 8. 周课表画板修复跨节次事件遮挡导致的网格错位问题,改进“完全遮挡/部分遮挡”渲染判定与 grid 行定位。 9. 助手流式区域优化“思考中”指示逻辑与样式,避免已有正文时仍展示回答中占位;同时补充全局组件视觉统一(弹窗/按钮)样式。 仓库: 10. 新增课表图片识别前端对接说明文档,补充主动优化能力 PRD 讨论稿,并在协作规范中新增“实现 Eino 新能力前需先查官方文档”的约束。
This commit is contained in:
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -13,11 +15,9 @@ import (
|
||||
)
|
||||
|
||||
type CourseHandler struct {
|
||||
// 伸出手:准备接住 Service
|
||||
service *service.CourseService
|
||||
}
|
||||
|
||||
// NewCourseHandler 创建 CourseHandler 实例
|
||||
func NewCourseHandler(service *service.CourseService) *CourseHandler {
|
||||
return &CourseHandler{
|
||||
service: service,
|
||||
@@ -25,45 +25,130 @@ func NewCourseHandler(service *service.CourseService) *CourseHandler {
|
||||
}
|
||||
|
||||
func (sa *CourseHandler) CheckUserCourse(c *gin.Context) {
|
||||
//1.从请求中获取课程信息
|
||||
var req model.UserCheckCourseRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
//2.调用 service 层的 CheckSingleCourse 方法进行校验
|
||||
result := service.CheckSingleCourse(req)
|
||||
//3.根据校验结果返回响应
|
||||
if result {
|
||||
|
||||
if service.CheckSingleCourse(req) {
|
||||
c.JSON(http.StatusOK, respond.Ok)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongCourseInfo)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, respond.WrongCourseInfo)
|
||||
}
|
||||
|
||||
func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
|
||||
//1.从请求中获取课程信息
|
||||
var req model.UserImportCoursesRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
//2.从上下文获取用户ID
|
||||
userIDInterface := c.GetInt("user_id")
|
||||
//3.调用 service 层的 AddUserCoursesIntoSchedule 方法添加课程
|
||||
// 创建一个带 1 秒超时的上下文
|
||||
|
||||
userID := c.GetInt("user_id")
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel() // 记得释放资源
|
||||
conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface)
|
||||
defer cancel()
|
||||
|
||||
conflicts, err := sa.service.AddUserCourses(ctx, req, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.ScheduleConflict) {
|
||||
c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflicts))
|
||||
return
|
||||
}
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
//4.返回成功响应
|
||||
|
||||
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()
|
||||
|
||||
draft, err := sa.service.ParseCourseTableImage(ctx, model.CourseImageParseRequest{
|
||||
Filename: fileHeader.Filename,
|
||||
MIMEType: fileHeader.Header.Get("Content-Type"),
|
||||
ImageBytes: imageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCourseImageParserUnavailable):
|
||||
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"})
|
||||
return
|
||||
case errors.Is(err, service.ErrCourseImageTooLarge):
|
||||
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"})
|
||||
return
|
||||
case errors.Is(err, service.ErrCourseImageUnsupportedMIME):
|
||||
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"})
|
||||
return
|
||||
case errors.Is(err, service.ErrCourseImageEmpty):
|
||||
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"})
|
||||
return
|
||||
default:
|
||||
log.Printf("[COURSE_PARSE][API] unexpected failure 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user