Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+39
View File
@@ -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"`
}
+325
View File
@@ -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
}
+101
View File
@@ -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)
}
}
+37
View File
@@ -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])
}
}
+334
View File
@@ -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)
}
}
+186
View File
@@ -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"` // 列表
}
+18
View File
@@ -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、短剧
)