package transport import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "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 selectionOptimize *app.ArticleSelectionOptimizeService promptGenerateSvc *app.PromptRuleGenerationService imitationSvc *app.ArticleImitationService streams *stream.GenerationHub app *bootstrap.App 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, ).WithConfigProvider(a.ConfigStore) return &ArticleHandler{ svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage). WithCache(a.Cache). WithGenerationRuntime( a.LLM, a.RabbitMQ, a.GenerationStreams, a.Config().Generation, a.Config().LLM.MaxOutputTokens, ). WithConfigProvider(a.ConfigStore), selectionOptimize: app.NewArticleSelectionOptimizeService( a.DB, a.LLM, a.Config().LLM.MaxOutputTokens, ).WithCache(a.Cache).WithConfigProvider(a.ConfigStore), promptGenerateSvc: app.NewPromptRuleGenerationService( a.DB, a.LLM, a.RabbitMQ, knowledgeSvc, a.GenerationStreams, a.Config().Generation, a.Config().LLM.MaxOutputTokens, ).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore), imitationSvc: app.NewArticleImitationService( a.DB, a.LLM, a.RabbitMQ, knowledgeSvc, a.GenerationStreams, a.Config().Generation, a.Config().LLM.MaxOutputTokens, ).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore), streams: a.GenerationStreams, app: a, 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 := normalizeSourceTypeParam(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("kol_prompt_id"); v != "" { if id, err := strconv.ParseInt(v, 10, 64); err == nil { params.KolPromptID = &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 normalizeSourceTypeParam(raw string) string { if strings.TrimSpace(raw) == "" { return "" } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, part := range parts { value := strings.TrimSpace(part) if value == "" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } return strings.Join(out, ",") } 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) GenerateImitation(c *gin.Context) { var req app.GenerateImitationRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) return } data, err := h.imitationSvc.Generate(c.Request.Context(), req) if err != nil { response.Error(c, err) return } response.SuccessWithStatus(c, http.StatusCreated, data) } func (h *ArticleHandler) Regenerate(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.Regenerate(c.Request.Context(), id) if err != nil { response.Error(c, err) return } response.SuccessWithStatus(c, http.StatusCreated, data) } func (h *ArticleHandler) Create(c *gin.Context) { var req app.CreateArticleRequest if err := c.ShouldBindJSON(&req); err != nil { // Allow empty body req = app.CreateArticleRequest{} } data, err := h.svc.Create(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) } type articleSelectionOptimizeStreamEvent struct { ArticleID int64 `json:"article_id"` Status string `json:"status,omitempty"` Delta string `json:"delta,omitempty"` Content string `json:"content,omitempty"` Error string `json:"error,omitempty"` Model string `json:"model,omitempty"` Done bool `json:"done"` } func (h *ArticleHandler) OptimizeSelectionStream(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.ArticleSelectionOptimizeRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, response.ErrBadRequest(40013, "invalid_payload", err.Error())) return } if err := h.selectionOptimize.ValidateRequest(c.Request.Context(), id, req); 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 } if err := writeSSE(c, "start", articleSelectionOptimizeStreamEvent{ ArticleID: id, Status: "generating", Done: false, }); err != nil { return } flusher.Flush() streamCtx, cancel := context.WithCancel(c.Request.Context()) defer cancel() var streamed strings.Builder var streamWriteErr error result, err := h.selectionOptimize.OptimizeSelection(streamCtx, id, req, func(delta string) { if delta == "" || streamWriteErr != nil { return } streamed.WriteString(delta) if writeErr := writeSSE(c, "delta", articleSelectionOptimizeStreamEvent{ ArticleID: id, Status: "generating", Delta: delta, Content: streamed.String(), Done: false, }); writeErr != nil { streamWriteErr = writeErr cancel() return } flusher.Flush() }) if streamWriteErr != nil { return } if err != nil { if errors.Is(streamCtx.Err(), context.Canceled) { return } appErr := response.Normalize(err) _ = writeSSE(c, "error", articleSelectionOptimizeStreamEvent{ ArticleID: id, Status: "failed", Content: strings.TrimSpace(streamed.String()), Error: resolveOptimizeStreamErrorMessage(appErr), Done: true, }) flusher.Flush() return } if err := writeSSE(c, "completed", articleSelectionOptimizeStreamEvent{ ArticleID: id, Status: "completed", Content: result.Content, Model: result.Model, Done: true, }); err != nil { return } flusher.Flush() } func (h *ArticleHandler) Stream(c *gin.Context) { cfg := h.app.Config() if cfg == nil || !cfg.Generation.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 } fallback := buildGenerationSnapshot(detail) if snapshot, ok := h.streams.Snapshot(id); !ok || (fallback.Done && snapshot.Status != fallback.Status) { h.streams.Seed(fallback) } events, snapshot, cancel := h.streams.Subscribe(id) defer cancel() if snapshot != nil { if err := writeSSE(c, snapshot.Type, snapshot); err != nil { return } flusher.Flush() if snapshot.Done { return } } else { 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 } func buildGenerationSnapshot(detail *app.ArticleDetailResponse) stream.GenerationEvent { event := 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 { event.Title = *detail.Title } if detail.MarkdownContent != nil { event.Content = *detail.MarkdownContent } if detail.GenerateStatus == "completed" { event.Type = "completed" } if detail.GenerateStatus == "failed" { event.Type = "error" if detail.GenerationErrorMessage != nil { event.Error = *detail.GenerationErrorMessage } } return event } func resolveOptimizeStreamErrorMessage(appErr *response.AppError) string { if appErr == nil { return "optimize selection failed" } if detail := strings.TrimSpace(appErr.Detail); detail != "" { return detail } if message := strings.TrimSpace(appErr.Message); message != "" { return message } return "optimize selection failed" }