216 lines
6.2 KiB
Go
216 lines
6.2 KiB
Go
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,
|
|
}
|
|
}
|