package apppopup import ( "context" "encoding/json" "errors" "net/http" "sort" "strconv" "strings" "time" "chatapp3-golang/internal/model" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // QueryEntryPopups 返回 APP 进场弹窗配置。服务端不做用户展示频控。 func (s *Service) QueryEntryPopups(ctx context.Context, user AuthUser, req EntryQueryRequest) (*EntryQueryResponse, error) { if s == nil || s.db == nil { return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_unavailable", "app popup service is unavailable") } sysOrigin := normalizeSysOrigin(user.SysOrigin) scene := normalizeScene(req.Scene) configs, err := s.loadConfigs(ctx, sysOrigin, scene) if err != nil { return nil, err } now := s.now() resp := &EntryQueryResponse{ RequestID: newRequestID(), Queue: []string{}, Items: map[string]PopupItem{}, } for _, cfg := range configs { key := normalizePopupKey(cfg.PopupKey) if key == "" { continue } item := s.buildPopupItem(cfg, req, user.UserID, now) resp.set(key, item) if item.Value { resp.Queue = append(resp.Queue, key) } } sort.SliceStable(resp.Queue, func(i, j int) bool { left := resp.Items[resp.Queue[i]] right := resp.Items[resp.Queue[j]] if left.Priority == right.Priority { return resp.Queue[i] < resp.Queue[j] } return left.Priority > right.Priority }) return resp, nil } func (s *Service) loadConfigs(ctx context.Context, sysOrigin, scene string) ([]cachedConfig, error) { cacheKey := appPopupConfigCacheKey(sysOrigin, scene) if s.cache != nil { raw, err := s.cache.Get(ctx, cacheKey).Result() if err == nil && strings.TrimSpace(raw) != "" { var configs []cachedConfig if jsonErr := json.Unmarshal([]byte(raw), &configs); jsonErr == nil { return configs, nil } } else if err != nil && !errors.Is(err, redis.Nil) { return nil, NewAppError(http.StatusServiceUnavailable, "app_popup_cache_failed", err.Error()) } } var rows []model.AppPopupConfig err := s.db.WithContext(ctx). Where("sys_origin = ? AND scene = ?", sysOrigin, scene). Order("priority DESC"). Order("id ASC"). Find(&rows).Error if errors.Is(err, gorm.ErrRecordNotFound) { return []cachedConfig{}, nil } if err != nil { return nil, err } configs := make([]cachedConfig, 0, len(rows)) for _, row := range rows { configs = append(configs, toCachedConfig(row)) } if s.cache != nil { if encoded, err := json.Marshal(configs); err == nil { _ = s.cache.Set(ctx, cacheKey, string(encoded), configCacheExpiration).Err() } } return configs, nil } func appPopupConfigCacheKey(sysOrigin, scene string) string { return "app_popup:configs:" + normalizeSysOrigin(sysOrigin) + ":" + normalizeScene(scene) } func (s *Service) buildPopupItem(cfg cachedConfig, req EntryQueryRequest, userID int64, now time.Time) PopupItem { content := parsePopupContent(cfg.ContentJSON) item := PopupItem{ Value: true, Limit: normalizeLimitDays(cfg.LimitDays), PopupID: cfg.ID, Version: normalizeVersion(cfg.Version), Priority: cfg.Priority, Title: content.Title, Image: content.Image, JumpType: strings.TrimSpace(cfg.JumpType), JumpURL: strings.TrimSpace(cfg.JumpURL), Reason: "", Content: content.Raw, } if !cfg.Enabled { item.Value = false item.Reason = reasonDisabled return item } if cfg.StartTime != nil && !cfg.StartTime.IsZero() && now.Before(*cfg.StartTime) { item.Value = false item.Reason = reasonNotStarted return item } if cfg.EndTime != nil && !cfg.EndTime.IsZero() && !now.Before(*cfg.EndTime) { item.Value = false item.Reason = reasonExpired return item } if !matchPlatforms(cfg.PlatformsJSON, req.Platform) { item.Value = false item.Reason = reasonPlatform return item } if !matchMinVersion(req.AppVersion, cfg.MinAppVersion) { item.Value = false item.Reason = reasonMinVersion return item } if !matchMaxVersion(req.AppVersion, cfg.MaxAppVersion) { item.Value = false item.Reason = reasonMaxVersion return item } if !matchUserScope(cfg.UserScopeType, cfg.UserScopeJSON, userID) { item.Value = false item.Reason = reasonUserScope return item } return item } func normalizeLimitDays(limit int) int { if limit < 0 { return 0 } return limit } func normalizeVersion(version int) int { if version <= 0 { return 1 } return version } func matchPlatforms(raw, platform string) bool { raw = strings.TrimSpace(raw) platform = normalizePlatform(platform) if raw == "" || raw == "null" || platform == "" { return true } var platforms []string if err := json.Unmarshal([]byte(raw), &platforms); err != nil { return false } if len(platforms) == 0 { return true } for _, item := range platforms { if normalizePlatform(item) == platform { return true } } return false } func matchMinVersion(appVersion, minVersion string) bool { appVersion = strings.TrimSpace(appVersion) minVersion = strings.TrimSpace(minVersion) if appVersion == "" || minVersion == "" { return true } return compareVersion(appVersion, minVersion) >= 0 } func matchMaxVersion(appVersion, maxVersion string) bool { appVersion = strings.TrimSpace(appVersion) maxVersion = strings.TrimSpace(maxVersion) if appVersion == "" || maxVersion == "" { return true } return compareVersion(appVersion, maxVersion) <= 0 } func compareVersion(left, right string) int { leftParts := parseVersion(left) rightParts := parseVersion(right) maxLen := len(leftParts) if len(rightParts) > maxLen { maxLen = len(rightParts) } for i := 0; i < maxLen; i++ { var lv, rv int if i < len(leftParts) { lv = leftParts[i] } if i < len(rightParts) { rv = rightParts[i] } if lv > rv { return 1 } if lv < rv { return -1 } } return 0 } func parseVersion(version string) []int { version = strings.TrimSpace(version) if version == "" { return []int{0} } parts := strings.Split(version, ".") result := make([]int, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { result = append(result, 0) continue } digits := strings.Builder{} for _, r := range part { if r < '0' || r > '9' { break } digits.WriteRune(r) } if digits.Len() == 0 { result = append(result, 0) continue } value, err := strconv.Atoi(digits.String()) if err != nil { result = append(result, 0) continue } result = append(result, value) } return result } func matchUserScope(scopeType, raw string, userID int64) bool { switch strings.ToUpper(strings.TrimSpace(scopeType)) { case "", "ALL": return true case "WHITELIST": return userID > 0 && userScopeContains(raw, userID) case "BLACKLIST": return userID <= 0 || !userScopeContains(raw, userID) default: return false } } func userScopeContains(raw string, userID int64) bool { raw = strings.TrimSpace(raw) if raw == "" || raw == "null" { return false } var payload struct { UserIDs []int64 `json:"userIds"` } if err := json.Unmarshal([]byte(raw), &payload); err != nil { return false } for _, item := range payload.UserIDs { if item == userID { return true } } return false }