package service import ( "context" "encoding/json" "strings" "sync/atomic" "time" "github.com/topfans/backend/pkg/logger" pbCastlove "github.com/topfans/backend/pkg/proto/castlove" pbCommon "github.com/topfans/backend/pkg/proto/common" "github.com/topfans/backend/pkg/models" "github.com/topfans/backend/services/assetService/repository" "go.uber.org/zap" "golang.org/x/sync/singleflight" ) // CastloveConfigService 铸爱工艺配置 Service 接口 type CastloveConfigService interface { // GetConfig 读取全量配置(含 5s 进程内缓存 + singleflight 防击穿) // 返回已 sanitize 后的数据 GetConfig(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) } // 缓存 TTL:5 秒(spec §3.4) // 平衡点:端上体验 vs DB 压力;运营反馈"改完看不到要等 5 秒太久"时可调到 2 秒 const castloveConfigCacheTTL = 5 * time.Second // castloveConfigService 实现 type castloveConfigService struct { repo repository.CastloveConfigRepository // 进程内缓存:原子指针,避免锁 cache atomic.Pointer[castloveConfigCacheEntry] // singleflight:多并发同时 miss 时只打一次 DB sfGroup singleflight.Group } // castloveConfigCacheEntry 缓存条目 type castloveConfigCacheEntry struct { data *pbCastlove.GetCastloveConfigResponse expiresAt time.Time } // NewCastloveConfigService 创建铸爱工艺配置 Service 实例 func NewCastloveConfigService(repo repository.CastloveConfigRepository) CastloveConfigService { return &castloveConfigService{ repo: repo, } } // GetConfig 读取全量配置(含 5s 进程内缓存 + singleflight 防击穿) func (s *castloveConfigService) GetConfig(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) { // 1. 命中缓存 if c := s.cache.Load(); c != nil && time.Now().Before(c.expiresAt) { return c.data, nil } // 2. 缓存未命中或已过期,走 singleflight(多并发只打一次 DB) v, err, _ := s.sfGroup.Do("castlove_config", func() (interface{}, error) { return s.loadFromDB(ctx) }) if err != nil { return nil, err } resp := v.(*pbCastlove.GetCastloveConfigResponse) // 3. 写入缓存(覆盖式) s.cache.Store(&castloveConfigCacheEntry{ data: resp, expiresAt: time.Now().Add(castloveConfigCacheTTL), }) return resp, nil } // loadFromDB 从 DB 读取并组装 + sanitize // (后台直连写库,这里做最低限度的兜底校验,防止脏数据穿透到前端) func (s *castloveConfigService) loadFromDB(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) { categories, err := s.repo.ListActiveCategories() if err != nil { logger.Logger.Error("castlove_config: list categories failed", zap.Error(err)) return nil, err } crafts, err := s.repo.ListActiveCrafts() if err != nil { logger.Logger.Error("castlove_config: list crafts failed", zap.Error(err)) return nil, err } maxUpdatedAt, err := s.repo.MaxUpdatedAt() if err != nil { // 失败不致命,降级 version = 当前时间 logger.Logger.Warn("castlove_config: maxUpdatedAt failed, fallback to now", zap.Error(err)) maxUpdatedAt = time.Now().UnixMilli() } // 构造 category_id -> []*craft 的 map,方便 O(1) 归类 craftsByCategory := make(map[int64][]*models.CastloveCraft, len(categories)) for _, c := range crafts { craftsByCategory[c.CategoryID] = append(craftsByCategory[c.CategoryID], c) } // 构造响应 out := &pbCastlove.GetCastloveConfigResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_OK, Message: "ok", Timestamp: time.Now().UnixMilli(), }, Categories: make([]*pbCastlove.CastloveCategoryProto, 0, len(categories)), Version: time.UnixMilli(maxUpdatedAt).UTC().Format(time.RFC3339), } for _, cat := range categories { protoCat := &pbCastlove.CastloveCategoryProto{ Id: cat.ID, Name: cat.Name, SortOrder: cat.SortOrder, Crafts: make([]*pbCastlove.CastloveCraftProto, 0), } if cat.TypeKey != nil { protoCat.TypeKey = *cat.TypeKey } // 归类该分类下的卡片 rawCrafts := craftsByCategory[cat.ID] for _, raw := range rawCrafts { // sanitize 兜底:返回 nil 表示丢弃 sanitized := sanitizeCastloveCraft(raw) if sanitized == nil { continue } protoCat.Crafts = append(protoCat.Crafts, sanitized) } out.Categories = append(out.Categories, protoCat) } return out, nil } // sanitizeCastloveCraft 兜底校验 // 返回 nil 表示该卡片应被丢弃(日志 warn,不抛错) // 规则(spec §3.5): // - image_url 必须 http(s) 开头 // - name 非空 // - route_path 若非空:必须以 / 开头,且不含 ? / # // - route_params 若非空:必须是合法 JSON object func sanitizeCastloveCraft(c *models.CastloveCraft) *pbCastlove.CastloveCraftProto { // 1. image_url 必须 http(s) 开头 if !strings.HasPrefix(c.ImageURL, "http://") && !strings.HasPrefix(c.ImageURL, "https://") { logger.Logger.Warn("castlove_config: drop craft, invalid image_url", zap.Int64("craft_id", c.ID), zap.String("image_url", c.ImageURL), ) return nil } // 2. name 非空 if strings.TrimSpace(c.Name) == "" { logger.Logger.Warn("castlove_config: drop craft, empty name", zap.Int64("craft_id", c.ID), ) return nil } // 3. route_path 兜底 routePath := "" if c.RoutePath != nil { raw := strings.TrimSpace(*c.RoutePath) if raw != "" { if !strings.HasPrefix(raw, "/") || strings.ContainsAny(raw, "?#") { logger.Logger.Warn("castlove_config: clear route_path (invalid format)", zap.Int64("craft_id", c.ID), zap.String("route_path", raw), ) // 降级为 NULL/空(行为同"未配置") raw = "" } } routePath = raw } // 4. route_params 兜底:必须是合法 JSON object routeParams := "" if c.RouteParams != nil { raw := strings.TrimSpace(*c.RouteParams) if raw != "" { var m map[string]json.RawMessage if err := json.Unmarshal([]byte(raw), &m); err != nil { logger.Logger.Warn("castlove_config: clear route_params (not JSON object)", zap.Int64("craft_id", c.ID), zap.String("route_params", raw), zap.Error(err), ) raw = "" } } routeParams = raw } return &pbCastlove.CastloveCraftProto{ Id: c.ID, Name: c.Name, ImageUrl: c.ImageURL, RoutePath: routePath, RouteParams: routeParams, SortOrder: c.SortOrder, } }