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
@@ -0,0 +1,349 @@
package paymentguideser
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"91porn-server/app/proto"
"91porn-server/app/service/sys_config"
"91porn-server/models/cache/sysconfdata"
"91porn-server/models/v/moduleconfmod"
"91porn-server/models/v/paymentguidemod"
"91porn-server/models/v/sysconfmod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/vidmod"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type GuideResp struct {
Show bool `json:"show"`
TotalWatchCount *uint64 `json:"totalWatchCount,omitempty"`
ConfigID string `json:"configId,omitempty"`
ContentVersion string `json:"contentVersion,omitempty"`
Segment string `json:"segment"`
Style string `json:"style,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Cover string `json:"cover,omitempty"`
Videos []vidmod.PaymentGuideVideo `json:"videos,omitempty"`
ProductID string `json:"productId,omitempty"`
DurationSeconds int64 `json:"durationSeconds,omitempty"`
Action *paymentguidemod.Action `json:"action,omitempty"`
}
type ImpressionReq struct {
ConfigID string `json:"configId" binding:"required"`
Scene string `json:"scene" binding:"required"`
ContentVersion string `json:"contentVersion"`
VideoID string `json:"videoId"`
RequestID string `json:"requestId" binding:"required"`
}
const videoPlayCountMultiplier = 99
var pingGuideScenes = []string{
paymentguidemod.SceneHomeNewUserFreeTrial,
paymentguidemod.SceneHomeOldUser,
paymentguidemod.SceneVideoPreviewEnd,
paymentguidemod.SceneDiscountCountdown,
paymentguidemod.SceneVideoBack,
paymentguidemod.SceneVIPCenter,
paymentguidemod.SceneVIPContentUpdate,
}
// impressionLimitedPingGuideScenes contains only scenes whose show state is
// suppressed after the same configuration has already been displayed. The
// discount countdown is intentionally excluded because the App controls its
// once-per-launch behavior and starts a fresh local countdown on every launch.
var impressionLimitedPingGuideScenes = map[string]struct{}{
paymentguidemod.SceneHomeNewUserFreeTrial: {},
paymentguidemod.SceneHomeOldUser: {},
paymentguidemod.SceneVideoPreviewEnd: {},
paymentguidemod.SceneVideoBack: {},
paymentguidemod.SceneVIPCenter: {},
}
type paymentGuideSwitchState struct {
global bool
home bool
}
func contentVersion(videos []vidmod.PaymentGuideVideo) string {
ids := make([]string, 0, len(videos))
for _, video := range videos {
ids = append(ids, video.ID)
}
sum := sha256.Sum256([]byte(strings.Join(ids, ",")))
return hex.EncodeToString(sum[:8])
}
func SegmentForUser(user *usermod.User) string {
switch user.GetPaymentStatusPopup() {
case usermod.UserPaymentStatusPopupNewUnpay:
return paymentguidemod.SegmentNewNeverPaid
case usermod.UserPaymentStatusPopupUnder7DayUnpay,
usermod.UserPaymentStatusPopupUnder7DayUnpayNoCountdown,
usermod.UserPaymentStatusPopupOver7DayUnpay:
return paymentguidemod.SegmentOldNeverPaid
case usermod.UserPaymentStatusPopupOver7DayNeedUpgrade:
return paymentguidemod.SegmentPaidUpgrade
case usermod.UserPaymentStatusPopupMaxVIPLevel:
return paymentguidemod.SegmentMaxVIP
case usermod.UserPaymentStatusPopupUnregistered:
return paymentguidemod.SegmentUnregistered
default:
return paymentguidemod.SegmentNormal
}
}
func hasFreeTrialRemaining(user *usermod.User) bool {
return user != nil && user.WatchCount > 0
}
func paymentGuideSwitches() (paymentGuideSwitchState, error) {
values, err := sysconfdata.GetBoolsFromSharedCache(
sysconfmod.VCodePaymentGuideEnabled,
sysconfmod.VCodePaymentGuideHomeEnabled,
)
if err != nil {
return paymentGuideSwitchState{}, err
}
return paymentGuideSwitchesFromValues(values), nil
}
func paymentGuideSwitchesFromValues(values map[sysconfmod.VCode]bool) paymentGuideSwitchState {
homeEnabled := true
if configured, exists := values[sysconfmod.VCodePaymentGuideHomeEnabled]; exists {
homeEnabled = configured
}
return paymentGuideSwitchState{
global: values[sysconfmod.VCodePaymentGuideEnabled],
home: homeEnabled,
}
}
func (s paymentGuideSwitchState) sceneEnabled(scene string) bool {
if scene == paymentguidemod.SceneHomeNewUser {
return false
}
if !s.global {
return false
}
if scene == paymentguidemod.SceneHomeNewUserFreeTrial || scene == paymentguidemod.SceneHomeOldUser {
return s.home
}
return true
}
// GetPingGuide resolves every payment-guide scene in two bounded database
// operations: one active-config aggregation and one impression lookup for the
// scenes that are suppressed after display. VIP_CONTENT_UPDATE only exposes
// its enabled state because videos and contentVersion must be evaluated in real
// time by GetGuide.
func GetPingGuide(user *usermod.User) (proto.PaymentGuidePing, error) {
segment := SegmentForUser(user)
totalWatchCount := sys_config.GetTotalWatchCount()
freeTrialRemaining := hasFreeTrialRemaining(user)
switches, err := paymentGuideSwitches()
if err != nil {
return buildPingGuide(paymentGuideSwitchState{}, segment, totalWatchCount, freeTrialRemaining, nil, nil), err
}
if !switches.global {
return buildPingGuide(switches, segment, totalWatchCount, freeTrialRemaining, nil, nil), nil
}
configs, err := paymentguidemod.FindActiveByScenes(pingGuideScenes, segment, time.Now())
if err != nil {
return buildPingGuide(switches, segment, totalWatchCount, freeTrialRemaining, nil, nil), err
}
configIDs := make([]primitive.ObjectID, 0, len(impressionLimitedPingGuideScenes))
for scene, config := range configs {
if _, limited := impressionLimitedPingGuideScenes[scene]; limited && switches.sceneEnabled(scene) {
configIDs = append(configIDs, config.ID)
}
}
var uid uint64
if user != nil {
uid = user.UID
}
shown, err := paymentguidemod.FindImpressionConfigIDs(uid, configIDs)
if err != nil {
// 展示记录不可用时失败关闭,避免已展示过的普通弹窗重复弹出。
return buildPingGuide(switches, segment, totalWatchCount, freeTrialRemaining, nil, nil), err
}
return buildPingGuide(switches, segment, totalWatchCount, freeTrialRemaining, configs, shown), nil
}
func buildPingGuide(
switches paymentGuideSwitchState,
segment string,
totalWatchCount uint64,
freeTrialRemaining bool,
configs map[string]paymentguidemod.PaymentGuide,
shown map[primitive.ObjectID]bool,
) proto.PaymentGuidePing {
resp := proto.PaymentGuidePing{
Enabled: switches.global,
Segment: segment,
Scenes: make(map[string]proto.PaymentGuidePingScene, len(pingGuideScenes)),
}
for _, scene := range pingGuideScenes {
item := proto.PaymentGuidePingScene{}
if scene == paymentguidemod.SceneHomeNewUserFreeTrial {
item.TotalWatchCount = &totalWatchCount
}
if scene != paymentguidemod.SceneVIPContentUpdate {
show := false
item.Show = &show
}
config, exists := configs[scene]
if !switches.sceneEnabled(scene) || !exists {
resp.Scenes[scene] = item
continue
}
item.Enabled = true
if scene == paymentguidemod.SceneVIPContentUpdate {
resp.Scenes[scene] = item
continue
}
fillPingSceneConfig(&item, config)
show := true
if _, limited := impressionLimitedPingGuideScenes[scene]; limited {
show = !shown[config.ID]
}
if scene == paymentguidemod.SceneHomeNewUserFreeTrial && !freeTrialRemaining {
show = false
}
item.Show = &show
resp.Scenes[scene] = item
}
return resp
}
func fillPingSceneConfig(item *proto.PaymentGuidePingScene, config paymentguidemod.PaymentGuide) {
item.ConfigID = config.ID.Hex()
item.Style = config.Style
item.Title = config.Title
description := config.Description
cover := config.Cover
productID := config.ProductID
durationSeconds := config.DurationSeconds
action := config.Action
item.Description = &description
item.Cover = &cover
item.ProductID = &productID
item.DurationSeconds = &durationSeconds
item.Action = &action
}
func GetGuide(uid uint64, scene string) (GuideResp, error) {
if !paymentguidemod.ValidScene(scene) {
return GuideResp{}, fmt.Errorf("unsupported scene: %s", scene)
}
user, err := usermod.FindUserByUID(uid)
if err != nil {
return GuideResp{}, err
}
segment := SegmentForUser(user)
resp := GuideResp{Segment: segment}
if scene == paymentguidemod.SceneHomeNewUserFreeTrial {
totalWatchCount := sys_config.GetTotalWatchCount()
resp.TotalWatchCount = &totalWatchCount
}
switches, err := paymentGuideSwitches()
if err != nil {
return resp, err
}
if !switches.sceneEnabled(scene) {
return resp, nil
}
if scene == paymentguidemod.SceneHomeNewUserFreeTrial && !hasFreeTrialRemaining(user) {
return resp, nil
}
config, err := paymentguidemod.FindActive(scene, segment, time.Now())
if err != nil || config == nil {
return resp, err
}
if scene == paymentguidemod.SceneVIPContentUpdate {
excludedModuleIDs, queryErr := moduleconfmod.ExcludedVideoModuleIDs(time.Now(), false)
if queryErr != nil {
return GuideResp{}, queryErr
}
resp.Videos, queryErr = vidmod.LatestVIPContent(config.VIPContentVideoLimit(), excludedModuleIDs)
if queryErr != nil {
return GuideResp{}, queryErr
}
if len(resp.Videos) == 0 {
return resp, nil
}
for i := range resp.Videos {
resp.Videos[i].PlayCount = (resp.Videos[i].PlayCount + resp.Videos[i].FakePlayCount) * videoPlayCountMultiplier
}
resp.ContentVersion = contentVersion(resp.Videos)
}
if scene != paymentguidemod.SceneDiscountCountdown {
shown, impressionErr := paymentguidemod.HasImpression(uid, config.ID, scene, resp.ContentVersion)
if impressionErr != nil {
return resp, impressionErr
}
if shown {
resp.Videos = nil
resp.ContentVersion = ""
return resp, nil
}
}
resp.Show = true
resp.ConfigID = config.ID.Hex()
resp.Style = config.Style
resp.Title = config.Title
resp.Description = config.Description
resp.Cover = config.Cover
resp.ProductID = config.ProductID
resp.DurationSeconds = config.DurationSeconds
resp.Action = &config.Action
return resp, nil
}
func RecordImpression(uid uint64, req ImpressionReq) error {
configID, err := primitive.ObjectIDFromHex(req.ConfigID)
if err != nil {
return fmt.Errorf("invalid configId")
}
if !paymentguidemod.ValidScene(req.Scene) {
return fmt.Errorf("unsupported scene: %s", req.Scene)
}
if _, err = uuid.Parse(req.RequestID); err != nil {
return fmt.Errorf("invalid requestId")
}
req.ContentVersion = strings.TrimSpace(req.ContentVersion)
if req.Scene == paymentguidemod.SceneVIPContentUpdate && req.ContentVersion == "" {
return fmt.Errorf("contentVersion is required")
}
config, err := paymentguidemod.GetByID(configID)
if err != nil {
return err
}
if config.Scene != req.Scene {
return fmt.Errorf("scene does not match config")
}
user, err := usermod.FindUserByUID(uid)
if err != nil {
return err
}
if !config.MatchesSegment(SegmentForUser(user)) {
return fmt.Errorf("config does not match user segment")
}
return paymentguidemod.RecordImpression(paymentguidemod.Impression{
UID: uid,
ConfigID: configID,
Scene: req.Scene,
ContentVersion: req.ContentVersion,
VideoID: req.VideoID,
RequestID: req.RequestID,
})
}
@@ -0,0 +1,397 @@
package paymentguideser
import (
"encoding/json"
"strings"
"testing"
"time"
"91porn-server/models/v/paymentguidemod"
"91porn-server/models/v/sysconfmod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/vidmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestSegmentForUser(t *testing.T) {
now := time.Now()
tests := []struct {
name string
user *usermod.User
want string
}{
{
name: "new unpaid",
user: &usermod.User{
CreatedAt: now.Add(-time.Hour),
WatchCount: 3,
},
want: paymentguidemod.SegmentNewNeverPaid,
},
{
name: "new user with exhausted free views stays new unpaid",
user: &usermod.User{
CreatedAt: now.Add(-time.Hour),
WatchCount: 0,
},
want: paymentguidemod.SegmentNewNeverPaid,
},
{
name: "expired is old unpaid",
user: &usermod.User{
CreatedAt: now.AddDate(0, 0, -30),
VipLevel: 2,
VipExpireDate: now.Add(-time.Hour),
},
want: paymentguidemod.SegmentOldNeverPaid,
},
{
name: "active paid upgrade",
user: &usermod.User{
CreatedAt: now.AddDate(0, 0, -30),
VipLevel: 2,
VipExpireDate: now.AddDate(0, 1, 0),
},
want: paymentguidemod.SegmentPaidUpgrade,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SegmentForUser(tt.user); got != tt.want {
t.Fatalf("SegmentForUser() = %q, want %q", got, tt.want)
}
})
}
}
func TestContentVersion(t *testing.T) {
videos := []vidmod.PaymentGuideVideo{{ID: "video-a"}, {ID: "video-b"}}
got := contentVersion(videos)
if got == "" {
t.Fatal("contentVersion must not be empty")
}
if got != contentVersion(videos) {
t.Fatal("contentVersion must be stable for the same videos")
}
reordered := []vidmod.PaymentGuideVideo{{ID: "video-b"}, {ID: "video-a"}}
if got == contentVersion(reordered) {
t.Fatal("contentVersion must change when video order changes")
}
expanded := append(append([]vidmod.PaymentGuideVideo{}, videos...), vidmod.PaymentGuideVideo{ID: "video-c"})
if got == contentVersion(expanded) {
t.Fatal("contentVersion must change when configured video count changes")
}
}
func TestPaymentGuideSwitchesDefaultLegacyHomeToEnabled(t *testing.T) {
legacy := paymentGuideSwitchesFromValues(map[sysconfmod.VCode]bool{
sysconfmod.VCodePaymentGuideEnabled: true,
})
if !legacy.global || !legacy.home {
t.Fatalf("legacy switches must keep the home scenes enabled: %+v", legacy)
}
disabled := paymentGuideSwitchesFromValues(map[sysconfmod.VCode]bool{
sysconfmod.VCodePaymentGuideEnabled: true,
sysconfmod.VCodePaymentGuideHomeEnabled: false,
})
if !disabled.global || disabled.home {
t.Fatalf("configured home switch must be honored: %+v", disabled)
}
}
func TestGuideRespUsesVideosWithoutVideoIDs(t *testing.T) {
body, err := json.Marshal(GuideResp{
Show: true,
Videos: []vidmod.PaymentGuideVideo{{ID: "video-a"}},
})
if err != nil {
t.Fatal(err)
}
jsonBody := string(body)
if !strings.Contains(jsonBody, `"videos"`) {
t.Fatalf("response must contain videos: %s", jsonBody)
}
if strings.Contains(jsonBody, `"videoIds"`) {
t.Fatalf("response must not contain videoIds: %s", jsonBody)
}
}
func TestBuildPingGuide(t *testing.T) {
freeTrialID := primitive.NewObjectID()
homeID := primitive.NewObjectID()
contentID := primitive.NewObjectID()
configs := map[string]paymentguidemod.PaymentGuide{
paymentguidemod.SceneHomeNewUserFreeTrial: {
ID: freeTrialID,
Scene: paymentguidemod.SceneHomeNewUserFreeTrial,
Style: "BOTTOM_SHEET",
Title: "free trial",
Action: paymentguidemod.Action{Type: "NONE"},
},
paymentguidemod.SceneHomeOldUser: {
ID: homeID,
Scene: paymentguidemod.SceneHomeOldUser,
Style: "BOTTOM_SHEET",
Title: "open vip",
ProductID: "product-id",
DurationSeconds: 60,
Action: paymentguidemod.Action{Type: "VIP_PRODUCT", Value: "product-id"},
},
paymentguidemod.SceneVIPContentUpdate: {
ID: contentID,
Scene: paymentguidemod.SceneVIPContentUpdate,
},
}
resp := buildPingGuide(paymentGuideSwitchState{global: true, home: true}, paymentguidemod.SegmentOldNeverPaid, 3, true, configs, nil)
if !resp.Enabled || resp.Segment != paymentguidemod.SegmentOldNeverPaid {
t.Fatalf("unexpected response header: %+v", resp)
}
if len(resp.Scenes) != len(pingGuideScenes) {
t.Fatalf("scene count = %d, want %d", len(resp.Scenes), len(pingGuideScenes))
}
freeTrial := resp.Scenes[paymentguidemod.SceneHomeNewUserFreeTrial]
if !freeTrial.Enabled || freeTrial.TotalWatchCount == nil || *freeTrial.TotalWatchCount != 3 || freeTrial.ConfigID != freeTrialID.Hex() {
t.Fatalf("unexpected new-user free-trial scene: %+v", freeTrial)
}
home := resp.Scenes[paymentguidemod.SceneHomeOldUser]
if !home.Enabled || home.Show == nil || !*home.Show || home.ConfigID != homeID.Hex() {
t.Fatalf("unexpected ordinary scene: %+v", home)
}
if home.Action == nil || home.Action.Value != "product-id" {
t.Fatalf("ordinary scene action missing: %+v", home)
}
content := resp.Scenes[paymentguidemod.SceneVIPContentUpdate]
if !content.Enabled || content.Show != nil || content.ConfigID != "" {
t.Fatalf("content-update Ping must expose enabled only: %+v", content)
}
missing := resp.Scenes[paymentguidemod.SceneVideoBack]
if missing.Enabled || missing.Show == nil || *missing.Show {
t.Fatalf("missing ordinary scene must be disabled: %+v", missing)
}
}
func TestBuildPingGuideSuppressesShownAndGlobalDisabled(t *testing.T) {
configID := primitive.NewObjectID()
configs := map[string]paymentguidemod.PaymentGuide{
paymentguidemod.SceneVIPCenter: {
ID: configID,
Scene: paymentguidemod.SceneVIPCenter,
Style: "CENTER_POPUP",
Title: "shown",
Description: "description",
Cover: "cover.png",
ProductID: "product-id",
DurationSeconds: 0,
Action: paymentguidemod.Action{Type: "VIP_PRODUCT", Value: "product-id"},
},
}
resp := buildPingGuide(
paymentGuideSwitchState{global: true, home: true},
paymentguidemod.SegmentNormal,
3,
true,
configs,
map[primitive.ObjectID]bool{configID: true},
)
vipCenter := resp.Scenes[paymentguidemod.SceneVIPCenter]
if !vipCenter.Enabled || vipCenter.Show == nil || *vipCenter.Show || vipCenter.ConfigID != configID.Hex() {
t.Fatalf("shown configuration must retain its display payload: %+v", vipCenter)
}
if vipCenter.Description == nil || *vipCenter.Description != "description" ||
vipCenter.Cover == nil || *vipCenter.Cover != "cover.png" ||
vipCenter.ProductID == nil || *vipCenter.ProductID != "product-id" ||
vipCenter.DurationSeconds == nil || *vipCenter.DurationSeconds != 0 ||
vipCenter.Action == nil || vipCenter.Action.Value != "product-id" {
t.Fatalf("shown configuration payload is incomplete: %+v", vipCenter)
}
disabled := buildPingGuide(paymentGuideSwitchState{}, paymentguidemod.SegmentNormal, 3, true, configs, nil)
if disabled.Enabled {
t.Fatal("global-disabled response must be disabled")
}
for scene, item := range disabled.Scenes {
if item.Enabled {
t.Fatalf("scene %s must be disabled", scene)
}
if scene != paymentguidemod.SceneVIPContentUpdate && (item.Show == nil || *item.Show) {
t.Fatalf("ordinary scene %s must explicitly return show=false", scene)
}
}
}
func TestBuildPingGuideUsesSharedHomeSwitch(t *testing.T) {
freeTrialID := primitive.NewObjectID()
homeOldID := primitive.NewObjectID()
videoBackID := primitive.NewObjectID()
configs := map[string]paymentguidemod.PaymentGuide{
paymentguidemod.SceneHomeNewUserFreeTrial: {ID: freeTrialID, Scene: paymentguidemod.SceneHomeNewUserFreeTrial},
paymentguidemod.SceneHomeOldUser: {ID: homeOldID, Scene: paymentguidemod.SceneHomeOldUser},
paymentguidemod.SceneVideoBack: {ID: videoBackID, Scene: paymentguidemod.SceneVideoBack},
}
resp := buildPingGuide(
paymentGuideSwitchState{global: true, home: false},
paymentguidemod.SegmentNormal,
3,
true,
configs,
nil,
)
for _, scene := range []string{paymentguidemod.SceneHomeNewUserFreeTrial, paymentguidemod.SceneHomeOldUser} {
item := resp.Scenes[scene]
if item.Enabled || item.Show == nil || *item.Show || item.ConfigID != "" {
t.Fatalf("home scene %s must be disabled by the shared switch: %+v", scene, item)
}
}
videoBack := resp.Scenes[paymentguidemod.SceneVideoBack]
if !videoBack.Enabled || videoBack.Show == nil || !*videoBack.Show || videoBack.ConfigID != videoBackID.Hex() {
t.Fatalf("non-home scene must not be affected by the home switch: %+v", videoBack)
}
}
func TestBuildPingGuideCountdownIsNotSuppressedByImpression(t *testing.T) {
configID := primitive.NewObjectID()
configs := map[string]paymentguidemod.PaymentGuide{
paymentguidemod.SceneDiscountCountdown: {
ID: configID,
Scene: paymentguidemod.SceneDiscountCountdown,
Style: "CENTER_POPUP",
Title: "限时优惠",
Description: "前端本地倒计时",
Cover: "countdown.png",
ProductID: "product-id",
DurationSeconds: 0,
Action: paymentguidemod.Action{Type: "VIP_PRODUCT", Value: "product-id"},
},
}
resp := buildPingGuide(
paymentGuideSwitchState{global: true, home: true},
paymentguidemod.SegmentNormal,
3,
true,
configs,
map[primitive.ObjectID]bool{configID: true},
)
countdown := resp.Scenes[paymentguidemod.SceneDiscountCountdown]
if !countdown.Enabled || countdown.Show == nil || !*countdown.Show || countdown.ConfigID != configID.Hex() {
t.Fatalf("countdown must remain displayable on every App launch: %+v", countdown)
}
if countdown.DurationSeconds == nil || *countdown.DurationSeconds != 0 {
t.Fatalf("countdown must return the complete configured card: %+v", countdown)
}
}
func TestPaymentGuidePingJSONOmitsContentUpdateShow(t *testing.T) {
body, err := json.Marshal(buildPingGuide(paymentGuideSwitchState{}, paymentguidemod.SegmentUnregistered, 3, false, nil, nil))
if err != nil {
t.Fatal(err)
}
var decoded struct {
Scenes map[string]map[string]interface{} `json:"scenes"`
}
var envelope struct {
Enabled bool `json:"enabled"`
Segment string `json:"segment"`
Scenes map[string]map[string]interface{} `json:"scenes"`
}
if err = json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
decoded.Scenes = envelope.Scenes
if _, exists := decoded.Scenes[paymentguidemod.SceneHomeNewUser]; exists {
t.Fatalf("legacy HOME_NEW_USER must not be returned in Ping: %s", body)
}
freeTrial := decoded.Scenes[paymentguidemod.SceneHomeNewUserFreeTrial]
if count, exists := freeTrial["totalWatchCount"]; !exists || count != float64(3) {
t.Fatalf("new-user free-trial Ping must include totalWatchCount=3: %s", body)
}
if _, exists := decoded.Scenes[paymentguidemod.SceneVIPContentUpdate]["show"]; exists {
t.Fatalf("VIP_CONTENT_UPDATE Ping must omit show: %s", body)
}
if show, exists := decoded.Scenes[paymentguidemod.SceneHomeOldUser]["show"]; !exists || show != false {
t.Fatalf("home Ping scene must include show=false: %s", body)
}
}
func TestPaymentGuidePingJSONKeepsConfiguredFieldsWhenShowFalse(t *testing.T) {
configID := primitive.NewObjectID()
body, err := json.Marshal(buildPingGuide(
paymentGuideSwitchState{global: true, home: true},
paymentguidemod.SegmentNormal,
3,
true,
map[string]paymentguidemod.PaymentGuide{
paymentguidemod.SceneVideoBack: {
ID: configID,
Scene: paymentguidemod.SceneVideoBack,
Style: "CENTER_POPUP",
Title: "返回引导",
Description: "",
Cover: "",
ProductID: "",
DurationSeconds: 0,
Action: paymentguidemod.Action{Type: "NONE"},
},
},
map[primitive.ObjectID]bool{configID: true},
))
if err != nil {
t.Fatal(err)
}
var envelope struct {
Scenes map[string]map[string]interface{} `json:"scenes"`
}
if err = json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
item := envelope.Scenes[paymentguidemod.SceneVideoBack]
for _, field := range []string{"configId", "style", "title", "description", "cover", "productId", "durationSeconds", "action"} {
if _, exists := item[field]; !exists {
t.Fatalf("configured show=false scene must include %s: %s", field, body)
}
}
if show, ok := item["show"].(bool); !ok || show {
t.Fatalf("configured scene must preserve show=false: %s", body)
}
}
func TestBuildPingGuideSuppressesExhaustedNewUserFreeTrialOnly(t *testing.T) {
freeTrialID := primitive.NewObjectID()
videoBackID := primitive.NewObjectID()
resp := buildPingGuide(
paymentGuideSwitchState{global: true, home: true},
paymentguidemod.SegmentNewNeverPaid,
3,
false,
map[string]paymentguidemod.PaymentGuide{
paymentguidemod.SceneHomeNewUserFreeTrial: {
ID: freeTrialID,
Scene: paymentguidemod.SceneHomeNewUserFreeTrial,
Style: "BOTTOM_SHEET",
Title: "free trial",
Action: paymentguidemod.Action{Type: "NONE"},
},
paymentguidemod.SceneVideoBack: {
ID: videoBackID,
Scene: paymentguidemod.SceneVideoBack,
Style: "BOTTOM_SHEET",
Title: "video back",
Action: paymentguidemod.Action{Type: "NONE"},
},
},
nil,
)
if resp.Segment != paymentguidemod.SegmentNewNeverPaid {
t.Fatalf("segment = %q, want %q", resp.Segment, paymentguidemod.SegmentNewNeverPaid)
}
freeTrial := resp.Scenes[paymentguidemod.SceneHomeNewUserFreeTrial]
if !freeTrial.Enabled || freeTrial.Show == nil || *freeTrial.Show || freeTrial.ConfigID != freeTrialID.Hex() {
t.Fatalf("exhausted free-trial scene must retain config with show=false: %+v", freeTrial)
}
videoBack := resp.Scenes[paymentguidemod.SceneVideoBack]
if !videoBack.Enabled || videoBack.Show == nil || !*videoBack.Show || videoBack.ConfigID != videoBackID.Hex() {
t.Fatalf("free-trial exhaustion must not suppress other matching scenes: %+v", videoBack)
}
}