package dataset import ( "errors" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/logic/auth" "senlinai-agent/backend/internal/models" ) type Handler struct { service *Service } type SourceDTO struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` URL string `json:"url"` IconURL string `json:"iconUrl"` Description string `json:"description"` Enabled bool `json:"enabled"` BuiltIn bool `json:"builtIn"` LastSyncedAt *time.Time `json:"lastSyncedAt"` ItemCount int64 `json:"itemCount"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type ItemDTO struct { ID string `json:"id"` SourceID string `json:"sourceId"` Title string `json:"title"` Summary string `json:"summary"` Content string `json:"content"` URL string `json:"url"` Status string `json:"status"` Starred bool `json:"starred"` PublishedAt *time.Time `json:"publishedAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type ItemPageDTO struct { Items []ItemDTO `json:"items"` Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } type SyncResultDTO struct { SourceID string `json:"sourceId"` Status string `json:"status"` Result string `json:"result"` } type DepositDTO struct { NoteID string `json:"noteId"` ProjectID string `json:"projectId"` } type sourceRequest struct { Name string `json:"name"` Kind string `json:"kind"` URL string `json:"url"` IconURL string `json:"iconUrl"` Description string `json:"description"` Enabled *bool `json:"enabled"` } func NewHandler(service *Service) *Handler { return &Handler{service: service} } func (h *Handler) Register(router gin.IRouter) { router.GET("/dataset-sources", h.listSources) router.POST("/dataset-sources", h.createSource) router.PATCH("/dataset-sources/:id", h.updateSource) router.DELETE("/dataset-sources/:id", h.deleteSource) router.GET("/dataset-items", h.listItems) router.POST("/dataset-items", h.createItem) router.PATCH("/dataset-items/:id", h.updateItem) router.POST("/dataset-items/:id/deposit", h.depositItem) router.POST("/dataset-sources/sync", h.syncSources) } func (h *Handler) listSources(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } records, err := h.service.ListSources(userID) if err != nil { writeError(c, err) return } items := make([]SourceDTO, 0, len(records)) for _, record := range records { items = append(items, sourceDTO(record.Source, record.ItemCount)) } c.JSON(http.StatusOK, items) } func (h *Handler) createSource(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } var input sourceRequest if err := c.ShouldBindJSON(&input); err != nil { writeInvalidRequest(c) return } enabled := true if input.Enabled != nil { enabled = *input.Enabled } source, err := h.service.CreateSource(userID, SourceInput{ Name: input.Name, Kind: input.Kind, URL: input.URL, IconURL: input.IconURL, Description: input.Description, Enabled: enabled, }) if err != nil { writeError(c, err) return } c.JSON(http.StatusCreated, sourceDTO(*source, 0)) } func (h *Handler) updateSource(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } identity, ok := httpx.IdentityParam(c, "id") if !ok { return } var input struct { Name *string `json:"name"` Kind *string `json:"kind"` URL *string `json:"url"` IconURL *string `json:"iconUrl"` Description *string `json:"description"` Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&input); err != nil { writeInvalidRequest(c) return } source, err := h.service.PatchSource(userID, identity, SourcePatch{ Name: input.Name, Kind: input.Kind, URL: input.URL, IconURL: input.IconURL, Description: input.Description, Enabled: input.Enabled, }) if err != nil { writeError(c, err) return } itemCount, err := h.service.CountItems(userID, source.ID) if err != nil { writeError(c, err) return } c.JSON(http.StatusOK, sourceDTO(*source, itemCount)) } func (h *Handler) deleteSource(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } identity, ok := httpx.IdentityParam(c, "id") if !ok { return } if err := h.service.DeleteSource(userID, identity); err != nil { writeError(c, err) return } c.Status(http.StatusNoContent) } func (h *Handler) listItems(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } limit, err := optionalNonNegativeInt(c.Query("limit")) if err != nil { writeInvalidRequest(c) return } offset, err := optionalNonNegativeInt(c.Query("offset")) if err != nil { writeInvalidRequest(c) return } page, err := h.service.ListItemsPage(userID, c.Query("sourceId"), limit, offset) if err != nil { writeError(c, err) return } result := make([]ItemDTO, 0, len(page.Items)) for _, item := range page.Items { result = append(result, itemDTO(item)) } _, hasLimit := c.GetQuery("limit") _, hasOffset := c.GetQuery("offset") if !hasLimit && !hasOffset { c.JSON(http.StatusOK, result) return } c.JSON(http.StatusOK, ItemPageDTO{ Items: result, Total: page.Total, Limit: page.Limit, Offset: page.Offset, }) } func (h *Handler) createItem(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } var input struct { SourceID string `json:"sourceId"` Title string `json:"title"` Summary string `json:"summary"` Content string `json:"content"` URL string `json:"url"` PublishedAt string `json:"publishedAt"` } if err := c.ShouldBindJSON(&input); err != nil { writeInvalidRequest(c) return } publishedAt, err := parseOptionalTime(input.PublishedAt) if err != nil { writeInvalidRequest(c) return } item, err := h.service.CreateItem(userID, ItemInput{ SourceIdentity: input.SourceID, Title: input.Title, Summary: input.Summary, Content: input.Content, URL: input.URL, PublishedAt: publishedAt, }) if err != nil { writeError(c, err) return } c.JSON(http.StatusCreated, itemDTO(*item)) } func (h *Handler) updateItem(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } identity, ok := httpx.IdentityParam(c, "id") if !ok { return } var input struct { Status *string `json:"status"` Starred *bool `json:"starred"` } if err := c.ShouldBindJSON(&input); err != nil { writeInvalidRequest(c) return } item, err := h.service.PatchItem(userID, identity, ItemPatch{Status: input.Status, Starred: input.Starred}) if err != nil { writeError(c, err) return } c.JSON(http.StatusOK, itemDTO(*item)) } func (h *Handler) depositItem(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } identity, ok := httpx.IdentityParam(c, "id") if !ok { return } var input struct { ProjectID string `json:"projectId"` } if err := c.ShouldBindJSON(&input); err != nil || strings.TrimSpace(input.ProjectID) == "" { writeInvalidRequest(c) return } result, err := h.service.DepositItem(userID, identity, input.ProjectID) if err != nil { writeError(c, err) return } c.JSON(http.StatusCreated, DepositDTO{ NoteID: result.NoteIdentity, ProjectID: result.ProjectIdentity, }) } func (h *Handler) syncSources(c *gin.Context) { userID, ok := currentUser(c) if !ok { return } results, err := h.service.SyncSources(c.Request.Context(), userID) if err != nil { writeError(c, err) return } response := make([]SyncResultDTO, 0, len(results)) for _, result := range results { response = append(response, SyncResultDTO{ SourceID: result.SourceIdentity, Status: result.Status, Result: result.Result, }) } c.JSON(http.StatusOK, response) } func currentUser(c *gin.Context) (uint, bool) { userID, ok := auth.CurrentUserID(c) if !ok { httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") return 0, false } return userID, true } func writeError(c *gin.Context, err error) { switch { case errors.Is(err, gorm.ErrRecordNotFound): httpx.Error(c, http.StatusNotFound, "not_found", "数据源或数据条目不存在") case errors.Is(err, ErrBuiltInSource): httpx.Error(c, http.StatusConflict, "built_in_source", "内置数据源不能删除,可以将其停用") case errors.Is(err, ErrSyncInProgress): httpx.Error(c, http.StatusConflict, "sync_in_progress", "已有数据源采集任务正在运行") case errors.Is(err, ErrNameRequired), errors.Is(err, ErrKindInvalid), errors.Is(err, ErrURLInvalid), errors.Is(err, ErrTitleRequired), errors.Is(err, ErrStatusInvalid): writeInvalidRequest(c) default: httpx.Error(c, http.StatusInternalServerError, "internal_error", "探索数据操作失败") } } func writeInvalidRequest(c *gin.Context) { httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") } func sourceDTO(source models.SaDatasetSource, itemCount int64) SourceDTO { return SourceDTO{ ID: source.Identity, Name: source.Name, Kind: source.Kind, URL: source.URL, IconURL: source.IconURL, Description: source.Description, Enabled: source.Enabled, BuiltIn: source.SeedKey != nil, LastSyncedAt: utcTime(source.LastSyncedAt), ItemCount: itemCount, CreatedAt: source.CreatedAt.UTC(), UpdatedAt: source.UpdatedAt.UTC(), } } func itemDTO(item models.SaDatasetItem) ItemDTO { return ItemDTO{ ID: item.Identity, SourceID: item.SourceIdentity, Title: item.Title, Summary: item.Summary, Content: item.Content, URL: item.URL, Status: item.Status, Starred: item.Starred, PublishedAt: utcTime(item.PublishedAt), CreatedAt: item.CreatedAt.UTC(), UpdatedAt: item.UpdatedAt.UTC(), } } func optionalNonNegativeInt(value string) (int, error) { if strings.TrimSpace(value) == "" { return 0, nil } parsed, err := strconv.Atoi(value) if err != nil || parsed < 0 { return 0, errors.New("invalid non-negative integer") } return parsed, nil } func parseOptionalTime(value string) (*time.Time, error) { if strings.TrimSpace(value) == "" { return nil, nil } parsed, err := time.Parse(time.RFC3339, value) if err != nil { return nil, err } parsed = parsed.UTC() return &parsed, nil } func utcTime(value *time.Time) *time.Time { if value == nil { return nil } result := value.UTC() return &result }