- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前,消除连接池占用。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential
/ErrTooManyLoginAttempts; 用户不存在/密码错/密码空 三路径统一返回同一错误;
mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级); user-not-found 走
dummy bcrypt 抹平 ~100ms 时序差,完全消除枚举侧信道;空密码分支已核实无时序 leak。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, 新 stub EventProducer.Publish no-op;
pkg/mq/mq.go Init 不再装配 streams; 全仓 grep 验证 11 处硬编码
'gallery'/'default' 集中到 pkg/queue/consts (值不变, 仅消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value (见上一个 commit 293c7b1)。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误
记 WARN 不静默; Dify err 映射稳定用户文案,原始 err 仅服务端日志。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop
worker; 失败 ERROR 日志带字段; drop 记 WARN; Close 可重复调用。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 5+ 处 GetFanIdentities 链式调用;
DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go
风险,见报告 §5 proto 风险复盘); 铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。
Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
4.1 KiB
Go
146 lines
4.1 KiB
Go
// Package starcache provides a short-TTL in-memory cache for pb.Star metadata
|
|
// looked up via UserSocialService.GetFanIdentities.
|
|
//
|
|
// Why this exists:
|
|
// Gateway controllers used to call ctrl.userServiceClient.GetFanIdentities
|
|
// after every Register/Login/GetCurrentUser/GetMyProfile/AddIdentity/SwitchIdentity
|
|
// just to fetch a single Star by ID. The full identity list is small (~few dozen
|
|
// stars) and effectively static, so we cache it for 60s with singleflight to
|
|
// collapse concurrent refreshes.
|
|
//
|
|
// Security: the cache only holds pb.Star structs (public star metadata). It
|
|
// does NOT hold any user-specific fields (mobile, token, password, fan_profiles).
|
|
// user_id is the cache key, but its only use is to look up the corresponding
|
|
// star_id - it is not stored in any cache entry. Star ID is treated as public
|
|
// data (it's what the registration UI itself displays).
|
|
package starcache
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/topfans/backend/pkg/logger"
|
|
pb "github.com/topfans/backend/pkg/proto/user"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// CacheTTL is the per-star expiration window. Short enough that star metadata
|
|
// changes (e.g. avatar update) propagate within a minute, long enough that the
|
|
// chained GetFanIdentities calls in Register/Login/GetCurrentUser/etc. reuse
|
|
// one RPC round-trip per minute of traffic.
|
|
const CacheTTL = 60 * time.Second
|
|
|
|
// Cache wraps a TTL map + singleflight for GetFanIdentities.
|
|
//
|
|
// Concurrency model:
|
|
// - mu guards stars/expiry (RWMutex: many readers, one writer).
|
|
// - sfMu guards the inflight map (separate lock so the slow path doesn't
|
|
// block the hot read path).
|
|
type Cache struct {
|
|
cli pb.UserSocialService
|
|
|
|
mu sync.RWMutex
|
|
stars map[int64]*pb.Star
|
|
expiry map[int64]time.Time
|
|
|
|
sfMu sync.Mutex
|
|
inflight map[int64]chan struct{}
|
|
}
|
|
|
|
// New constructs a Cache backed by the given UserSocialService client.
|
|
func New(cli pb.UserSocialService) *Cache {
|
|
return &Cache{
|
|
cli: cli,
|
|
stars: make(map[int64]*pb.Star),
|
|
expiry: make(map[int64]time.Time),
|
|
inflight: make(map[int64]chan struct{}),
|
|
}
|
|
}
|
|
|
|
// GetStar returns the cached pb.Star for starID, refreshing from the upstream
|
|
// RPC on miss or expiry. Returns (nil, err) on upstream error; callers should
|
|
// treat nil as "star info unavailable" and continue with the rest of the
|
|
// response.
|
|
//
|
|
// starID == 0 is a no-op (returns nil, nil) so that callers don't need to
|
|
// pre-check empty identities.
|
|
func (c *Cache) GetStar(ctx context.Context, starID int64) (*pb.Star, error) {
|
|
if starID == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Hot path: read lock.
|
|
c.mu.RLock()
|
|
if s, ok := c.stars[starID]; ok {
|
|
if exp, ok := c.expiry[starID]; ok && time.Now().Before(exp) {
|
|
c.mu.RUnlock()
|
|
return s, nil
|
|
}
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
// Singleflight: ensure only one refresh per starID at a time.
|
|
c.sfMu.Lock()
|
|
if ch, ok := c.inflight[starID]; ok {
|
|
c.sfMu.Unlock()
|
|
<-ch
|
|
// Re-read cache after the leader populated it.
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.stars[starID], nil
|
|
}
|
|
ch := make(chan struct{})
|
|
c.inflight[starID] = ch
|
|
c.sfMu.Unlock()
|
|
|
|
defer func() {
|
|
close(ch)
|
|
c.sfMu.Lock()
|
|
delete(c.inflight, starID)
|
|
c.sfMu.Unlock()
|
|
}()
|
|
|
|
resp, err := c.cli.GetFanIdentities(ctx, &pb.GetFanIdentitiesRequest{})
|
|
if err != nil {
|
|
logger.Logger.Warn("starcache: GetFanIdentities refresh failed",
|
|
zap.Int64("star_id", starID),
|
|
zap.Error(err),
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
now := time.Now()
|
|
for _, s := range resp.Stars {
|
|
c.stars[s.StarId] = s
|
|
c.expiry[s.StarId] = now.Add(CacheTTL)
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.stars[starID], nil
|
|
}
|
|
|
|
// Invalidate removes the cached entry for starID. Call this when star metadata
|
|
// changes (e.g. admin updates a star's avatar) so the next GetStar refreshes.
|
|
//
|
|
// Safe to call with starID == 0 (no-op).
|
|
func (c *Cache) Invalidate(starID int64) {
|
|
if starID == 0 {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.stars, starID)
|
|
delete(c.expiry, starID)
|
|
}
|
|
|
|
// Len returns the number of cached stars. Useful for tests and metrics.
|
|
func (c *Cache) Len() int {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return len(c.stars)
|
|
}
|