@@ -0,0 +1,182 @@
|
||||
package contentmarkerser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/constant/redisconst"
|
||||
"91porn-server/common/log"
|
||||
"91porn-server/models/v/mediamod"
|
||||
"91porn-server/models/v/moduleconfmod"
|
||||
"91porn-server/models/v/tagmod"
|
||||
"91porn-server/models/v/vidmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type ModuleMarker struct {
|
||||
ModuleID string `json:"moduleId"`
|
||||
LatestAt *time.Time `json:"latestAt"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
HomeLatestAt *time.Time `json:"homeLatestAt"`
|
||||
Modules []ModuleMarker `json:"modules"`
|
||||
}
|
||||
|
||||
// MarkerCacheReader is the Redis JSON subset used to share marker snapshots.
|
||||
type MarkerCacheReader interface {
|
||||
GetWithJson2Any(key string, val any) error
|
||||
}
|
||||
|
||||
// MarkerCache adds atomic cache population to the marker snapshot reader.
|
||||
type MarkerCache interface {
|
||||
MarkerCacheReader
|
||||
SetNX(key string, value interface{}, expiration time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
// GetCached returns the shared marker snapshot, loading and caching it on a miss.
|
||||
func GetCached(now time.Time, cache MarkerCache) (Response, error) {
|
||||
data, err := getCached(now, cache, Get)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if latestAt, cacheHit := homeLatestAtFromCachedList(cache); cacheHit {
|
||||
data.HomeLatestAt = latestAt
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func getCached(now time.Time, cache MarkerCache, load func(time.Time) (Response, error)) (Response, error) {
|
||||
if data, ok := ReadCached(cache); ok {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
data, err := load(now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if cache != nil {
|
||||
payload, marshalErr := json.Marshal(data)
|
||||
if marshalErr != nil {
|
||||
log.Warn("marshal content update markers failed", log.E(marshalErr))
|
||||
return data, nil
|
||||
}
|
||||
stored, cacheErr := cache.SetNX(
|
||||
redisconst.ContentUpdateMarkersCache,
|
||||
string(payload),
|
||||
redisconst.ContentUpdateMarkersCacheExpire,
|
||||
)
|
||||
if cacheErr != nil {
|
||||
log.Warn("cache content update markers failed", log.E(cacheErr))
|
||||
} else if !stored {
|
||||
if current, ok := ReadCached(cache); ok {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ReadCached returns only an existing marker snapshot and never queries MongoDB.
|
||||
func ReadCached(cache MarkerCacheReader) (Response, bool) {
|
||||
if cache == nil {
|
||||
return Response{}, false
|
||||
}
|
||||
var data Response
|
||||
if err := cache.GetWithJson2Any(redisconst.ContentUpdateMarkersCache, &data); err != nil {
|
||||
return Response{}, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
type cachedHomeLatestList struct {
|
||||
Videos []struct {
|
||||
ReviewAt time.Time `json:"reviewAt"`
|
||||
} `json:"videos"`
|
||||
}
|
||||
|
||||
const (
|
||||
homeLatestListSortType = 1
|
||||
homeLatestListPageNumber = 1
|
||||
homeLatestListPageSize = 10
|
||||
)
|
||||
|
||||
// homeLatestAtFromCachedList makes the "latest" red dot describe the same
|
||||
// first-page snapshot that the client can currently read from the list cache.
|
||||
func homeLatestAtFromCachedList(cache MarkerCacheReader) (*time.Time, bool) {
|
||||
if cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
var data cachedHomeLatestList
|
||||
key := redisconst.GetMostNewModuleVideoListKey(
|
||||
homeLatestListSortType,
|
||||
homeLatestListPageNumber,
|
||||
homeLatestListPageSize,
|
||||
)
|
||||
if err := cache.GetWithJson2Any(key, &data); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if len(data.Videos) == 0 || data.Videos[0].ReviewAt.IsZero() {
|
||||
return nil, true
|
||||
}
|
||||
latestAt := data.Videos[0].ReviewAt
|
||||
return &latestAt, true
|
||||
}
|
||||
|
||||
func Get(now time.Time) (Response, error) {
|
||||
modules, err := moduleconfmod.GetAllActiveModule(now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
excluded, err := moduleconfmod.ExcludedVideoModuleIDs(now, false)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
baseFilter := bson.M{"status": vidmod.CheckPass, "newsType": vidmod.SP}
|
||||
if len(excluded) > 0 {
|
||||
baseFilter["mId"] = bson.M{"$nin": excluded}
|
||||
}
|
||||
if darkTag, findErr := tagmod.FindOneTagByName("暗网"); findErr == nil && !darkTag.ID.IsZero() {
|
||||
baseFilter["tags"] = bson.M{"$ne": darkTag.ID}
|
||||
}
|
||||
|
||||
homeLatestAt, err := vidmod.LatestReviewAt(baseFilter)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
moduleIDs := make([]string, 0, len(modules))
|
||||
moduleObjectIDs := make([]primitive.ObjectID, 0, len(modules))
|
||||
for _, module := range modules {
|
||||
moduleIDs = append(moduleIDs, module.ID.Hex())
|
||||
moduleObjectIDs = append(moduleObjectIDs, module.ID)
|
||||
}
|
||||
moduleTimes, err := vidmod.LatestReviewAtByModules(moduleIDs)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
mediaModuleTimes, err := mediamod.LatestContentAtByModules(moduleObjectIDs)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
markers := make([]ModuleMarker, 0, len(modules))
|
||||
for _, module := range modules {
|
||||
var latest *time.Time
|
||||
if value, ok := moduleTimes[module.ID.Hex()]; ok {
|
||||
valueCopy := value
|
||||
latest = &valueCopy
|
||||
}
|
||||
if value, ok := mediaModuleTimes[module.ID.Hex()]; ok && (latest == nil || value.After(*latest)) {
|
||||
valueCopy := value
|
||||
latest = &valueCopy
|
||||
}
|
||||
markers = append(markers, ModuleMarker{ModuleID: module.ID.Hex(), LatestAt: latest})
|
||||
}
|
||||
return Response{
|
||||
HomeLatestAt: homeLatestAt,
|
||||
Modules: markers,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package contentmarkerser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/constant/redisconst"
|
||||
)
|
||||
|
||||
type markerCacheStub struct {
|
||||
data Response
|
||||
getErr error
|
||||
setData Response
|
||||
setCalls int
|
||||
|
||||
latestListCached bool
|
||||
latestListReviewAt *time.Time
|
||||
}
|
||||
|
||||
type markerRaceCache struct {
|
||||
mu sync.Mutex
|
||||
payload string
|
||||
exists bool
|
||||
}
|
||||
|
||||
func (c *markerRaceCache) GetWithJson2Any(_ string, val any) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.exists {
|
||||
return errors.New("cache miss")
|
||||
}
|
||||
return json.Unmarshal([]byte(c.payload), val)
|
||||
}
|
||||
|
||||
func (c *markerRaceCache) SetNX(_ string, val interface{}, _ time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.exists {
|
||||
return false, nil
|
||||
}
|
||||
payload, ok := val.(string)
|
||||
if !ok {
|
||||
return false, errors.New("cache value is not JSON text")
|
||||
}
|
||||
c.payload = payload
|
||||
c.exists = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *markerCacheStub) GetWithJson2Any(key string, val any) error {
|
||||
if key == redisconst.GetMostNewModuleVideoListKey(
|
||||
homeLatestListSortType,
|
||||
homeLatestListPageNumber,
|
||||
homeLatestListPageSize,
|
||||
) {
|
||||
if !c.latestListCached {
|
||||
return errors.New("latest list cache miss")
|
||||
}
|
||||
data := cachedHomeLatestList{}
|
||||
if c.latestListReviewAt != nil {
|
||||
data.Videos = append(data.Videos, struct {
|
||||
ReviewAt time.Time `json:"reviewAt"`
|
||||
}{ReviewAt: *c.latestListReviewAt})
|
||||
}
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(payload, val)
|
||||
}
|
||||
if c.getErr != nil {
|
||||
return c.getErr
|
||||
}
|
||||
payload, err := json.Marshal(c.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(payload, val)
|
||||
}
|
||||
|
||||
func (c *markerCacheStub) SetNX(_ string, val interface{}, _ time.Duration) (bool, error) {
|
||||
c.setCalls++
|
||||
payload, ok := val.(string)
|
||||
if !ok {
|
||||
return false, errors.New("cache value is not JSON text")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &c.setData); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestResponseOmitsTodayLatestAt(t *testing.T) {
|
||||
payload, err := json.Marshal(Response{})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(Response{}) error = %v", err)
|
||||
}
|
||||
if bytes.Contains(payload, []byte("todayLatestAt")) {
|
||||
t.Fatalf("response still contains removed todayLatestAt field: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedReturnsCachedMarkers(t *testing.T) {
|
||||
latest := time.Date(2026, 8, 4, 11, 30, 0, 123000000, time.FixedZone("CST", 8*60*60))
|
||||
cache := &markerCacheStub{data: Response{HomeLatestAt: &latest}}
|
||||
|
||||
got, err := GetCached(time.Now(), cache)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCached() error = %v", err)
|
||||
}
|
||||
if got.HomeLatestAt == nil || !got.HomeLatestAt.Equal(latest) {
|
||||
t.Fatalf("GetCached() homeLatestAt = %v, want %v", got.HomeLatestAt, latest)
|
||||
}
|
||||
if cache.setCalls != 0 {
|
||||
t.Fatalf("GetCached() cache writes = %d, want 0", cache.setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCachedReportsMiss(t *testing.T) {
|
||||
cache := &markerCacheStub{getErr: errors.New("cache miss")}
|
||||
if _, ok := ReadCached(cache); ok {
|
||||
t.Fatal("ReadCached() hit = true, want false")
|
||||
}
|
||||
if _, ok := ReadCached(nil); ok {
|
||||
t.Fatal("ReadCached(nil) hit = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedUsesHomeLatestAtFromCachedList(t *testing.T) {
|
||||
markerLatest := time.Date(2026, 8, 4, 11, 30, 0, 0, time.UTC)
|
||||
listLatest := markerLatest.Add(-2 * time.Minute)
|
||||
cache := &markerCacheStub{
|
||||
data: Response{HomeLatestAt: &markerLatest},
|
||||
latestListCached: true,
|
||||
latestListReviewAt: &listLatest,
|
||||
}
|
||||
|
||||
got, err := GetCached(time.Now(), cache)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCached() error = %v", err)
|
||||
}
|
||||
if got.HomeLatestAt == nil || !got.HomeLatestAt.Equal(listLatest) {
|
||||
t.Fatalf("GetCached() homeLatestAt = %v, want cached list time %v", got.HomeLatestAt, listLatest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedClearsHomeLatestAtForCachedEmptyList(t *testing.T) {
|
||||
markerLatest := time.Date(2026, 8, 4, 11, 30, 0, 0, time.UTC)
|
||||
cache := &markerCacheStub{
|
||||
data: Response{HomeLatestAt: &markerLatest},
|
||||
latestListCached: true,
|
||||
}
|
||||
|
||||
got, err := GetCached(time.Now(), cache)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCached() error = %v", err)
|
||||
}
|
||||
if got.HomeLatestAt != nil {
|
||||
t.Fatalf("GetCached() homeLatestAt = %v, want nil for cached empty list", got.HomeLatestAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedLoadsAndWritesCacheBeforeReturning(t *testing.T) {
|
||||
latest := time.Date(2026, 8, 4, 11, 31, 0, 0, time.UTC)
|
||||
cache := &markerCacheStub{getErr: errors.New("cache miss")}
|
||||
loadCalls := 0
|
||||
load := func(time.Time) (Response, error) {
|
||||
loadCalls++
|
||||
return Response{HomeLatestAt: &latest}, nil
|
||||
}
|
||||
|
||||
got, err := getCached(time.Now(), cache, load)
|
||||
if err != nil {
|
||||
t.Fatalf("getCached() error = %v", err)
|
||||
}
|
||||
if loadCalls != 1 {
|
||||
t.Fatalf("getCached() loader calls = %d, want 1", loadCalls)
|
||||
}
|
||||
if cache.setCalls != 1 || cache.setData.HomeLatestAt == nil {
|
||||
t.Fatalf("getCached() cache write = (%d, %v), want one marker write", cache.setCalls, cache.setData.HomeLatestAt)
|
||||
}
|
||||
if got.HomeLatestAt == nil || !got.HomeLatestAt.Equal(latest) {
|
||||
t.Fatalf("getCached() homeLatestAt = %v, want %v", got.HomeLatestAt, latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedConcurrentMissReturnsWinningSnapshot(t *testing.T) {
|
||||
oldLatest := time.Date(2026, 8, 4, 11, 30, 0, 0, time.UTC)
|
||||
newLatest := oldLatest.Add(time.Minute)
|
||||
cache := &markerRaceCache{}
|
||||
oldLoaderStarted := make(chan struct{})
|
||||
releaseOldLoader := make(chan struct{})
|
||||
type outcome struct {
|
||||
data Response
|
||||
err error
|
||||
}
|
||||
oldOutcome := make(chan outcome, 1)
|
||||
|
||||
go func() {
|
||||
data, err := getCached(time.Now(), cache, func(time.Time) (Response, error) {
|
||||
close(oldLoaderStarted)
|
||||
<-releaseOldLoader
|
||||
return Response{HomeLatestAt: &oldLatest}, nil
|
||||
})
|
||||
oldOutcome <- outcome{data: data, err: err}
|
||||
}()
|
||||
|
||||
<-oldLoaderStarted
|
||||
newData, err := getCached(time.Now(), cache, func(time.Time) (Response, error) {
|
||||
return Response{HomeLatestAt: &newLatest}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new getCached() error = %v", err)
|
||||
}
|
||||
close(releaseOldLoader)
|
||||
oldResult := <-oldOutcome
|
||||
if oldResult.err != nil {
|
||||
t.Fatalf("old getCached() error = %v", oldResult.err)
|
||||
}
|
||||
for name, data := range map[string]Response{"new": newData, "old": oldResult.data} {
|
||||
if data.HomeLatestAt == nil || !data.HomeLatestAt.Equal(newLatest) {
|
||||
t.Fatalf("%s getCached() homeLatestAt = %v, want winning %v", name, data.HomeLatestAt, newLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user