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:
212
backend/gateway/api/active_schedule.go
Normal file
212
backend/gateway/api/active_schedule.go
Normal 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 服务返回 JSON,gateway 只做边缘透传。
|
||||
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)
|
||||
}
|
||||
382
backend/gateway/api/agent.go
Normal file
382
backend/gateway/api/agent.go
Normal 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))
|
||||
}
|
||||
12
backend/gateway/api/container.go
Normal file
12
backend/gateway/api/container.go
Normal 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
|
||||
}
|
||||
154
backend/gateway/api/course.go
Normal file
154
backend/gateway/api/course.go
Normal 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))
|
||||
}
|
||||
290
backend/gateway/api/memory.go
Normal file
290
backend/gateway/api/memory.go
Normal 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,
|
||||
}
|
||||
}
|
||||
128
backend/gateway/api/notification.go
Normal file
128
backend/gateway/api/notification.go
Normal 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))
|
||||
}
|
||||
194
backend/gateway/api/schedule.go
Normal file
194
backend/gateway/api/schedule.go
Normal 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))
|
||||
}
|
||||
192
backend/gateway/api/task-class.go
Normal file
192
backend/gateway/api/task-class.go
Normal 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
229
backend/gateway/api/task.go
Normal 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_id,Service 会继续用该 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}))
|
||||
}
|
||||
98
backend/gateway/api/userauth/handler.go
Normal file
98
backend/gateway/api/userauth/handler.go
Normal 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)
|
||||
}
|
||||
28
backend/gateway/api/userauth/routes.go
Normal file
28
backend/gateway/api/userauth/routes.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user