diff --git a/api/admin/v1/help_center.go b/api/admin/v1/help_center.go index 8c5695d..76e50fa 100644 --- a/api/admin/v1/help_center.go +++ b/api/admin/v1/help_center.go @@ -11,7 +11,7 @@ type HelpCategoryCreateReq struct { g.Meta `path:"/help-categories" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助分类"` ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"` Name string `json:"name" v:"required|length:1,100" dc:"分类名称"` - Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Description string `json:"description" dc:"分类描述"` SortOrder int `json:"sort_order" d:"0" dc:"排序序号"` Icon string `json:"icon" dc:"图标名称"` @@ -27,7 +27,7 @@ type HelpCategoryUpdateReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"` Name string `json:"name" v:"required|length:1,100" dc:"分类名称"` - Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Description string `json:"description" dc:"分类描述"` SortOrder int `json:"sort_order" d:"0" dc:"排序序号"` Icon string `json:"icon" dc:"图标名称"` @@ -77,7 +77,7 @@ type HelpArticleCreateReq struct { g.Meta `path:"/help-articles" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助文章"` CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"` Title string `json:"title" v:"required|length:1,200" dc:"文章标题"` - Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Content string `json:"content" v:"required" dc:"文章内容(Markdown)"` Summary string `json:"summary" dc:"文章摘要"` Status string `json:"status" d:"draft" v:"in:draft,published" dc:"状态"` @@ -94,7 +94,7 @@ type HelpArticleUpdateReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"` Title string `json:"title" v:"required|length:1,200" dc:"文章标题"` - Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Content string `json:"content" dc:"文章内容(Markdown),不传则不更新"` Summary string `json:"summary" dc:"文章摘要"` Status string `json:"status" v:"in:draft,published" dc:"状态"` diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 08d5e2d..1e73ac2 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -182,6 +182,7 @@ const ( CodeHelpCategorySlugExists = 10074 CodeHelpArticleNotFound = 10075 CodeHelpArticleSlugExists = 10076 + CodeHelpRateLimitExceeded = 10099 // Registration errors CodeRegistrationDisabled = 10077 @@ -337,6 +338,7 @@ const ( MsgHelpCategorySlugExists = "分类标识已存在" MsgHelpArticleNotFound = "帮助文章不存在" MsgHelpArticleSlugExists = "文章标识已存在" + MsgHelpRateLimitExceeded = "访问过于频繁,请稍后再试" // Registration errors MsgRegistrationDisabled = "注册功能已关闭" MsgEmailVerificationDisabled = "注册邮箱验证未启用" diff --git a/internal/logic/admin/help_center.go b/internal/logic/admin/help_center.go index f940437..3099daf 100644 --- a/internal/logic/admin/help_center.go +++ b/internal/logic/admin/help_center.go @@ -28,6 +28,13 @@ func (s *sAdmin) CreateHelpCategory(ctx context.Context, req *v1.HelpCategoryCre return nil, common.NewBusinessError(consts.CodeHelpCategorySlugExists, consts.MsgHelpCategorySlugExists) } + // 父分类校验:必须存在、必须是顶级分类(仅支持两级) + if req.ParentId > 0 { + if err = s.validateHelpCategoryParent(ctx, 0, req.ParentId); err != nil { + return nil, err + } + } + isVisible := true if req.IsVisible != nil { isVisible = *req.IsVisible @@ -77,6 +84,13 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd return nil, common.NewBusinessError(consts.CodeBadRequest, "不能将分类设置为自己的子分类") } + // 父分类校验:必须存在、必须是顶级分类(仅支持两级),且祖先链中不能出现自己(防环导致分类互相锁定无法删除) + if req.ParentId > 0 { + if err = s.validateHelpCategoryParent(ctx, req.Id, req.ParentId); err != nil { + return nil, err + } + } + updateData := do.SptCategories{ ParentId: req.ParentId, Name: req.Name, @@ -100,6 +114,45 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd return &v1.HelpCategoryUpdateRes{}, nil } +// validateHelpCategoryParent 校验父分类:必须存在、必须是顶级分类(仅支持两级结构), +// 且父分类的祖先链中不能出现 selfId(否则会形成 A→B→A 的环,环上分类因互为父子被删除校验拦截,永远无法删除) +func (s *sAdmin) validateHelpCategoryParent(ctx context.Context, selfId, parentId int64) error { + var parent *struct { + Id int64 `json:"id"` + ParentId int64 `json:"parent_id"` + } + err := dao.SptCategories.Ctx(ctx).Where("id", parentId).Scan(&parent) + if err = common.IgnoreScanNoRows(err); err != nil { + return err + } + if parent == nil { + return common.NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound) + } + if parent.ParentId != 0 { + return common.NewBusinessError(consts.CodeBadRequest, "仅支持两级分类,父分类必须是顶级分类") + } + + // 沿祖先链向上检查是否会出现自己(数据异常时用深度上限兜底) + current := parent.ParentId + for i := 0; i < 50 && current != 0; i++ { + if current == selfId { + return common.NewBusinessError(consts.CodeBadRequest, "不能将分类挂到自身或其子分类下") + } + var ancestor *struct { + ParentId int64 `json:"parent_id"` + } + err = dao.SptCategories.Ctx(ctx).Where("id", current).Scan(&ancestor) + if err = common.IgnoreScanNoRows(err); err != nil { + return err + } + if ancestor == nil { + return nil + } + current = ancestor.ParentId + } + return nil +} + // DeleteHelpCategory 删除帮助分类 func (s *sAdmin) DeleteHelpCategory(ctx context.Context, req *v1.HelpCategoryDeleteReq) (*v1.HelpCategoryDeleteRes, error) { var cat *struct { @@ -223,9 +276,10 @@ func (s *sAdmin) CreateHelpArticle(ctx context.Context, req *v1.HelpArticleCreat // UpdateHelpArticle 更新帮助文章 func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdateReq) (*v1.HelpArticleUpdateRes, error) { var article *struct { - Id int64 `json:"id"` - CategoryId int64 `json:"category_id"` - Status string `json:"status"` + Id int64 `json:"id"` + CategoryId int64 `json:"category_id"` + Status string `json:"status"` + PublishedAt *gtime.Time `json:"published_at"` } err := dao.SptArticles.Ctx(ctx).Where("id", req.Id).Scan(&article) if err = common.IgnoreScanNoRows(err); err != nil { @@ -269,8 +323,8 @@ func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdat data.Content = req.Content } - // 从草稿发布时设置发布时间 - if req.Status == "published" && article.Status != "published" { + // 从草稿发布时设置发布时间(仅首次发布固定;下架再上架不重置,保持原始发布时间与排序位置) + if req.Status == "published" && article.Status != "published" && article.PublishedAt == nil { data.PublishedAt = gtime.Now() } diff --git a/internal/logic/common/help_center.go b/internal/logic/common/help_center.go index cd38bfa..d952d21 100644 --- a/internal/logic/common/help_center.go +++ b/internal/logic/common/help_center.go @@ -2,8 +2,13 @@ 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" ) @@ -65,10 +70,12 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error countMap[cr.CategoryId] = cr.ArticleCount } - // 构建所有节点 - nodeMap := make(map[int64]*HelpPublicCategoryItem) + // 构建所有节点(rows 已按 sort_order 排序,同时维护有序切片保证树节点顺序稳定, + // 不能遍历 map 组装——Go map 遍历顺序随机,会导致分类顺序每次请求都变) + nodeMap := make(map[int64]*HelpPublicCategoryItem, len(rows)) + orderedNodes := make([]*HelpPublicCategoryItem, 0, len(rows)) for _, r := range rows { - nodeMap[r.Id] = &HelpPublicCategoryItem{ + node := &HelpPublicCategoryItem{ Id: r.Id, ParentId: r.ParentId, Name: r.Name, @@ -78,11 +85,13 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error 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 nodeMap { + for _, node := range orderedNodes { if node.ParentId == 0 { roots = append(roots, node) } else if parent, ok := nodeMap[node.ParentId]; ok { @@ -120,18 +129,19 @@ func ListPublicArticles(ctx context.Context, categorySlug string, page, pageSize return nil, 0, 0, 0, err } if cat == nil { - return nil, 0, 0, 0, NewBusinessError(10073, "帮助分类不存在") + 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()). - OrderDesc("published_at"). OrderAsc("sort_order"). + OrderDesc("published_at"). Page(page, pageSize). ScanAndCount(&rows, &total, false) if err != nil { @@ -177,16 +187,18 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai return nil, err } if row.Id == 0 { - return nil, NewBusinessError(10075, "帮助文章不存在") + return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound) } if row.Status != "published" || row.PublishedAt == nil || row.PublishedAt.Timestamp() > gtime.Now().Timestamp() { - return nil, NewBusinessError(10075, "帮助文章不存在") + return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound) } - // 增加浏览计数 - dao.SptArticles.Ctx(ctx). + // 增加浏览计数(失败仅记日志,不影响详情返回) + if _, err = dao.SptArticles.Ctx(ctx). Where("id", row.Id). - Increment("view_count", 1) + Increment("view_count", 1); err != nil { + g.Log().Warningf(ctx, "帮助文章浏览计数失败 id=%d: %v", row.Id, err) + } return &HelpPublicArticleDetail{ Id: row.Id, @@ -201,18 +213,23 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai }, nil } -// SearchPublicArticles 全文搜索已发布文章 +// 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("to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(content,'')) @@ plainto_tsquery('simple', ?)", query). + 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 { @@ -221,3 +238,33 @@ func SearchPublicArticles(ctx context.Context, query string, page, pageSize int) 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 +} diff --git a/internal/logic/tenant/help_center.go b/internal/logic/tenant/help_center.go index 35d14be..4d51914 100644 --- a/internal/logic/tenant/help_center.go +++ b/internal/logic/tenant/help_center.go @@ -3,14 +3,26 @@ package tenant import ( "context" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/logic/common" ) +// helpRateLimit 帮助中心公开接口统一入口限流(非 HTTP 上下文跳过) +func helpRateLimit(ctx context.Context) error { + if r := g.RequestFromCtx(ctx); r != nil { + return common.CheckHelpCenterRateLimit(ctx, r.GetClientIp()) + } + return nil +} + // ListHelpPublicCategories 公开帮助分类列表(树结构) func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublicCategoryListReq) (*v1.HelpPublicCategoryListRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } items, err := common.ListPublicCategories(ctx) if err != nil { return nil, err @@ -22,6 +34,9 @@ func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublic // ListHelpPublicArticles 分类下的文章列表 func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublicArticleListReq) (*v1.HelpPublicArticleListRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } items, total, page, pageSize, err := common.ListPublicArticles(ctx, req.CategorySlug, req.Page, req.PageSize) if err != nil { return nil, err @@ -50,6 +65,9 @@ func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublic // GetHelpPublicArticle 文章详情 func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicArticleGetReq) (*v1.HelpPublicArticleGetRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } detail, err := common.GetPublicArticle(ctx, req.Slug) if err != nil { return nil, err @@ -75,6 +93,9 @@ func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicAr // SearchHelpPublicArticles 搜索文章 func (s *sTenant) SearchHelpPublicArticles(ctx context.Context, req *v1.HelpPublicSearchReq) (*v1.HelpPublicSearchRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } items, total, page, pageSize, err := common.SearchPublicArticles(ctx, req.Query, req.Page, req.PageSize) if err != nil { return nil, err diff --git a/migrations/000021_drop_spt_articles_fulltext_index.sql b/migrations/000021_drop_spt_articles_fulltext_index.sql new file mode 100644 index 0000000..952efef --- /dev/null +++ b/migrations/000021_drop_spt_articles_fulltext_index.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- 帮助中心文章搜索从 to_tsvector 全文检索改为 ILIKE 模糊匹配: +-- PostgreSQL 'simple' 分词配置不做中文分词,连续中文被切成单个长 token, +-- 中文关键词几乎无法命中(帮助文档以中文内容为主)。该 GIN 索引不再被查询使用,删除以释放空间。 + +DROP INDEX IF EXISTS idx_spt_articles_search; + +-- +goose Down +-- 恢复全文检索索引(仅回滚迁移时使用,此时搜索已退回 to_tsvector 方案) +CREATE INDEX idx_spt_articles_search ON spt_articles USING gin (to_tsvector('simple'::regconfig, (((COALESCE(title, ''::character varying))::text || ' '::text) || COALESCE(content, ''::text)))); diff --git a/web/admin/src/views/system/HelpArticlesPage.vue b/web/admin/src/views/system/HelpArticlesPage.vue index ff62c43..8445cd2 100644 --- a/web/admin/src/views/system/HelpArticlesPage.vue +++ b/web/admin/src/views/system/HelpArticlesPage.vue @@ -1,11 +1,14 @@ - + - 创建分类 + 创建分类 - + - + - - {{ cat.name }} - + + + + {{ cat.name }} + + + + {{ child.name }} + + @@ -367,7 +396,12 @@ onMounted(fetchCategories) - + + + + 加载中... + + - + @@ -389,6 +423,17 @@ onMounted(fetchCategories) {{ cat.article_count }} 篇文章 + + + + {{ child.name }} · {{ child.article_count }} + +
加载中...