feat(help-center): expose public article lists

This commit is contained in:
2026-06-06 07:09:52 +08:00
parent 0aa0470ac9
commit 53434ab397
11 changed files with 298 additions and 10 deletions
+187
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -22,6 +23,55 @@ type ArticleHandler struct {
portalSvc *service.PortalService
}
// PublicList returns published help-center articles for the public portal/widget views.
// GET /hc/:slug/:locale/articles.json
func (h *ArticleHandler) PublicList(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
page, perPage, offset, ok := publicArticlePagination(c)
if !ok {
return
}
locale := c.Param("locale")
query := strings.TrimSpace(c.Query("query"))
params := repository.ArticleSearchParams{
PortalID: portal.ID,
Query: query,
CategorySlug: c.Param("category_slug"),
Locale: locale,
Status: string(model.ArticleStatusPublished),
SortBy: c.Query("sort"),
Offset: offset,
Limit: perPage,
}
countParams := repository.ArticleSearchParams{
PortalID: portal.ID,
Locale: locale,
Status: string(model.ArticleStatusPublished),
}
articlesCount, err := h.svc.Count(c.Request.Context(), countParams)
if err != nil {
applogger.L().Errorf("Public article count: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
articles, _, err := h.svc.Search(c.Request.Context(), params)
if err != nil {
applogger.L().Errorf("Public article list: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
c.JSON(http.StatusOK, gin.H{
"payload": publicArticlePayloads(articles, portal.Slug),
"meta": gin.H{"articles_count": articlesCount, "current_page": page},
})
}
// NewArticleHandler creates a new ArticleHandler.
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
h := &ArticleHandler{svc: svc}
@@ -456,6 +506,53 @@ func (h *ArticleHandler) resolvePortal(c *gin.Context, accountID uint) (*model.P
return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true
}
func (h *ArticleHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool) {
if h.portalSvc == nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return nil, false
}
portal, err := h.portalSvc.ResolvePublicBySlug(c.Request.Context(), c.Param("slug"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return nil, false
}
return portal, true
}
func publicArticlePagination(c *gin.Context) (int, int, int, bool) {
page := 1
if raw := c.Query("page"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid page")
return 0, 0, 0, false
}
if parsed > 0 {
page = parsed
}
}
perPage := 0
if raw := c.Query("per_page"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid per_page")
return 0, 0, 0, false
}
perPage = parsed
if perPage < 1 {
perPage = 25
}
if perPage > 100 {
perPage = 100
}
}
offset := 0
if perPage > 0 {
offset = (page - 1) * perPage
}
return page, perPage, offset, true
}
func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offset, limit int) (repository.ArticleSearchParams, bool) {
params := repository.ArticleSearchParams{
PortalID: portalID,
@@ -581,6 +678,96 @@ func associatedArticlePayloads(articles []model.Article) []gin.H {
return payload
}
func publicArticlePayloads(articles []model.Article, portalSlug string) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
payload = append(payload, publicArticlePayload(&articles[i], portalSlug))
}
return payload
}
func publicArticlePayload(article *model.Article, portalSlug string) gin.H {
if article == nil {
return gin.H{}
}
if article.Portal.Slug != "" {
portalSlug = article.Portal.Slug
}
payload := gin.H{
"id": article.ID,
"category_id": article.CategoryID,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"position": article.Position,
"account_id": article.AccountID,
"last_updated_at": article.UpdatedAt,
"slug": article.Slug,
"views": article.Views,
"associated_articles": publicAssociatedArticlePayloads(article.AssociatedArticles),
"link": "hc/" + portalSlug + "/articles/" + article.Slug,
}
if article.Portal.ID != 0 {
payload["portal"] = publicPortalPayload(&article.Portal)
}
if article.Category != nil && article.Category.ID != 0 {
payload["category"] = gin.H{"id": article.Category.ID, "slug": article.Category.Slug, "locale": article.Category.Locale}
}
if article.Author != nil && article.Author.ID != 0 {
payload["author"] = publicAuthorPayload(article.Author)
}
return payload
}
func publicAssociatedArticlePayloads(articles []model.Article) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
article := &articles[i]
item := gin.H{
"id": article.ID,
"category_id": article.CategoryID,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"account_id": article.AccountID,
"last_updated_at": article.UpdatedAt,
"views": article.Views,
}
if article.Author != nil && article.Author.ID != 0 {
item["author"] = publicAuthorPayload(article.Author)
}
payload = append(payload, item)
}
return payload
}
func publicPortalPayload(portal *model.Portal) gin.H {
if portal == nil {
return gin.H{}
}
return gin.H{
"custom_domain": portal.CustomDomain,
"header_text": portal.HeaderText,
"homepage_link": portal.HomepageLink,
"name": portal.Name,
"page_title": portal.PageTitle,
"slug": portal.Slug,
}
}
func publicAuthorPayload(user *model.User) gin.H {
if user == nil {
return gin.H{}
}
availableName := user.DisplayName
if availableName == "" {
availableName = user.Name
}
return gin.H{"available_name": availableName, "name": user.Name, "thumbnail": user.AvatarURL}
}
func articleListMetaPayload(meta *service.ArticleListMeta, currentPage int) gin.H {
return gin.H{
"all_articles_count": meta.AllArticlesCount,
@@ -277,6 +277,57 @@ func (s *ArticleHandlerTestSuite) TestList_Success() {
assert.Contains(s.T(), meta, "all_articles_count")
}
func (s *ArticleHandlerTestSuite) TestPublicList_WidgetPopularArticles() {
r := gin.New()
r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList)
author := &model.User{AccountID: s.account.ID, Name: "Article Author", DisplayName: "Writer", Email: "public-article-author@example.com", Password: "secret"}
s.Require().NoError(s.db.Create(author).Error)
category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Guides", Slug: "guides", Locale: "en"}
s.Require().NoError(s.db.Create(category).Error)
low := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "Low Views", Slug: "public-low-views", Status: "published", Locale: "en", Views: 3, Content: "low"}
high := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "High Views", Slug: "public-high-views", Status: "published", Locale: "en", Views: 9, Content: "high"}
draft := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "Draft", Slug: "public-draft", Status: "draft", Locale: "en", Views: 99, Content: "draft"}
french := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "French", Slug: "public-french", Status: "published", Locale: "fr", Views: 99, Content: "fr"}
s.Require().NoError(s.db.Create(low).Error)
s.Require().NoError(s.db.Create(high).Error)
s.Require().NoError(s.db.Create(draft).Error)
s.Require().NoError(s.db.Create(french).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/test-portal/en/articles.json?page=1&sort=views&status=1&per_page=2", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].([]interface{})
assert.Len(s.T(), payload, 2)
first := payload[0].(map[string]interface{})
second := payload[1].(map[string]interface{})
assert.Equal(s.T(), "High Views", first["title"])
assert.Equal(s.T(), "Low Views", second["title"])
assert.Equal(s.T(), "hc/test-portal/articles/public-high-views", first["link"])
assert.Equal(s.T(), "guides", first["category"].(map[string]interface{})["slug"])
assert.Equal(s.T(), "Writer", first["author"].(map[string]interface{})["available_name"])
meta := resp["meta"].(map[string]interface{})
assert.EqualValues(s.T(), 2, meta["articles_count"])
}
func (s *ArticleHandlerTestSuite) TestPublicList_ArchivedPortalNotFound() {
r := gin.New()
r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList)
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived", Slug: "archived-public", Archived: true}
s.Require().NoError(s.db.Create(portal).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/archived-public/en/articles.json", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ArticleHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create)