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,