Files
geo/server/internal/tenant/transport/article_handler.go
T
root 94f7186cce feat: add image upload functionality for articles
- Implemented image upload API in the article service, allowing users to upload images associated with articles.
- Added validation for image size and type, ensuring only supported formats (PNG, JPG, GIF, WebP) are accepted.
- Created a new Aliyun object storage client to handle image storage and retrieval.
- Updated the article handler to include an endpoint for image uploads.
- Enhanced error handling for image upload failures and added relevant error messages.
- Introduced public URL generation for stored images, allowing access via signed tokens.
- Updated localization files to include new messages related to image upload functionality.
- Added tests for image upload and retrieval processes.
2026-04-05 22:10:05 +08:00

327 lines
8.0 KiB
Go

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
}