Files
huangguo_server/app/service/paymentguideser/payment_guide.go
T
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

350 lines
11 KiB
Go

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,
})
}