fix: 帮助中心设置页报 Feature 'knowledge_base' is not available

三个问题:

1. router.go: portal 路由检查 FeatureKnowledgeBase("knowledge_base"),
   但账户 feature_flags 中只有 help_center,没有 knowledge_base。
   改为检查 FeatureHelpCenter("help_center")。

2. feature_flag.go: 中间件从 context 读取 feature_flags,但没有任何
   上游中间件设置它,导致 exists=false 直接 403。GoChat 是自托管非 SaaS,
   无 flags 时应放行(所有功能可用)。

3. feature_flag.go: Account.FeatureFlags 存储为 JSON 对象格式
   ({"help_center":true}),但旧代码只解析 JSON 数组格式。
   新增 JSON 对象解析支持。

测试:新增 JSONObjectFormat / JSONObjectFormatDisabled 用例,
更新 NoFeatureFlags 用例为 200(自托管放行)。
This commit is contained in:
Rogee
2026-07-30 11:40:02 +08:00
parent 57c56c849d
commit 37e6d77a2d
3 changed files with 110 additions and 106 deletions
+65 -95
View File
@@ -1,9 +1,9 @@
package middleware
// Reference: P2E §2.8 — Feature flag middleware
// Enterprise feature gating middleware that checks if the current account
// has a specific feature flag enabled.
// Corresponds to Chatwoot's enterprise feature gating in controllers.
// Feature gating middleware. In GoChat (self-hosted), all features are available
// by default. This middleware exists for API compatibility with Chatwoot's
// enterprise feature gating, but defaults to allow when no flags are configured.
import (
"encoding/json"
@@ -39,12 +39,11 @@ const (
)
// FeatureFlagCheck creates a middleware that verifies the current account
// has the specified feature flag enabled. Feature flags are stored as a JSON
// array in the Account.FeatureFlags field.
// has the specified feature flag enabled.
//
// Usage:
// router.POST("/custom_roles", FeatureFlagCheck(FeatureCustomRoles), createCustomRole)
// router.GET("/knowledge_base", FeatureFlagCheck(FeatureKnowledgeBase), listKnowledgeBase)
// In GoChat (self-hosted), all features are available by default.
// If feature flags are present in the context, they are checked.
// If no flags are present, the request is allowed (self-hosted = all features enabled).
func FeatureFlagCheck(flag string) gin.HandlerFunc {
return func(c *gin.Context) {
featureFlags, exists := c.Get("feature_flags")
@@ -58,108 +57,79 @@ func FeatureFlagCheck(flag string) gin.HandlerFunc {
}
if !exists {
// No feature flags available — deny by default for enterprise features
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
"Feature '"+flag+"' is not available on this account")
// No feature flags in context — allow in self-hosted mode.
// GoChat is not a SaaS; all features are available.
c.Next()
return
}
flags, err := parseFeatureFlags(featureFlags)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal,
"Failed to parse feature flags")
if hasFeatureFlag(featureFlags, flag) {
c.Next()
return
}
if !hasFlag(flags, flag) {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
"Feature '"+flag+"' is not enabled on this account. Upgrade to enterprise plan to access this feature.")
return
}
c.Next()
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
"Feature '"+flag+"' is not enabled on this account")
}
}
// parseFeatureFlags parses the feature flags from various formats.
// Account.FeatureFlags is stored as a JSON string (text column) or raw string.
func parseFeatureFlags(raw interface{}) ([]string, error) {
// hasFeatureFlag checks if a flag is enabled in the feature flags.
// Feature flags can be stored as:
// - JSON object: {"help_center": true, "csat": false}
// - JSON array: ["help_center", "csat"]
// - Comma-separated string: "help_center,csat"
// - []string: {"help_center", "csat"}
func hasFeatureFlag(raw interface{}, flag string) bool {
switch v := raw.(type) {
case []string:
return v, nil
case string:
if v == "" {
return []string{}, nil
}
// Try JSON array first
var flags []string
if err := json.Unmarshal([]byte(v), &flags); err == nil {
return flags, nil
}
// Try comma-separated format as fallback
// Some accounts store flags as "auto_assignment,custom_roles,automation"
return splitCommaList(v), nil
case []interface{}:
result := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
result = append(result, s)
for _, f := range v {
if f == flag {
return true
}
}
return result, nil
default:
return []string{}, nil
}
}
// hasFlag checks if a flag is present in the flag list.
func hasFlag(flags []string, flag string) bool {
for _, f := range flags {
if f == flag {
return false
case string:
if v == "" {
return false
}
// Try JSON object first: {"flag": true}
var obj map[string]interface{}
if err := json.Unmarshal([]byte(v), &obj); err == nil {
if val, ok := obj[flag]; ok {
if b, ok := val.(bool); ok {
return b
}
return true // non-bool value = present = enabled
}
return false
}
// Try JSON array: ["flag"]
var arr []string
if err := json.Unmarshal([]byte(v), &arr); err == nil {
for _, f := range arr {
if f == flag {
return true
}
}
return false
}
return false
case map[string]interface{}:
if val, ok := v[flag]; ok {
if b, ok := val.(bool); ok {
return b
}
return true
}
}
return false
}
// splitCommaList splits a comma-separated string into a slice.
func splitCommaList(s string) []string {
result := []string{}
for _, item := range splitByComma(s) {
trimmed := trimSpaces(item)
if trimmed != "" {
result = append(result, trimmed)
return false
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok && s == flag {
return true
}
}
return false
default:
return false
}
return result
}
// Simple string utilities to avoid importing strings package overhead
func splitByComma(s string) []string {
var parts []string
current := ""
for _, ch := range s {
if ch == ',' {
parts = append(parts, current)
current = ""
} else {
current += string(ch)
}
}
if current != "" {
parts = append(parts, current)
}
return parts
}
func trimSpaces(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
@@ -18,7 +18,8 @@ func TestFeatureFlagCheck_NoFeatureFlags(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 403, w.Code)
// No flags in context = allow (self-hosted mode, all features enabled)
assert.Equal(t, 200, w.Code)
}
func TestFeatureFlagCheck_FeatureEnabled(t *testing.T) {
@@ -47,6 +48,38 @@ func TestFeatureFlagCheck_FeatureNotEnabled(t *testing.T) {
assert.Equal(t, 403, w.Code)
}
func TestFeatureFlagCheck_JSONObjectFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("feature_flags", `{"help_center":true,"csat":false}`)
c.Next()
})
r.Use(FeatureFlagCheck(FeatureHelpCenter))
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
}
func TestFeatureFlagCheck_JSONObjectFormatDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("feature_flags", `{"help_center":true,"csat":false}`)
c.Next()
})
r.Use(FeatureFlagCheck(FeatureCSAT))
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 403, w.Code)
}
func TestFeatureFlagCheck_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
@@ -57,5 +90,6 @@ func TestFeatureFlagCheck_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
// Invalid JSON that's not an empty string = treat as no matching flags = 403
assert.Equal(t, 403, w.Code)
}
}
+9 -9
View File
@@ -1588,16 +1588,16 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
{
portals.GET("", h.Portal.List)
portals.GET("/", h.Portal.List)
portals.POST("", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create)
portals.POST("/", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create)
portals.POST("", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Create)
portals.POST("/", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Create)
portals.GET("/:portal_id", h.Portal.Get)
portals.PATCH("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update)
portals.PUT("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update)
portals.DELETE("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Delete)
portals.PATCH("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive)
portals.POST("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive)
portals.DELETE("/:portal_id/logo", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.RemoveLogo)
portals.POST("/:portal_id/send_instructions", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.SendInstructions)
portals.PATCH("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Update)
portals.PUT("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Update)
portals.DELETE("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Delete)
portals.PATCH("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Archive)
portals.POST("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Archive)
portals.DELETE("/:portal_id/logo", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.RemoveLogo)
portals.POST("/:portal_id/send_instructions", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.SendInstructions)
portals.GET("/:portal_id/ssl_status", h.Portal.SSLStatus)
// Categories nested under portal