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
+196
View File
@@ -0,0 +1,196 @@
package productser
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"91porn-server/app/appg"
"91porn-server/app/service/mediacontentser"
"91porn-server/common/db"
"91porn-server/common/log"
redisutil "91porn-server/common/redis"
"91porn-server/common/stderr"
"91porn-server/middleware/ua"
"91porn-server/models/v/media_buy_record_mod"
"91porn-server/models/v/mediacontentmod"
"91porn-server/models/v/mediamod"
"91porn-server/models/v/txnmod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/walletmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
dramaBuyRequestTTL = 24 * time.Hour
dramaBuyLockTTL = 2 * time.Minute
)
var errDramaAlreadyBought = errors.New("drama episode already bought")
var releaseDramaBuyLockScript = redisutil.NewScript(`
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
return 0
end
return redis.call('DEL', KEYS[1])
`)
type BuyDramaEpisodeResponse struct {
OrderID primitive.ObjectID `json:"orderId"`
MediaID primitive.ObjectID `json:"mediaId"`
ContentID primitive.ObjectID `json:"contentId"`
AccessType string `json:"accessType"`
CanPlay bool `json:"canPlay"`
PaidCoin int64 `json:"paidCoin"`
CoinBalance int64 `json:"coinBalance"`
}
func BuyDramaEpisode(
uid uint64,
mediaID, contentID primitive.ObjectID,
checkoutContextID, requestID string,
uaInfo ua.UA,
ip string,
) (BuyDramaEpisodeResponse, stderr.Code) {
requestID = strings.TrimSpace(requestID)
checkoutContextID = strings.TrimSpace(checkoutContextID)
if requestID == "" || len(requestID) > 128 || contentID.IsZero() || mediaID.IsZero() ||
!strings.HasPrefix(checkoutContextID, "drama-checkout-") {
return BuyDramaEpisodeResponse{}, stderr.ErrParamError
}
media, err := mediamod.GetInfo(mediaID)
if err != nil || media.ID.IsZero() || media.MediaType != mediamod.MediaTypeDrama || media.Status != 1 || media.IsDelete {
return BuyDramaEpisodeResponse{}, stderr.InvalidProduct
}
content, err := mediacontentmod.GetInfo(contentID, true)
if err != nil || content.ID.IsZero() || content.MediaID != mediaID || content.MediaType != mediamod.MediaTypeDrama || content.IsDelete {
return BuyDramaEpisodeResponse{}, stderr.InvalidProduct
}
if bought, buyErr := media_buy_record_mod.IsBuy(uid, mediaID, contentID); buyErr != nil {
return BuyDramaEpisodeResponse{}, stderr.PayBusy
} else if bought {
return BuyDramaEpisodeResponse{}, stderr.RepeatPurchase
}
hasCard, cardErr := mediacontentser.ActiveDramaCardStatus(uid)
if cardErr != nil {
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
isFree, canPlay, accessType := mediacontentser.ResolveDramaAccess(content, media, false, hasCard)
if isFree || (canPlay && accessType == "card") {
return BuyDramaEpisodeResponse{}, stderr.RepeatPurchase
}
if content.Price <= 0 {
return BuyDramaEpisodeResponse{}, stderr.InvalidProduct
}
wallet, err := walletmod.GetWallet(uid)
if err != nil || wallet == nil || wallet.ID.IsZero() {
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
plan := debitPlan(wallet, content.Price)
if plan == nil {
return BuyDramaEpisodeResponse{}, stderr.InsufficientBalance
}
user, err := usermod.FindUserByUID(uid)
if err != nil || user == nil {
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
if appg.Redis == nil {
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
requestKey := dramaBuyKey("request", uid, requestID)
requestAcquired, err := appg.Redis.SetNX(requestKey, "1", dramaBuyRequestTTL)
if err != nil {
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
if !requestAcquired {
if bought, _ := media_buy_record_mod.IsBuy(uid, mediaID, contentID); bought {
return BuyDramaEpisodeResponse{}, stderr.RepeatPurchase
}
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
lockKey := dramaBuyKey("episode", uid, contentID.Hex())
lockAcquired, err := appg.Redis.SetNX(lockKey, requestID, dramaBuyLockTTL)
if err != nil || !lockAcquired {
_, _ = appg.Redis.Del(requestKey)
return BuyDramaEpisodeResponse{}, stderr.PayBusy
}
defer releaseDramaBuyLock(lockKey, requestID)
orderID := primitive.NewObjectID()
createdAt := time.Now()
balanceBefore := wallet.Amount + wallet.Income
balanceAfter := balanceBefore
err = appg.VideoDB.Trans(func(t *db.MongoTool) error {
alreadyBought, checkErr := media_buy_record_mod.IsBuyWithTool(t, uid, mediaID, contentID)
if checkErr != nil {
return checkErr
}
if alreadyBought {
return errDramaAlreadyBought
}
updatedWallet, debitErr := walletmod.Debit(t, plan, uid)
if debitErr != nil {
return debitErr
}
balanceAfter = updatedWallet.Amount + updatedWallet.Income
record := &media_buy_record_mod.MediaBuyRecord{
ID: orderID, MediaId: mediaID, MediaType: mediamod.MediaTypeDrama,
Uid: uid, Type: 0, ContentId: contentID, Coins: content.Price,
PayMoney: content.Price, CreatedAt: createdAt, UpdateTime: createdAt,
}
if createErr := media_buy_record_mod.Create(t, record); createErr != nil {
return createErr
}
transaction := txnmod.TransactionLog{
TransNo: orderID, UID: uid, Amount: -content.Price, ActualAmount: float64(-content.Price),
TranType: txnmod.BuyAcg.Key(), TranTypeInt: int64(txnmod.BuyAcg),
Desc: fmt.Sprintf("短剧-单集购买-%s-第%d集", media.Title, content.EpisodeNumber),
DiscDoc: user.DiscDoc, SysType: user.SysType,
RealAmount: walletmod.GetRealAmount(updatedWallet),
}
if logErr := txnmod.InsertTransactionLog(t, &transaction); logErr != nil {
return logErr
}
if countErr := mediamod.IncPurchasesCountWithTool(t, mediaID, 1); countErr != nil {
return countErr
}
if countErr := mediacontentmod.IncCountPurchasesWithTool(t, contentID, 1); countErr != nil {
return countErr
}
return nil
})
if err != nil {
_, _ = appg.Redis.Del(requestKey)
if errors.Is(err, errDramaAlreadyBought) {
return BuyDramaEpisodeResponse{}, stderr.RepeatPurchase
}
log.Error("buy drama episode transaction failed", log.E(err), log.Any("uid", uid), log.Any("contentID", contentID))
return BuyDramaEpisodeResponse{}, stderr.BuyFailed
}
return BuyDramaEpisodeResponse{
OrderID: orderID, MediaID: mediaID, ContentID: contentID,
AccessType: "bought", CanPlay: true, PaidCoin: content.Price, CoinBalance: balanceAfter,
}, stderr.Success
}
func dramaBuyKey(scope string, uid uint64, value string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", scope, uid, value)))
return "drama:buy:" + scope + ":" + hex.EncodeToString(sum[:])
}
func releaseDramaBuyLock(key, token string) {
if appg.Redis == nil || key == "" || token == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := appg.Redis.RunScriptContext(ctx, releaseDramaBuyLockScript, []string{key}, token); err != nil {
log.Error("release drama buy lock failed", log.E(err), log.Any("key", key))
}
}
+17
View File
@@ -0,0 +1,17 @@
package productser
import "go.mongodb.org/mongo-driver/bson/primitive"
type ActivityCouponDetailResponse struct {
OriginalPrice int64 `json:"originalPrice" ` // 原价
DiscountedPrice int64 `json:"discountedPrice"` // 现价
CouponList []ActivityCoupon `json:"couponList"` // 优惠卷列表
IsDiscounted bool `json:"isDiscounted"` // 是否折扣
}
type ActivityCoupon struct {
ID primitive.ObjectID `json:"id"` // 优惠卷ID
Name string `json:"name"` // 优惠卷名字
Count int32 `json:"count"` // 优惠卷数量
DiscountedPrice int64 `json:"discountedPrice"` // 现价
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
package productser
import (
"91porn-server/common/log"
"91porn-server/common/stderr"
"91porn-server/models/commod"
"91porn-server/models/v/prdcthsomod"
"91porn-server/models/v/productmod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/videocoupon"
"time"
)
// formatMoney 生成支付通道支持的金额
func formatMoney(price int64) int64 {
if price <= 0 {
return 0
}
if price <= 300 {
return 300
}
if price <= 500 {
return 500
}
for i := 1; i < 10; i++ {
if price <= int64(i*1000) {
return int64(i * 1000)
}
}
return price
}
func CheckUserUpgradeProducts(uid uint64, vips []productmod.Product) {
// 获取当前用户VIP信息
user, uErr := usermod.FindUserByUID(uid)
if uErr != nil {
log.Error("CheckUserUpgrade FindUserByUID Error", log.Any("uid", uid))
return
}
if !user.IsVIP(time.Now()) {
return
}
// 替换产品升级价格
lastPID, lastVipName, lastAmount := prdcthsomod.GetUserLastVip(uid)
if lastPID.IsZero() || lastAmount < 500 {
return
}
for i, p := range vips {
vips[i].PurchasePrice = p.DiscountedPrice
if !p.CheckUpgrade(lastPID) {
continue
}
switch p.ProductType {
case commod.VIP, commod.NEWUSERCard:
if lastAmount >= p.DiscountedPrice {
continue
}
case commod.AdvanceCard:
if lastAmount >= p.AdvanceAmount {
continue
}
realPriceAdv := formatMoney(p.AdvanceAmount - lastAmount)
vips[i].AdvanceAmount = realPriceAdv
default:
continue
}
vips[i].IsUpgrade = true
realPrice := formatMoney(p.DiscountedPrice - lastAmount)
vips[i].DiscountedPrice = realPrice
vips[i].DiscountedPriceAnd = &realPrice
vips[i].DiscountedPriceIos = &realPrice
vips[i].CurrentVipName = lastVipName
vips[i].CurrentVipPrice = lastAmount
}
}
// CheckUserUpgrade 检查用户当前VIP是否为升级
func CheckUserUpgrade(uid uint64, product *productmod.Product) {
// 获取当前用户VIP信息
user, uErr := usermod.FindUserByUID(uid)
if uErr != nil {
log.Error("CheckUserUpgrade FindUserByUID Error", log.Any("uid", uid))
return
}
if !user.IsVIP(time.Now()) {
return
}
// 替换产品升级价格
lastPID, lastVipName, lastAmount := prdcthsomod.GetUserLastVip(uid)
if lastPID.IsZero() || lastAmount < 500 {
return
}
product.PurchasePrice = product.DiscountedPrice
if !product.CheckUpgrade(lastPID) {
return
}
switch product.ProductType {
case commod.VIP, commod.NEWUSERCard:
if lastAmount >= product.DiscountedPrice {
return
}
case commod.AdvanceCard:
if lastAmount >= product.AdvanceAmount {
return
}
realPriceAdv := formatMoney(product.AdvanceAmount - lastAmount)
product.AdvanceAmount = realPriceAdv
default:
return
}
realPrice := formatMoney(product.DiscountedPrice - lastAmount)
product.IsUpgrade = true
product.DiscountedPrice = realPrice
product.DiscountedPriceAnd = &realPrice
product.DiscountedPriceIos = &realPrice
product.CurrentVipName = lastVipName
product.CurrentVipPrice = lastAmount
}
// 金币视频抵用券业务处理
func GoldVideoCoupleHandler(p *productmod.Product, u *usermod.User, sel usermod.UserSelector) usermod.UserSelector {
goldVideoCoupon := make([]usermod.UserGoldVideoCoupon, 0)
//存在
coupon_bool := true
//处理相同面值优惠券, 券数量相加
if u.GoldVideoCoupon != nil && len(u.GoldVideoCoupon) > 0 {
goldVideoCoupon = u.GoldVideoCoupon
for index, gvc := range goldVideoCoupon {
if gvc.Gold == p.GoldVideoCouponNum {
goldVideoCoupon[index].Count += p.GoldVideoCouponCount
coupon_bool = false
break
}
}
}
//用户不存在当前面值的优惠券, 则新增
if coupon_bool {
goldVideoCoupon = append(goldVideoCoupon, usermod.UserGoldVideoCoupon{
Gold: p.GoldVideoCouponNum,
Count: p.GoldVideoCouponCount,
})
}
if len(goldVideoCoupon) > 0 {
sel.GoldVideoCoupon = &goldVideoCoupon
}
return sel
}
// HandleGoldVideoCoupon 处理观影券。source表示观影券来源,如购买VIP。
func HandleGoldVideoCoupon(p *productmod.Product, uid uint64, source videocoupon.GoldVideoCouponSource) []videocoupon.UserGoldVideoCoupon {
if p.GoldVideoCouponNum <= 0 {
return nil
}
// 该产品设置了附赠观影券
coupons := make([]videocoupon.UserGoldVideoCoupon, p.GoldVideoCouponCount)
for i := 0; i < p.GoldVideoCouponCount; i++ {
coupons[i] = videocoupon.UserGoldVideoCoupon{
UID: uid,
Num: p.GoldVideoCouponNum,
Used: false,
Source: source,
}
}
return coupons
}
// CheckGoldVideoCoupon 校验用户选择的观影券是否符合要求
// userChoose:用户选择的观影券面值(金币数)。
// videoCouponsH5改版重构后的观影券,传入的 UserGoldVideoCoupon 均保证面值与用户的选择相等。
// 返回bool值: true 表示用户未选择观影券; false 表示用户未选择观影券
func CheckGoldVideoCoupon(userChoose, videoCoins int64, u *usermod.User, videoCoupons []videocoupon.UserGoldVideoCoupon) (bool, stderr.Code) {
if userChoose <= 0 { // 用户未选择观影券
return false, stderr.Success
}
if userChoose < videoCoins {
// 用户选择的观影券面值不够
return false, stderr.GoldVideoCoupleAmountErr
}
// u.GoldVideoCoupon 老版观影券数据结构, 需做兼容并判断观影券金额是否足够
// videoCoupons 新版(H5改版重构后)观影券数据结构, 无需进行金额判定
if len(u.GoldVideoCoupon) <= 0 && len(videoCoupons) <= 0 {
// 新老版数据结构皆显示用户无观影券可用
return false, stderr.GoldVideoCoupleNotExist
}
// 先在老版观影券结构中寻找是否有合适观影券
for _, coupon := range u.GoldVideoCoupon {
if coupon.Count > 0 && int64(coupon.Gold) >= userChoose {
return true, stderr.Success
}
}
// 若老版观影券结构中未找到, 则从新版观影券结构中寻找
if len(videoCoupons) > 0 {
// 找到新版观影券
return true, stderr.Success
}
// 新老版数据结构中皆未找到用户观影券
return false, stderr.GoldVideoCoupleNotExist
}
@@ -0,0 +1,85 @@
package productser
import (
"fmt"
"strings"
"91porn-server/models/v/vipcardexperimentmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const maxVIPExperimentAttributionLength = 128
// VIPExperimentAttribution records the A/B assignment that led to a coin purchase.
type VIPExperimentAttribution struct {
ExperimentID string
ExperimentVariant string
SessionID string
}
func validateVIPExperimentAttribution(
uid uint64,
productID primitive.ObjectID,
input VIPExperimentAttribution,
) (VIPExperimentAttribution, error) {
attribution, err := normalizeVIPExperimentAttribution(input)
if err != nil || attribution.ExperimentID == "" {
return attribution, err
}
experiment, err := vipcardexperimentmod.FindByExperimentID(attribution.ExperimentID)
if err != nil {
return VIPExperimentAttribution{}, err
}
if experiment == nil {
return VIPExperimentAttribution{}, fmt.Errorf("experiment does not exist")
}
if err = validateVIPExperimentAttributionForExperiment(uid, productID, attribution, experiment); err != nil {
return VIPExperimentAttribution{}, err
}
return attribution, nil
}
func normalizeVIPExperimentAttribution(input VIPExperimentAttribution) (VIPExperimentAttribution, error) {
input.ExperimentID = strings.TrimSpace(input.ExperimentID)
input.ExperimentVariant = strings.ToUpper(strings.TrimSpace(input.ExperimentVariant))
input.SessionID = strings.TrimSpace(input.SessionID)
if input.ExperimentID == "" {
return VIPExperimentAttribution{}, nil
}
values := map[string]string{
"experimentId": input.ExperimentID,
"experimentVariant": input.ExperimentVariant,
"sessionId": input.SessionID,
}
for name, value := range values {
if len(value) > maxVIPExperimentAttributionLength {
return VIPExperimentAttribution{}, fmt.Errorf("%s must not exceed %d characters", name, maxVIPExperimentAttributionLength)
}
}
if input.ExperimentVariant == "" || input.SessionID == "" {
return VIPExperimentAttribution{}, fmt.Errorf("experimentVariant and sessionId are required with experimentId")
}
return input, nil
}
func validateVIPExperimentAttributionForExperiment(
uid uint64,
productID primitive.ObjectID,
attribution VIPExperimentAttribution,
experiment *vipcardexperimentmod.Experiment,
) error {
config, ok := experiment.ConfigFor(attribution.ExperimentVariant)
if !ok {
return fmt.Errorf("experimentVariant must be A or B")
}
if assigned := experiment.Assign(uid); assigned != attribution.ExperimentVariant {
return fmt.Errorf("experimentVariant does not match user assignment")
}
for _, configuredProductID := range config.ProductIDs {
if configuredProductID == productID {
return nil
}
}
return fmt.Errorf("productID does not belong to experiment variant")
}
@@ -0,0 +1,78 @@
package productser
import (
"strings"
"testing"
"91porn-server/models/v/vipcardexperimentmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestNormalizeVIPExperimentAttribution(t *testing.T) {
attribution, err := normalizeVIPExperimentAttribution(VIPExperimentAttribution{
ExperimentID: " experiment ",
ExperimentVariant: " a ",
SessionID: " session ",
})
if err != nil {
t.Fatalf("normalizeVIPExperimentAttribution() error = %v", err)
}
if attribution.ExperimentID != "experiment" ||
attribution.ExperimentVariant != vipcardexperimentmod.VariantA ||
attribution.SessionID != "session" {
t.Fatalf("normalized attribution = %#v", attribution)
}
empty, err := normalizeVIPExperimentAttribution(VIPExperimentAttribution{
ExperimentVariant: vipcardexperimentmod.VariantA,
SessionID: "session",
})
if err != nil || empty != (VIPExperimentAttribution{}) {
t.Fatalf("empty experiment attribution = %#v, error = %v", empty, err)
}
}
func TestNormalizeVIPExperimentAttributionRequiresCompleteFields(t *testing.T) {
_, err := normalizeVIPExperimentAttribution(VIPExperimentAttribution{ExperimentID: "experiment"})
if err == nil {
t.Fatal("expected incomplete attribution to fail")
}
_, err = normalizeVIPExperimentAttribution(VIPExperimentAttribution{
ExperimentID: strings.Repeat("a", maxVIPExperimentAttributionLength+1),
ExperimentVariant: vipcardexperimentmod.VariantA,
SessionID: "session",
})
if err == nil {
t.Fatal("expected oversized experimentId to fail")
}
}
func TestValidateVIPExperimentAttributionForExperiment(t *testing.T) {
productID := primitive.NewObjectID()
experiment := &vipcardexperimentmod.Experiment{
ExperimentID: "experiment",
TrafficA: 100,
VariantA: vipcardexperimentmod.VariantConfig{
ProductIDs: []primitive.ObjectID{productID},
},
VariantB: vipcardexperimentmod.VariantConfig{
ProductIDs: []primitive.ObjectID{primitive.NewObjectID()},
},
}
attribution := VIPExperimentAttribution{
ExperimentID: experiment.ExperimentID,
ExperimentVariant: vipcardexperimentmod.VariantA,
SessionID: "session",
}
if err := validateVIPExperimentAttributionForExperiment(123, productID, attribution, experiment); err != nil {
t.Fatalf("valid attribution rejected: %v", err)
}
if err := validateVIPExperimentAttributionForExperiment(123, primitive.NewObjectID(), attribution, experiment); err == nil {
t.Fatal("product outside variant must be rejected")
}
attribution.ExperimentVariant = vipcardexperimentmod.VariantB
if err := validateVIPExperimentAttributionForExperiment(123, productID, attribution, experiment); err == nil {
t.Fatal("variant outside stable assignment must be rejected")
}
}