feat(questions): make brand questions the primary monitoring & template axis
- add /questions/combination-fill endpoint with AI-driven, IP-region-aware matrix fill - extract ip2region resolver from ops/app into shared/ipregion for cross-service use - thread question_id filter through dashboard composite, citation summary, and collect-now - switch template wizard from keyword inputs to brand-question selection (primary + supplemental) - pass brand_question and supplemental_questions through assist/title/outline prompts - add AiWaitingModal + 45s client timeout and request_timeout error mapping for long AI flows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ type Config struct {
|
||||
MonitoringDispatch MonitoringDispatchConfig `mapstructure:"monitoring_dispatch"`
|
||||
Membership MembershipConfig `mapstructure:"membership"`
|
||||
BrandLibrary BrandLibraryConfig `mapstructure:"brand_library"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
Qdrant QdrantConfig `mapstructure:"qdrant"`
|
||||
ObjectStorage ObjectStorageConfig `mapstructure:"object_storage"`
|
||||
@@ -244,6 +245,11 @@ type BrandLibraryConfig struct {
|
||||
QuestionLimitsByPlan map[string]int `mapstructure:"question_limits_by_plan"`
|
||||
}
|
||||
|
||||
type IPRegionConfig struct {
|
||||
V4XDBPath string `mapstructure:"v4_xdb_path"`
|
||||
V6XDBPath string `mapstructure:"v6_xdb_path"`
|
||||
}
|
||||
|
||||
func (c BrandLibraryConfig) BrandLimitForPlan(planCode string) int {
|
||||
if strings.EqualFold(strings.TrimSpace(planCode), "free") {
|
||||
return c.FreeBrandLimit
|
||||
@@ -958,6 +964,12 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
|
||||
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
|
||||
}
|
||||
if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
|
||||
cfg.IPRegion.V4XDBPath = path
|
||||
}
|
||||
if path, ok := lookupNonEmptyEnv("IP_REGION_V6_XDB_PATH"); ok {
|
||||
cfg.IPRegion.V6XDBPath = path
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
|
||||
|
||||
@@ -65,6 +65,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if !reflect.DeepEqual(previous.BrandLibrary, current.BrandLibrary) {
|
||||
addChange("brand_library", true)
|
||||
}
|
||||
if previous.IPRegion != current.IPRegion {
|
||||
addChange("ip_region", false)
|
||||
}
|
||||
if previous.Qdrant != current.Qdrant {
|
||||
addChange("qdrant", true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package ipregion
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||
)
|
||||
|
||||
//go:embed ipregiondata/ip2region_v4.xdb ipregiondata/ip2region_v6.xdb
|
||||
var embeddedIPRegionFS embed.FS
|
||||
|
||||
const (
|
||||
embeddedV4XDBPath = "ipregiondata/ip2region_v4.xdb"
|
||||
embeddedV6XDBPath = "ipregiondata/ip2region_v6.xdb"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
searcher searcher
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type searcher interface {
|
||||
Search(ip any) (string, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
func NewResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*Resolver, error) {
|
||||
searcher, err := newSearcher(v4XDBPath, v6XDBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Resolver{searcher: searcher, logger: logger}, nil
|
||||
}
|
||||
|
||||
func newSearcher(v4XDBPath, v6XDBPath string) (*bufferSearcher, error) {
|
||||
v4Content, err := loadContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ipv4 xdb: %w", err)
|
||||
}
|
||||
v6Content, err := loadContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ipv6 xdb: %w", err)
|
||||
}
|
||||
|
||||
searcher := &bufferSearcher{}
|
||||
if len(v4Content) > 0 {
|
||||
searcher.v4, err = xdb.NewWithBuffer(xdb.IPv4, v4Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ipv4 searcher: %w", err)
|
||||
}
|
||||
}
|
||||
if len(v6Content) > 0 {
|
||||
searcher.v6, err = xdb.NewWithBuffer(xdb.IPv6, v6Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ipv6 searcher: %w", err)
|
||||
}
|
||||
}
|
||||
return searcher, nil
|
||||
}
|
||||
|
||||
func loadContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) {
|
||||
if externalPath != "" {
|
||||
content, err := loadContentFromFile(externalPath, version)
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
content, err := xdb.LoadContentFromFS(embeddedIPRegionFS, embeddedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyVersion(content, version); err != nil {
|
||||
return nil, fmt.Errorf("verify embedded xdb %s: %w", embeddedPath, err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func loadContentFromFile(path string, version *xdb.Version) ([]byte, error) {
|
||||
content, err := xdb.LoadContentFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyVersion(content, version); err != nil {
|
||||
return nil, fmt.Errorf("verify external xdb %s: %w", path, err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func verifyVersion(content []byte, expected *xdb.Version) error {
|
||||
if len(content) < xdb.HeaderInfoLength {
|
||||
return fmt.Errorf("xdb content too short: got %d bytes", len(content))
|
||||
}
|
||||
header, err := xdb.LoadHeaderFromBuff(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load header: %w", err)
|
||||
}
|
||||
version, err := xdb.VersionFromHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if version.Id != expected.Id {
|
||||
return fmt.Errorf("ip version mismatch: got %s, want %s", version.Name, expected.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Resolver) Lookup(rawIP string) string {
|
||||
ipText := strings.TrimSpace(rawIP)
|
||||
if ipText == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(ipText); err == nil {
|
||||
ipText = host
|
||||
}
|
||||
|
||||
ip := net.ParseIP(ipText)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
return "本机"
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return "内网IP"
|
||||
}
|
||||
if r == nil || r.searcher == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
region, err := r.searcher.Search(ip.String())
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("ip2region lookup failed",
|
||||
zap.String("ip", ip.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(region)
|
||||
}
|
||||
|
||||
func (r *Resolver) Close() {
|
||||
if r != nil && r.searcher != nil {
|
||||
r.searcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type bufferSearcher struct {
|
||||
v4 *xdb.Searcher
|
||||
v4Mu sync.Mutex
|
||||
v6 *xdb.Searcher
|
||||
v6Mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *bufferSearcher) Search(ip any) (string, error) {
|
||||
ipBytes, err := parseIPBytes(ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch len(ipBytes) {
|
||||
case xdb.IPv4.Bytes:
|
||||
if s == nil || s.v4 == nil {
|
||||
return "", nil
|
||||
}
|
||||
s.v4Mu.Lock()
|
||||
defer s.v4Mu.Unlock()
|
||||
return s.v4.Search(ipBytes)
|
||||
case xdb.IPv6.Bytes:
|
||||
if s == nil || s.v6 == nil {
|
||||
return "", nil
|
||||
}
|
||||
s.v6Mu.Lock()
|
||||
defer s.v6Mu.Unlock()
|
||||
return s.v6.Search(ipBytes)
|
||||
default:
|
||||
return "", fmt.Errorf("invalid byte ip address with len=%d", len(ipBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *bufferSearcher) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.v4 != nil {
|
||||
s.v4.Close()
|
||||
}
|
||||
if s.v6 != nil {
|
||||
s.v6.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPBytes(ip any) ([]byte, error) {
|
||||
switch v := ip.(type) {
|
||||
case string:
|
||||
return xdb.ParseIP(v)
|
||||
case []byte:
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ip value type %T", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ipregion
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolverLookupSpecialAddresses(t *testing.T) {
|
||||
resolver := &Resolver{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want string
|
||||
}{
|
||||
{name: "ipv4 loopback", ip: "127.0.0.1", want: "本机"},
|
||||
{name: "ipv6 loopback", ip: "::1", want: "本机"},
|
||||
{name: "private network", ip: "192.168.1.10", want: "内网IP"},
|
||||
{name: "host port", ip: "10.0.0.8:443", want: "内网IP"},
|
||||
{name: "invalid", ip: "not-an-ip", want: ""},
|
||||
{name: "public disabled", ip: "8.8.8.8", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := resolver.Lookup(tt.ip); got != tt.want {
|
||||
t.Fatalf("Lookup(%q) = %q, want %q", tt.ip, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverLookupFromXDB(t *testing.T) {
|
||||
resolver, err := NewResolver("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewResolver() error = %v", err)
|
||||
}
|
||||
defer resolver.Close()
|
||||
|
||||
if got := resolver.Lookup("8.8.8.8"); got == "" {
|
||||
t.Fatal("Lookup(8.8.8.8) returned empty region")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) {
|
||||
resolver, err := NewResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewResolver() error = %v", err)
|
||||
}
|
||||
defer resolver.Close()
|
||||
|
||||
if got := resolver.Lookup("8.8.8.8"); got == "" {
|
||||
t.Fatal("Lookup(8.8.8.8) returned empty region")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -178,6 +178,7 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
|
||||
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
|
||||
"POST /api/tenant/brands/:id/questions/combination-preview": {"拓词工具预览", "按地域词、前缀词、核心词、行业词、后缀词组合生成问题候选,仅预览不入库。"},
|
||||
"POST /api/tenant/brands/:id/questions/combination-fill": {"拓词工具 AI 填词", "根据用户 IP 本地识别地域,并结合品牌信息补全拓词工具的五列词组。"},
|
||||
"POST /api/tenant/brands/:id/questions/ai-distill": {"AI 扩展问题", "围绕品牌和主题生成问题候选,使用结构化输出并按 AI 点计费。"},
|
||||
"POST /api/tenant/brands/:id/questions/classify-metadata": {"问题元数据分类", "批量为问题文本推断 layer 和 intent 元数据。"},
|
||||
"POST /api/tenant/brands/:id/questions/materialize": {"保存问题候选", "将用户选中的问题候选保存到当前品牌问题集,执行配额、去重和审计。"},
|
||||
|
||||
@@ -320,9 +320,9 @@ func queryParameterNames(route gin.RouteInfo) []string {
|
||||
case strings.Contains(path, "/tenant/brands") && strings.Contains(path, "/questions"):
|
||||
add("keyword_id")
|
||||
case strings.Contains(path, "/tenant/monitoring/dashboard/composite"):
|
||||
add("brand_id", "keyword_id", "days", "business_date", "ai_platform_id")
|
||||
add("brand_id", "keyword_id", "question_id", "days", "business_date", "ai_platform_id")
|
||||
case strings.Contains(path, "/tenant/monitoring/citation-summary"):
|
||||
add("days")
|
||||
add("days", "brand_id", "keyword_id", "question_id", "business_date", "ai_platform_id")
|
||||
case strings.Contains(path, "/tenant/monitoring/brands/"):
|
||||
add("ai_platform_id", "date_from", "date_to", "question_hash")
|
||||
case strings.Contains(path, "/tenant/kol/marketplace/packages"):
|
||||
@@ -359,7 +359,7 @@ func schemaForQueryParameter(name string) map[string]any {
|
||||
switch name {
|
||||
case "page", "page_size", "limit", "offset", "days", "period_days":
|
||||
return map[string]any{"type": "integer"}
|
||||
case "brand_id", "keyword_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
||||
case "brand_id", "keyword_id", "question_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
||||
return map[string]any{"type": "integer", "format": "int64"}
|
||||
case "force":
|
||||
return map[string]any{"type": "string", "enum": []string{"1"}}
|
||||
|
||||
Reference in New Issue
Block a user