Files
fm_be/internal/shared/pkg/userctx/user_name_cache.go
2026-07-16 22:16:45 +07:00

347 lines
9.1 KiB
Go

package userctx
import (
"context"
"strings"
"sync"
"time"
"wucher/internal/domain/user"
)
// UserNameGetter is the interface used by handlers to resolve user IDs to names.
type UserNameGetter interface {
GetLastName(ctx context.Context, userID []byte) (string, error)
}
// UserFullNameGetter is an optional extension for getters that can resolve
// full names (first + last). Code should prefer this when available.
type UserFullNameGetter interface {
GetFullName(ctx context.Context, userID []byte) (string, error)
}
// UserShortNameGetter is an optional extension for getters that can resolve a
// user's short name (profile 3-letter code, e.g. "GAN"). Code should prefer
// this when available, falling back to initials from the display name.
type UserShortNameGetter interface {
GetShortName(ctx context.Context, userID []byte) (string, error)
}
// shortNameRepo is the optional repository capability used by UserNameCache to
// look up a profile short name. Only the concrete MySQL repo implements it;
// keeping it out of user.Repository avoids churning every mock.
type shortNameRepo interface {
GetShortNameByID(ctx context.Context, id []byte) (string, error)
}
// initialsFromFullName derives an uppercase initials short name from a full
// name: "System Admin" → "SA", "Gantner" → "G". Empty when there is no name.
func initialsFromFullName(fullName string) string {
parts := strings.Fields(strings.TrimSpace(fullName))
if len(parts) == 0 {
return ""
}
if len(parts) == 1 {
r := []rune(parts[0])
if len(r) == 0 {
return ""
}
return strings.ToUpper(string(r[0]))
}
first := []rune(parts[0])
last := []rune(parts[len(parts)-1])
if len(first) == 0 || len(last) == 0 {
return ""
}
return strings.ToUpper(string(first[0]) + string(last[0]))
}
// GlobalUserNameGetter is set once at application startup via WireUserNameGetter.
// It is used by uuidStringOrEmpty helpers across packages.
var GlobalUserNameGetter UserNameGetter
// WireUserNameGetter configures the global user name getter.
// Call this once during application startup (e.g. in main.go).
func WireUserNameGetter(getter UserNameGetter) {
GlobalUserNameGetter = getter
}
// contextKey for storing the getter in context.
type contextKey string
const getterKey contextKey = "userctx-getter"
// WithGetter stores a UserNameGetter in the context.
func WithGetter(ctx context.Context, getter UserNameGetter) context.Context {
return context.WithValue(ctx, getterKey, getter)
}
// GetGetter retrieves the UserNameGetter from context.
func GetGetter(ctx context.Context) UserNameGetter {
if ctx == nil {
return nil
}
if getter, ok := any(ctx).(interface{ UserValue(any) any }); ok {
raw := getter.UserValue(string(getterKey))
if raw != nil {
return raw.(UserNameGetter)
}
}
v := ctx.Value(getterKey)
if v == nil {
return nil
}
g, _ := v.(UserNameGetter)
return g
}
// GetDisplayName resolves a user ID to a display name.
// Priority:
// 1) full name from context getter (if supported)
// 2) last name from context getter
// 3) full name from global getter (if supported)
// 4) last name from global getter
func GetDisplayName(ctx context.Context, userID []byte) string {
if len(userID) == 0 {
return ""
}
type userNameGetter interface {
GetLastName(context.Context, []byte) (string, error)
}
type userFullNameGetter interface {
GetFullName(context.Context, []byte) (string, error)
}
// Backward compatibility: some call sites pass a context-like object that
// implements getter methods directly.
if fullGetter, ok := any(ctx).(userFullNameGetter); ok {
if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" {
return name
}
}
if getter, ok := any(ctx).(userNameGetter); ok {
if name, err := getter.GetLastName(ctx, userID); err == nil && name != "" {
return name
}
}
if getter := GetGetter(ctx); getter != nil {
if fullGetter, ok := getter.(UserFullNameGetter); ok {
if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" {
return name
}
}
if name, err := getter.GetLastName(ctx, userID); err == nil && name != "" {
return name
}
}
if GlobalUserNameGetter != nil {
if fullGetter, ok := GlobalUserNameGetter.(UserFullNameGetter); ok {
if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" {
return name
}
}
if name, err := GlobalUserNameGetter.GetLastName(ctx, userID); err == nil && name != "" {
return name
}
}
return ""
}
// GetShortName resolves a user ID to a short name (profile 3-letter code, e.g.
// "GAN"), falling back to initials derived from the user's display name.
// Priority mirrors GetDisplayName: context getter, then global getter, then a
// display-name-initials fallback so it always returns something short.
func GetShortName(ctx context.Context, userID []byte) string {
if len(userID) == 0 {
return ""
}
type shortGetter interface {
GetShortName(context.Context, []byte) (string, error)
}
// Backward compatibility: a context-like object implementing the getter.
if g, ok := any(ctx).(shortGetter); ok {
if s, err := g.GetShortName(ctx, userID); err == nil && s != "" {
return s
}
}
if getter := GetGetter(ctx); getter != nil {
if sg, ok := getter.(UserShortNameGetter); ok {
if s, err := sg.GetShortName(ctx, userID); err == nil && s != "" {
return s
}
}
}
if GlobalUserNameGetter != nil {
if sg, ok := GlobalUserNameGetter.(UserShortNameGetter); ok {
if s, err := sg.GetShortName(ctx, userID); err == nil && s != "" {
return s
}
}
}
// Universal fallback: initials from whatever display name we can resolve.
return initialsFromFullName(GetDisplayName(ctx, userID))
}
// GetLastName resolves a user ID to their last name using the getter from context.
// Returns an empty string if no getter is available or if userID is nil/empty.
func GetLastName(ctx context.Context, userID []byte) string {
if len(userID) == 0 {
return ""
}
getter := GetGetter(ctx)
if getter == nil {
return ""
}
name, err := getter.GetLastName(ctx, userID)
if err != nil {
return ""
}
return name
}
// cacheEntry holds a cached last name with expiry time.
type cacheEntry struct {
lastName string
expires time.Time
}
// UserNameCache is an in-memory TTL cache backed by a user.Repository.
type UserNameCache struct {
repo user.Repository
cache map[string]cacheEntry
shortCache map[string]cacheEntry
mu sync.RWMutex
ttl time.Duration
}
// NewUserNameCache creates a UserNameCache with the given TTL (default 5 minutes).
func NewUserNameCache(repo user.Repository, ttl time.Duration) *UserNameCache {
if ttl <= 0 {
ttl = 5 * time.Minute
}
c := &UserNameCache{
repo: repo,
cache: make(map[string]cacheEntry),
shortCache: make(map[string]cacheEntry),
ttl: ttl,
}
go c.cleanupLoop()
return c
}
// GetShortName resolves a user ID to their profile short name (3-letter code),
// falling back to initials derived from their full name. Cached with the same
// TTL as names. (The cacheEntry.lastName field stores the short name here.)
func (c *UserNameCache) GetShortName(ctx context.Context, userID []byte) (string, error) {
if len(userID) == 0 {
return "", nil
}
key := string(userID)
c.mu.RLock()
entry, ok := c.shortCache[key]
c.mu.RUnlock()
if ok && time.Now().Before(entry.expires) {
return entry.lastName, nil
}
short := ""
if sr, ok := c.repo.(shortNameRepo); ok {
s, err := sr.GetShortNameByID(ctx, userID)
if err != nil {
return "", err
}
short = strings.TrimSpace(s)
}
if short == "" {
// Fallback: initials derived from the full name.
if full, err := c.GetFullName(ctx, userID); err == nil {
short = initialsFromFullName(full)
}
}
c.mu.Lock()
c.shortCache[key] = cacheEntry{lastName: short, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
return short, nil
}
func (c *UserNameCache) GetLastName(ctx context.Context, userID []byte) (string, error) {
if len(userID) == 0 {
return "", nil
}
key := string(userID)
c.mu.RLock()
entry, ok := c.cache[key]
c.mu.RUnlock()
if ok && time.Now().Before(entry.expires) {
return entry.lastName, nil
}
lastName, err := c.repo.GetLastNameByID(ctx, userID)
if err != nil {
return "", err
}
if lastName == "" {
lastName = "(Unknown)"
}
c.mu.Lock()
c.cache[key] = cacheEntry{lastName: lastName, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
return lastName, nil
}
func (c *UserNameCache) GetFullName(ctx context.Context, userID []byte) (string, error) {
if len(userID) == 0 {
return "", nil
}
u, err := c.repo.GetByID(ctx, userID)
if err != nil {
return "", err
}
if u == nil {
return "", nil
}
first := ""
last := ""
if u.FirstName != "" {
first = u.FirstName
}
if u.LastName != "" {
last = u.LastName
}
if first == "" {
return last, nil
}
if last == "" {
return first, nil
}
return first + " " + last, nil
}
func (c *UserNameCache) cleanupLoop() {
ticker := time.NewTicker(c.ttl * 2)
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, v := range c.cache {
if now.After(v.expires) {
delete(c.cache, k)
}
}
for k, v := range c.shortCache {
if now.After(v.expires) {
delete(c.shortCache, k)
}
}
c.mu.Unlock()
}
}