topfans/backend/services/assetService/service/castlove_config_service.go
2026-06-09 12:38:12 +08:00

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,
}
}