@@ -0,0 +1,39 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type AppModuleConf struct {
|
||||
HomePage []APPModuleConf `json:"homePage" bson:"homePage"` // 首页
|
||||
Community []APPModuleConf `json:"community" bson:"community"` // 社区
|
||||
//PrivateCircle []APPModuleConf `json:"privateCircle" bson:"privateCircle"` // 私密圈
|
||||
DeepWeb []APPModuleConf `json:"deepWeb" bson:"deepWeb"` // 暗网
|
||||
Novel []APPModuleConf `json:"novel" bson:"novel"` // 小说
|
||||
Pics []APPModuleConf `json:"pics" bson:"pics"` // 图集
|
||||
NakedChat []APPModuleConf `json:"nakedChat" bson:"nakedChat"` // 裸聊模块
|
||||
ShortPage []APPModuleConf `json:"shortPage" bson:"shortPage"` // 短视频模块
|
||||
AiPlaza []APPModuleConf `json:"aiPlaza" bson:"aiPlaza"` // AI广场(弃用2026-1)
|
||||
DramaPage []APPModuleConf `json:"dramaPage" bson:"dramaPage"` // 短剧频道
|
||||
}
|
||||
|
||||
type APPModuleConf struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` // 模块ID
|
||||
ModuleName string `json:"moduleName" bson:"moduleName" binding:"required"` // 模块名称
|
||||
Cover string `json:"cover" bson:"cover"` // 封面
|
||||
Type int `json:"type" bson:"type"` // 模块类型
|
||||
ShowType int `json:"showType" bson:"showType"` // 模块展示类型
|
||||
ShowJG bool `json:"showJG" bson:"showJG"` // 是否展示金刚区
|
||||
HaiJiaoStyle HaiJiaoStyle `json:"haiJiaoStyle"` // 跟海角样式关联的展示样式
|
||||
AiPlazaStyle AiPlazaStyle `json:"aiPlazaStyle" bson:"aiPlazaStyle"` // ai广场样式(弃用2026-1)
|
||||
DefaultTagId string `json:"defaultTagId" bson:"defaultTagId"` // 社区默认展示标签
|
||||
PureVersion bool `json:"pureVersion" bson:"pureVersion"` // 纯净模式无广告
|
||||
OnlineAt *time.Time `json:"onlineAt,omitempty" bson:"onlineAt,omitempty"`
|
||||
OfflineAt *time.Time `json:"offlineAt,omitempty" bson:"offlineAt,omitempty"`
|
||||
ExcludeLatest bool `json:"excludeLatest" bson:"excludeLatest"`
|
||||
ExcludeRecommend bool `json:"excludeRecommend" bson:"excludeRecommend"`
|
||||
ExcludeSearch bool `json:"excludeSearch" bson:"excludeSearch"`
|
||||
SearchOnlyWhenInactive bool `json:"searchOnlyWhenInactive" bson:"searchOnlyWhenInactive"`
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"91porn-server/common/log"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/db"
|
||||
"91porn-server/models"
|
||||
"91porn-server/models/commod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var mdb *db.MongoDB
|
||||
|
||||
const table = models.ModuleConf
|
||||
|
||||
var moduleMetadataCache = newModuleSnapshotCache(loadModuleMetadata, time.Now, moduleSnapshotTTL, moduleSnapshotRetryDelay)
|
||||
|
||||
func loadModuleMetadata() (modules []ModuleConf, err error) {
|
||||
err = coll(nil).Find(&modules, bson.M{})
|
||||
return
|
||||
}
|
||||
|
||||
// InitIndex 设置index
|
||||
func initIndex() {
|
||||
coll := coll(nil)
|
||||
many := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "moduleName", Value: 1}, {Key: "subModuleName", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
}
|
||||
if _, err := coll.CreateIndex(many); err != nil {
|
||||
panic(fmt.Sprintf("%s model set index err ==>[%+v]", table, err))
|
||||
}
|
||||
}
|
||||
|
||||
func coll(t *db.MongoTool) *db.MongoTool {
|
||||
if t == nil {
|
||||
return mdb.Coll(table)
|
||||
}
|
||||
return t.Coll(table)
|
||||
}
|
||||
|
||||
// InsertOne 插入一条数据
|
||||
func InsertOne(p *ModuleConf) error {
|
||||
p.HaiJiaoStyle.NormalizeRefreshConfig()
|
||||
if err := p.HaiJiaoStyle.ValidateRefreshConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.ValidateSchedule(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.CreatedAt = time.Now()
|
||||
_, err := coll(nil).InsertOne(p)
|
||||
if err == nil {
|
||||
moduleMetadataCache.invalidate()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateOne 更新一条数据
|
||||
func UpdateOne(set EditSelector) error {
|
||||
current, err := getByIDFromDB(set.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if set.HaiJiaoStyle != nil {
|
||||
set.HaiJiaoStyle.NormalizeRefreshConfig()
|
||||
if err = set.HaiJiaoStyle.ValidateRefreshConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if set.ClearOnlineAt && set.OnlineAt != nil {
|
||||
return fmt.Errorf("onlineAt and clearOnlineAt cannot be set together")
|
||||
}
|
||||
if set.ClearOfflineAt && set.OfflineAt != nil {
|
||||
return fmt.Errorf("offlineAt and clearOfflineAt cannot be set together")
|
||||
}
|
||||
if set.OnlineAt != nil {
|
||||
current.OnlineAt = set.OnlineAt
|
||||
}
|
||||
if set.OfflineAt != nil {
|
||||
current.OfflineAt = set.OfflineAt
|
||||
}
|
||||
if set.ClearOnlineAt {
|
||||
current.OnlineAt = nil
|
||||
}
|
||||
if set.ClearOfflineAt {
|
||||
current.OfflineAt = nil
|
||||
}
|
||||
if err = current.ValidateSchedule(); err != nil {
|
||||
return err
|
||||
}
|
||||
set.UpdatedAt = time.Now()
|
||||
update := bson.M{"$set": set}
|
||||
unset := bson.M{}
|
||||
if set.ClearOnlineAt {
|
||||
unset["onlineAt"] = ""
|
||||
}
|
||||
if set.ClearOfflineAt {
|
||||
unset["offlineAt"] = ""
|
||||
}
|
||||
if len(unset) > 0 {
|
||||
update["$unset"] = unset
|
||||
}
|
||||
_, err = coll(nil).UpdateOne(bson.M{"_id": set.ID}, update)
|
||||
if err == nil {
|
||||
moduleMetadataCache.invalidate()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p ModuleConf) ValidateSchedule() error {
|
||||
if p.OnlineAt != nil && p.OfflineAt != nil && !p.OfflineAt.After(*p.OnlineAt) {
|
||||
return fmt.Errorf("offlineAt must be later than onlineAt")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ModuleConf) IsActiveAt(now time.Time) bool {
|
||||
if p.Status != 1 || p.DeletedAt != nil {
|
||||
return false
|
||||
}
|
||||
if p.OnlineAt != nil && p.OnlineAt.After(now) {
|
||||
return false
|
||||
}
|
||||
return p.OfflineAt == nil || p.OfflineAt.After(now)
|
||||
}
|
||||
|
||||
// DeleteOne 删除一条数据
|
||||
func DeleteOne(id primitive.ObjectID) (err error) {
|
||||
now := time.Now()
|
||||
status := uint8(0)
|
||||
set := EditSelector{ID: id, DeletedAt: &now, Status: &status}
|
||||
return UpdateOne(set)
|
||||
}
|
||||
|
||||
// GetByID 根据id获取一条记录
|
||||
func GetByID(id primitive.ObjectID) (conf ModuleConf, err error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return ModuleConf{}, err
|
||||
}
|
||||
conf, ok := snapshot.findByID(id)
|
||||
if !ok {
|
||||
return ModuleConf{}, mongo.ErrNoDocuments
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func getByIDFromDB(id primitive.ObjectID) (conf ModuleConf, err error) {
|
||||
err = coll(nil).FindOne(&conf, bson.M{"_id": id})
|
||||
return
|
||||
}
|
||||
|
||||
// GetModuleConf 获取一个模块下的所有配置
|
||||
func GetModuleConf(moduleName string) (ret []ModuleConf, err error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.findByModuleName(moduleName), nil
|
||||
}
|
||||
|
||||
// GetModuleConfByType 根据类型获取配置
|
||||
func GetModuleConfByType(moduleType int) (ret []ModuleConf, err error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("[METHOD-%s]==> Model %s %s fail error:%+v:", "GetModuleConfByType", table, "snapshot", err))
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.findByType(moduleType), nil
|
||||
}
|
||||
|
||||
// GetAllModule 获取所有开启(status=1)的模块配置
|
||||
func GetAllModule() (ret []ModuleConf, err error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.enabled(), nil
|
||||
}
|
||||
|
||||
// GetAllActiveModule 获取当前处于有效展示时间内的亚模块。
|
||||
func GetAllActiveModule(now time.Time) (ret []ModuleConf, err error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.activeAt(now), nil
|
||||
}
|
||||
|
||||
// ExcludedVideoModuleIDs 返回指定场景需要从聚合列表排除的亚模块 ID。
|
||||
func ExcludedVideoModuleIDs(now time.Time, recommend bool) ([]string, error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.excludedVideoModuleIDs(now, recommend), nil
|
||||
}
|
||||
|
||||
// ExcludedSearchModuleIDs 返回配置为不进入搜索列表的亚模块 ID。
|
||||
func ExcludedSearchModuleIDs() ([]string, error) {
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.excludedSearchModuleIDs(), nil
|
||||
}
|
||||
|
||||
// BlockedOutsideSearchModuleIDs 返回当前仅允许通过搜索入口访问的亚模块 ID 集合。
|
||||
func BlockedOutsideSearchModuleIDs(moduleIDs []string, now time.Time) (map[string]struct{}, error) {
|
||||
objectIDs := make([]primitive.ObjectID, 0, len(moduleIDs))
|
||||
seen := make(map[primitive.ObjectID]struct{}, len(moduleIDs))
|
||||
for _, moduleID := range moduleIDs {
|
||||
id, parseErr := primitive.ObjectIDFromHex(moduleID)
|
||||
if parseErr != nil || id.IsZero() {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
objectIDs = append(objectIDs, id)
|
||||
}
|
||||
if len(objectIDs) == 0 {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.blockedOutsideSearchModuleIDs(objectIDs, now), nil
|
||||
}
|
||||
|
||||
// CanBrowseModule 判断亚模块是否能从搜索以外的普通入口访问。
|
||||
func CanBrowseModule(moduleID primitive.ObjectID, now time.Time) (bool, error) {
|
||||
module, err := GetByID(moduleID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return module.IsActiveAt(now), nil
|
||||
}
|
||||
|
||||
// Search 根据搜索条件检索
|
||||
func Search(q QuerySelector, page commod.Page) (resp ListResp, err error) {
|
||||
skip := int64(page.Skip())
|
||||
limit := int64(page.Limit() + 1)
|
||||
opts := options.FindOptions{
|
||||
Skip: &skip,
|
||||
Limit: &limit,
|
||||
Sort: bson.D{{Key: "sortNum", Value: 1}},
|
||||
}
|
||||
filter := bson.M{}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if q.ModuleName != nil {
|
||||
filter["moduleName"] = primitive.Regex{
|
||||
Pattern: *q.ModuleName,
|
||||
}
|
||||
}
|
||||
if q.SubModuleName != nil {
|
||||
filter["subModuleName"] = primitive.Regex{
|
||||
Pattern: *q.SubModuleName,
|
||||
}
|
||||
}
|
||||
if q.Type != nil {
|
||||
filter["type"] = *q.Type
|
||||
}
|
||||
if q.ShowType != nil {
|
||||
filter["showType"] = q.ShowType
|
||||
}
|
||||
data := []ModuleConf{}
|
||||
if err = coll(nil).Find(&data, filter, &opts); err != nil {
|
||||
return
|
||||
}
|
||||
total := int64(0)
|
||||
if total, err = coll(nil).Count(filter); err != nil {
|
||||
return
|
||||
}
|
||||
hasNext := false
|
||||
if uint64(len(data)) > page.Limit() {
|
||||
hasNext = true
|
||||
data = data[:page.Limit()]
|
||||
}
|
||||
resp.List = data
|
||||
resp.Total = total
|
||||
resp.HasNext = hasNext
|
||||
return
|
||||
}
|
||||
|
||||
// FindByIDs 根据section id批量获取section详情
|
||||
func FindByIDs(ids []primitive.ObjectID) (list []ModuleConf, err error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
snapshot, err := moduleMetadataCache.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot.findByIDs(ids), nil
|
||||
}
|
||||
|
||||
func FindOneById(id primitive.ObjectID) (ModuleConf, error) {
|
||||
return GetByID(id)
|
||||
}
|
||||
|
||||
// QueryAllList 分页查询文档
|
||||
func QueryAllList(filter primitive.M, opts ...*options.FindOptions) (out []*ModuleConf, err error) {
|
||||
if err = coll(nil).Find(&out, filter, opts...); err != nil {
|
||||
log.Error(fmt.Sprintf("[METHOD-%s]==> Model %s %s fail error:%+v:", "QueryAllList", table, "Find", err),
|
||||
log.Any("filter", filter),
|
||||
log.Any("opts", opts),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func TestModuleConfValidateSchedule(t *testing.T) {
|
||||
start := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC)
|
||||
after := start.Add(time.Hour)
|
||||
before := start.Add(-time.Second)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
module ModuleConf
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "no limits", module: ModuleConf{}},
|
||||
{name: "only online", module: ModuleConf{OnlineAt: &start}},
|
||||
{name: "only offline", module: ModuleConf{OfflineAt: &after}},
|
||||
{name: "valid window", module: ModuleConf{OnlineAt: &start, OfflineAt: &after}},
|
||||
{name: "equal boundary", module: ModuleConf{OnlineAt: &start, OfflineAt: &start}, wantErr: true},
|
||||
{name: "reversed window", module: ModuleConf{OnlineAt: &start, OfflineAt: &before}, wantErr: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.module.ValidateSchedule()
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("ValidateSchedule() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleConfIsActiveAt(t *testing.T) {
|
||||
now := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC)
|
||||
past := now.Add(-time.Hour)
|
||||
future := now.Add(time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
module ModuleConf
|
||||
want bool
|
||||
}{
|
||||
{name: "enabled without window", module: ModuleConf{Status: 1}, want: true},
|
||||
{name: "disabled", module: ModuleConf{Status: 0}, want: false},
|
||||
{name: "not online yet", module: ModuleConf{Status: 1, OnlineAt: &future}, want: false},
|
||||
{name: "online boundary is inclusive", module: ModuleConf{Status: 1, OnlineAt: &now}, want: true},
|
||||
{name: "inside window", module: ModuleConf{Status: 1, OnlineAt: &past, OfflineAt: &future}, want: true},
|
||||
{name: "offline boundary is exclusive", module: ModuleConf{Status: 1, OfflineAt: &now}, want: false},
|
||||
{name: "already offline", module: ModuleConf{Status: 1, OfflineAt: &past}, want: false},
|
||||
{name: "deleted", module: ModuleConf{Status: 1, DeletedAt: &past}, want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.module.IsActiveAt(now); got != test.want {
|
||||
t.Fatalf("IsActiveAt() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleConfJSONIncludesExcludeSearch(t *testing.T) {
|
||||
data, err := json.Marshal(ModuleConf{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, exists := decoded["excludeSearch"]
|
||||
if !exists || value != false {
|
||||
t.Fatalf("excludeSearch = %#v, exists = %v; want false and present", value, exists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditSelectorBSONIncludesFalseExcludeSearch(t *testing.T) {
|
||||
value := false
|
||||
data, err := bson.Marshal(EditSelector{
|
||||
ID: primitive.NewObjectID(),
|
||||
ExcludeSearch: &value,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded bson.M
|
||||
if err = bson.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, exists := decoded["excludeSearch"]
|
||||
if !exists || got != false {
|
||||
t.Fatalf("excludeSearch = %#v, exists = %v; want false and present", got, exists)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"91porn-server/models/commod"
|
||||
)
|
||||
|
||||
func TestNormalizeLegacyHotRefreshConfig(t *testing.T) {
|
||||
item := SortItem{Val: commod.MostHot}
|
||||
item.NormalizeRefreshConfig()
|
||||
if item.RefreshMode != RefreshModeRandomTopN || item.RandomCandidateN != DefaultRandomCandidateN {
|
||||
t.Fatalf("unexpected normalized item: %+v", item)
|
||||
}
|
||||
if err := item.ValidateRefreshConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDefaultRefreshConfig(t *testing.T) {
|
||||
item := SortItem{Val: commod.New}
|
||||
item.NormalizeRefreshConfig()
|
||||
if item.RefreshMode != RefreshModeDefault || item.RandomCandidateN != 0 {
|
||||
t.Fatalf("unexpected normalized item: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSortRulesCreatesRefreshableLegacyDefaults(t *testing.T) {
|
||||
style := HaiJiaoStyle{}
|
||||
style.EnsureSortRules()
|
||||
if len(style.SortRules) != 4 {
|
||||
t.Fatalf("got %d default sort rules", len(style.SortRules))
|
||||
}
|
||||
if style.SortRules[1].RefreshMode != RefreshModeRandomTopN {
|
||||
t.Fatalf("hot default should support random refresh: %+v", style.SortRules[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/log"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
moduleSnapshotTTL = 15 * time.Second
|
||||
moduleSnapshotRetryDelay = time.Second
|
||||
)
|
||||
|
||||
var errModuleSnapshotInvalidated = errors.New("module configuration snapshot invalidated while loading")
|
||||
|
||||
type moduleSnapshotLoader func() ([]ModuleConf, error)
|
||||
|
||||
// moduleSnapshot is immutable after construction. Every accessor returns
|
||||
// copies so callers cannot mutate data shared by concurrent requests.
|
||||
type moduleSnapshot struct {
|
||||
modules []ModuleConf
|
||||
byID map[primitive.ObjectID]int
|
||||
}
|
||||
|
||||
func newModuleSnapshot(modules []ModuleConf) *moduleSnapshot {
|
||||
copied := cloneModuleConfs(modules)
|
||||
byID := make(map[primitive.ObjectID]int, len(copied))
|
||||
for i := range copied {
|
||||
byID[copied[i].ID] = i
|
||||
}
|
||||
return &moduleSnapshot{
|
||||
modules: copied,
|
||||
byID: byID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) all() []ModuleConf {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneModuleConfs(s.modules)
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) findByID(id primitive.ObjectID) (ModuleConf, bool) {
|
||||
if s == nil {
|
||||
return ModuleConf{}, false
|
||||
}
|
||||
index, ok := s.byID[id]
|
||||
if !ok {
|
||||
return ModuleConf{}, false
|
||||
}
|
||||
return cloneModuleConf(s.modules[index]), true
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) findByIDs(ids []primitive.ObjectID) []ModuleConf {
|
||||
if s == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := make(map[primitive.ObjectID]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
modules := make([]ModuleConf, 0, len(wanted))
|
||||
for i := range s.modules {
|
||||
if _, ok := wanted[s.modules[i].ID]; ok {
|
||||
modules = append(modules, cloneModuleConf(s.modules[i]))
|
||||
}
|
||||
}
|
||||
return modules
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) findByModuleName(moduleName string) []ModuleConf {
|
||||
modules := make([]ModuleConf, 0)
|
||||
for i := range s.modules {
|
||||
if s.modules[i].ModuleName == moduleName {
|
||||
modules = append(modules, cloneModuleConf(s.modules[i]))
|
||||
}
|
||||
}
|
||||
return modules
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) findByType(moduleType int) []ModuleConf {
|
||||
modules := make([]ModuleConf, 0)
|
||||
for i := range s.modules {
|
||||
if s.modules[i].Type == moduleType {
|
||||
modules = append(modules, cloneModuleConf(s.modules[i]))
|
||||
}
|
||||
}
|
||||
return modules
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) enabled() []ModuleConf {
|
||||
modules := make([]ModuleConf, 0)
|
||||
for i := range s.modules {
|
||||
if s.modules[i].Status == 1 && s.modules[i].DeletedAt == nil {
|
||||
modules = append(modules, cloneModuleConf(s.modules[i]))
|
||||
}
|
||||
}
|
||||
sortModuleConfs(modules)
|
||||
return modules
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) activeAt(now time.Time) []ModuleConf {
|
||||
modules := make([]ModuleConf, 0)
|
||||
for i := range s.modules {
|
||||
if s.modules[i].IsActiveAt(now) {
|
||||
modules = append(modules, cloneModuleConf(s.modules[i]))
|
||||
}
|
||||
}
|
||||
sortModuleConfs(modules)
|
||||
return modules
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) excludedVideoModuleIDs(now time.Time, recommend bool) []string {
|
||||
ids := make([]string, 0)
|
||||
for i := range s.modules {
|
||||
module := s.modules[i]
|
||||
excludedByScene := module.ExcludeLatest
|
||||
if recommend {
|
||||
excludedByScene = module.ExcludeRecommend
|
||||
}
|
||||
if excludedByScene || (module.SearchOnlyWhenInactive && !module.IsActiveAt(now)) {
|
||||
ids = append(ids, module.ID.Hex())
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) excludedSearchModuleIDs() []string {
|
||||
ids := make([]string, 0)
|
||||
for i := range s.modules {
|
||||
if s.modules[i].ExcludeSearch {
|
||||
ids = append(ids, s.modules[i].ID.Hex())
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *moduleSnapshot) blockedOutsideSearchModuleIDs(moduleIDs []primitive.ObjectID, now time.Time) map[string]struct{} {
|
||||
blocked := make(map[string]struct{})
|
||||
seen := make(map[primitive.ObjectID]struct{}, len(moduleIDs))
|
||||
for _, moduleID := range moduleIDs {
|
||||
if moduleID.IsZero() {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[moduleID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[moduleID] = struct{}{}
|
||||
index, ok := s.byID[moduleID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
module := s.modules[index]
|
||||
if module.SearchOnlyWhenInactive && !module.IsActiveAt(now) {
|
||||
blocked[module.ID.Hex()] = struct{}{}
|
||||
}
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
|
||||
type moduleSnapshotCache struct {
|
||||
mu sync.RWMutex
|
||||
loadGroup singleflight.Group
|
||||
loader moduleSnapshotLoader
|
||||
now func() time.Time
|
||||
ttl time.Duration
|
||||
retryDelay time.Duration
|
||||
|
||||
snapshot *moduleSnapshot
|
||||
refreshAt time.Time
|
||||
generation uint64
|
||||
}
|
||||
|
||||
func newModuleSnapshotCache(
|
||||
loader moduleSnapshotLoader,
|
||||
now func() time.Time,
|
||||
ttl time.Duration,
|
||||
retryDelay time.Duration,
|
||||
) *moduleSnapshotCache {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = moduleSnapshotTTL
|
||||
}
|
||||
if retryDelay <= 0 {
|
||||
retryDelay = moduleSnapshotRetryDelay
|
||||
}
|
||||
return &moduleSnapshotCache{
|
||||
loader: loader,
|
||||
now: now,
|
||||
ttl: ttl,
|
||||
retryDelay: retryDelay,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *moduleSnapshotCache) get() (*moduleSnapshot, error) {
|
||||
for {
|
||||
now := c.now()
|
||||
c.mu.RLock()
|
||||
if c.snapshot != nil && now.Before(c.refreshAt) {
|
||||
snapshot := c.snapshot
|
||||
c.mu.RUnlock()
|
||||
return snapshot, nil
|
||||
}
|
||||
generation := c.generation
|
||||
c.mu.RUnlock()
|
||||
|
||||
key := fmt.Sprintf("module-snapshot-%d", generation)
|
||||
value, err, _ := c.loadGroup.Do(key, func() (interface{}, error) {
|
||||
return c.load(generation)
|
||||
})
|
||||
if errors.Is(err, errModuleSnapshotInvalidated) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A write may invalidate the cache after load() installs the snapshot
|
||||
// but before singleflight returns it to this reader.
|
||||
c.mu.RLock()
|
||||
currentGeneration := c.generation
|
||||
c.mu.RUnlock()
|
||||
if currentGeneration != generation {
|
||||
continue
|
||||
}
|
||||
return value.(*moduleSnapshot), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *moduleSnapshotCache) load(generation uint64) (*moduleSnapshot, error) {
|
||||
now := c.now()
|
||||
c.mu.RLock()
|
||||
if generation != c.generation {
|
||||
c.mu.RUnlock()
|
||||
return nil, errModuleSnapshotInvalidated
|
||||
}
|
||||
if c.snapshot != nil && now.Before(c.refreshAt) {
|
||||
snapshot := c.snapshot
|
||||
c.mu.RUnlock()
|
||||
return snapshot, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
modules, err := c.loader()
|
||||
loadedAt := c.now()
|
||||
|
||||
c.mu.Lock()
|
||||
if generation != c.generation {
|
||||
c.mu.Unlock()
|
||||
return nil, errModuleSnapshotInvalidated
|
||||
}
|
||||
if err != nil {
|
||||
if c.snapshot == nil {
|
||||
c.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
// Avoid retrying Mongo on every request while still keeping retries
|
||||
// frequent enough for a transient outage to recover quickly.
|
||||
c.refreshAt = loadedAt.Add(c.retryDelay)
|
||||
snapshot := c.snapshot
|
||||
c.mu.Unlock()
|
||||
log.Warn("module configuration snapshot refresh failed; using last-known-good data", log.E(err))
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
c.snapshot = newModuleSnapshot(modules)
|
||||
c.refreshAt = loadedAt.Add(c.ttl)
|
||||
snapshot := c.snapshot
|
||||
c.mu.Unlock()
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// invalidate forces the next reader to reload. The previous snapshot remains
|
||||
// available as last-known-good if the refresh fails.
|
||||
func (c *moduleSnapshotCache) invalidate() {
|
||||
c.mu.Lock()
|
||||
c.generation++
|
||||
c.refreshAt = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// reset is used when the package is rebound to a new Mongo client.
|
||||
func (c *moduleSnapshotCache) reset() {
|
||||
c.mu.Lock()
|
||||
c.generation++
|
||||
c.snapshot = nil
|
||||
c.refreshAt = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func cloneModuleConfs(modules []ModuleConf) []ModuleConf {
|
||||
if modules == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make([]ModuleConf, len(modules))
|
||||
for i := range modules {
|
||||
copied[i] = cloneModuleConf(modules[i])
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func cloneModuleConf(module ModuleConf) ModuleConf {
|
||||
copied := module
|
||||
copied.OnlineAt = cloneTime(module.OnlineAt)
|
||||
copied.OfflineAt = cloneTime(module.OfflineAt)
|
||||
copied.DeletedAt = cloneTime(module.DeletedAt)
|
||||
if module.HaiJiaoStyle.SortRules != nil {
|
||||
copied.HaiJiaoStyle.SortRules = append([]SortItem(nil), module.HaiJiaoStyle.SortRules...)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func cloneTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func sortModuleConfs(modules []ModuleConf) {
|
||||
sort.SliceStable(modules, func(i, j int) bool {
|
||||
return modules[i].SortNum < modules[j].SortNum
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"91porn-server/models/commod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
type moduleCacheTestClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *moduleCacheTestClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *moduleCacheTestClock) Advance(duration time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.now = c.now.Add(duration)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestModuleSnapshotCacheConcurrentReadersShareOneLoad(t *testing.T) {
|
||||
clock := &moduleCacheTestClock{now: time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)}
|
||||
moduleID := primitive.NewObjectID()
|
||||
var calls atomic.Int32
|
||||
var startedOnce sync.Once
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
||||
calls.Add(1)
|
||||
startedOnce.Do(func() { close(started) })
|
||||
<-release
|
||||
return []ModuleConf{{ID: moduleID, SubModuleName: "cached"}}, nil
|
||||
}, clock.Now, time.Minute, time.Second)
|
||||
|
||||
const readers = 32
|
||||
errs := make(chan error, readers)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(readers)
|
||||
for i := 0; i < readers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
snapshot, err := cache.get()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
module, ok := snapshot.findByID(moduleID)
|
||||
if !ok || module.SubModuleName != "cached" {
|
||||
errs <- errors.New("reader received an unexpected snapshot")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
<-started
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("loader calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSnapshotCacheInitialFailureReturnsError(t *testing.T) {
|
||||
wantErr := errors.New("mongo unavailable")
|
||||
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
||||
return nil, wantErr
|
||||
}, time.Now, time.Minute, time.Second)
|
||||
|
||||
if _, err := cache.get(); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("get() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSnapshotCacheUsesLastKnownGoodAndRetries(t *testing.T) {
|
||||
clock := &moduleCacheTestClock{now: time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)}
|
||||
moduleID := primitive.NewObjectID()
|
||||
var calls atomic.Int32
|
||||
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
||||
switch calls.Add(1) {
|
||||
case 1:
|
||||
return []ModuleConf{{ID: moduleID, SubModuleName: "v1"}}, nil
|
||||
case 2:
|
||||
return nil, errors.New("temporary mongo failure")
|
||||
default:
|
||||
return []ModuleConf{{ID: moduleID, SubModuleName: "v2"}}, nil
|
||||
}
|
||||
}, clock.Now, 10*time.Second, time.Second)
|
||||
|
||||
assertCachedModuleName(t, cache, moduleID, "v1")
|
||||
clock.Advance(11 * time.Second)
|
||||
assertCachedModuleName(t, cache, moduleID, "v1")
|
||||
assertCachedModuleName(t, cache, moduleID, "v1")
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("loader calls during retry delay = %d, want 2", got)
|
||||
}
|
||||
|
||||
clock.Advance(2 * time.Second)
|
||||
assertCachedModuleName(t, cache, moduleID, "v2")
|
||||
if got := calls.Load(); got != 3 {
|
||||
t.Fatalf("loader calls after retry delay = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSnapshotCacheInvalidateForcesReload(t *testing.T) {
|
||||
moduleID := primitive.NewObjectID()
|
||||
var calls atomic.Int32
|
||||
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
||||
version := calls.Add(1)
|
||||
return []ModuleConf{{
|
||||
ID: moduleID,
|
||||
SubModuleName: "v" + string(rune('0'+version)),
|
||||
}}, nil
|
||||
}, time.Now, time.Hour, time.Second)
|
||||
|
||||
assertCachedModuleName(t, cache, moduleID, "v1")
|
||||
cache.invalidate()
|
||||
assertCachedModuleName(t, cache, moduleID, "v2")
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("loader calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSnapshotCacheInvalidateDuringLoadDiscardsStaleResult(t *testing.T) {
|
||||
moduleID := primitive.NewObjectID()
|
||||
var calls atomic.Int32
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
||||
call := calls.Add(1)
|
||||
if call == 1 {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
return []ModuleConf{{ID: moduleID, SubModuleName: "stale"}}, nil
|
||||
}
|
||||
return []ModuleConf{{ID: moduleID, SubModuleName: "fresh"}}, nil
|
||||
}, time.Now, time.Hour, time.Second)
|
||||
|
||||
result := make(chan string, 1)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
snapshot, err := cache.get()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
module, _ := snapshot.findByID(moduleID)
|
||||
result <- module.SubModuleName
|
||||
}()
|
||||
|
||||
<-firstStarted
|
||||
cache.invalidate()
|
||||
close(releaseFirst)
|
||||
|
||||
select {
|
||||
case err := <-errs:
|
||||
t.Fatal(err)
|
||||
case got := <-result:
|
||||
if got != "fresh" {
|
||||
t.Fatalf("module name = %q, want fresh", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("cache reload timed out")
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("loader calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSnapshotScheduleBoundariesDoNotRequireReload(t *testing.T) {
|
||||
base := time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)
|
||||
onlineAt := base.Add(time.Hour)
|
||||
offlineAt := base.Add(2 * time.Hour)
|
||||
moduleID := primitive.NewObjectID()
|
||||
var calls atomic.Int32
|
||||
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
||||
calls.Add(1)
|
||||
return []ModuleConf{{
|
||||
ID: moduleID,
|
||||
Status: 1,
|
||||
OnlineAt: &onlineAt,
|
||||
OfflineAt: &offlineAt,
|
||||
SearchOnlyWhenInactive: true,
|
||||
}}, nil
|
||||
}, func() time.Time { return base }, time.Hour, time.Second)
|
||||
|
||||
snapshot, err := cache.get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStringSetContains(t, snapshot.excludedVideoModuleIDs(onlineAt.Add(-time.Nanosecond), false), moduleID.Hex(), true)
|
||||
assertStringSetContains(t, snapshot.excludedVideoModuleIDs(onlineAt, false), moduleID.Hex(), false)
|
||||
assertStringSetContains(t, snapshot.excludedVideoModuleIDs(offlineAt, false), moduleID.Hex(), true)
|
||||
if _, ok := snapshot.blockedOutsideSearchModuleIDs([]primitive.ObjectID{moduleID}, onlineAt.Add(-time.Nanosecond))[moduleID.Hex()]; !ok {
|
||||
t.Fatal("module should be blocked before onlineAt")
|
||||
}
|
||||
if _, ok := snapshot.blockedOutsideSearchModuleIDs([]primitive.ObjectID{moduleID}, onlineAt)[moduleID.Hex()]; ok {
|
||||
t.Fatal("module should be browsable at onlineAt")
|
||||
}
|
||||
if _, ok := snapshot.blockedOutsideSearchModuleIDs([]primitive.ObjectID{moduleID}, offlineAt)[moduleID.Hex()]; !ok {
|
||||
t.Fatal("module should be blocked at offlineAt")
|
||||
}
|
||||
|
||||
if got := len(snapshot.activeAt(onlineAt.Add(-time.Nanosecond))); got != 0 {
|
||||
t.Fatalf("active before onlineAt = %d, want 0", got)
|
||||
}
|
||||
if got := len(snapshot.activeAt(onlineAt)); got != 1 {
|
||||
t.Fatalf("active at onlineAt = %d, want 1", got)
|
||||
}
|
||||
if got := len(snapshot.activeAt(offlineAt)); got != 0 {
|
||||
t.Fatalf("active at offlineAt = %d, want 0", got)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("loader calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSnapshotExcludedSearchModuleIDs(t *testing.T) {
|
||||
excludedID := primitive.NewObjectID()
|
||||
includedID := primitive.NewObjectID()
|
||||
snapshot := newModuleSnapshot([]ModuleConf{
|
||||
{ID: excludedID, ExcludeSearch: true},
|
||||
{ID: includedID, ExcludeSearch: false},
|
||||
})
|
||||
|
||||
ids := snapshot.excludedSearchModuleIDs()
|
||||
assertStringSetContains(t, ids, excludedID.Hex(), true)
|
||||
assertStringSetContains(t, ids, includedID.Hex(), false)
|
||||
}
|
||||
|
||||
func TestModuleSnapshotAccessorsReturnDeepCopies(t *testing.T) {
|
||||
moduleID := primitive.NewObjectID()
|
||||
onlineAt := time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)
|
||||
snapshot := newModuleSnapshot([]ModuleConf{{
|
||||
ID: moduleID,
|
||||
SubModuleName: "original",
|
||||
OnlineAt: &onlineAt,
|
||||
HaiJiaoStyle: HaiJiaoStyle{
|
||||
SortRules: []SortItem{{Val: commod.MostHot, Name: "original-rule"}},
|
||||
},
|
||||
}})
|
||||
|
||||
first := snapshot.all()
|
||||
first[0].SubModuleName = "changed"
|
||||
*first[0].OnlineAt = first[0].OnlineAt.Add(time.Hour)
|
||||
first[0].HaiJiaoStyle.SortRules[0].Name = "changed-rule"
|
||||
|
||||
second := snapshot.all()
|
||||
if second[0].SubModuleName != "original" {
|
||||
t.Fatalf("shared scalar was mutated: %+v", second[0])
|
||||
}
|
||||
if !second[0].OnlineAt.Equal(onlineAt) {
|
||||
t.Fatalf("shared schedule was mutated: %v", second[0].OnlineAt)
|
||||
}
|
||||
if second[0].HaiJiaoStyle.SortRules[0].Name != "original-rule" {
|
||||
t.Fatalf("shared sort rules were mutated: %+v", second[0].HaiJiaoStyle.SortRules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByIDPreservesNoDocumentsContract(t *testing.T) {
|
||||
original := moduleMetadataCache
|
||||
moduleMetadataCache = newModuleSnapshotCache(
|
||||
func() ([]ModuleConf, error) { return []ModuleConf{}, nil },
|
||||
time.Now,
|
||||
time.Hour,
|
||||
time.Second,
|
||||
)
|
||||
t.Cleanup(func() { moduleMetadataCache = original })
|
||||
|
||||
if _, err := GetByID(primitive.NewObjectID()); !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
t.Fatalf("GetByID() error = %v, want mongo.ErrNoDocuments", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCachedModuleName(
|
||||
t *testing.T,
|
||||
cache *moduleSnapshotCache,
|
||||
moduleID primitive.ObjectID,
|
||||
want string,
|
||||
) {
|
||||
t.Helper()
|
||||
snapshot, err := cache.get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module, ok := snapshot.findByID(moduleID)
|
||||
if !ok {
|
||||
t.Fatalf("module %s not found", moduleID.Hex())
|
||||
}
|
||||
if module.SubModuleName != want {
|
||||
t.Fatalf("module name = %q, want %q", module.SubModuleName, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSetContains(t *testing.T, values []string, target string, want bool) {
|
||||
t.Helper()
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
if !want {
|
||||
t.Fatalf("%q unexpectedly found in %v", target, values)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if want {
|
||||
t.Fatalf("%q not found in %v", target, values)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package moduleconfmod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/db"
|
||||
"91porn-server/models/commod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// ShowType 取值
|
||||
const (
|
||||
HJShowType = 1 // 模块普通海角系样式
|
||||
AllSectionShowType = 2 // 全专题组合样式
|
||||
HengSlideShowType = 3 // (17岁)单排专题横滑动展示
|
||||
ActressShowType = 4 // 女优网黄展示样式
|
||||
)
|
||||
|
||||
// ModuleConf 模块配置
|
||||
type ModuleConf struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` // ID
|
||||
ModuleName string `json:"moduleName" bson:"moduleName" binding:"required"` // 版块名称,如“撩吧”
|
||||
SubModuleName string `json:"subModuleName" bson:"subModuleName" binding:"required"` // 版块下亚模块名称,如“撩吧”下的“原创”
|
||||
Status uint8 `json:"status" bson:"status"` // 状态,0-关闭,1-展示
|
||||
SectionLimit int `json:"sectionLimit" bson:"sectionLimit" binding:"required"` // 专题数量限制,0-不限制
|
||||
ShowType int `json:"showType" bson:"showType"` // 模块排版类型
|
||||
ShowJG bool `json:"showJG" bson:"showJG"` // 是否展示金刚区
|
||||
Cover string `json:"cover" bson:"cover"` // 封面
|
||||
AiPlazaStyle AiPlazaStyle `json:"aiPlazaStyle" bson:"aiPlazaStyle"` // ai广场样式
|
||||
HaiJiaoStyle HaiJiaoStyle `json:"haiJiaoStyle" bson:"haiJiaoStyle"` // 海角样式详情
|
||||
DefaultTagId primitive.ObjectID `json:"defaultTagId" bson:"defaultTagId"` // 社区默认展示标签
|
||||
SortNum int `json:"sortNum" bson:"sortNum"` // 模块排序
|
||||
Type int `json:"type" bson:"type"` // 模块类型
|
||||
PureVersion bool `json:"pureVersion" bson:"pureVersion"` // 是否纯净版无广告
|
||||
OnlineAt *time.Time `json:"onlineAt" bson:"onlineAt,omitempty"` // 定时上架时间,为空表示不限制
|
||||
OfflineAt *time.Time `json:"offlineAt" bson:"offlineAt,omitempty"` // 定时下架时间,为空表示不限制
|
||||
ExcludeLatest bool `json:"excludeLatest" bson:"excludeLatest"` // 亚模块内容不进入最新列表
|
||||
ExcludeRecommend bool `json:"excludeRecommend" bson:"excludeRecommend"` // 亚模块内容不进入推荐列表
|
||||
ExcludeSearch bool `json:"excludeSearch" bson:"excludeSearch"` // 亚模块内容不进入搜索列表
|
||||
SearchOnlyWhenInactive bool `json:"searchOnlyWhenInactive" bson:"searchOnlyWhenInactive"` // 失效后仅允许搜索入口访问
|
||||
CreatedAt time.Time `json:"createdAt" bson:"createdAt,omitempty"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` // 刷新时间
|
||||
DeletedAt *time.Time `json:"deletedAt" bson:"deletedAt,omitempty"` // 删除时间,不能去除omitempty
|
||||
}
|
||||
|
||||
type AiPlazaStyle struct {
|
||||
Type int `json:"type" bson:"type"` // 1-ai图片换脸 2-ai视频换脸 3-ai脱衣 4-ai图生视频 5-ai绘画
|
||||
Select bool `json:"select" bson:"select"` // 默认选中
|
||||
}
|
||||
|
||||
type HaiJiaoStyle struct {
|
||||
SectionStyle int `json:"sectionStyle" bson:"sectionStyle"` // 专题展示样式 0-不展示 1-十六岁专题 2-女优专题 3-网黄专题 4-普通专题
|
||||
SortStyle int `json:"sortShow" bson:"sectionShow"` // 排序规则展示样式 展示样式 0-不展示 1-展示
|
||||
DefaultShow int `json:"defaultShow" bson:"defaultShow"` // 0-一排两个 1-一排一个
|
||||
ShowChosenVideo int `json:"showChosenVideo" bson:"showChosenVideo"` // 0-不展示精选视频 1-展示精选视频
|
||||
SortRules []SortItem `json:"sortRules" bson:"sortRules"` // 排序规则
|
||||
}
|
||||
|
||||
type SortItem struct {
|
||||
Val commod.SortType `json:"val" bson:"val"` // 排序规则 // 1、最新上架,2、热门推荐,3、最多观看 7-最多收藏 9、最新热评
|
||||
Top bool `json:"top" bson:"top"` // 是否置顶排序 在原有的排序规则上增加一个置顶排序,置顶条件优先
|
||||
Name string `json:"name" bson:"name"`
|
||||
RefreshMode string `json:"refreshMode" bson:"refreshMode"` // 刷新方式 DEFAULT / RANDOM_TOP_N
|
||||
RandomCandidateN int `json:"randomCandidateN" bson:"randomCandidateN"` // 随机候选池大小,最大30
|
||||
}
|
||||
|
||||
const (
|
||||
RefreshModeDefault = "DEFAULT"
|
||||
RefreshModeRandomTopN = "RANDOM_TOP_N"
|
||||
DefaultRandomCandidateN = 30
|
||||
MaxRandomCandidateN = 30
|
||||
)
|
||||
|
||||
// NormalizeRefreshConfig 补齐历史排序配置的刷新语义。
|
||||
// 历史数据没有 refreshMode 时,仅按稳定排序值 MostHot 做一次兼容,
|
||||
// App 后续只消费 refreshMode,不依赖后台可编辑标题。
|
||||
func (s *SortItem) NormalizeRefreshConfig() {
|
||||
s.RefreshMode = strings.ToUpper(strings.TrimSpace(s.RefreshMode))
|
||||
if s.RefreshMode == "" {
|
||||
s.RefreshMode = RefreshModeDefault
|
||||
if s.Val == commod.MostHot {
|
||||
s.RefreshMode = RefreshModeRandomTopN
|
||||
}
|
||||
}
|
||||
if s.RefreshMode == RefreshModeRandomTopN {
|
||||
if s.RandomCandidateN <= 0 || s.RandomCandidateN > MaxRandomCandidateN {
|
||||
s.RandomCandidateN = DefaultRandomCandidateN
|
||||
}
|
||||
return
|
||||
}
|
||||
s.RandomCandidateN = 0
|
||||
}
|
||||
|
||||
func (s SortItem) ValidateRefreshConfig() error {
|
||||
switch s.RefreshMode {
|
||||
case RefreshModeDefault:
|
||||
if s.RandomCandidateN != 0 {
|
||||
return fmt.Errorf("randomCandidateN must be 0 when refreshMode is DEFAULT")
|
||||
}
|
||||
case RefreshModeRandomTopN:
|
||||
if s.RandomCandidateN < 1 || s.RandomCandidateN > MaxRandomCandidateN {
|
||||
return fmt.Errorf("randomCandidateN must be between 1 and %d", MaxRandomCandidateN)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported refreshMode: %s", s.RefreshMode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HaiJiaoStyle) NormalizeRefreshConfig() {
|
||||
for i := range h.SortRules {
|
||||
h.SortRules[i].NormalizeRefreshConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureSortRules 保持与历史 App 接口一致:后台未配置排序项时返回默认排序项。
|
||||
func (h *HaiJiaoStyle) EnsureSortRules() {
|
||||
if len(h.SortRules) == 0 {
|
||||
h.SortRules = []SortItem{
|
||||
{Val: commod.New},
|
||||
{Val: commod.MostHot},
|
||||
{Val: commod.MostWatch},
|
||||
{Val: commod.MostCollect},
|
||||
}
|
||||
}
|
||||
h.NormalizeRefreshConfig()
|
||||
}
|
||||
|
||||
func (h HaiJiaoStyle) ValidateRefreshConfig() error {
|
||||
for _, rule := range h.SortRules {
|
||||
if err := rule.ValidateRefreshConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Init() {
|
||||
mdb = db.Init(table)
|
||||
moduleMetadataCache.reset()
|
||||
initIndex()
|
||||
}
|
||||
|
||||
type EditSelector struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty" binding:"required"`
|
||||
ModuleName *string `json:"moduleName" bson:"moduleName,omitempty"`
|
||||
SubModuleName *string `json:"subModuleName" bson:"subModuleName,omitempty"`
|
||||
Status *uint8 `json:"status" bson:"status,omitempty"`
|
||||
SectionLimit *int `json:"sectionLimit" bson:"sectionLimit,omitempty"`
|
||||
SortNum *int `json:"sortNum" bson:"sortNum,omitempty"`
|
||||
Type *int `json:"type" bson:"type"` // 模块类型
|
||||
AiPlazaStyle *AiPlazaStyle `json:"aiPlazaStyle" bson:"aiPlazaStyle"` // ai广场样式
|
||||
HaiJiaoStyle *HaiJiaoStyle `json:"haiJiaoStyle" bson:"haiJiaoStyle"` // 海角样式详情
|
||||
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
|
||||
DeletedAt *time.Time `json:"deletedAt" bson:"deletedAt,omitempty"`
|
||||
ShowType *int `json:"showType" bson:"showType,omitempty"` // 模块排版类型
|
||||
DefaultTagId *primitive.ObjectID `json:"defaultTagId" bson:"defaultTagId"` // 社区默认展示标签
|
||||
PureVersion *bool `json:"pureVersion" bson:"pureVersion,omitempty"` // 是否纯净版无广告
|
||||
OnlineAt *time.Time `json:"onlineAt" bson:"onlineAt,omitempty"`
|
||||
OfflineAt *time.Time `json:"offlineAt" bson:"offlineAt,omitempty"`
|
||||
ClearOnlineAt bool `json:"clearOnlineAt" bson:"-"`
|
||||
ClearOfflineAt bool `json:"clearOfflineAt" bson:"-"`
|
||||
ExcludeLatest *bool `json:"excludeLatest" bson:"excludeLatest,omitempty"`
|
||||
ExcludeRecommend *bool `json:"excludeRecommend" bson:"excludeRecommend,omitempty"`
|
||||
ExcludeSearch *bool `json:"excludeSearch" bson:"excludeSearch,omitempty"`
|
||||
SearchOnlyWhenInactive *bool `json:"searchOnlyWhenInactive" bson:"searchOnlyWhenInactive,omitempty"`
|
||||
}
|
||||
|
||||
type QuerySelector struct {
|
||||
ID *primitive.ObjectID `json:"id,omitempty" bson:"_id,omitempty"`
|
||||
ModuleName *string `json:"moduleName,omitempty" bson:"moduleName,omitempty"`
|
||||
SubModuleName *string `json:"subModuleName,omitempty" bson:"subModuleName,omitempty"`
|
||||
Status *uint8 `json:"status,omitempty" bson:"status,omitempty"`
|
||||
Type *int `json:"type" bson:"type"` // 模块类型
|
||||
ShowType *int `json:"showType" bson:"showType"` // 模块排版类型
|
||||
|
||||
}
|
||||
|
||||
type ListResp struct {
|
||||
Total int64 `json:"total"` // 总数
|
||||
HasNext bool `json:"hasNext"` // 是否还有下一页
|
||||
List []ModuleConf `json:"list"` // 列表
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package moduleconfmod
|
||||
|
||||
type ModuleType int
|
||||
|
||||
const (
|
||||
HomePage = iota + 1 // 1、首页-视频
|
||||
Community // 2、社区
|
||||
DeepWeb // 3、暗网
|
||||
Cartoon // 4、动画
|
||||
Comics // 5、漫画
|
||||
Novel // 6、小说
|
||||
Game // 7、黄游(弃用)
|
||||
Pics // 8、图集
|
||||
ShortPage // 9、短视频
|
||||
PrivateCircle // 10、私密圈(弃用)
|
||||
AiPlaza // 11、AI广场
|
||||
Drama // 12、短剧
|
||||
)
|
||||
Reference in New Issue
Block a user