@@ -0,0 +1,381 @@
|
||||
package paymentguidemod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/db"
|
||||
"91porn-server/models"
|
||||
|
||||
"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
|
||||
|
||||
func Init() {
|
||||
mdb = db.Init(models.PaymentGuide)
|
||||
initIndexes()
|
||||
}
|
||||
|
||||
func guideColl(t *db.MongoTool) *db.MongoTool {
|
||||
if t == nil {
|
||||
return mdb.Coll(models.PaymentGuide)
|
||||
}
|
||||
return t.Coll(models.PaymentGuide)
|
||||
}
|
||||
|
||||
func impressionColl() *db.MongoTool {
|
||||
return mdb.Coll(models.PaymentGuideImpression)
|
||||
}
|
||||
|
||||
func activeGuideIndexKeys() bson.D {
|
||||
return bson.D{
|
||||
{Key: "scene", Value: 1},
|
||||
{Key: "enable", Value: 1},
|
||||
{Key: "sort", Value: -1},
|
||||
{Key: "updatedAt", Value: -1},
|
||||
{Key: "_id", Value: -1},
|
||||
}
|
||||
}
|
||||
|
||||
func initIndexes() {
|
||||
if _, err := guideColl(nil).CreateIndex([]mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "scene", Value: 1},
|
||||
{Key: "enable", Value: 1},
|
||||
{Key: "sort", Value: -1},
|
||||
{Key: "startAt", Value: 1},
|
||||
{Key: "endAt", Value: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: activeGuideIndexKeys(),
|
||||
},
|
||||
}); err != nil {
|
||||
panic(fmt.Sprintf("%s model set index err ==>[%+v]", models.PaymentGuide, err))
|
||||
}
|
||||
//if err := impressionColl().DropIndexIfExists("uid_1_configId_1_scene_1"); err != nil {
|
||||
// panic(fmt.Sprintf("%s model drop legacy index err ==>[%+v]", models.PaymentGuideImpression, err))
|
||||
//}
|
||||
if _, err := impressionColl().CreateIndex([]mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "uid", Value: 1},
|
||||
{Key: "configId", Value: 1},
|
||||
{Key: "scene", Value: 1},
|
||||
{Key: "contentVersion", Value: 1},
|
||||
},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "requestId", Value: 1}},
|
||||
},
|
||||
}); err != nil {
|
||||
panic(fmt.Sprintf("%s model set index err ==>[%+v]", models.PaymentGuideImpression, err))
|
||||
}
|
||||
}
|
||||
|
||||
func Insert(p *PaymentGuide) error {
|
||||
p.Normalize()
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
p.ID = primitive.NewObjectID()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
_, err := guideColl(nil).InsertOne(p)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertMany validates every configuration before writing and inserts the
|
||||
// bounded batch in one transaction so callers never observe a partial batch.
|
||||
func InsertMany(ctx context.Context, configs []PaymentGuide) error {
|
||||
if len(configs) == 0 {
|
||||
return fmt.Errorf("payment guide configs are required")
|
||||
}
|
||||
if len(configs) > MaxBatchSceneCount {
|
||||
return fmt.Errorf("payment guide configs cannot contain more than %d entries", MaxBatchSceneCount)
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range configs {
|
||||
configs[i].Normalize()
|
||||
if err := configs[i].Validate(); err != nil {
|
||||
return fmt.Errorf("config %d: %w", i, err)
|
||||
}
|
||||
configs[i].ID = primitive.NewObjectID()
|
||||
configs[i].CreatedAt = now
|
||||
configs[i].UpdatedAt = now
|
||||
}
|
||||
// Use the request context and disable the legacy in-place transaction
|
||||
// re-entry. A transient error is returned instead of rerunning writes in an
|
||||
// already-started transaction, and cancelled requests stop database work.
|
||||
return mdb.TransCtx(ctx, func(t *db.MongoTool) error {
|
||||
_, err := guideColl(t).InsertMany(configs)
|
||||
return err
|
||||
}, (&db.TransOpts{}).SetReEntry(0))
|
||||
}
|
||||
|
||||
func Update(p *PaymentGuide) error {
|
||||
if p.ID.IsZero() {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
p.Normalize()
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
set := bson.M{
|
||||
"scene": p.Scene,
|
||||
"segments": p.Segments,
|
||||
"style": p.Style,
|
||||
"title": p.Title,
|
||||
"description": p.Description,
|
||||
"cover": p.Cover,
|
||||
"videoIds": p.VideoIDs,
|
||||
"videoLimit": p.VideoLimit,
|
||||
"productId": p.ProductID,
|
||||
"durationSeconds": p.DurationSeconds,
|
||||
"action": p.Action,
|
||||
"enable": p.Enable,
|
||||
"sort": p.Sort,
|
||||
"updatedAt": p.UpdatedAt,
|
||||
}
|
||||
update := bson.M{"$set": set}
|
||||
unset := bson.M{}
|
||||
if p.StartAt == nil {
|
||||
unset["startAt"] = ""
|
||||
} else {
|
||||
set["startAt"] = *p.StartAt
|
||||
}
|
||||
if p.EndAt == nil {
|
||||
unset["endAt"] = ""
|
||||
} else {
|
||||
set["endAt"] = *p.EndAt
|
||||
}
|
||||
if len(unset) > 0 {
|
||||
update["$unset"] = unset
|
||||
}
|
||||
result, err := guideColl(nil).UpdateOne(bson.M{"_id": p.ID}, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.MatchedCount == 0 {
|
||||
return mongo.ErrNoDocuments
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Delete(id primitive.ObjectID) error {
|
||||
if id.IsZero() {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
_, err := guideColl(nil).DeleteById(id)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetByID(id primitive.ObjectID) (PaymentGuide, error) {
|
||||
var out PaymentGuide
|
||||
err := guideColl(nil).FindOne(&out, bson.M{"_id": id})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func List(scene string, skip, limit int64) ([]PaymentGuide, int64, bool, error) {
|
||||
filter := paymentGuideListFilter(scene)
|
||||
total, err := guideColl(nil).Count(filter)
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
var list []PaymentGuide
|
||||
err = guideColl(nil).Find(&list, filter, options.Find().
|
||||
SetSort(bson.D{{Key: "sort", Value: -1}, {Key: "updatedAt", Value: -1}, {Key: "_id", Value: -1}}).
|
||||
SetSkip(skip).
|
||||
SetLimit(limit+1))
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
hasNext := len(list) > int(limit)
|
||||
if hasNext {
|
||||
list = list[:limit]
|
||||
}
|
||||
return list, total, hasNext, nil
|
||||
}
|
||||
|
||||
func paymentGuideListFilter(scene string) bson.M {
|
||||
filter := bson.M{"scene": bson.M{"$in": ConfigurableScenes()}}
|
||||
if scene != "" {
|
||||
filter["scene"] = scene
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func FindActive(scene, segment string, now time.Time) (*PaymentGuide, error) {
|
||||
configs, err := FindActiveByScenes([]string{scene}, segment, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, exists := configs[strings.ToUpper(strings.TrimSpace(scene))]
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// FindActiveByScenes returns at most one highest-priority active configuration
|
||||
// for every requested scene. The scene batch is deliberately bounded by the
|
||||
// supported-scene count so Ping never fans out into per-scene database reads.
|
||||
func FindActiveByScenes(scenes []string, segment string, now time.Time) (map[string]PaymentGuide, error) {
|
||||
normalizedScenes, err := normalizeSceneBatch(scenes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]PaymentGuide, len(normalizedScenes))
|
||||
if len(normalizedScenes) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
segment = strings.ToUpper(strings.TrimSpace(segment))
|
||||
segmentFilters := bson.A{
|
||||
bson.M{"segments": bson.M{"$exists": false}},
|
||||
bson.M{"segments": nil},
|
||||
bson.M{"segments": bson.M{"$size": 0}},
|
||||
}
|
||||
if segment != "" {
|
||||
segmentFilters = append(segmentFilters, bson.M{"segments": segment})
|
||||
}
|
||||
filter := bson.M{
|
||||
"scene": bson.M{"$in": normalizedScenes},
|
||||
"enable": true,
|
||||
"$and": bson.A{
|
||||
bson.M{"$or": bson.A{
|
||||
bson.M{"startAt": bson.M{"$exists": false}},
|
||||
bson.M{"startAt": nil},
|
||||
bson.M{"startAt": bson.M{"$lte": now}},
|
||||
}},
|
||||
bson.M{"$or": bson.A{
|
||||
bson.M{"endAt": bson.M{"$exists": false}},
|
||||
bson.M{"endAt": nil},
|
||||
bson.M{"endAt": bson.M{"$gt": now}},
|
||||
}},
|
||||
bson.M{"$or": segmentFilters},
|
||||
},
|
||||
}
|
||||
pipeline := []bson.M{
|
||||
{"$match": filter},
|
||||
{"$sort": bson.D{
|
||||
{Key: "scene", Value: 1},
|
||||
{Key: "sort", Value: -1},
|
||||
{Key: "updatedAt", Value: -1},
|
||||
{Key: "_id", Value: -1},
|
||||
}},
|
||||
{"$group": bson.D{
|
||||
{Key: "_id", Value: "$scene"},
|
||||
{Key: "config", Value: bson.D{{Key: "$first", Value: "$$ROOT"}}},
|
||||
}},
|
||||
{"$replaceRoot": bson.D{{Key: "newRoot", Value: "$config"}}},
|
||||
}
|
||||
var configs []PaymentGuide
|
||||
aggregateOpts := options.Aggregate().
|
||||
SetHint(activeGuideIndexKeys()).
|
||||
SetMaxTime(2 * time.Second)
|
||||
if err = guideColl(nil).Aggregate(&configs, pipeline, aggregateOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, config := range configs {
|
||||
result[config.Scene] = config
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeSceneBatch(scenes []string) ([]string, error) {
|
||||
if len(scenes) > MaxBatchSceneCount {
|
||||
return nil, fmt.Errorf("scenes cannot contain more than %d entries", MaxBatchSceneCount)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(scenes))
|
||||
normalized := make([]string, 0, len(scenes))
|
||||
for _, scene := range scenes {
|
||||
scene = strings.ToUpper(strings.TrimSpace(scene))
|
||||
if !ValidScene(scene) {
|
||||
return nil, fmt.Errorf("unsupported scene: %s", scene)
|
||||
}
|
||||
if _, exists := seen[scene]; exists {
|
||||
continue
|
||||
}
|
||||
seen[scene] = struct{}{}
|
||||
normalized = append(normalized, scene)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func HasImpression(uid uint64, configID primitive.ObjectID, scene, contentVersion string) (bool, error) {
|
||||
count, err := impressionColl().Count(bson.M{
|
||||
"uid": uid,
|
||||
"configId": configID,
|
||||
"scene": scene,
|
||||
"contentVersion": contentVersion,
|
||||
})
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// FindImpressionConfigIDs performs one bounded lookup for the ordinary Ping
|
||||
// scenes and returns the configurations already shown to the user.
|
||||
func FindImpressionConfigIDs(uid uint64, configIDs []primitive.ObjectID) (map[primitive.ObjectID]bool, error) {
|
||||
result := make(map[primitive.ObjectID]bool, len(configIDs))
|
||||
if uid == 0 || len(configIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if len(configIDs) > MaxBatchSceneCount {
|
||||
return nil, fmt.Errorf("configIds cannot contain more than %d entries", MaxBatchSceneCount)
|
||||
}
|
||||
seen := make(map[primitive.ObjectID]struct{}, len(configIDs))
|
||||
uniqueIDs := make([]primitive.ObjectID, 0, len(configIDs))
|
||||
for _, configID := range configIDs {
|
||||
if configID.IsZero() {
|
||||
return nil, fmt.Errorf("configId cannot be empty")
|
||||
}
|
||||
if _, exists := seen[configID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[configID] = struct{}{}
|
||||
uniqueIDs = append(uniqueIDs, configID)
|
||||
}
|
||||
type impressionRef struct {
|
||||
ConfigID primitive.ObjectID `bson:"configId"`
|
||||
}
|
||||
var rows []impressionRef
|
||||
findOpts := options.Find().
|
||||
SetProjection(bson.M{"_id": 0, "configId": 1}).
|
||||
SetLimit(int64(len(uniqueIDs)))
|
||||
if err := impressionColl().Find(&rows, bson.M{
|
||||
"uid": uid,
|
||||
"configId": bson.M{"$in": uniqueIDs},
|
||||
"contentVersion": "",
|
||||
}, findOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ConfigID] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func RecordImpression(item Impression) error {
|
||||
item.ID = primitive.NilObjectID
|
||||
item.CreatedAt = time.Now()
|
||||
_, err := impressionColl().UpsertOne(
|
||||
bson.M{
|
||||
"uid": item.UID,
|
||||
"configId": item.ConfigID,
|
||||
"scene": item.Scene,
|
||||
"contentVersion": item.ContentVersion,
|
||||
},
|
||||
bson.M{"$setOnInsert": item},
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package paymentguidemod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
// SceneHomeNewUser is kept only for backward compatibility with legacy
|
||||
// records and clients. It is no longer configurable or returned to App/Web.
|
||||
SceneHomeNewUser = "HOME_NEW_USER"
|
||||
SceneHomeNewUserFreeTrial = "HOME_NEW_USER_FREE_TRIAL"
|
||||
SceneHomeOldUser = "HOME_OLD_USER"
|
||||
SceneVideoPreviewEnd = "VIDEO_PREVIEW_END"
|
||||
SceneDiscountCountdown = "DISCOUNT_COUNTDOWN"
|
||||
SceneVideoBack = "VIDEO_BACK"
|
||||
SceneVIPCenter = "VIP_CENTER"
|
||||
SceneVIPContentUpdate = "VIP_CONTENT_UPDATE"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultVIPContentVideoLimit int64 = 4
|
||||
MaxVIPContentVideoLimit int64 = 20
|
||||
MaxBatchSceneCount = 7
|
||||
)
|
||||
|
||||
const (
|
||||
SegmentNewNeverPaid = "NEW_NEVER_PAID"
|
||||
SegmentOldNeverPaid = "OLD_NEVER_PAID"
|
||||
SegmentPaidUpgrade = "PAID_UPGRADE"
|
||||
SegmentMaxVIP = "MAX_VIP"
|
||||
SegmentNormal = "NORMAL"
|
||||
SegmentUnregistered = "UNREGISTERED"
|
||||
)
|
||||
|
||||
var validScenes = map[string]struct{}{
|
||||
SceneHomeNewUser: {},
|
||||
SceneHomeNewUserFreeTrial: {},
|
||||
SceneHomeOldUser: {},
|
||||
SceneVideoPreviewEnd: {},
|
||||
SceneDiscountCountdown: {},
|
||||
SceneVideoBack: {},
|
||||
SceneVIPCenter: {},
|
||||
SceneVIPContentUpdate: {},
|
||||
}
|
||||
|
||||
var configurableScenes = []string{
|
||||
SceneHomeNewUserFreeTrial,
|
||||
SceneHomeOldUser,
|
||||
SceneVideoPreviewEnd,
|
||||
SceneDiscountCountdown,
|
||||
SceneVideoBack,
|
||||
SceneVIPCenter,
|
||||
SceneVIPContentUpdate,
|
||||
}
|
||||
|
||||
var validSegments = map[string]struct{}{
|
||||
SegmentNewNeverPaid: {},
|
||||
SegmentOldNeverPaid: {},
|
||||
SegmentPaidUpgrade: {},
|
||||
SegmentMaxVIP: {},
|
||||
SegmentNormal: {},
|
||||
SegmentUnregistered: {},
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type string `json:"type" bson:"type"`
|
||||
Value string `json:"value" bson:"value"`
|
||||
}
|
||||
|
||||
type PaymentGuide struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
|
||||
Scene string `json:"scene" bson:"scene"`
|
||||
Segments []string `json:"segments" bson:"segments"`
|
||||
Style string `json:"style" bson:"style"`
|
||||
Title string `json:"title" bson:"title"`
|
||||
Description string `json:"description" bson:"description"`
|
||||
Cover string `json:"cover" bson:"cover"`
|
||||
VideoIDs []string `json:"videoIds" bson:"videoIds"`
|
||||
VideoLimit int64 `json:"videoLimit,omitempty" bson:"videoLimit,omitempty"`
|
||||
ProductID string `json:"productId" bson:"productId"`
|
||||
DurationSeconds int64 `json:"durationSeconds" bson:"durationSeconds"`
|
||||
Action Action `json:"action" bson:"action"`
|
||||
Enable bool `json:"enable" bson:"enable"`
|
||||
Sort int `json:"sort" bson:"sort"`
|
||||
StartAt *time.Time `json:"startAt,omitempty" bson:"startAt,omitempty"`
|
||||
EndAt *time.Time `json:"endAt,omitempty" bson:"endAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"`
|
||||
}
|
||||
|
||||
type Impression struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
|
||||
UID uint64 `json:"uid" bson:"uid"`
|
||||
ConfigID primitive.ObjectID `json:"configId" bson:"configId"`
|
||||
Scene string `json:"scene" bson:"scene"`
|
||||
ContentVersion string `json:"contentVersion" bson:"contentVersion"`
|
||||
VideoID string `json:"videoId" bson:"videoId"`
|
||||
RequestID string `json:"requestId" bson:"requestId"`
|
||||
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
|
||||
}
|
||||
|
||||
func ValidScene(scene string) bool {
|
||||
_, ok := validScenes[scene]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ConfigurableScenes returns the bounded scene list exposed to App/Web. The
|
||||
// legacy HOME_NEW_USER code remains valid for old data but is intentionally
|
||||
// absent from this list.
|
||||
func ConfigurableScenes() []string {
|
||||
return append([]string(nil), configurableScenes...)
|
||||
}
|
||||
|
||||
func ConfigurableScene(scene string) bool {
|
||||
for _, candidate := range configurableScenes {
|
||||
if scene == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *PaymentGuide) Normalize() {
|
||||
p.Scene = strings.TrimSpace(strings.ToUpper(p.Scene))
|
||||
p.Style = strings.TrimSpace(strings.ToUpper(p.Style))
|
||||
p.Title = strings.TrimSpace(p.Title)
|
||||
p.ProductID = strings.TrimSpace(p.ProductID)
|
||||
p.Action.Type = strings.TrimSpace(strings.ToUpper(p.Action.Type))
|
||||
p.Action.Value = strings.TrimSpace(p.Action.Value)
|
||||
if p.Action.Type == "VIP_PRODUCT" {
|
||||
if p.ProductID == "" {
|
||||
p.ProductID = p.Action.Value
|
||||
}
|
||||
if p.Action.Value == "" {
|
||||
p.Action.Value = p.ProductID
|
||||
}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(p.Segments))
|
||||
segments := make([]string, 0, len(p.Segments))
|
||||
for _, segment := range p.Segments {
|
||||
segment = strings.TrimSpace(strings.ToUpper(segment))
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[segment]; exists {
|
||||
continue
|
||||
}
|
||||
seen[segment] = struct{}{}
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
p.Segments = segments
|
||||
}
|
||||
|
||||
func (p PaymentGuide) Validate() error {
|
||||
if !ValidScene(p.Scene) {
|
||||
return fmt.Errorf("unsupported scene: %s", p.Scene)
|
||||
}
|
||||
for _, segment := range p.Segments {
|
||||
if _, ok := validSegments[segment]; !ok {
|
||||
return fmt.Errorf("unsupported segment: %s", segment)
|
||||
}
|
||||
}
|
||||
if p.Style == "" {
|
||||
return fmt.Errorf("style is required")
|
||||
}
|
||||
if p.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if p.DurationSeconds < 0 {
|
||||
return fmt.Errorf("durationSeconds cannot be negative")
|
||||
}
|
||||
if p.VideoLimit < 0 || p.VideoLimit > MaxVIPContentVideoLimit {
|
||||
return fmt.Errorf("videoLimit must be between 0 and %d", MaxVIPContentVideoLimit)
|
||||
}
|
||||
if p.StartAt != nil && p.EndAt != nil && !p.EndAt.After(*p.StartAt) {
|
||||
return fmt.Errorf("endAt must be later than startAt")
|
||||
}
|
||||
switch p.Action.Type {
|
||||
case "VIP_PRODUCT":
|
||||
if p.Action.Value == "" {
|
||||
return fmt.Errorf("action.value is required for %s", p.Action.Type)
|
||||
}
|
||||
if p.ProductID != p.Action.Value {
|
||||
return fmt.Errorf("productId must match action.value for VIP_PRODUCT")
|
||||
}
|
||||
case "INTERNAL", "EXTERNAL":
|
||||
if p.Action.Value == "" {
|
||||
return fmt.Errorf("action.value is required for %s", p.Action.Type)
|
||||
}
|
||||
case "NONE":
|
||||
if p.Action.Value != "" {
|
||||
return fmt.Errorf("action.value must be empty for NONE")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported action.type: %s", p.Action.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveVideoLimit returns the configured video count with a safe default
|
||||
// for legacy configurations that do not contain videoLimit.
|
||||
func (p PaymentGuide) EffectiveVideoLimit() int64 {
|
||||
if p.VideoLimit <= 0 {
|
||||
return DefaultVIPContentVideoLimit
|
||||
}
|
||||
if p.VideoLimit > MaxVIPContentVideoLimit {
|
||||
return MaxVIPContentVideoLimit
|
||||
}
|
||||
return p.VideoLimit
|
||||
}
|
||||
|
||||
// VIPContentVideoLimit keeps the scene-specific call site readable while
|
||||
// sharing the same defaulting rules used by the Web configuration page.
|
||||
func (p PaymentGuide) VIPContentVideoLimit() int64 {
|
||||
return p.EffectiveVideoLimit()
|
||||
}
|
||||
|
||||
func (p PaymentGuide) ActiveAt(now time.Time) bool {
|
||||
if !p.Enable {
|
||||
return false
|
||||
}
|
||||
if p.StartAt != nil && p.StartAt.After(now) {
|
||||
return false
|
||||
}
|
||||
return p.EndAt == nil || p.EndAt.After(now)
|
||||
}
|
||||
|
||||
func (p PaymentGuide) MatchesSegment(segment string) bool {
|
||||
if len(p.Segments) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range p.Segments {
|
||||
if allowed == segment {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package paymentguidemod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
)
|
||||
|
||||
func TestActiveGuideIndexKeysMatchBatchQueryOrder(t *testing.T) {
|
||||
want := bson.D{
|
||||
{Key: "scene", Value: 1},
|
||||
{Key: "enable", Value: 1},
|
||||
{Key: "sort", Value: -1},
|
||||
{Key: "updatedAt", Value: -1},
|
||||
{Key: "_id", Value: -1},
|
||||
}
|
||||
if got := activeGuideIndexKeys(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("activeGuideIndexKeys() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideNormalizeAndValidate(t *testing.T) {
|
||||
config := PaymentGuide{
|
||||
Scene: "video_preview_end",
|
||||
Segments: []string{"old_never_paid", "OLD_NEVER_PAID"},
|
||||
Style: "bottom_sheet",
|
||||
Title: "开通会员",
|
||||
Action: Action{Type: "vip_product", Value: "product-id"},
|
||||
}
|
||||
config.Normalize()
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Scene != SceneVideoPreviewEnd || len(config.Segments) != 1 {
|
||||
t.Fatalf("unexpected normalized config: %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideActiveAt(t *testing.T) {
|
||||
now := time.Now()
|
||||
start := now.Add(-time.Minute)
|
||||
end := now.Add(time.Minute)
|
||||
config := PaymentGuide{Enable: true, StartAt: &start, EndAt: &end}
|
||||
if !config.ActiveAt(now) {
|
||||
t.Fatal("config should be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideVIPContentVideoLimit(t *testing.T) {
|
||||
if DefaultVIPContentVideoLimit != 4 {
|
||||
t.Fatalf("DefaultVIPContentVideoLimit = %d, want 4", DefaultVIPContentVideoLimit)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
limit int64
|
||||
want int64
|
||||
}{
|
||||
{name: "legacy default", limit: 0, want: DefaultVIPContentVideoLimit},
|
||||
{name: "minimum", limit: 1, want: 1},
|
||||
{name: "configured", limit: 12, want: 12},
|
||||
{name: "maximum", limit: MaxVIPContentVideoLimit, want: MaxVIPContentVideoLimit},
|
||||
{name: "defensive clamp", limit: MaxVIPContentVideoLimit + 1, want: MaxVIPContentVideoLimit},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := PaymentGuide{VideoLimit: tt.limit}
|
||||
if got := config.VIPContentVideoLimit(); got != tt.want {
|
||||
t.Fatalf("VIPContentVideoLimit() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideBatchSceneLimitMatchesSupportedScenes(t *testing.T) {
|
||||
if len(ConfigurableScenes()) != MaxBatchSceneCount {
|
||||
t.Fatalf("len(ConfigurableScenes()) = %d, MaxBatchSceneCount = %d", len(ConfigurableScenes()), MaxBatchSceneCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyHomeNewUserSceneIsNotConfigurable(t *testing.T) {
|
||||
if !ValidScene(SceneHomeNewUser) {
|
||||
t.Fatal("legacy HOME_NEW_USER must remain recognizable")
|
||||
}
|
||||
if ConfigurableScene(SceneHomeNewUser) {
|
||||
t.Fatal("legacy HOME_NEW_USER must not be returned as configurable")
|
||||
}
|
||||
if !ConfigurableScene(SceneHomeNewUserFreeTrial) {
|
||||
t.Fatal("HOME_NEW_USER_FREE_TRIAL must be configurable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurableScenesReturnsCopy(t *testing.T) {
|
||||
scenes := ConfigurableScenes()
|
||||
scenes[0] = "CHANGED"
|
||||
if ConfigurableScenes()[0] != SceneHomeNewUserFreeTrial {
|
||||
t.Fatal("ConfigurableScenes must not expose mutable package state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideListFilterExcludesLegacyScene(t *testing.T) {
|
||||
filter := paymentGuideListFilter("")
|
||||
sceneFilter, ok := filter["scene"].(bson.M)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected scene filter: %#v", filter["scene"])
|
||||
}
|
||||
scenes, ok := sceneFilter["$in"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected scene inclusion filter: %#v", sceneFilter["$in"])
|
||||
}
|
||||
for _, scene := range scenes {
|
||||
if scene == SceneHomeNewUser {
|
||||
t.Fatalf("legacy HOME_NEW_USER must not be listed: %#v", scenes)
|
||||
}
|
||||
}
|
||||
if filter := paymentGuideListFilter(SceneVIPCenter); filter["scene"] != SceneVIPCenter {
|
||||
t.Fatalf("explicit scene filter was not preserved: %#v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscountCountdownIsSupportedScene(t *testing.T) {
|
||||
if !ValidScene(SceneDiscountCountdown) {
|
||||
t.Fatal("DISCOUNT_COUNTDOWN must be a supported payment-guide scene")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideInsertManyRejectsUnboundedBatchesBeforeDatabaseAccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configs []PaymentGuide
|
||||
match string
|
||||
}{
|
||||
{name: "empty", configs: nil, match: "required"},
|
||||
{name: "too many", configs: make([]PaymentGuide, MaxBatchSceneCount+1), match: "more than"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := InsertMany(context.Background(), tt.configs)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.match) {
|
||||
t.Fatalf("InsertMany() error = %v, want match %q", err, tt.match)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSceneBatch(t *testing.T) {
|
||||
scenes, err := normalizeSceneBatch([]string{" home_new_user ", "HOME_NEW_USER", "vip_center"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(scenes) != 2 || scenes[0] != SceneHomeNewUser || scenes[1] != SceneVIPCenter {
|
||||
t.Fatalf("normalizeSceneBatch() = %#v", scenes)
|
||||
}
|
||||
if _, err = normalizeSceneBatch([]string{"UNKNOWN"}); err == nil {
|
||||
t.Fatal("normalizeSceneBatch must reject unsupported scenes")
|
||||
}
|
||||
if _, err = normalizeSceneBatch(make([]string, MaxBatchSceneCount+1)); err == nil {
|
||||
t.Fatal("normalizeSceneBatch must reject an unbounded batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentGuideValidateVideoLimit(t *testing.T) {
|
||||
valid := PaymentGuide{
|
||||
Scene: SceneVIPContentUpdate,
|
||||
Style: "BOTTOM_SHEET",
|
||||
Title: "VIP内容更新",
|
||||
Action: Action{Type: "NONE"},
|
||||
VideoLimit: MaxVIPContentVideoLimit,
|
||||
}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
for _, limit := range []int64{-1, MaxVIPContentVideoLimit + 1} {
|
||||
invalid := valid
|
||||
invalid.VideoLimit = limit
|
||||
if err := invalid.Validate(); err == nil {
|
||||
t.Fatalf("Validate() with videoLimit %d should fail", limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user