Files

271 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package common
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
)
// ============================================================
// 公开帮助中心逻辑
// ============================================================
// HelpPublicCategoryItem 公开分类树节点
type HelpPublicCategoryItem struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Icon string `json:"icon"`
ArticleCount int `json:"article_count"`
Children []*HelpPublicCategoryItem `json:"children"`
}
// ListPublicCategories 返回可见分类的树结构
func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error) {
type categoryRow struct {
Id int64 `json:"id" orm:"id"`
ParentId int64 `json:"parent_id" orm:"parent_id"`
Name string `json:"name" orm:"name"`
Slug string `json:"slug" orm:"slug"`
Description string `json:"description" orm:"description"`
Icon string `json:"icon" orm:"icon"`
IsVisible bool `json:"is_visible" orm:"is_visible"`
}
var rows []categoryRow
err := dao.SptCategories.Ctx(ctx).
Where("is_visible", true).
OrderAsc("sort_order").
Scan(&rows)
if err != nil {
return nil, err
}
// 实时统计每个分类的已发布文章数(不依赖冗余字段 article_count)
type countRow struct {
CategoryId int64 `json:"category_id" orm:"category_id"`
ArticleCount int `json:"count" orm:"count"`
}
var countRows []countRow
err = dao.SptArticles.Ctx(ctx).
Where("status", "published").
Where("published_at IS NOT NULL").
Where("published_at <=", gtime.Now()).
Fields("category_id, COUNT(*) as count").
Group("category_id").
Scan(&countRows)
if err != nil {
return nil, err
}
countMap := make(map[int64]int, len(countRows))
for _, cr := range countRows {
countMap[cr.CategoryId] = cr.ArticleCount
}
// 构建所有节点(rows 已按 sort_order 排序,同时维护有序切片保证树节点顺序稳定,
// 不能遍历 map 组装——Go map 遍历顺序随机,会导致分类顺序每次请求都变)
nodeMap := make(map[int64]*HelpPublicCategoryItem, len(rows))
orderedNodes := make([]*HelpPublicCategoryItem, 0, len(rows))
for _, r := range rows {
node := &HelpPublicCategoryItem{
Id: r.Id,
ParentId: r.ParentId,
Name: r.Name,
Slug: r.Slug,
Description: r.Description,
Icon: r.Icon,
ArticleCount: countMap[r.Id],
Children: make([]*HelpPublicCategoryItem, 0),
}
nodeMap[r.Id] = node
orderedNodes = append(orderedNodes, node)
}
// 组装树结构(按 sort_order 顺序遍历,roots 与 children 均保持稳定排序)
var roots []*HelpPublicCategoryItem
for _, node := range orderedNodes {
if node.ParentId == 0 {
roots = append(roots, node)
} else if parent, ok := nodeMap[node.ParentId]; ok {
parent.Children = append(parent.Children, node)
}
}
return roots, nil
}
// HelpPublicArticleItem 公开文章摘要
type HelpPublicArticleItem struct {
Id int64 `json:"id"`
CategoryId int64 `json:"category_id"`
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
ViewCount int `json:"view_count"`
PublishedAt *gtime.Time `json:"published_at"`
}
// ListPublicArticles 按分类 slug 获取已发布文章列表
func ListPublicArticles(ctx context.Context, categorySlug string, page, pageSize int) ([]*HelpPublicArticleItem, int, int, int, error) {
page, pageSize = NormalizePagination(page, pageSize)
// 查找分类
var cat *struct {
Id int64 `json:"id" orm:"id"`
}
err := dao.SptCategories.Ctx(ctx).
Where("slug", categorySlug).
Where("is_visible", true).
Scan(&cat)
if err != nil {
return nil, 0, 0, 0, err
}
if cat == nil {
return nil, 0, 0, 0, NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound)
}
var total int
rows := make([]*HelpPublicArticleItem, 0)
// 排序与管理后台列表保持一致:sort_order 优先,其次发布时间倒序
err = dao.SptArticles.Ctx(ctx).
Where("category_id", cat.Id).
Where("status", "published").
Where("published_at IS NOT NULL").
Where("published_at <=", gtime.Now()).
OrderAsc("sort_order").
OrderDesc("published_at").
Page(page, pageSize).
ScanAndCount(&rows, &total, false)
if err != nil {
return nil, 0, 0, 0, err
}
return rows, total, page, pageSize, nil
}
// HelpPublicArticleDetail 公开文章详情
type HelpPublicArticleDetail struct {
Id int64 `json:"id"`
CategoryId int64 `json:"category_id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
Summary string `json:"summary"`
ViewCount int `json:"view_count"`
Keywords []string `json:"keywords"`
PublishedAt *gtime.Time `json:"published_at"`
}
// GetPublicArticle 按 slug 获取文章详情,并增加浏览计数
func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetail, error) {
type articleRow struct {
Id int64 `json:"id" orm:"id"`
CategoryId int64 `json:"category_id" orm:"category_id"`
Title string `json:"title" orm:"title"`
Slug string `json:"slug" orm:"slug"`
Content string `json:"content" orm:"content"`
Summary string `json:"summary" orm:"summary"`
Status string `json:"status" orm:"status"`
ViewCount int `json:"view_count" orm:"view_count"`
Keywords []string `json:"keywords" orm:"keywords"`
PublishedAt *gtime.Time `json:"published_at" orm:"published_at"`
}
var row articleRow
err := dao.SptArticles.Ctx(ctx).
Where("slug", slug).
Scan(&row)
if err != nil {
return nil, err
}
if row.Id == 0 {
return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound)
}
if row.Status != "published" || row.PublishedAt == nil || row.PublishedAt.Timestamp() > gtime.Now().Timestamp() {
return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound)
}
// 增加浏览计数(失败仅记日志,不影响详情返回)
if _, err = dao.SptArticles.Ctx(ctx).
Where("id", row.Id).
Increment("view_count", 1); err != nil {
g.Log().Warningf(ctx, "帮助文章浏览计数失败 id=%d: %v", row.Id, err)
}
return &HelpPublicArticleDetail{
Id: row.Id,
CategoryId: row.CategoryId,
Title: row.Title,
Slug: row.Slug,
Content: row.Content,
Summary: row.Summary,
ViewCount: row.ViewCount + 1,
Keywords: row.Keywords,
PublishedAt: row.PublishedAt,
}, nil
}
// SearchPublicArticles 搜索已发布文章
// 用 ILIKE 模糊匹配而非 to_tsvector 全文检索:PostgreSQL 'simple' 分词配置不做中文分词,
// 连续中文会被切成单个长 token,中文关键词几乎无法命中(帮助文档以中文内容为主)。
// 帮助文章量级通常为百级,ILIKE 顺序扫描性能足够。
func SearchPublicArticles(ctx context.Context, query string, page, pageSize int) ([]*HelpPublicArticleItem, int, int, int, error) {
page, pageSize = NormalizePagination(page, pageSize)
likePat := "%" + escapeLikePattern(query) + "%"
var total int
rows := make([]*HelpPublicArticleItem, 0)
err := dao.SptArticles.Ctx(ctx).
Where("status", "published").
Where("published_at IS NOT NULL").
Where("published_at <=", gtime.Now()).
Where("(title ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')", likePat, likePat).
OrderDesc("published_at").
OrderAsc("sort_order").
Page(page, pageSize).
ScanAndCount(&rows, &total, false)
if err != nil {
return nil, 0, 0, 0, err
}
return rows, total, page, pageSize, nil
}
// escapeLikePattern 转义 ILIKE 模式中的通配符(配合 ESCAPE '\' 使用),防止用户输入 % _ \ 干扰匹配
func escapeLikePattern(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
// CheckHelpCenterRateLimit 帮助中心公开接口 IP 限流(公开无认证端点,防刷浏览量/搜索滥用)
// 默认每 IP 每分钟 60 次,可通过配置 help_center_rate_limit_per_minute 调整;Redis 不可用时放行(帮助中心为公开只读内容)
func CheckHelpCenterRateLimit(ctx context.Context, ipAddress string) error {
if ipAddress == "" {
return nil
}
limit := Config().GetInt(ctx, "help_center_rate_limit_per_minute")
if limit <= 0 {
limit = 60
}
key := fmt.Sprintf("help:center:rl:%s:%d", ipAddress, time.Now().Unix()/60)
count, err := incrementWithExpire(ctx, key, 120)
if err != nil {
g.Log().Warningf(ctx, "帮助中心限流检查失败: %v", err)
return nil
}
if count > int64(limit) {
return NewBusinessError(consts.CodeHelpRateLimitExceeded, consts.MsgHelpRateLimitExceeded)
}
return nil
}