Version: 0.9.69.dev.260504

后端:
1. 阶段 4 active-scheduler 服务边界落地,新增 `cmd/active-scheduler`、`services/active_scheduler`、`shared/contracts/activescheduler` 和 active-scheduler port,迁移 dry-run、trigger、preview、confirm zrpc 能力
2. active-scheduler outbox consumer、relay、retry loop 和 due job scanner 迁入独立服务入口,gateway `/active-schedule/*` 改为通过 zrpc client 调用
3. gateway 目录收口为 `gateway/api` + `gateway/client`,统一归档 userauth、notification、active-scheduler 的 HTTP 门面和 zrpc client
4. 将旧 `backend/active_scheduler` 领域核心下沉到 `services/active_scheduler/core`,清退旧根目录活跃实现,并补充 active-scheduler 启动期跨域依赖表检查
5. 调整单体启动与 outbox 归属,`cmd/all` 不再启动 active-scheduler workflow、scanner 或 handler

文档:
1. 更新微服务迁移计划,将阶段 4 active-scheduler 标记为首轮收口完成,并明确下一阶段进入 schedule / task / course / task-class
This commit is contained in:
Losita
2026-05-04 21:01:00 +08:00
parent abe3b4960e
commit 4d9a5c4d30
66 changed files with 2048 additions and 466 deletions

View File

@@ -0,0 +1,212 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/respond"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/activescheduler"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
const activeScheduleAPITimeout = 8 * time.Second
// ActiveScheduleAPI 承载主动调度开发期和验收期 API。
//
// 职责边界:
// 1. 只负责鉴权用户、绑定请求和调用 active-scheduler zrpc client
// 2. 不直接读取 DAO、不生成候选、不写 preview
// 3. 复杂响应由 active-scheduler 服务返回 JSONgateway 只做边缘透传。
type ActiveScheduleAPI struct {
client ports.ActiveSchedulerCommandClient
}
func NewActiveScheduleAPI(client ports.ActiveSchedulerCommandClient) *ActiveScheduleAPI {
return &ActiveScheduleAPI{client: client}
}
// DryRun 同步执行主动调度诊断,不写 preview、不发通知、不修改正式日程。
func (api *ActiveScheduleAPI) DryRun(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 zrpc client 未初始化")))
return
}
var req contracts.ActiveScheduleRequest
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(), activeScheduleAPITimeout)
defer cancel()
result, err := api.client.DryRun(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}
// Trigger 写入正式主动调度 trigger 并发布 active_schedule.triggered。
func (api *ActiveScheduleAPI) Trigger(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 zrpc client 未初始化")))
return
}
var req contracts.ActiveScheduleRequest
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(), activeScheduleAPITimeout)
defer cancel()
result, err := api.client.Trigger(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}
// CreatePreview 先同步 dry-run再把 top1 候选固化为待确认预览。
func (api *ActiveScheduleAPI) CreatePreview(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 zrpc client 未初始化")))
return
}
var req contracts.ActiveScheduleRequest
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(), activeScheduleAPITimeout)
defer cancel()
result, err := api.client.CreatePreview(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}
// GetPreview 查询主动调度预览详情。
func (api *ActiveScheduleAPI) GetPreview(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 zrpc client 未初始化")))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), activeScheduleAPITimeout)
defer cancel()
detail, err := api.client.GetPreview(ctx, contracts.GetPreviewRequest{
UserID: c.GetInt("user_id"),
PreviewID: c.Param("preview_id"),
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail))
}
// ConfirmPreview 同步确认并正式应用主动调度预览。
func (api *ActiveScheduleAPI) ConfirmPreview(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 zrpc client 未初始化")))
return
}
var req contracts.ConfirmPreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.PreviewID = c.Param("preview_id")
req.UserID = c.GetInt("user_id")
if req.RequestedAt.IsZero() {
req.RequestedAt = time.Now()
}
ctx, cancel := context.WithTimeout(c.Request.Context(), activeScheduleAPITimeout)
defer cancel()
result, err := api.client.ConfirmPreview(ctx, req)
if err != nil {
writeActiveScheduleConfirmError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}
// writeActiveScheduleConfirmError 将 confirm/apply 的可预期业务拒绝映射为 4xx。
//
// 职责边界:
// 1. 只处理 active-scheduler zrpc client 已恢复的 ApplyError
// 2. 不吞掉数据库、超时、panic recover 等系统错误,未知错误继续交给通用 respond 走 500
// 3. 响应体保留 error_code / error_message便于前端按过期、冲突、越权等场景给出明确交互。
func writeActiveScheduleConfirmError(c *gin.Context, err error) {
var applyErr *contracts.ApplyError
if errors.As(err, &applyErr) {
status := activeScheduleApplyHTTPStatus(applyErr.Code)
message := applyErr.Message
if message == "" {
message = applyErr.Error()
}
applyStatus := contracts.ApplyStatusRejected
if applyErr.Code == contracts.ApplyErrorCodeExpired {
applyStatus = contracts.ApplyStatusExpired
}
if applyErr.Code == contracts.ApplyErrorCodeDBError {
applyStatus = contracts.ApplyStatusFailed
}
c.JSON(status, respond.RespWithData(respond.Response{
Status: fmt.Sprintf("%d", status),
Info: message,
}, contracts.ConfirmErrorResult{
ApplyStatus: applyStatus,
ErrorCode: applyErr.Code,
ErrorMessage: message,
}))
return
}
respond.DealWithError(c, err)
}
// activeScheduleApplyHTTPStatus 只负责错误码到 HTTP 语义的稳定映射。
func activeScheduleApplyHTTPStatus(code contracts.ApplyErrorCode) int {
switch code {
case contracts.ApplyErrorCodeInvalidRequest,
contracts.ApplyErrorCodeInvalidEditedChanges,
contracts.ApplyErrorCodeUnsupportedChangeType:
return http.StatusBadRequest
case contracts.ApplyErrorCodeForbidden:
return http.StatusForbidden
case contracts.ApplyErrorCodeTargetNotFound:
return http.StatusNotFound
case contracts.ApplyErrorCodeExpired,
contracts.ApplyErrorCodeIdempotencyConflict,
contracts.ApplyErrorCodeBaseVersionChanged,
contracts.ApplyErrorCodeTargetCompleted,
contracts.ApplyErrorCodeTargetAlreadySchedule,
contracts.ApplyErrorCodeSlotConflict,
contracts.ApplyErrorCodeAlreadyApplied:
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
type nilServiceError string
func (e nilServiceError) Error() string {
return string(e)
}

View File

@@ -0,0 +1,382 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
type AgentHandler struct {
svc *service.AgentService
}
// NewAgentHandler 组装 AgentHandler。
func NewAgentHandler(svc *service.AgentService) *AgentHandler {
return &AgentHandler{
svc: svc,
}
}
func writeSSEData(w io.Writer, payload string) error {
_, err := io.WriteString(w, "data: "+payload+"\n\n")
return err
}
// mapResumeConfirmAction 把 extra.resume.action 映射为现有 confirm_action 口径。
//
// 映射规则:
// 1. approve -> accept确认执行
// 2. reject/cancel -> reject拒绝执行
// 3. 兜底走 reject避免脏值误触发执行。
func mapResumeConfirmAction(action model.AgentResumeAction) string {
switch action {
case model.AgentResumeActionApprove:
return "accept"
case model.AgentResumeActionReject, model.AgentResumeActionCancel:
return "reject"
default:
return "reject"
}
}
func (api *AgentHandler) ChatAgent(c *gin.Context) {
// 1) 设置 SSE 响应头
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
c.Writer.Header().Set("X-Accel-Buffering", "no")
// 2) 解析请求体
var req model.UserSendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
// 2.1 兼容新恢复协议:把 extra.resume 统一映射到现有内部字段。
// 1. 前端新协议只传 resume不再直接传 confirm_action
// 2. 后端这里做一次入口归一,保证下游状态机继续按既有字段消费;
// 3. 解析失败直接返回 400避免把非法恢复请求当普通消息继续执行。
resumeReq, resumeErr := req.ResumeRequest()
if resumeErr != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
if resumeReq != nil {
if req.Extra == nil {
req.Extra = make(map[string]any)
}
req.Extra["resume_interaction_id"] = resumeReq.InteractionID
if resumeReq.IsConfirmResume() {
req.Extra["confirm_action"] = mapResumeConfirmAction(resumeReq.Action)
}
}
// 3) 规范化会话 ID
conversationID := strings.TrimSpace(req.ConversationID)
if conversationID == "" {
// 恢复类请求必须关联既有会话状态,缺少 conversation_id 直接报错。
if resumeReq != nil {
c.JSON(http.StatusBadRequest, respond.MissingConversationID)
return
}
// 兼容旧协议confirm_action 也必须绑定已有会话。
if _, ok := req.Extra["confirm_action"]; ok {
c.JSON(http.StatusBadRequest, respond.MissingConversationID)
return
}
conversationID = uuid.NewString()
}
c.Writer.Header().Set("X-Conversation-ID", conversationID)
userID := c.GetInt("user_id")
outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID, req.Extra)
// 4) 转发 SSE 流
// 4.0 心跳保活LLM thinking 静默期可达 10+ 秒Vite dev proxy 会判 idle 切断连接。
// 每 5 秒发送 SSE 标准注释行 ": ping\n\n",前端 JSON.parse 失败后丢弃,不污染 UI。
heartbeat := time.NewTicker(5 * time.Second)
defer heartbeat.Stop()
c.Stream(func(w io.Writer) bool {
select {
case err, ok := <-errChan:
if ok && err != nil {
// 4.1 统一 SSE 错误体:
// 4.1.1 默认按内部错误输出 message/type
// 4.1.2 若是 respond.Response含业务码额外透传 code便于前端识别 5xxxx 等自定义错误。
errorBody := map[string]any{
"message": err.Error(),
"type": "server_error",
}
var respErr respond.Response
if errors.As(err, &respErr) {
errorBody["code"] = respErr.Status
}
errPayload, _ := json.Marshal(map[string]any{
"error": errorBody,
})
_ = writeSSEData(w, string(errPayload))
_ = writeSSEData(w, "[DONE]")
}
return false
case msg, ok := <-outChan:
if !ok {
return false
}
if err := writeSSEData(w, msg); err != nil {
return false
}
return true
case <-c.Request.Context().Done():
return false
// 心跳分支LLM thinking 静默期每 5 秒推送 SSE 注释行,防止代理判 idle 断连。
case <-heartbeat.C:
io.WriteString(w, ": ping\n\n")
c.Writer.(http.Flusher).Flush()
return true
}
})
}
// GetConversationMeta 返回单个会话的元信息(标题、消息数、最近消息时间等)。
// 设计说明:
// 1) 该接口用于配合 SSE 聊天链路:标题异步生成后,前端可通过 conversation_id 拉取;
// 2) 不依赖 SSE header 动态更新避免“header 必须首包前写入”的协议限制;
// 3) 会话不存在或不属于当前用户时返回 404避免前端把无效会话误判成参数类型错误。
func (api *AgentHandler) GetConversationMeta(c *gin.Context) {
// 1. 读取 query 参数并做基础校验。
conversationID := strings.TrimSpace(c.Query("conversation_id"))
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
// 2. 统一透传 user_id避免越权读取他人会话。
userID := c.GetInt("user_id")
// 3. 设置短超时,避免该查询接口被慢查询长时间占用。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调 service 查询会话元信息。
meta, err := api.svc.GetConversationMeta(ctx, userID, conversationID)
if err != nil {
// 会话不存在或越权访问时返回 404让前端能和“参数格式错误”区分开。
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, respond.ConversationNotFound)
return
}
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, meta))
}
// GetConversationList 返回当前登录用户的会话列表(分页)。
//
// 设计说明:
// 1) 接口只返回“列表元信息”,不返回消息正文,避免列表接口过重;
// 2) page/page_size 为可选参数,缺省值由 service 层统一兜底;
// 3) status 可选,支持 active/archived非法值直接返回 400。
func (api *AgentHandler) GetConversationList(c *gin.Context) {
// 1. 从 JWT 上下文读取 user_id保证只查“当前用户自己的会话”。
userID := c.GetInt("user_id")
// 2. 解析分页参数(可选):
// 2.1 参数不存在时保持 0让 service 使用默认值;
// 2.2 参数存在但格式非法时直接返回 400避免脏参数下沉。
page := 0
if rawPage := strings.TrimSpace(c.Query("page")); rawPage != "" {
parsedPage, err := strconv.Atoi(rawPage)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
page = parsedPage
}
pageSize := 0
if rawPageSize := strings.TrimSpace(c.Query("page_size")); rawPageSize != "" {
parsedPageSize, err := strconv.Atoi(rawPageSize)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
pageSize = parsedPageSize
}
// 2.3 limit 是 page_size 的懒加载别名:
// 2.3.1 前端若显式传 limit则以 limit 为准,避免前端再做字段转换;
// 2.3.2 若 limit 非法同样直接返回 400避免把脏参数下沉到 service
// 2.3.3 若未传 limit则继续沿用历史 page_size 行为,保持老前端兼容。
if rawLimit := strings.TrimSpace(c.Query("limit")); rawLimit != "" {
parsedLimit, err := strconv.Atoi(rawLimit)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
pageSize = parsedLimit
}
// 3. status 过滤器可选,最终合法性由 service 层统一校验。
status := strings.TrimSpace(c.Query("status"))
// 4. 读接口设置短超时,避免慢查询占用连接。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 5. 调 service 查询并返回统一响应结构。
resp, err := api.svc.GetConversationList(ctx, userID, page, pageSize, status)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
// GetConversationTimeline 返回指定会话的统一时间线(正文+卡片)。
//
// 说明:
// 1. 该接口是新前端刷新重建的单一来源;
// 2. 返回结果已按 seq 升序,前端按数组顺序渲染即可;
// 3. 会话不存在或不属于当前用户时统一返回 404避免误判成参数格式问题。
func (api *AgentHandler) GetConversationTimeline(c *gin.Context) {
conversationID := strings.TrimSpace(c.Query("conversation_id"))
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
userID := c.GetInt("user_id")
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
timeline, err := api.svc.GetConversationTimeline(ctx, userID, conversationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, respond.ConversationNotFound)
return
}
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, timeline))
}
// GetSchedulePlanPreview 返回“指定会话”的排程结构化预览。
//
// 设计说明:
// 1) 该接口只读 Redis 预览快照,不修改聊天主链路协议;
// 2) 按 conversation_id + user_id 读取,避免跨用户越权访问;
// 3) 预览受 TTL 影响,若不存在会返回业务错误码。
func (api *AgentHandler) GetSchedulePlanPreview(c *gin.Context) {
// 1. 参数校验conversation_id 必填。
conversationID := strings.TrimSpace(c.Query("conversation_id"))
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
// 2. 从鉴权上下文取当前用户 ID保证查询范围只在“本人会话”内。
userID := c.GetInt("user_id")
// 3. 设置短超时,防止缓存抖动时占用连接过久。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调 service 查询并返回统一响应结构。
preview, err := api.svc.GetSchedulePlanPreview(ctx, userID, conversationID)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, preview))
}
// GetContextStats 获取指定会话的上下文窗口 token 分布统计。
func (api *AgentHandler) GetContextStats(c *gin.Context) {
conversationID := strings.TrimSpace(c.Query("conversation_id"))
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
userID := c.GetInt("user_id")
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
statsJSON, err := api.svc.GetContextStats(ctx, userID, conversationID)
if err != nil {
respond.DealWithError(c, err)
return
}
// 直接透传 JSON 字符串,避免二次序列化。
// 当会话尚未产生 compaction 统计时LoadContextTokenStats 返回空字符串,
// 此时 json.RawMessage("") 在 MarshalJSON 时会报 "unexpected end of JSON input"
// 所以空值时需要替换为 "null",保证序列化安全。
if strings.TrimSpace(statsJSON) == "" {
statsJSON = "null"
}
var raw json.RawMessage = json.RawMessage(statsJSON)
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, raw))
}
// SaveScheduleState 前端暂存日程调整到 Redis 快照。
//
// 设计说明:
// 1. 前端在 confirm 卡片上拖拽调整任务位置后,调用此接口以绝对时间格式提交放置项;
// 2. 后端将绝对坐标转换为 ScheduleState 内部的相对 day_index只修改 task_item不动课程
// 3. 不触发 LLM 调用、不写 MySQL、不刷新预览缓存。
//
// 降级策略:
// 1. 快照不存在TTL 过期或会话未进入排程)返回 400让前端提示用户重新对话
// 2. 坐标越界、task_item_id 不存在等校验错误统一返回 400。
func (api *AgentHandler) SaveScheduleState(c *gin.Context) {
// 1. 解析请求体。
var req model.SaveScheduleStateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
// 2. 校验 conversation_id。
conversationID := strings.TrimSpace(req.ConversationID)
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
// 3. 从鉴权上下文取当前用户 ID。
userID := c.GetInt("user_id")
// 4. 设置短超时,防止快照读写阻塞过久。
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
// 5. 调用 service 层执行 Load → 应用放置项 → Save。
if err := api.svc.SaveScheduleState(ctx, userID, conversationID, req.Items); err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, nil))
}

View File

@@ -0,0 +1,12 @@
package api
type ApiHandlers struct {
TaskHandler *TaskHandler
CourseHandler *CourseHandler
TaskClassHandler *TaskClassHandler
ScheduleHandler *ScheduleAPI
AgentHandler *AgentHandler
MemoryHandler *MemoryHandler
ActiveSchedule *ActiveScheduleAPI
Notification *NotificationAPI
}

View File

@@ -0,0 +1,154 @@
package api
import (
"context"
"errors"
"io"
"log"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/service"
"github.com/gin-gonic/gin"
)
type CourseHandler struct {
service *service.CourseService
}
func NewCourseHandler(service *service.CourseService) *CourseHandler {
return &CourseHandler{
service: service,
}
}
func (sa *CourseHandler) CheckUserCourse(c *gin.Context) {
var req model.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)
return
}
c.JSON(http.StatusBadRequest, respond.WrongCourseInfo)
}
func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
var req model.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)
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
}
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))
}

View File

@@ -0,0 +1,290 @@
package api
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
memorypkg "github.com/LoveLosita/smartflow/backend/memory"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/gin-gonic/gin"
)
type MemoryHandler struct {
module *memorypkg.Module
}
var errMemoryHandlerNotReady = errors.New("memory handler is not initialized")
func NewMemoryHandler(module *memorypkg.Module) *MemoryHandler {
return &MemoryHandler{module: module}
}
func (h *MemoryHandler) ListItems(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
limit, ok := parseOptionalInt(c.Query("limit"))
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
statusesRaw := c.Query("statuses")
if strings.TrimSpace(statusesRaw) == "" {
statusesRaw = c.Query("status")
}
memoryTypesRaw := c.Query("memory_types")
if strings.TrimSpace(memoryTypesRaw) == "" {
memoryTypesRaw = c.Query("memory_type")
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
items, err := h.module.ListItems(ctx, memorymodel.ListItemsRequest{
UserID: c.GetInt("user_id"),
ConversationID: strings.TrimSpace(c.Query("conversation_id")),
Statuses: splitCSV(statusesRaw),
MemoryTypes: splitCSV(memoryTypesRaw),
Limit: limit,
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemViews(items)))
}
func (h *MemoryHandler) GetItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.GetItem(ctx, model.MemoryGetItemRequest{
UserID: c.GetInt("user_id"),
MemoryID: memoryID,
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) CreateItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
var req model.MemoryCreateItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.UserID = c.GetInt("user_id")
req.OperatorType = "user"
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.CreateItem(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) UpdateItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
var req model.MemoryUpdateItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.UserID = c.GetInt("user_id")
req.MemoryID = memoryID
req.OperatorType = "user"
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.UpdateItem(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) DeleteItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.DeleteItem(ctx, model.MemoryDeleteItemRequest{
UserID: c.GetInt("user_id"),
MemoryID: memoryID,
Reason: strings.TrimSpace(body.Reason),
OperatorType: "user",
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) RestoreItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.RestoreItem(ctx, model.MemoryRestoreItemRequest{
UserID: c.GetInt("user_id"),
MemoryID: memoryID,
Reason: strings.TrimSpace(body.Reason),
OperatorType: "user",
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func parseMemoryIDParam(c *gin.Context) (int64, bool) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
return 0, false
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value <= 0 {
return 0, false
}
return value, true
}
func parseOptionalInt(raw string) (int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, true
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
return value, true
}
func splitCSV(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
result = append(result, part)
}
return result
}
func toMemoryItemViews(items []memorymodel.ItemDTO) []model.MemoryItemView {
if len(items) == 0 {
return nil
}
result := make([]model.MemoryItemView, 0, len(items))
for _, item := range items {
result = append(result, toMemoryItemView(&item))
}
return result
}
func toMemoryItemView(item *memorymodel.ItemDTO) model.MemoryItemView {
if item == nil {
return model.MemoryItemView{}
}
return model.MemoryItemView{
ID: item.ID,
UserID: item.UserID,
ConversationID: item.ConversationID,
AssistantID: item.AssistantID,
RunID: item.RunID,
MemoryType: item.MemoryType,
Title: item.Title,
Content: item.Content,
ContentHash: item.ContentHash,
Confidence: item.Confidence,
Importance: item.Importance,
SensitivityLevel: item.SensitivityLevel,
IsExplicit: item.IsExplicit,
Status: item.Status,
TTLAt: item.TTLAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}

View File

@@ -0,0 +1,128 @@
package api
import (
"context"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/respond"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/notification"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
const notificationAPITimeout = 8 * time.Second
// NotificationAPI 承载当前用户的外部通知通道配置接口。
//
// 职责边界:
// 1. 只负责从 JWT 上下文取得当前 user_id、绑定请求体并调用 notification zrpc client
// 2. 不直接读写 user_notification_channels避免 API 层绕过 webhook 校验和脱敏规则;
// 3. 不参与主动调度、notification_records 状态机和 outbox 消费。
type NotificationAPI struct {
client ports.NotificationCommandClient
}
func NewNotificationAPI(client ports.NotificationCommandClient) *NotificationAPI {
return &NotificationAPI{client: client}
}
type saveFeishuWebhookRequest struct {
Enabled *bool `json:"enabled"`
WebhookURL string `json:"webhook_url" binding:"required"`
AuthType string `json:"auth_type"`
BearerToken string `json:"bearer_token"`
}
// GetFeishuWebhook 查询当前用户的飞书 Webhook 触发器配置。
func (api *NotificationAPI) GetFeishuWebhook(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("通知通道 service 未初始化")))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), notificationAPITimeout)
defer cancel()
channel, err := api.client.GetFeishuWebhook(ctx, contracts.GetFeishuWebhookRequest{
UserID: c.GetInt("user_id"),
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, channel))
}
// SaveFeishuWebhook 幂等保存当前用户的飞书 Webhook 触发器配置。
func (api *NotificationAPI) SaveFeishuWebhook(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("通知通道 service 未初始化")))
return
}
var req saveFeishuWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
ctx, cancel := context.WithTimeout(c.Request.Context(), notificationAPITimeout)
defer cancel()
channel, err := api.client.SaveFeishuWebhook(ctx, contracts.SaveFeishuWebhookRequest{
UserID: c.GetInt("user_id"),
Enabled: enabled,
WebhookURL: req.WebhookURL,
AuthType: req.AuthType,
BearerToken: req.BearerToken,
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, channel))
}
// DeleteFeishuWebhook 删除当前用户的飞书 Webhook 触发器配置。
func (api *NotificationAPI) DeleteFeishuWebhook(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("通知通道 service 未初始化")))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), notificationAPITimeout)
defer cancel()
if err := api.client.DeleteFeishuWebhook(ctx, contracts.DeleteFeishuWebhookRequest{
UserID: c.GetInt("user_id"),
}); err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"deleted": true}))
}
// TestFeishuWebhook 发送一条最小业务 JSON 到当前用户配置的飞书 Webhook。
func (api *NotificationAPI) TestFeishuWebhook(c *gin.Context) {
if api == nil || api.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("通知通道 service 未初始化")))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), notificationAPITimeout)
defer cancel()
result, err := api.client.TestFeishuWebhook(ctx, contracts.TestFeishuWebhookRequest{
UserID: c.GetInt("user_id"),
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}

View File

@@ -0,0 +1,194 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/service"
"github.com/gin-gonic/gin"
)
type ScheduleAPI struct {
scheduleService *service.ScheduleService
}
func NewScheduleAPI(scheduleService *service.ScheduleService) *ScheduleAPI {
return &ScheduleAPI{
scheduleService: scheduleService,
}
}
func (s *ScheduleAPI) GetUserTodaySchedule(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
//2.调用服务层方法获取用户当天的日程安排
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
todaySchedules, err := s.scheduleService.GetUserTodaySchedule(ctx, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
//3.返回日程安排数据给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, todaySchedules))
}
func (s *ScheduleAPI) GetUserWeeklySchedule(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
// 2. 从查询参数中获取 week 参数
week, err := strconv.Atoi(c.Query("week"))
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//3.调用服务层方法获取用户当周的日程安排
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
weeklySchedules, err := s.scheduleService.GetUserWeeklySchedule(ctx, userID, week)
if err != nil {
respond.DealWithError(c, err)
return
}
//4.返回日程安排数据给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, weeklySchedules))
}
func (s *ScheduleAPI) DeleteScheduleEvent(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
// 2. 从请求体中获取要删除的日程事件信息
var req []model.UserDeleteScheduleEvent
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//3.调用服务层方法删除指定的日程事件
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err := s.scheduleService.DeleteScheduleEvent(ctx, req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
//4.返回删除成功的响应给前端
c.JSON(http.StatusOK, respond.Ok)
}
func (s *ScheduleAPI) GetUserRecentCompletedSchedules(c *gin.Context) {
// 1. 从请求上下文中获取用户ID以及其他查询参数如 index 和 limit
userID := c.GetInt("user_id")
index := c.Query("index")
limit := c.Query("limit")
intIndex, err := strconv.Atoi(index)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
intLimit, err := strconv.Atoi(limit)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//2.调用服务层方法获取用户最近完成的日程事件
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
completedSchedules, err := s.scheduleService.GetUserRecentCompletedSchedules(ctx, userID, intIndex, intLimit)
if err != nil {
respond.DealWithError(c, err)
return
}
//3.返回数据给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, completedSchedules))
}
func (s *ScheduleAPI) GetUserOngoingSchedule(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
//2.调用服务层方法获取用户正在进行的日程事件
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
ongoingSchedule, err := s.scheduleService.GetUserOngoingSchedule(ctx, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
//3.返回数据给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, ongoingSchedule))
}
func (s *ScheduleAPI) UserRevocateTaskItemFromSchedule(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
// 2. 获取要撤销的任务块ID
eventID := c.Query("event_id")
intEventID, err := strconv.Atoi(eventID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//3.调用服务层方法撤销任务块的安排
err = s.scheduleService.RevocateUserTaskClassItem(context.Background(), userID, intEventID)
if err != nil {
respond.DealWithError(c, err)
return
}
//4.返回撤销成功的响应给前端
c.JSON(http.StatusOK, respond.Ok)
}
func (s *ScheduleAPI) SmartPlanning(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
// 2. 从请求参数中获取智能规划的 task_class_id
taskClassID := c.Query("task_class_id")
intTaskClassID, err := strconv.Atoi(taskClassID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//3.调用服务层方法进行智能规划
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
res, err := s.scheduleService.SmartPlanning(ctx, userID, intTaskClassID)
if err != nil {
respond.DealWithError(c, err)
return
}
//4.返回智能规划成功的响应给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, res))
}
// SmartPlanningMulti 处理“多任务类智能粗排”请求。
//
// 职责边界:
// 1. 只负责参数绑定、超时控制、错误透传;
// 2. 具体业务校验与排序策略由 service 层统一处理;
// 3. 保留已有单任务类接口,不与其互斥。
func (s *ScheduleAPI) SmartPlanningMulti(c *gin.Context) {
// 1. 从请求上下文中读取登录用户 ID。
userID := c.GetInt("user_id")
// 2. 绑定多任务类请求体。
var req model.UserSmartPlanningMultiRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
// 3. 调用服务层执行多任务类粗排。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
res, err := s.scheduleService.SmartPlanningMulti(ctx, userID, req.TaskClassIDs)
if err != nil {
respond.DealWithError(c, err)
return
}
// 4. 返回成功响应。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, res))
}

View File

@@ -0,0 +1,192 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/service"
"github.com/gin-gonic/gin"
)
type TaskClassHandler struct {
svc *service.TaskClassService
}
// NewTaskClassHandler 组装 Handler 的“工厂”
func NewTaskClassHandler(svc *service.TaskClassService) *TaskClassHandler {
return &TaskClassHandler{
svc: svc, // 把传进来的 Service 揣进口袋里
}
}
const (
create = 0
update = 1
)
func (api *TaskClassHandler) UserAddTaskClass(c *gin.Context) {
var req model.UserAddTaskClassRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userIDInterface := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err = api.svc.AddOrUpdateTaskClass(ctx, &req, userIDInterface, create, 0)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}
func (api *TaskClassHandler) UserGetTaskClassInfos(c *gin.Context) {
userIDInterface := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
resp, err := api.svc.GetUserTaskClassInfos(ctx, userIDInterface)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
func (api *TaskClassHandler) UserGetCompleteTaskClass(c *gin.Context) {
taskClassID := c.Query("task_class_id")
//将taskClassID转换为int
intTaskClassID, err := strconv.Atoi(taskClassID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
if taskClassID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
userIDInterface := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
resp, err := api.svc.GetUserCompleteTaskClass(ctx, userIDInterface, intTaskClassID)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
func (api *TaskClassHandler) UserUpdateTaskClass(c *gin.Context) {
var req model.UserAddTaskClassRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
taskClassID := c.Query("task_class_id")
//将taskClassID转换为int
intTaskClassID, err := strconv.Atoi(taskClassID)
userIDInterface := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err = api.svc.AddOrUpdateTaskClass(ctx, &req, userIDInterface, update, intTaskClassID)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}
func (api *TaskClassHandler) UserAddTaskClassItemIntoSchedule(c *gin.Context) {
var req model.UserInsertTaskClassItemToScheduleRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
taskID := c.Query("task_item_id")
//将taskID转换为int
intTaskID, err := strconv.Atoi(taskID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userIDInterface := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err = api.svc.AddTaskClassItemIntoSchedule(ctx, &req, userIDInterface, intTaskID)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}
func (api *TaskClassHandler) DeleteTaskClassItem(c *gin.Context) {
taskID := c.Query("task_item_id")
//将taskID转换为int
intTaskID, err := strconv.Atoi(taskID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userID := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err = api.svc.DeleteTaskClassItem(ctx, userID, intTaskID)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}
func (api *TaskClassHandler) DeleteTaskClass(c *gin.Context) {
taskClassID := c.Query("task_class_id")
//将taskClassID转换为int
intTaskClassID, err := strconv.Atoi(taskClassID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userID := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err = api.svc.DeleteTaskClass(ctx, userID, intTaskClassID)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}
func (api *TaskClassHandler) UserInsertBatchTaskClassItemsIntoSchedule(c *gin.Context) {
var req model.UserInsertTaskClassItemToScheduleRequestBatch
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userID := c.GetInt("user_id")
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
err = api.svc.BatchApplyPlans(ctx, req.TaskClassID, userID, &req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}

229
backend/gateway/api/task.go Normal file
View File

@@ -0,0 +1,229 @@
package api
import (
"context"
"fmt"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/service"
"github.com/gin-gonic/gin"
)
type TaskHandler struct {
// 伸出手:准备接住 Service
svc *service.TaskService
}
// NewTaskHandler 创建 TaskHandler 实例
func NewTaskHandler(svc *service.TaskService) *TaskHandler {
return &TaskHandler{
svc: svc,
}
}
func (th *TaskHandler) AddTask(c *gin.Context) {
//1. 绑定请求参数
var req model.UserAddTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
fmt.Println(err)
return
}
// 用户ID从上下文中获取
userID := c.GetInt("user_id")
//2. 调用 Service 层处理业务逻辑
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
resp, err := th.svc.AddTask(ctx, &req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
//3. 返回响应
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
func (th *TaskHandler) GetUserTasks(c *gin.Context) {
// 用户ID从上下文中获取
userID := c.GetInt("user_id")
//2. 调用 Service 层处理业务逻辑
// 创建一个带 1 秒超时的上下文
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
resp, err := th.svc.GetUserTasks(ctx, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
//3. 返回响应
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
// BatchTaskStatus 批量查询当前用户任务的实时完成状态。
//
// 职责边界:
// 1. 负责解析 ids 与读取鉴权上下文中的 user_id
// 2. 负责调用 Service 复用任务缓存读取链路;
// 3. 不修改任务、不触发幂等中间件、不反写 NewAgent timeline 历史 payload。
func (th *TaskHandler) BatchTaskStatus(c *gin.Context) {
// 1. 绑定请求参数。ids 允许为空切片,表示前端当前没有需要 hydration 的任务卡片。
var req model.BatchTaskStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
fmt.Println(err)
return
}
// 2. 从鉴权上下文读取 user_idService 会继续用该 user_id 限定任务集合。
userID := c.GetInt("user_id")
// 3. 设置短超时:该接口只读缓存/任务列表,避免异常情况下长时间占用连接。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调用 Service 做 ID 归一化与当前状态查询。
resp, err := th.svc.BatchTaskStatus(ctx, &req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构items 为空时仍按 success 返回,便于前端无分支处理。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
// CompleteTask 标记任务为已完成。
//
// 职责边界:
// 1. 负责解析请求与读取 user_id
// 2. 负责调用 Service 执行业务;
// 3. 不负责幂等校验(幂等由路由中间件处理)。
func (th *TaskHandler) CompleteTask(c *gin.Context) {
// 1. 绑定请求参数。
var req model.UserCompleteTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
fmt.Println(err)
return
}
// 2. 从鉴权上下文获取 user_id保证只能操作自己的任务。
userID := c.GetInt("user_id")
// 3. 设置短超时,避免该写接口长期占用连接。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调用 Service 执行"标记完成"逻辑。
resp, err := th.svc.CompleteTask(ctx, &req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
// UndoCompleteTask 取消任务"已完成"勾选。
//
// 职责边界:
// 1. 负责解析请求与读取 user_id
// 2. 负责调用 Service 执行业务恢复;
// 3. 不负责"任务是否已完成"的业务判断(由 Service/DAO 负责)。
func (th *TaskHandler) UndoCompleteTask(c *gin.Context) {
// 1. 绑定请求参数。
var req model.UserUndoCompleteTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
fmt.Println(err)
return
}
// 2. 从鉴权上下文读取 user_id保证只操作当前用户任务。
userID := c.GetInt("user_id")
// 3. 设置短超时,避免该写接口占用连接过久。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调用 Service 执行"取消已完成勾选"逻辑。
resp, err := th.svc.UndoCompleteTask(ctx, &req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
// UpdateTask 更新任务属性(部分更新)。
//
// 职责边界:
// 1. 负责解析请求与读取 user_id
// 2. 负责调用 Service 执行业务;
// 3. 不负责幂等校验(幂等由路由中间件处理)。
func (th *TaskHandler) UpdateTask(c *gin.Context) {
// 1. 绑定请求参数。
var req model.UserUpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
fmt.Println(err)
return
}
// 2. 从鉴权上下文读取 user_id保证只操作当前用户任务。
userID := c.GetInt("user_id")
// 3. 设置短超时,避免该写接口占用连接过久。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调用 Service 执行更新逻辑。
resp, err := th.svc.UpdateTask(ctx, &req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
}
// DeleteTask 永久删除指定任务。
//
// 职责边界:
// 1. 负责解析请求与读取 user_id
// 2. 负责调用 Service 执行删除;
// 3. 不负责幂等校验(幂等由路由中间件处理)。
func (th *TaskHandler) DeleteTask(c *gin.Context) {
// 1. 绑定请求参数。
var req model.UserCompleteTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
fmt.Println(err)
return
}
// 2. 从鉴权上下文读取 user_id保证只操作当前用户任务。
userID := c.GetInt("user_id")
// 3. 设置短超时,避免该写接口占用连接过久。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调用 Service 执行删除逻辑。
taskID, err := th.svc.DeleteTask(ctx, &req, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"task_id": taskID}))
}

View File

@@ -0,0 +1,98 @@
package userauthapi
import (
"context"
"net/http"
"strings"
"time"
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
"github.com/LoveLosita/smartflow/backend/respond"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
client ports.UserCommandClient
}
// NewUserHandler 只接收 user/auth 客户端,不再直接依赖本地 user service。
func NewUserHandler(client ports.UserCommandClient) *UserHandler {
return &UserHandler{client: client}
}
func (api *UserHandler) UserRegister(c *gin.Context) {
var req contracts.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
retUser, err := api.client.Register(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, retUser))
}
func (api *UserHandler) UserLogin(c *gin.Context) {
var req contracts.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
tokens, err := api.client.Login(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
}
func (api *UserHandler) RefreshTokenHandler(c *gin.Context) {
var req contracts.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
if strings.TrimSpace(req.RefreshToken) == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
tokens, err := api.client.RefreshToken(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
}
func (api *UserHandler) UserLogout(c *gin.Context) {
token := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
if token == "" {
c.JSON(http.StatusUnauthorized, respond.MissingToken)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := api.client.Logout(ctx, token); err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}

View File

@@ -0,0 +1,28 @@
package userauthapi
import (
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
// RegisterRoutes 把 user/auth HTTP 入口挂到 gateway 路由组。
// 职责边界:
// 1. 只注册 /user 下的边缘路由,不关心其它业务域路由;
// 2. 登录、注册、刷新 token 只做请求转发;登出需要先经过 access token 边缘鉴权;
// 3. 限流仍复用当前通用中间件,后续若 gateway 独立成包,可再整体下沉。
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *UserHandler, authClient ports.AccessTokenValidator, limiter *pkg.RateLimiter) {
if apiGroup == nil || handler == nil {
return
}
userGroup := apiGroup.Group("/user")
{
userGroup.POST("/register", handler.UserRegister)
userGroup.POST("/login", handler.UserLogin)
userGroup.POST("/refresh-token", handler.RefreshTokenHandler)
userGroup.POST("/logout", gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1), handler.UserLogout)
}
}