init: hgmj server initial commit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:50:13 +08:00
co-authored by Claude Opus 4.7
commit 625e89c15d
1870 changed files with 254090 additions and 0 deletions
@@ -0,0 +1,65 @@
package rechargeser
import (
"91jd-server/models/commod"
"91jd-server/models/v/advanceordermod"
"91jd-server/models/v/productmod"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
// applyAdvanceDisplay changes response-only prices after a successful deposit.
// Checkout still loads the original product and order from the database.
func applyAdvanceDisplay(uid uint64, products []productmod.Product, now time.Time) error {
if uid == 0 {
return nil
}
ids := make([]primitive.ObjectID, 0)
for _, p := range products {
if balanceCardEligible(p, now) {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
orders, err := advanceordermod.QueryAllDocument(bson.M{
"uid": uid, "productID": bson.M{"$in": ids},
"status": bson.M{"$in": []int{advanceordermod.AdvanceSUCCESS, advanceordermod.BalanceProcessing}},
"endTime": bson.M{"$gt": now},
}, options.Find().SetLimit(int64(len(ids))).SetMaxTime(3*time.Second).
SetProjection(bson.M{"productID": 1, "_id": 0}))
if err != nil {
return err
}
pending := make(map[primitive.ObjectID]bool, len(orders))
for _, order := range orders {
pending[order.ProductID] = true
}
for i := range products {
if pending[products[i].ID] && balanceCardEligible(products[i], now) {
setBalanceDisplay(&products[i])
}
}
return nil
}
func balanceCardEligible(p productmod.Product, now time.Time) bool {
return p.ProductType == commod.AdvanceCard && p.BalanceAmount > 0 &&
!now.Before(p.StartTime) && now.Before(p.EndTime)
}
func setBalanceDisplay(p *productmod.Product) {
price := p.BalanceAmount
// Compact cards read advanceAmount when their presale context is absent.
// Like upgrade pricing, normalize this response field, not stored amounts.
p.AdvanceAmount = price
p.DiscountedPrice, p.DiscountedPriceIos, p.DiscountedPriceAnd = price, &price, &price
p.IsUpgrade = false
if p.EndBGImg != "" {
p.BGImg = p.EndBGImg
}
}
+792
View File
@@ -0,0 +1,792 @@
package rechargeser
import (
"91jd-server/app/service/productser"
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
"91jd-server/app/appg"
"91jd-server/app/proto"
"91jd-server/common"
"91jd-server/common/db"
"91jd-server/common/log"
"91jd-server/common/rchgutil"
"91jd-server/common/stderr"
"91jd-server/models/commod"
"91jd-server/models/v/currencymod"
"91jd-server/models/v/goldcfgmod"
"91jd-server/models/v/goldextramod"
"91jd-server/models/v/prdcthsomod"
"91jd-server/models/v/productmod"
"91jd-server/models/v/productposimod"
"91jd-server/models/v/rchgamtmod"
"91jd-server/models/v/rchgordmod"
"91jd-server/models/v/txnmod"
"91jd-server/models/v/usermod"
"91jd-server/models/v/walletmod"
)
// New_CurrencyList 货币列表
func New_CurrencyList(ctx context.Context, uid uint64, sysType string, t commod.CurrencyType) ([]*proto.CurrencyListResponse, stderr.Code) {
if !isLegalCurrencyType(t) {
log.ErrorX(ctx, "货币类型异常", log.Any("CurrencyType", t))
return nil, stderr.ErrParamError
}
var (
wg sync.WaitGroup // 同步控制
currencys []currencymod.Currency // 货币列表
moneys []string // 金额区间
removeRepeat = make(map[int64]struct{}, 0) // 金额去重
productType int // 产品类型 棋牌:1 站群:0
err error // 错误
isFirstPay bool // 棋牌首次充值
)
wg.Add(1)
common.Go(func() {
defer wg.Done()
currencys, err = currencymod.List(t)
if err != nil {
log.ErrorX(ctx, "货币列表==>获取列表异常", log.Any("CurrencyType", t), log.E(err))
}
})
if t == commod.GameCoin {
wg.Add(1)
productType = 1
common.Go(func() {
defer wg.Done()
u, err := usermod.FindUserByUID(uid)
if err != nil {
log.Error(fmt.Sprintf("货币列表==>棋牌首次充值查询异常[%v]", err))
return
}
if u.HasFirstChargeWaLi {
//userLevel = 1
} else {
isFirstPay = true
}
})
}
wg.Wait()
if err != nil {
return nil, stderr.ErrNetWorkBusy
}
for _, c := range currencys {
if _, ok := removeRepeat[c.Price]; ok {
continue
}
removeRepeat[c.Price] = struct{}{}
moneys = append(moneys, FenToYuan(c.Price))
}
req := rchgutil.GainPayTypeReq{Money: moneys}
bc, err := req.GetPayType()
if err != nil {
log.ErrorX(ctx, "获取支付通道列表异常", log.Any("moneys", moneys), log.Any("sysType", sysType),
log.Any("productType", productType), log.E(err))
return nil, stderr.ErrNetWorkBusy
}
data := make([]*proto.CurrencyListResponse, 0, len(currencys))
for _, v := range currencys {
if v.Price < 100 {
continue
}
rechargeType := GetPayChannelDetails_New(v.Price, bc, productType)
if len(rechargeType) <= 0 { // 无支付方式的充值金额不需要展示
continue
}
c := proto.CurrencyListResponse{
ID: v.ID,
Amount: v.Coins,
TypeName: v.Name,
Money: v.Price,
GiveVipDays: v.GiveVipDays,
LouFengUnlockTimes: v.LouFengUnlockTimes,
GiveGold: v.GiveGold,
}
if isFirstPay || v.NotFirst || t == commod.FruitCoin || t == commod.Gold {
c.CouponDesc = v.CouponDesc
}
c.RechargeType = rechargeType
data = append(data, &c)
}
return data, stderr.Success
}
// getChannelDisplayName 获取支付渠道显示名称
func getChannelDisplayName(pType string) string {
channelNames := map[string]string{
"wechat": "微信(当前通道拥挤)",
"alipay": "支付宝(官方强烈推荐)",
"union": "银联",
"daichong": "代充",
"usdt": "USDT(官方)",
"quickUnion": "快捷银联",
}
if name, exists := channelNames[pType]; exists {
return name
}
return ""
}
func FenToYuan(price int64) string {
return strconv.FormatInt(price/100, 10)
}
func YuanToFen(money string) int64 {
// 去除前后空格
money = strings.TrimSpace(money)
if money == "" {
return 0
}
// 检查是否为负数
isNegative := false
if strings.HasPrefix(money, "-") {
isNegative = true
money = money[1:]
}
// 按小数点分割
parts := strings.Split(money, ".")
switch len(parts) {
case 1:
// 只有整数部分,如 "123", "456"
yuan, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
log.Warn(fmt.Sprintf("invalid integer part: %v", err))
return 0
}
result := yuan * 100
if isNegative {
result = -result
}
return result
case 2:
// 有整数和小数部分,如 "123.45", "78.9"
yuan, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
log.Warn(fmt.Sprintf("invalid integer part: %v", err))
return 0
}
// 处理小数部分
decimalPart := parts[1]
if len(decimalPart) > 2 {
// 如果小数部分超过2位,进行四舍五入或截断
// 这里选择截断,也可以根据需要改为四舍五入
decimalPart = decimalPart[:2]
} else if len(decimalPart) == 1 {
// 如果只有1位小数,补零
decimalPart += "0"
}
fen, err := strconv.ParseInt(decimalPart, 10, 64)
if err != nil {
log.Warn(fmt.Sprintf("invalid decimal part: %v", err))
return 0
}
result := yuan*100 + fen
if isNegative {
result = -result
}
return result
default:
log.Warn(fmt.Sprintf("invalid money format: %s", money))
return 0
}
}
func GetPayChannelDetails_New(money int64, payTypes []rchgutil.AllPayType, proT int) []rchgamtmod.PayChannelRes {
// 后台系统配置的支付方式展示名称与顺序
cfg := getPayTypeDisplayConfig()
channels := make([]rchgamtmod.PayChannelRes, 0)
for _, payType := range payTypes {
// 金额匹配检查
if YuanToFen(payType.Money) != money {
continue
}
// 处理支持的支付类型
for _, t := range payType.Types {
// 展示名称优先取后台配置,未配置则回退默认硬编码名称
channelName := cfg.typeName(t.Type)
if channelName == "" {
continue
}
channels = append(channels, rchgamtmod.PayChannelRes{
Type: t.Type,
TypeName: channelName,
Channel: t.Type, // 根据实际情况调整
})
}
}
// 按后台配置的顺序排序,未配置顺序的支付方式排在最后
cfg.sortChannels(channels)
return channels
}
func New_ProductList(uid uint64, sysType string, newUser bool, proT int) (res []proto.ProductList, err error) {
var data []productmod.Product
var discounts []*goldcfgmod.DiscountConfig
var positionM map[string]string
var positionL []string
var ps map[string]int64
var (
goldsError, discountsError, positionError error
)
wg := sync.WaitGroup{}
wg.Add(3)
//获取金币配置优惠
common.Go(func() {
defer wg.Done()
discounts, discountsError = goldcfgmod.GetGoldConfigList()
if discountsError != nil {
err = goldsError
return
}
})
common.Go(func() {
defer wg.Done()
flag := true
data, goldsError = productmod.FindProductsByProductType(&flag, newUser, sysType)
if goldsError != nil {
err = goldsError
return
}
// 根据用户当前卡进行升级
productser.CheckUserUpgradeProducts(uid, data)
})
common.Go(func() {
defer wg.Done()
positionM, ps, positionL, positionError = productposimod.FindNamesWithStatusTrue()
if positionError != nil {
err = positionError
return
}
})
wg.Wait()
if goldsError != nil {
return nil, goldsError
}
if err := applyAdvanceDisplay(uid, data, time.Now()); err != nil {
return nil, err
}
moneys := GetUniqueMoneys(data)
// 缓存支付通道信息
req := rchgutil.GainPayTypeReq{Money: moneys}
bc, err := req.GetPayTypeFromCache()
if err != nil {
log.Warn(fmt.Sprintf("用户ID:%d;rchgutil GetPayType:%v", uid, err))
}
// 构建结果
res = make([]proto.ProductList, len(positionL))
for i := range positionL {
pl := make([]proto.VIPListRes, 0)
res[i] = proto.ProductList{
ShowType: ps[positionL[i]],
Position: positionM[positionL[i]],
List: pl,
PositionID: positionL[i],
}
}
for _, v := range data {
chans := GetPayChannelDetails_New(v.DiscountedPrice*10, bc, proT)
if v.ProductType == commod.PhysicalGoods {
v.Name = ""
}
temp := proto.VIPListRes{
Product: v,
}
if discountsError == nil && len(discounts) > 0 {
for i := 0; i < len(chans); i++ {
for _, d := range discounts {
if chans[i].Type == d.Type && v.DiscountedPrice == d.Amount {
chans[i].IncrAmount = d.IncrAmount
chans[i].IncTax = d.IncTax
}
}
}
}
temp.RechargeType = chans
for k := range res {
if positionM[v.Position] == res[k].Position {
res[k].List = append(res[k].List, temp)
continue
}
}
}
return
}
func GetUniqueMoneys(data []productmod.Product) []string {
moneySet := make(map[string]bool)
for _, p := range data {
money := FenToYuan(p.DiscountedPrice * 10)
moneySet[money] = true
if p.AdvanceAmount > 0 {
advanceAmount := FenToYuan(p.AdvanceAmount * 10)
moneySet[advanceAmount] = true
}
if p.BalanceAmount > 0 {
balanceAmount := FenToYuan(p.BalanceAmount * 10)
moneySet[balanceAmount] = true
}
}
moneys := make([]string, 0, len(moneySet))
for money := range moneySet {
moneys = append(moneys, money)
}
return moneys
}
// RefundCallBack 退款回调处理逻辑
func RefundCallBack(ctx context.Context, oid string, tradeNo string) error {
log.InfoX(ctx, "退款回调", log.Any("oid", oid), log.Any("tradeNo", tradeNo))
// 查询订单
r, err := rchgordmod.FindRechargeOrderByID(tradeNo)
if err != nil {
log.ErrorX(ctx, "充值回调查询订单异常", log.Any("oid", oid), log.Any("tradeNo", tradeNo), log.E(err))
return err
}
if r.ID.IsZero() {
log.WarnX(ctx, "充值回调订单不存在", log.Any("oid", oid), log.Any("tradeNo", tradeNo))
return errors.New("订单不存在")
}
if r.Status == rchgordmod.REFUND {
log.Warn(fmt.Sprintf("oid:%v, tradeNo:%v, 退款回调重复回调", oid, tradeNo))
return nil
}
if r.Status != rchgordmod.SUCCESS {
log.Warn(fmt.Sprintf("oid:%v, tradeNo:%v, 退款回调订单状态异常", oid, tradeNo))
return nil
}
var (
fn func(*db.MongoTool) error
now = time.Now()
desc = rchgordmod.Status(rchgordmod.REFUND).Desc()
remark = "订单退款"
)
switch r.BuyType {
case commod.BuyGold:
if fn, err = RefundGold(ctx, r); err != nil {
log.ErrorX(ctx, "退款回调购买金币异常", log.Any("oid", oid), log.Any("tradeNo", tradeNo), log.E(err))
return err
}
case commod.BuyProduct:
if fn, err = RefundProduct(ctx, r); err != nil {
log.ErrorX(ctx, "退款回调购买商品异常", log.Any("oid", oid), log.Any("tradeNo", tradeNo), log.E(err))
return err
}
default: // 默认为旧版购买处理
log.InfoX(ctx, "退款回调旧版本处理", log.Any("oid", oid), log.Any("tradeNo", tradeNo))
return errors.New("旧版退款回调不再支持, 请使用新版回调方式")
}
// 新版处理
if r.BuyType > 0 {
err = appg.VideoDB.Trans(func(t *db.MongoTool) error {
if err = rchgordmod.RefundCallBackModify(ctx, t, r.ID, rchgordmod.EditSelector{
OID: &oid,
UpdatedAt: now,
RefundAt: &now,
Status: rchgordmod.REFUND,
StatusDesc: &desc,
Remark: &remark,
}); err != nil {
return err
}
return fn(t)
})
return err
}
if err != nil {
log.Error(fmt.Sprintf("oid:%s,refund callBack err:%v", oid, err))
return errors.New("退款失败")
}
return nil
}
// RefundGold 退款金币 payMoney单位为分
func RefundGold(ctx context.Context, order rchgordmod.RechargeOrder) (func(*db.MongoTool) error, error) {
payMoney := order.PayMoney
var gold = payMoney / 10
// 查询金币配置
currency, err := currencymod.Get(order.ProductID)
if err != nil {
log.ErrorX(ctx, fmt.Sprintf("充值金币-订单号[%s] 产品ID[%v] 查询异常[%v]", order.ID, order.ProductID, err))
return nil, err
}
// 未匹配到对应配置
if currency.ID.IsZero() {
log.ErrorX(ctx, fmt.Sprintf("充值金币-订单号[%s] 产品ID[%v] 不存在", order.ID, order.ProductID))
} else { // 匹配到对应配置
// 金额不能低于配置金额1元并且有赠送金额
if (payMoney+100-currency.Price) > 0 && currency.GiveGold > 0 {
gold += currency.GiveGold
}
}
return func(t *db.MongoTool) error {
// 增加金币与总充值金额
originGold := gold
goldExtra := uint64(0)
if order.GoldExtraID != nil {
extra, err := goldextramod.GetUserGoldExtraByID(t, *order.GoldExtraID)
if err != nil {
log.ErrorX(ctx, fmt.Sprintf("充值退款金币-订单号[%s] 查询金币加购券[%v] 出错: %s", order.ID, *order.GoldExtraID, err.Error()))
return err
}
if extra.Uid != order.UID || extra.Used || extra.Expired.Before(time.Now()) {
log.ErrorX(ctx, fmt.Sprintf("充值退款金币-订单号[%s] 查询金币加购券[%v] 无效", order.ID, *order.GoldExtraID))
return errors.New("无效的金币加购券")
}
if err := goldextramod.UseGoldExtra(t, order.UID, *order.GoldExtraID); err != nil {
log.ErrorX(ctx, fmt.Sprintf("充值退款金币-订单号[%s] 使用金币加购券[%v] 出错: %s", order.ID, *order.GoldExtraID, err.Error()))
return err
}
goldExtra = extra.Amount
gold += int64(extra.Amount)
}
gold = -gold
payMoney = -payMoney
w, err := walletmod.Credit(t, walletmod.CreditPlan{
Amount: &gold,
Consumption: &payMoney,
}, order.UID)
if err != nil {
return err
}
desc := "官方充值-退款" + strconv.FormatInt(gold, 10) + "金币"
if goldExtra != 0 {
desc = "官方充值-退款" + strconv.FormatInt(originGold, 10) + "金币, 消耗金币加购券退款" + strconv.FormatUint(goldExtra, 10) + "金币"
}
return txnmod.InsertTransactionLog(t, &txnmod.TransactionLog{
TransNo: order.ID,
UID: order.UID,
Amount: gold,
ActualAmount: float64(gold),
TranType: txnmod.OrderRefund.Key(),
TranTypeInt: int64(txnmod.OrderRefund),
ChannelType: order.RechargeType,
Desc: desc,
DiscDoc: order.DiscDoc,
SysType: order.DevType,
RealAmount: w.RealAmount(),
})
}, nil
}
// RefundProduct 退款购买商品
func RefundProduct(ctx context.Context, order rchgordmod.RechargeOrder) (func(*db.MongoTool) error, error) {
payMoney := order.PayMoney
var (
creditPlan = walletmod.CreditPlan{Consumption: &payMoney}
u *usermod.User
p *productmod.Product
err error
wg sync.WaitGroup
w *walletmod.Wallet
)
wg.Add(3)
common.Go(func() {
defer wg.Done()
// 查询会员卡信息
p, err = productmod.FindProduct(order.ProductID, order.DevType)
if err != nil {
log.ErrorX(ctx, fmt.Sprintf("充值购买会员卡-订单号[%v] 产品ID[%v] 查询会员卡信息异常[%v]", order.ID, order.ProductID, err))
}
})
common.Go(func() {
defer wg.Done()
// 查询用户信息
u, err = usermod.FindUserByUID(order.UID)
if err != nil {
log.ErrorX(ctx, fmt.Sprintf("充值购买会员卡-订单号[%s] 用户ID[%d] 查询用户信息异常 [%v]", order.ID, order.UID, err))
}
})
common.Go(func() {
defer wg.Done()
var errG error
w, errG = walletmod.GetWallet(order.UID)
if errG != nil {
log.ErrorX(ctx, fmt.Sprintf("充值购买会员卡-订单号[%s] 用户ID[%d] 查询用户钱包异常 [%v]", order.ID, order.UID, err))
}
})
wg.Wait()
if p == nil {
log.ErrorX(ctx, fmt.Sprintf("充值购买会员卡-订单号[%v] 产品ID[%v]不存在", order.ID, order.ProductID))
return nil, fmt.Errorf("充值购买会员卡-订单号[%v] 产品ID[%v]不存在", order.ID, order.ProductID)
}
if u == nil {
log.ErrorX(ctx, fmt.Sprintf("充值购买会员卡-订单号[%v] 用户ID[%d]不存在", order.ID, order.UID))
return nil, fmt.Errorf("充值购买会员卡-订单号[%v] 用户ID[%d]不存在", order.ID, order.UID)
}
// 充值金额低于会员卡售价1元 转化为充值对应金额金币
var (
disPay = payMoney - p.DiscountedPrice*10
fn func(t *db.MongoTool) error
)
if disPay < -100 {
var (
gold = -(payMoney / 10)
)
creditPlan.Amount = &gold
return func(t *db.MongoTool) error {
w, err := walletmod.Credit(t, creditPlan, order.UID)
if err != nil {
return err
}
//插入一条支出流水
if err = txnmod.InsertTransactionLog(t, &txnmod.TransactionLog{
TransNo: order.ID,
UID: order.UID,
Amount: gold,
ActualAmount: float64(gold),
TranType: txnmod.OrderRefund.Key(),
TranTypeInt: int64(txnmod.OrderRefund),
ChannelType: order.RechargeType,
Desc: fmt.Sprintf("退款官方充值购买[%s]实际支付[%d]元;退款[%d]金币", p.Name, payMoney/100, payMoney/10),
DiscDoc: order.DiscDoc,
SysType: order.DevType,
RealAmount: w.RealAmount(),
}); err != nil {
log.ErrorX(ctx, fmt.Sprintf("rechargeSer CallbackAddCoins Trans fail error:%+v order:%+v", err, order))
return err
}
return nil
}, nil
}
// 购买商品处理
switch p.ProductType {
case commod.VIP, commod.NEWUSERCard:
if fn, err = RefundBuyVIP(order, p, "退款官方充值-购买[%s]", w); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("退款充值购买会员卡-订单号[%s] 商品ID[%s] 未知商品", order.ID, order.ProductID)
}
// 返回事务函数
return func(t *db.MongoTool) error {
// 充值金额大于会员卡售价1角 转化为充值对应金额金币
if disPay > 9 {
var (
gold = -(disPay / 10)
)
creditPlan.Amount = &gold
w, err := walletmod.Credit(t, creditPlan, order.UID)
if err != nil {
return err
}
//插入一条支出流水
if err = txnmod.InsertTransactionLog(t, &txnmod.TransactionLog{
TransNo: order.ID,
UID: order.UID,
Amount: gold,
ActualAmount: float64(gold),
TranType: txnmod.OrderRefund.Key(),
TranTypeInt: int64(txnmod.OrderRefund),
ChannelType: order.RechargeType,
Desc: fmt.Sprintf("退款官方充值购买[%s]实际支付[%d]元;超额部分退款为[%d]金币", p.Name, payMoney/100, disPay),
DiscDoc: order.DiscDoc,
SysType: order.DevType,
RealAmount: w.RealAmount(),
}); err != nil {
log.ErrorX(ctx, fmt.Sprintf("rechargeSer CallbackAddCoins Trans fail error:%+v order:%+v", err, order))
return err
}
}
if err = fn(t); err != nil {
return err
}
return nil
}, nil
}
func RefundBuyVIP(order rchgordmod.RechargeOrder, p *productmod.Product, desc string, w *walletmod.Wallet) (func(*db.MongoTool) error, error) {
var (
//商品购买记录
history = prdcthsomod.ProductHistory{
ID: primitive.NewObjectID(),
UID: order.UID,
ProductID: p.ID,
Name: p.Name,
Amount: p.DiscountedPrice,
ProductType: prdcthsomod.VIP,
DiscDoc: order.DiscDoc,
SysType: order.DevType,
ProductSnapShot: p,
}
sel usermod.UserSelector
creditPlan = walletmod.CreditPlan{}
)
// 资金流水记录
txnLogs := []txnmod.TransactionLog{
{
UID: order.UID,
TranType: txnmod.OrderRefundVip.Key(),
TranTypeInt: int64(txnmod.OrderRefundVip),
TransNo: history.ID,
Desc: fmt.Sprintf(desc, p.Name),
DiscDoc: order.DiscDoc,
SysType: order.DevType,
CurrencyType: txnmod.CurrencyTypeCash,
Amount: -p.DiscountedPrice,
ActualAmount: float64(-p.DiscountedPrice),
RealAmount: w.RealAmount(),
},
}
// 查询用户信息
u, err := usermod.FindUserByUID(order.UID)
if err != nil {
log.Error(fmt.Sprintf("退款充值购买会员卡-订单号[%s] 查询用户信息异常 [%v]", order.ID, err))
return nil, err
}
// VIP变更
if p.Duration > 0 {
var expire time.Time
// VIP未过期
if u.VipExpireDate.After(time.Now()) {
expire = u.VipExpireDate.AddDate(0, 0, -p.Duration)
} else {
expire = time.Now().AddDate(0, 0, -p.Duration)
}
log.Info(fmt.Sprintf("到期时间:%v", expire))
sel.VipExpireDate = &expire
}
// 增加金币视频免费天数
if p.GoldVideoFreeDay > 0 {
var expire time.Time
if u.GoldVideoFreeExpire.IsZero() || u.GoldVideoFreeExpire.Before(time.Now()) {
expire = time.Now().AddDate(0, 0, -p.GoldVideoFreeDay)
} else {
expire = u.GoldVideoFreeExpire.AddDate(0, 0, -p.GoldVideoFreeDay)
}
sel.GoldVideoFreeExpire = &expire
}
if p.BroadcastDays > 0 {
expire := time.Time{}
if u.BroadcastExpire.IsZero() || u.BroadcastExpire.Before(time.Now()) {
expire = time.Now().AddDate(0, 0, -p.BroadcastDays)
} else {
expire = u.BroadcastExpire.AddDate(0, 0, -p.BroadcastDays)
}
sel.BroadcastExpire = &expire
}
if p.DramaDays > 0 {
expire := usermod.RenewDramaExpire(u.DramaExpire, time.Now(), -p.DramaDays)
sel.DramaExpire = &expire
}
// 回收赠送金币
if p.GiveCoin > 0 {
coin := -p.GiveCoin
creditPlan.Amount = &coin
}
// 回收赠送下载次数
if p.DownloadCount > 0 {
count := -p.DownloadCount
creditPlan.DownloadCount = &count
}
// 回收赠送AI免费脱衣次数
if p.AiUndressCount > 0 {
aiUndressFreeTimes := int64(-p.AiUndressCount)
creditPlan.AiUndressFreeTimes = &aiUndressFreeTimes
}
return func(t *db.MongoTool) error {
// 钱包变更
if p.GiveCoin > 0 || p.DownloadCount > 0 || p.AiUndressCount > 0 {
w, err := walletmod.Credit(t, creditPlan, order.UID)
if err != nil {
return err
}
if p.DownloadCount > 0 {
txnLogs = append(txnLogs, txnmod.TransactionLog{
UID: order.UID,
DownloadCount: p.DownloadCount,
TranType: txnmod.AdminDebitDownloadCount.Key(),
TranTypeInt: int64(txnmod.AdminDebitDownloadCount),
TransNo: history.ID,
Desc: fmt.Sprintf(desc, p.Name) + fmt.Sprintf("-回收赠送[%d]次数", p.DownloadCount),
DiscDoc: u.DiscDoc,
SysType: u.SysType,
})
}
// 插入购买会员卡回收赠送金币流水
if p.GiveCoin > 0 {
giveLog := txnmod.TransactionLog{
UID: order.UID,
Amount: p.GiveCoin,
ActualAmount: float64(p.GiveCoin),
TranType: txnmod.RefundVipCardGive.Key(),
TranTypeInt: int64(txnmod.RefundVipCardGive),
TransNo: history.ID,
Desc: fmt.Sprintf("购买%s-回收赠送金币[%v个]", p.Name, p.GiveCoin),
SysType: u.SysType,
RealAmount: w.RealAmount(),
}
txnLogs = append(txnLogs, giveLog)
}
// 回收赠送免费次数
if p.AiUndressCount > 0 {
txnLogs = append(txnLogs, txnmod.TransactionLog{
UID: order.UID,
Amount: int64(p.AiUndressCount),
ActualAmount: float64(p.AiUndressCount),
TranType: txnmod.AdminDebitAiUndressFreeTimes.Key(),
TranTypeInt: int64(txnmod.AdminDebitAiUndressFreeTimes),
TransNo: history.ID,
Desc: fmt.Sprintf(desc, p.Name) + fmt.Sprintf("-回收赠送AI脱衣免费次数[%d次]", p.AiUndressCount),
DiscDoc: u.DiscDoc,
SysType: u.SysType,
RealAmount: w.RealAmount(),
})
}
}
// 用户信息变更
if p.Duration > 0 || p.GoldVideoFreeDay > 0 || p.BroadcastDays > 0 || p.DramaDays > 0 {
if _, err = usermod.UpdateTrans(t, u.UID, sel); err != nil {
return err
}
}
// 保存资金流水记录
if err = txnmod.InsertManyTransactionLog(t, txnLogs); err != nil {
return err
}
return nil
}, nil
}
+59
View File
@@ -0,0 +1,59 @@
package rechargeser
import (
"sort"
"91jd-server/models/cache/sysconfdata"
"91jd-server/models/v/rchgamtmod"
"91jd-server/models/v/sysconfmod"
)
// payTypeDisplayConfig 支付方式展示配置(来自后台系统配置 GPCodePay)
type payTypeDisplayConfig struct {
nameMap map[string]string // 支付方式 -> 展示名称
sortIndex map[string]int // 支付方式 -> 顺序(越小越靠前)
}
// getPayTypeDisplayConfig 读取支付方式展示配置(带缓存)。
// 读取失败时返回空配置,调用方会回退到默认硬编码名称与上游默认顺序。
func getPayTypeDisplayConfig() payTypeDisplayConfig {
cfg := payTypeDisplayConfig{}
conf, err := sysconfdata.GetByGpCodeFromCache(sysconfmod.GPCodePay)
if err != nil {
return cfg
}
cfg.nameMap = conf.GetObject(sysconfmod.VCodePayTypeName)
sortList := conf.GetStrSlice(sysconfmod.VCodePayTypeSort)
cfg.sortIndex = make(map[string]int, len(sortList))
for i, t := range sortList {
if _, ok := cfg.sortIndex[t]; !ok {
cfg.sortIndex[t] = i
}
}
return cfg
}
// typeName 返回支付方式展示名称,未配置则回退到默认硬编码名称
func (c payTypeDisplayConfig) typeName(payType string) string {
if name, ok := c.nameMap[payType]; ok && name != "" {
return name
}
return getChannelDisplayName(payType)
}
// sortChannels 按后台配置的顺序对支付方式排序,未配置顺序的支付方式排在最后(保持相对顺序稳定)
func (c payTypeDisplayConfig) sortChannels(channels []rchgamtmod.PayChannelRes) {
if len(c.sortIndex) == 0 || len(channels) <= 1 {
return
}
const last = 1 << 30
orderOf := func(t string) int {
if idx, ok := c.sortIndex[t]; ok {
return idx
}
return last
}
sort.SliceStable(channels, func(i, j int) bool {
return orderOf(channels[i].Type) < orderOf(channels[j].Type)
})
}
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
package rechargeser
import (
"91jd-server/app/service/productser"
"91jd-server/common"
"91jd-server/common/log"
"91jd-server/middleware/ua"
"91jd-server/models/v/advanceordermod"
"errors"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson"
"91jd-server/models/commod"
"91jd-server/models/v/currencymod"
"91jd-server/models/v/goldextramod"
"91jd-server/models/v/productmod"
"91jd-server/models/v/rchgordmod"
"91jd-server/models/v/usermod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
// Recommended stable values for reporting. The API also accepts custom values.
OrderSourcePageHomeUserSegment = "HOME_USER_SEGMENT"
OrderSourcePageVideoBottomBanner = "VIDEO_BOTTOM_BANNER"
OrderSourcePageVideoBottomSheet = "VIDEO_BOTTOM_SHEET"
OrderSourcePageVIPCenter = "VIP_CENTER"
OrderSourcePageH5Activity = "H5_ACTIVITY"
OrderSourcePageDramaPaywall = "DRAMA_PAYWALL"
OrderSourcePageUnknown = "UNKNOWN"
)
// NormalizeOrderSourcePage trims sourcePage and maps an empty value to UNKNOWN.
// Non-empty values are intentionally preserved to support new frontend sources
// without requiring a backend allowlist release.
func NormalizeOrderSourcePage(sourcePage string) string {
normalized := strings.TrimSpace(sourcePage)
if normalized == "" {
return OrderSourcePageUnknown
}
return normalized
}
// CurrencyListRequest 货币列表请求
type CurrencyListRequest struct {
Type int `form:"type" binding:"required"` //货币类型 1-金币 2-游戏币 3-果币
}
// CurrencyListResponse 货币列表回复
// RechargeRequest 充值请求
type RechargeRequest struct {
UID uint64 `swaggerignore:"true"`
IP string `swaggerignore:"true"`
RechargeType string `json:"rechargeType" binding:"required"` // 充值类型
ProductID primitive.ObjectID `json:"productID" binding:"required"` // 产品ID 如:会员卡ID
ProductChildID primitive.ObjectID `json:"productChildId"` // 产品子ID 如:服务ID
GoldExtraID *primitive.ObjectID `json:"goldExtraID"` // 金币加购券id(仅仅在购买金币时使用)
BuyType commod.BuyType `json:"buyType" binding:"required"` // 购买类型
SourcePage string `json:"sourcePage"` // 下单来源页面
SourceRef string `json:"sourceRef"` // 来源业务对象
VideoID string `json:"videoId"` // 来源视频ID
ActivityID string `json:"activityId"` // 来源活动ID
ExperimentID string `json:"experimentId"` // A/B实验ID
ExperimentVariant string `json:"experimentVariant"` // A/B实验分组
SessionID string `json:"sessionId"` // 前端会话ID
MediaID primitive.ObjectID `json:"mediaId"` // 短剧ID
ContentID primitive.ObjectID `json:"contentId"` // 短剧单集ID
CheckoutContextID string `json:"checkoutContextId"` // 短剧付费墙上下文
}
// ActivityDeduct 活动服下单附加的会员抵扣券信息。
// 仅由活动服 HMAC 鉴权入口(CreateRechargeOrder)构造并显式传入,与客户端请求体(RechargeRequest)解耦,
// 客户端普通下单(/api/app/mine/topay)传 nil,无从注入抵扣,杜绝自设 deductAmount 低价购卡。
type ActivityDeduct struct {
CouponID string // 会员抵扣券ID(活动服券号,不透明串)
DeductAmount int64 // 活动服建议抵扣金额(分),本服再自行校验上限后折价
}
func (r *RechargeRequest) CreateOrder(u *usermod.User, ua ua.UA, deduct *ActivityDeduct) (*rchgordmod.RechargeOrder, *advanceordermod.AdvanceOrder, error) {
orderSysType := u.SysType
if strings.TrimSpace(ua.SysType) != "" {
orderSysType = ua.SysType
}
order := &rchgordmod.RechargeOrder{
UID: u.UID,
DevID: u.DevID,
UserIP: r.IP,
DevType: common.HandleSysType(orderSysType),
DiscDoc: u.DiscDoc,
Name: u.Name,
Tel: u.Mobile,
RechargeType: r.RechargeType,
ProductID: r.ProductID,
Channel: "self",
BuyType: r.BuyType,
Ua: ua,
SourcePage: NormalizeOrderSourcePage(r.SourcePage),
SourceRef: r.SourceRef,
VideoID: r.VideoID,
ActivityID: r.ActivityID,
ExperimentID: r.ExperimentID,
ExperimentVariant: r.ExperimentVariant,
SessionID: r.SessionID,
MediaID: r.MediaID,
ContentID: r.ContentID,
CheckoutContextID: r.CheckoutContextID,
}
var advanceOrder advanceordermod.AdvanceOrder
if r.GoldExtraID != nil && !(*r.GoldExtraID).IsZero() {
if r.BuyType != commod.BuyGold {
return nil, nil, errors.New("金币加购券只能在购买金币时使用")
}
goldExtra, err := goldextramod.GetUserGoldExtraByID(nil, *r.GoldExtraID)
if err != nil {
return nil, nil, err
}
if goldExtra.Uid != u.UID || goldExtra.Used || goldExtra.Expired.Before(time.Now()) {
return nil, nil, errors.New("无效的金币加购券")
}
order.GoldExtraID = r.GoldExtraID
}
if r.RechargeType == "daichong" {
order.RechargeType = "alipay"
}
switch r.BuyType {
case commod.BuyGold, commod.BuyFruitCoin, commod.BuyGameCoin:
// 查询货币信息
currency, err := currencymod.Get(r.ProductID)
if err != nil {
return nil, nil, err
}
order.Money = currency.Price
order.Amount = currency.Coins
if r.BuyType == commod.BuyGameCoin {
order.ProductType = 1
}
order.ProductName = currency.Name
case commod.BuyProduct:
// 查询会员卡信息
product, err := productmod.FindProduct(r.ProductID, order.DevType)
if err != nil {
return nil, nil, err
}
// 用户会员卡升级检查
productser.CheckUserUpgrade(u.UID, product)
order.Amount = product.DiscountedPrice
order.Money = product.DiscountedPrice * 10
order.VipID = r.ProductID
order.ProductName = product.Name
if product.ProductType == commod.AdvanceCard {
data, err := advanceordermod.IsExist(bson.M{"productID": r.ProductID, "uid": u.UID})
if err != nil {
return nil, nil, errors.New("advance order query IsExist err")
}
if data == nil || data.ID.IsZero() {
prepaidPrivilege := advanceordermod.AdvanceCardPrepaidPrivilege{}
if product.PrepaidPrivilege != nil {
prepaidPrivilege = advanceordermod.AdvanceCardPrepaidPrivilege{
CoinVideoLimitPerDay: product.PrepaidPrivilege.CoinVideoLimitPerDay,
LuckyDrawLimitPerDay: product.PrepaidPrivilege.LuckyDrawLimitPerDay,
AiUndressLimitPerDay: product.PrepaidPrivilege.AiUndressLimitPerDay,
DownloadLimitPerDay: product.PrepaidPrivilege.DownloadLimitPerDay,
}
}
// 没有创建预售订单,创建预售订单(首付状态)
order.Amount = product.AdvanceAmount
order.Money = product.AdvanceAmount * 10
// 创建预售订单
now := time.Now()
advanceOrder.UID = u.UID
advanceOrder.CreatedAt = now
advanceOrder.Status = advanceordermod.AdvanceProcessing
advanceOrder.AdvanceAmount = product.AdvanceAmount * 10
advanceOrder.BalanceAmount = product.BalanceAmount * 10
advanceOrder.StartTime = product.StartTime
advanceOrder.EndTime = product.EndTime
advanceOrder.ProductID = product.ID
advanceOrder.TotalAmount = (product.AdvanceAmount + product.BalanceAmount) * 10
advanceOrder.PrepaidPrivilege = prepaidPrivilege
advanceOrder.TodayUse = advanceordermod.DayUse{}
} else if data.Status == advanceordermod.AdvanceProcessing {
// 有预售订单,且是首付状态
order.Amount = product.AdvanceAmount
order.Money = product.AdvanceAmount * 10
data.AdvanceAmount = product.AdvanceAmount * 10
data.TotalAmount = (product.AdvanceAmount + product.BalanceAmount) * 10
advanceOrder = *data
} else if (data.Status == advanceordermod.AdvanceSUCCESS && time.Now().After(product.StartTime)) || data.Status == advanceordermod.BalanceProcessing {
// 尾款预付状态
order.Amount = product.BalanceAmount
order.Money = product.BalanceAmount * 10
advanceOrder = *data
} else if data.Status == advanceordermod.AdvanceSUCCESS && !time.Now().After(product.StartTime) {
log.Error("RechargeRequest.CreateOrder 支付预售订单失败,未到尾款支付时间", log.Any("advanceOrder.Status", data.Status), log.Any("product", product), log.E(err))
return nil, nil, errors.New("advance order status is err")
} else if data.Status == advanceordermod.BalanceSUCCESS {
log.Error("RechargeRequest.CreateOrder 支付预售订单失败,该订单已经预售支付完成", log.Any("advanceOrder.Status", data.Status), log.Any("product", product), log.E(err))
return nil, nil, errors.New("advance order is paid")
}
advanceOrder.ProductID = product.ID
}
// 会员抵扣券支付抵扣:折后价写入 order.Money,couponId 持久化到订单
applyDeductCoupon(order, deduct, product)
switch product.ProductType {
case commod.VIP, commod.MeetingCard, commod.AdvanceCard, commod.GameAdvanceCard,
commod.NEWUSERCard, commod.PhysicalGoods, commod.VideoDiscount, commod.VideoFreeCard, commod.CoinMonthCard:
order.Category = 1
default:
order.Category = 0
}
}
return order, &advanceOrder, nil
}
// minChannelPayFen 支付渠道最低可支付金额(分),抵扣后实付不得低于此值
const minChannelPayFen = 1
// applyDeductCoupon 应用会员抵扣券支付抵扣。
// deduct 由活动服入口传入(客户端下单为 nil);其 DeductAmount 仅为建议值,不可无条件信任,须自行封顶:
//
// 实际抵扣 = min(DeductAmount, 原价 - 最低实付),折后实付 >= 渠道最低可支付金额。
//
// 仅普通会员卡(buyType=商品且非预售卡)生效;预售卡分阶段付款不参与抵扣。
// 折后价写回 order.Money(下单/回调均以此为准),couponId 与实际抵扣额持久化到订单,
// 供支付结果回调向活动服核销时原样回传。
func applyDeductCoupon(order *rchgordmod.RechargeOrder, deduct *ActivityDeduct, product *productmod.Product) {
if deduct == nil || deduct.CouponID == "" || deduct.DeductAmount <= 0 {
return
}
if product.ProductType == commod.AdvanceCard {
return
}
maxDeduct := order.Money - minChannelPayFen
if maxDeduct <= 0 {
return
}
amount := deduct.DeductAmount
if amount > maxDeduct {
amount = maxDeduct
}
order.Money -= amount
order.CouponID = deduct.CouponID
order.DeductAmount = amount
}
@@ -0,0 +1,204 @@
package rechargeser
import (
"strings"
"testing"
"91jd-server/middleware/ua"
"91jd-server/models/commod"
"91jd-server/models/v/usermod"
)
func TestNormalizeOrderSourcePage(t *testing.T) {
tests := []struct {
name string
sourcePage string
want string
}{
{name: "canonical", sourcePage: OrderSourcePageVideoBottomSheet, want: OrderSourcePageVideoBottomSheet},
{name: "trim arbitrary source", sourcePage: " custom_Page-01 ", want: "custom_Page-01"},
{name: "empty", sourcePage: "", want: OrderSourcePageUnknown},
{name: "whitespace", sourcePage: " ", want: OrderSourcePageUnknown},
{name: "previously unsupported", sourcePage: "OTHER", want: "OTHER"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeOrderSourcePage(tt.sourcePage); got != tt.want {
t.Fatalf("NormalizeOrderSourcePage(%q) = %q, want %q", tt.sourcePage, got, tt.want)
}
})
}
}
func TestRechargeRequestCreateOrderCopiesAttribution(t *testing.T) {
request := &RechargeRequest{
SourcePage: " custom_source_page ",
SourceRef: "source-ref",
VideoID: "video-id",
ActivityID: "activity-id",
ExperimentID: "experiment-id",
ExperimentVariant: "A",
SessionID: "session-id",
}
order, _, err := request.CreateOrder(&usermod.User{UID: 123}, ua.UA{}, nil)
if err != nil {
t.Fatalf("CreateOrder() error = %v", err)
}
if order.SourcePage != "custom_source_page" {
t.Fatalf("SourcePage = %q, want custom_source_page", order.SourcePage)
}
if order.SourceRef != request.SourceRef ||
order.VideoID != request.VideoID ||
order.ActivityID != request.ActivityID ||
order.ExperimentID != request.ExperimentID ||
order.ExperimentVariant != request.ExperimentVariant ||
order.SessionID != request.SessionID {
t.Fatalf("order attribution fields were not copied: %+v", order)
}
legacyOrder, _, err := (&RechargeRequest{}).CreateOrder(&usermod.User{UID: 456}, ua.UA{}, nil)
if err != nil {
t.Fatalf("legacy CreateOrder() error = %v", err)
}
if legacyOrder.SourcePage != OrderSourcePageUnknown {
t.Fatalf("legacy SourcePage = %q, want %q", legacyOrder.SourcePage, OrderSourcePageUnknown)
}
}
func TestRechargeRequestCreateOrderUsesCurrentRequestSysType(t *testing.T) {
tests := []struct {
name string
userSysType string
requestSysType string
want string
}{
{name: "android request overrides historical ios", userSysType: "ios", requestSysType: "android", want: "android"},
{name: "ios request overrides historical android", userSysType: "android", requestSysType: "IOS-18", want: "ios"},
{name: "missing request falls back to user", userSysType: "ios", want: "ios"},
{name: "missing request and user use default", want: "android"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
order, _, err := (&RechargeRequest{}).CreateOrder(
&usermod.User{UID: 123, SysType: tt.userSysType},
ua.UA{SysType: tt.requestSysType},
nil,
)
if err != nil {
t.Fatalf("CreateOrder() error = %v", err)
}
if order.DevType != tt.want {
t.Fatalf("DevType = %q, want %q", order.DevType, tt.want)
}
})
}
}
func TestNormalizeAttributionPreservesNonExperimentSession(t *testing.T) {
request := RechargeRequest{
SourcePage: " H5_ACTIVITY ",
ActivityID: " activity-1 ",
ExperimentVariant: "A",
SessionID: " session-1 ",
}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() error = %v", err)
}
if request.SourcePage != OrderSourcePageH5Activity {
t.Fatalf("SourcePage = %q, want %q", request.SourcePage, OrderSourcePageH5Activity)
}
if request.ActivityID != "activity-1" {
t.Fatalf("ActivityID = %q", request.ActivityID)
}
if request.ExperimentVariant != "" {
t.Fatalf("ExperimentVariant = %q, want empty without experimentId", request.ExperimentVariant)
}
if request.SessionID != "session-1" {
t.Fatalf("SessionID = %q, want session-1", request.SessionID)
}
}
func TestValidateAttributionSourcePageLength(t *testing.T) {
request := RechargeRequest{SourcePage: strings.Repeat("页", maxAttributionValueLength)}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() rejected 128 characters: %v", err)
}
request.SourcePage = strings.Repeat("页", maxAttributionValueLength+1)
if err := request.validateAttribution(123); err == nil {
t.Fatal("validateAttribution() should reject sourcePage longer than 128 characters")
}
}
func TestValidateAttributionDramaPaywallGoldRechargeAllowsSourceOnly(t *testing.T) {
request := RechargeRequest{
BuyType: commod.BuyGold,
SourcePage: OrderSourcePageDramaPaywall,
}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() rejected drama paywall gold recharge: %v", err)
}
}
func TestValidateAttributionDramaPaywallProductAllowsNoContext(t *testing.T) {
request := RechargeRequest{
BuyType: commod.BuyProduct,
SourcePage: OrderSourcePageDramaPaywall,
}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() rejected drama product without context: %v", err)
}
}
func TestValidateAttributionDramaPaywallProductAllowsPartialContext(t *testing.T) {
request := RechargeRequest{
BuyType: commod.BuyProduct,
SourcePage: OrderSourcePageDramaPaywall,
SourceRef: "legacy-client-value",
SessionID: "session-1",
CheckoutContextID: "checkout-context-1",
}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() rejected partial drama context: %v", err)
}
if request.SourceRef != request.CheckoutContextID {
t.Fatalf("SourceRef = %q, want checkoutContextId %q", request.SourceRef, request.CheckoutContextID)
}
}
func TestValidateAttributionDramaPaywallSessionDoesNotRequireContext(t *testing.T) {
request := RechargeRequest{
BuyType: commod.BuyProduct,
SourcePage: OrderSourcePageDramaPaywall,
SourceRef: "legacy-client-value",
SessionID: "session-1",
}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() rejected experiment session without drama context: %v", err)
}
if request.SourceRef != "" {
t.Fatalf("SourceRef = %q, want empty without checkoutContextId", request.SourceRef)
}
}
func TestValidateAttributionDramaPaywallIgnoresOversizedCheckoutContext(t *testing.T) {
request := RechargeRequest{
BuyType: commod.BuyProduct,
SourcePage: OrderSourcePageDramaPaywall,
CheckoutContextID: strings.Repeat("x", maxAttributionValueLength+1),
}
if err := request.validateAttribution(123); err != nil {
t.Fatalf("validateAttribution() rejected oversized optional drama context: %v", err)
}
if request.CheckoutContextID != "" || request.SourceRef != "" {
t.Fatalf("optional drama context was not cleared: checkout=%q sourceRef=%q", request.CheckoutContextID, request.SourceRef)
}
}
@@ -0,0 +1,93 @@
package rechargeser
import (
"fmt"
"strings"
"unicode/utf8"
"91jd-server/models/commod"
"91jd-server/models/v/vipcardexperimentmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const maxAttributionValueLength = 128
func (r *RechargeRequest) normalizeAttribution() {
r.SourcePage = NormalizeOrderSourcePage(r.SourcePage)
r.SourceRef = strings.TrimSpace(r.SourceRef)
r.VideoID = strings.TrimSpace(r.VideoID)
r.ActivityID = strings.TrimSpace(r.ActivityID)
r.ExperimentID = strings.TrimSpace(r.ExperimentID)
r.ExperimentVariant = strings.ToUpper(strings.TrimSpace(r.ExperimentVariant))
r.SessionID = strings.TrimSpace(r.SessionID)
r.CheckoutContextID = strings.TrimSpace(r.CheckoutContextID)
}
func (r *RechargeRequest) validateAttribution(uid uint64) error {
r.normalizeAttribution()
if utf8.RuneCountInString(r.SourcePage) > maxAttributionValueLength {
return fmt.Errorf("sourcePage must not exceed %d characters", maxAttributionValueLength)
}
if r.SourcePage == OrderSourcePageDramaPaywall {
if r.BuyType != commod.BuyGold && r.BuyType != commod.BuyProduct {
return fmt.Errorf("buyType must be 1 or 4 for DRAMA_PAYWALL")
}
// sourceRef is the generic order attribution field. For drama paywall
// orders, derive it from the backend-issued checkout context instead of
// requiring clients to submit the same identifier twice. The remaining
// drama fields are attribution-only and must not block a real payment.
if len(r.CheckoutContextID) > maxAttributionValueLength {
r.CheckoutContextID = ""
}
r.SourceRef = r.CheckoutContextID
}
values := map[string]string{
"sourceRef": r.SourceRef,
"videoId": r.VideoID,
"activityId": r.ActivityID,
"experimentId": r.ExperimentID,
"experimentVariant": r.ExperimentVariant,
"sessionId": r.SessionID,
"checkoutContextId": r.CheckoutContextID,
}
for name, value := range values {
if len(value) > maxAttributionValueLength {
return fmt.Errorf("%s must not exceed %d characters", name, maxAttributionValueLength)
}
}
if r.ExperimentID == "" {
r.ExperimentVariant = ""
return nil
}
if r.ExperimentVariant == "" || r.SessionID == "" {
return fmt.Errorf("experimentVariant and sessionId are required with experimentId")
}
experiment, err := vipcardexperimentmod.FindByExperimentID(r.ExperimentID)
if err != nil {
return err
}
if experiment == nil {
return fmt.Errorf("experiment does not exist")
}
config, ok := experiment.ConfigFor(r.ExperimentVariant)
if !ok {
return fmt.Errorf("experimentVariant must be A or B")
}
if assigned := experiment.Assign(uid); assigned != r.ExperimentVariant {
return fmt.Errorf("experimentVariant does not match user assignment")
}
if !containsObjectID(config.ProductIDs, r.ProductID) {
return fmt.Errorf("productID does not belong to experiment variant")
}
return nil
}
func containsObjectID(ids []primitive.ObjectID, target primitive.ObjectID) bool {
for _, id := range ids {
if id == target {
return true
}
}
return false
}