300 lines
7.0 KiB
Go
300 lines
7.0 KiB
Go
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
|
|
}
|