431 lines
11 KiB
Go
431 lines
11 KiB
Go
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 CronDTO struct {
|
|
ID string `json:"id"`
|
|
SourceID string `json:"sourceId"`
|
|
Status string `json:"status"`
|
|
LastRunAt *time.Time `json:"lastRunAt"`
|
|
LastResult string `json:"lastResult"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
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.GET("/dataset-crons", h.listCrons)
|
|
router.POST("/dataset-crons/sync", h.queueSync)
|
|
}
|
|
|
|
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) listCrons(c *gin.Context) {
|
|
userID, ok := currentUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
crons, err := h.service.ListCrons(userID)
|
|
if err != nil {
|
|
writeError(c, err)
|
|
return
|
|
}
|
|
result := make([]CronDTO, 0, len(crons))
|
|
for _, cron := range crons {
|
|
result = append(result, cronDTO(cron))
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *Handler) queueSync(c *gin.Context) {
|
|
userID, ok := currentUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
crons, err := h.service.QueueSources(userID)
|
|
if err != nil {
|
|
writeError(c, err)
|
|
return
|
|
}
|
|
result := make([]CronDTO, 0, len(crons))
|
|
for _, cron := range crons {
|
|
result = append(result, cronDTO(cron))
|
|
}
|
|
c.JSON(http.StatusAccepted, result)
|
|
}
|
|
|
|
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 cronDTO(cron models.SaDatasetCron) CronDTO {
|
|
return CronDTO{
|
|
ID: cron.Identity, SourceID: cron.SourceIdentity, Status: cron.Status,
|
|
LastRunAt: utcTime(cron.LastRunAt), LastResult: cron.LastResult,
|
|
CreatedAt: cron.CreatedAt.UTC(), UpdatedAt: cron.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
|
|
}
|