package transport import ( "encoding/json" "io" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/geo-platform/tenant-api/internal/bootstrap" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/shared/stream" "github.com/geo-platform/tenant-api/internal/tenant/app" ) type ArticleHandler struct { svc *app.ArticleService promptGenerateSvc *app.PromptRuleGenerationService streams *stream.GenerationHub streamEnabled bool assets *AssetHandler } func NewArticleHandler(a *bootstrap.App) *ArticleHandler { knowledgeSvc := app.NewKnowledgeService( a.DB, a.RetrievalProvider, a.VectorStore, a.ObjectStorage, a.LLM, a.Logger, a.Config.Retrieval, a.Config.LLM, 0, 0, ) return &ArticleHandler{ svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage), promptGenerateSvc: app.NewPromptRuleGenerationService( a.DB, a.LLM, knowledgeSvc, a.GenerationStreams, a.Config.Generation, a.Config.LLM.MaxOutputTokens, ), streams: a.GenerationStreams, streamEnabled: a.Config.Generation.StreamEnabled, assets: NewAssetHandler(a), } } func (h *ArticleHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) params := app.ArticleListParams{Page: page, PageSize: pageSize} if v := c.Query("generate_status"); v != "" { params.GenerateStatus = &v } if v := c.Query("publish_status"); v != "" { params.PublishStatus = &v } if v := c.Query("source_type"); v != "" { params.SourceType = &v } if v := c.Query("generation_mode"); v != "" { params.GenerationMode = &v } if v := c.Query("template_id"); v != "" { if id, err := strconv.ParseInt(v, 10, 64); err == nil { params.TemplateID = &id } } if v := c.Query("prompt_rule_id"); v != "" { if id, err := strconv.ParseInt(v, 10, 64); err == nil { params.PromptRuleID = &id } } if v := c.Query("keyword"); v != "" { params.Keyword = &v } if v := c.Query("created_from"); v != "" { parsed, err := time.Parse(time.RFC3339, v) if err != nil { response.Error(c, response.ErrBadRequest(40014, "invalid_created_from", "created_from must be RFC3339")) return } params.CreatedFrom = &parsed } if v := c.Query("created_to"); v != "" { parsed, err := time.Parse(time.RFC3339, v) if err != nil { response.Error(c, response.ErrBadRequest(40015, "invalid_created_to", "created_to must be RFC3339")) return } params.CreatedTo = &parsed } data, err := h.svc.List(c.Request.Context(), params) if err != nil { response.Error(c, err) return } response.Success(c, data) } func (h *ArticleHandler) GenerateFromRule(c *gin.Context) { var req app.GenerateFromRuleRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) return } data, err := h.promptGenerateSvc.GenerateFromRule(c.Request.Context(), req) if err != nil { response.Error(c, err) return } response.SuccessWithStatus(c, http.StatusCreated, data) } func (h *ArticleHandler) Detail(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) return } data, err := h.svc.Detail(c.Request.Context(), id) if err != nil { response.Error(c, err) return } response.Success(c, data) } func (h *ArticleHandler) Versions(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) return } data, err := h.svc.Versions(c.Request.Context(), id) if err != nil { response.Error(c, err) return } response.Success(c, data) } func (h *ArticleHandler) Update(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) return } var req app.UpdateArticleRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, response.ErrBadRequest(40013, "invalid_payload", err.Error())) return } data, err := h.svc.Update(c.Request.Context(), id, req) if err != nil { response.Error(c, err) return } response.Success(c, data) } func (h *ArticleHandler) UploadImage(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) return } fileHeader, err := c.FormFile("file") if err != nil { response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file is required")) return } file, err := fileHeader.Open() if err != nil { response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be opened")) return } defer file.Close() content, err := io.ReadAll(file) if err != nil { response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be read")) return } data, err := h.svc.UploadImage(c.Request.Context(), id, fileHeader.Filename, content) if err != nil { response.Error(c, err) return } data.URL = h.assets.BuildArticleImageURL(data.ObjectKey) response.SuccessWithStatus(c, http.StatusCreated, data) } func (h *ArticleHandler) Stream(c *gin.Context) { if !h.streamEnabled { response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled")) return } id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) return } detail, err := h.svc.Detail(c.Request.Context(), id) if err != nil { response.Error(c, err) return } c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") flusher, ok := c.Writer.(http.Flusher) if !ok { response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming")) return } events, snapshot, cancel := h.streams.Subscribe(id) defer cancel() if snapshot != nil { if err := writeSSE(c, snapshot.Type, snapshot); err != nil { return } flusher.Flush() } else { fallback := stream.GenerationEvent{ Type: "snapshot", ArticleID: detail.ID, Status: detail.GenerateStatus, Done: detail.GenerateStatus == "completed" || detail.GenerateStatus == "failed", UpdatedAt: time.Now().UTC(), } if detail.Title != nil { fallback.Title = *detail.Title } if detail.MarkdownContent != nil { fallback.Content = *detail.MarkdownContent } if err := writeSSE(c, fallback.Type, fallback); err != nil { return } flusher.Flush() if fallback.Done { return } } heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-c.Request.Context().Done(): return case <-heartbeat.C: if err := writeSSE(c, "ping", gin.H{"ts": time.Now().UTC()}); err != nil { return } flusher.Flush() case event, ok := <-events: if !ok { return } if err := writeSSE(c, event.Type, event); err != nil { return } flusher.Flush() if event.Done { return } } } } func (h *ArticleHandler) Delete(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) return } if err := h.svc.Delete(c.Request.Context(), id); err != nil { response.Error(c, err) return } response.Success(c, nil) } func writeSSE(c *gin.Context, event string, payload interface{}) error { data, err := json.Marshal(payload) if err != nil { return err } if _, err := c.Writer.Write([]byte("event: " + event + "\n")); err != nil { return err } if _, err := c.Writer.Write([]byte("data: " + string(data) + "\n\n")); err != nil { return err } return nil }