@@ -0,0 +1,244 @@
|
||||
package aiser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common/constant/redisconst"
|
||||
"91porn-server/common/db"
|
||||
"91porn-server/common/laosiji_app"
|
||||
"91porn-server/common/log"
|
||||
"91porn-server/common/redis"
|
||||
"91porn-server/models/commod"
|
||||
"91porn-server/models/v/fundtransferlogmod"
|
||||
"91porn-server/models/v/usermod"
|
||||
"91porn-server/models/v/walletmod"
|
||||
|
||||
"github.com/go-redsync/redsync/v4"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const aiFundLockExpiry = 30 * time.Second
|
||||
|
||||
type GetAuthURLResp struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type transferDependencies struct {
|
||||
findLatest func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error)
|
||||
findLatestOut func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error)
|
||||
getWallet func(uint64) (*walletmod.Wallet, error)
|
||||
findUser func(uint64) (*usermod.User, error)
|
||||
getAuthURL func(context.Context, laosiji_app.GetAiMateURLReq) (laosiji_app.GetAiMateURLResp, error)
|
||||
bringOut func(context.Context, laosiji_app.AiMateBringOutReq) (laosiji_app.AiMateBringOutResp, error)
|
||||
debitAndLog func(uint64, int64, time.Time) error
|
||||
creditAndLog func(uint64, int64, float64, string, time.Time) error
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func defaultTransferDependencies() transferDependencies {
|
||||
return transferDependencies{
|
||||
findLatest: fundtransferlogmod.FindLatestByUIDAndCategory,
|
||||
findLatestOut: fundtransferlogmod.FindLatestOutByUIDAndCategory,
|
||||
getWallet: walletmod.GetWallet,
|
||||
findUser: usermod.FindUserByUID,
|
||||
getAuthURL: laosiji_app.GetAiMateURL,
|
||||
bringOut: laosiji_app.AiMateBringOut,
|
||||
debitAndLog: debitAndLog,
|
||||
creditAndLog: creditAndLog,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func acquireLock(uid uint64) (func(), error) {
|
||||
if appg.Redis == nil {
|
||||
return nil, errors.New("redis is unavailable")
|
||||
}
|
||||
lock := redis.BuildLock(
|
||||
appg.Redis,
|
||||
redisconst.GetAiFundLockKey(uid),
|
||||
redsync.WithExpiry(aiFundLockExpiry),
|
||||
redsync.WithTries(1),
|
||||
)
|
||||
if err := lock.Lock(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
if _, err := lock.Unlock(); err != nil {
|
||||
log.Warn("unlock AI fund lock failed", log.Any("uid", uid), log.E(err))
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAuthURL 先回收上一次第三方余额,再将主钱包金币上分并返回AI女友授权地址。
|
||||
func GetAuthURL(ctx context.Context, uid uint64) (GetAuthURLResp, error) {
|
||||
unlock, err := acquireLock(uid)
|
||||
if err != nil {
|
||||
return GetAuthURLResp{}, errors.New("操作频繁,请稍后再试")
|
||||
}
|
||||
defer unlock()
|
||||
return getAuthURL(ctx, uid, defaultTransferDependencies())
|
||||
}
|
||||
|
||||
func getAuthURL(ctx context.Context, uid uint64, deps transferDependencies) (GetAuthURLResp, error) {
|
||||
if err := settleDown(ctx, uid, deps); err != nil {
|
||||
return GetAuthURLResp{}, fmt.Errorf("下分失败: %w", err)
|
||||
}
|
||||
|
||||
wallet, err := deps.getWallet(uid)
|
||||
if err != nil {
|
||||
return GetAuthURLResp{}, fmt.Errorf("获取钱包失败: %w", err)
|
||||
}
|
||||
var amount int64
|
||||
if wallet != nil {
|
||||
amount = wallet.Amount
|
||||
}
|
||||
|
||||
var remainder float64
|
||||
lastOut, err := deps.findLatestOut(uid, fundtransferlogmod.CategoryAiGirlfriend)
|
||||
if err != nil {
|
||||
return GetAuthURLResp{}, fmt.Errorf("获取下分余数失败: %w", err)
|
||||
}
|
||||
if lastOut != nil {
|
||||
remainder = lastOut.Remainder
|
||||
}
|
||||
|
||||
nickname, avatar := "用户", ""
|
||||
if user, findErr := deps.findUser(uid); findErr == nil && user != nil {
|
||||
if user.Name != "" {
|
||||
nickname = user.Name
|
||||
}
|
||||
avatar = user.Portrait
|
||||
}
|
||||
username := laosiji_app.GetUserName(appg.Conf.Base.Env, commod.KFK_APPID, uid)
|
||||
authResp, err := deps.getAuthURL(ctx, laosiji_app.GetAiMateURLReq{
|
||||
Username: username,
|
||||
Nickname: nickname,
|
||||
Asset: assetAmount(amount, remainder),
|
||||
Currency: "CNY",
|
||||
Theme: "dark",
|
||||
UserAvatar: avatar,
|
||||
})
|
||||
if err != nil {
|
||||
return GetAuthURLResp{}, fmt.Errorf("获取AI女友地址失败: %w", err)
|
||||
}
|
||||
if authResp.AuthURL == "" {
|
||||
return GetAuthURLResp{}, errors.New("AI女友地址为空")
|
||||
}
|
||||
|
||||
if amount > 0 {
|
||||
if err = deps.debitAndLog(uid, amount, deps.now()); err != nil {
|
||||
return GetAuthURLResp{}, fmt.Errorf("上分扣除金币失败: %w", err)
|
||||
}
|
||||
}
|
||||
return GetAuthURLResp{URL: authResp.AuthURL}, nil
|
||||
}
|
||||
|
||||
// TrySettleDown 在钱包查询前尝试将AI女友剩余余额下分回主钱包。
|
||||
func TrySettleDown(ctx context.Context, uid uint64) error {
|
||||
unlock, err := acquireLock(uid)
|
||||
if err != nil {
|
||||
return errors.New("操作频繁,请稍后再试")
|
||||
}
|
||||
defer unlock()
|
||||
return settleDown(ctx, uid, defaultTransferDependencies())
|
||||
}
|
||||
|
||||
func settleDown(ctx context.Context, uid uint64, deps transferDependencies) error {
|
||||
latest, err := deps.findLatest(uid, fundtransferlogmod.CategoryAiGirlfriend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == nil || latest.FundType != fundtransferlogmod.FundTypeIn {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := deps.bringOut(ctx, laosiji_app.AiMateBringOutReq{
|
||||
Username: laosiji_app.GetUserName(appg.Conf.Base.Env, commod.KFK_APPID, uid),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
returnAmount, remainder, err := balanceToWallet(resp.Balance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desc := "AI女友下分"
|
||||
if returnAmount == 0 {
|
||||
desc += "-金币为0"
|
||||
}
|
||||
return deps.creditAndLog(uid, returnAmount, remainder, desc, deps.now())
|
||||
}
|
||||
|
||||
func assetAmount(amount int64, remainder float64) string {
|
||||
amountDecimal := decimal.NewFromInt(amount).Div(decimal.NewFromInt(10))
|
||||
return amountDecimal.Add(decimal.NewFromFloat(remainder)).Round(2).StringFixed(2)
|
||||
}
|
||||
|
||||
func balanceToWallet(balance string) (int64, float64, error) {
|
||||
value, err := decimal.NewFromString(balance)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("无效的第三方余额: %w", err)
|
||||
}
|
||||
if value.IsNegative() {
|
||||
return 0, 0, errors.New("第三方余额不能为负数")
|
||||
}
|
||||
coins := value.Mul(decimal.NewFromInt(10)).Floor()
|
||||
if coins.GreaterThan(decimal.NewFromInt(math.MaxInt64)) {
|
||||
return 0, 0, errors.New("第三方余额超出范围")
|
||||
}
|
||||
returnAmount := coins.IntPart()
|
||||
remainderDecimal := value.Sub(decimal.NewFromInt(returnAmount).Div(decimal.NewFromInt(10))).Round(2)
|
||||
remainder, _ := remainderDecimal.Float64()
|
||||
return returnAmount, remainder, nil
|
||||
}
|
||||
|
||||
func debitAndLog(uid uint64, amount int64, now time.Time) error {
|
||||
if appg.VideoDB == nil {
|
||||
return errors.New("video database is unavailable")
|
||||
}
|
||||
return appg.VideoDB.Trans(func(t *db.MongoTool) error {
|
||||
wallet, err := walletmod.DebitAmount(t, amount, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fundtransferlogmod.Insert(t, &fundtransferlogmod.FundTransferLog{
|
||||
ID: primitive.NewObjectID(),
|
||||
UID: uid,
|
||||
Category: fundtransferlogmod.CategoryAiGirlfriend,
|
||||
FundType: fundtransferlogmod.FundTypeIn,
|
||||
Amount: amount,
|
||||
Balance: wallet.Amount,
|
||||
Desc: "AI女友上分",
|
||||
CreatedAt: now,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func creditAndLog(uid uint64, amount int64, remainder float64, desc string, now time.Time) error {
|
||||
if appg.VideoDB == nil {
|
||||
return errors.New("video database is unavailable")
|
||||
}
|
||||
return appg.VideoDB.Trans(func(t *db.MongoTool) error {
|
||||
wallet, err := walletmod.CreditAmount(t, amount, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fundtransferlogmod.Insert(t, &fundtransferlogmod.FundTransferLog{
|
||||
ID: primitive.NewObjectID(),
|
||||
UID: uid,
|
||||
Category: fundtransferlogmod.CategoryAiGirlfriend,
|
||||
FundType: fundtransferlogmod.FundTypeOut,
|
||||
Amount: amount,
|
||||
Balance: wallet.Amount,
|
||||
Remainder: remainder,
|
||||
Desc: desc,
|
||||
CreatedAt: now,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package aiser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
"91porn-server/common/laosiji_app"
|
||||
"91porn-server/models/v/fundtransferlogmod"
|
||||
"91porn-server/models/v/usermod"
|
||||
"91porn-server/models/v/walletmod"
|
||||
)
|
||||
|
||||
func TestGetAuthURLTransfersWalletAmount(t *testing.T) {
|
||||
originalConfig := appg.Conf
|
||||
appg.Conf = &appg.GlobalConfig{}
|
||||
appg.Conf.Base.Env = "test"
|
||||
t.Cleanup(func() { appg.Conf = originalConfig })
|
||||
|
||||
var (
|
||||
gotAsset string
|
||||
gotDebit int64
|
||||
gotUser string
|
||||
debitTime time.Time
|
||||
)
|
||||
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
|
||||
deps := transferDependencies{
|
||||
findLatest: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
return nil, nil
|
||||
},
|
||||
findLatestOut: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
return &fundtransferlogmod.FundTransferLog{Remainder: 0.04}, nil
|
||||
},
|
||||
getWallet: func(uint64) (*walletmod.Wallet, error) {
|
||||
return &walletmod.Wallet{Amount: 123}, nil
|
||||
},
|
||||
findUser: func(uint64) (*usermod.User, error) {
|
||||
return &usermod.User{Name: "测试", Portrait: "avatar"}, nil
|
||||
},
|
||||
getAuthURL: func(_ context.Context, req laosiji_app.GetAiMateURLReq) (laosiji_app.GetAiMateURLResp, error) {
|
||||
gotAsset = req.Asset
|
||||
gotUser = req.Username
|
||||
return laosiji_app.GetAiMateURLResp{AuthURL: "https://example.com/auth"}, nil
|
||||
},
|
||||
bringOut: func(context.Context, laosiji_app.AiMateBringOutReq) (laosiji_app.AiMateBringOutResp, error) {
|
||||
t.Fatal("bringOut must not be called without pending transfer")
|
||||
return laosiji_app.AiMateBringOutResp{}, nil
|
||||
},
|
||||
debitAndLog: func(_ uint64, amount int64, at time.Time) error {
|
||||
gotDebit, debitTime = amount, at
|
||||
return nil
|
||||
},
|
||||
creditAndLog: func(uint64, int64, float64, string, time.Time) error {
|
||||
t.Fatal("creditAndLog must not be called")
|
||||
return nil
|
||||
},
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
resp, err := getAuthURL(context.Background(), 99, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("getAuthURL() error = %v", err)
|
||||
}
|
||||
if resp.URL != "https://example.com/auth" {
|
||||
t.Fatalf("URL = %q", resp.URL)
|
||||
}
|
||||
if gotAsset != "12.34" {
|
||||
t.Fatalf("asset = %q, want 12.34", gotAsset)
|
||||
}
|
||||
if gotUser != "TEST-204_99" {
|
||||
t.Fatalf("username = %q", gotUser)
|
||||
}
|
||||
if gotDebit != 123 || !debitTime.Equal(now) {
|
||||
t.Fatalf("debit = %d at %s", gotDebit, debitTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthURLDoesNotDebitWhenThirdPartyFails(t *testing.T) {
|
||||
originalConfig := appg.Conf
|
||||
appg.Conf = &appg.GlobalConfig{}
|
||||
t.Cleanup(func() { appg.Conf = originalConfig })
|
||||
|
||||
deps := transferDependencies{
|
||||
findLatest: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
return nil, nil
|
||||
},
|
||||
findLatestOut: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
return nil, nil
|
||||
},
|
||||
getWallet: func(uint64) (*walletmod.Wallet, error) {
|
||||
return &walletmod.Wallet{Amount: 100}, nil
|
||||
},
|
||||
findUser: func(uint64) (*usermod.User, error) { return nil, nil },
|
||||
getAuthURL: func(context.Context, laosiji_app.GetAiMateURLReq) (laosiji_app.GetAiMateURLResp, error) {
|
||||
return laosiji_app.GetAiMateURLResp{}, errors.New("remote failed")
|
||||
},
|
||||
debitAndLog: func(uint64, int64, time.Time) error {
|
||||
t.Fatal("debitAndLog must not be called after remote failure")
|
||||
return nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
if _, err := getAuthURL(context.Background(), 99, deps); err == nil {
|
||||
t.Fatal("getAuthURL() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthURLSettlesPendingTransferBeforeNewTransfer(t *testing.T) {
|
||||
originalConfig := appg.Conf
|
||||
appg.Conf = &appg.GlobalConfig{}
|
||||
appg.Conf.Base.Env = "test"
|
||||
t.Cleanup(func() { appg.Conf = originalConfig })
|
||||
|
||||
steps := make([]string, 0, 5)
|
||||
deps := transferDependencies{
|
||||
findLatest: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
steps = append(steps, "find-pending")
|
||||
return &fundtransferlogmod.FundTransferLog{FundType: fundtransferlogmod.FundTypeIn}, nil
|
||||
},
|
||||
bringOut: func(context.Context, laosiji_app.AiMateBringOutReq) (laosiji_app.AiMateBringOutResp, error) {
|
||||
steps = append(steps, "bring-out")
|
||||
return laosiji_app.AiMateBringOutResp{Balance: "1.00"}, nil
|
||||
},
|
||||
creditAndLog: func(uint64, int64, float64, string, time.Time) error {
|
||||
steps = append(steps, "credit")
|
||||
return nil
|
||||
},
|
||||
getWallet: func(uint64) (*walletmod.Wallet, error) {
|
||||
steps = append(steps, "wallet")
|
||||
return &walletmod.Wallet{Amount: 20}, nil
|
||||
},
|
||||
findLatestOut: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
return nil, nil
|
||||
},
|
||||
findUser: func(uint64) (*usermod.User, error) { return nil, nil },
|
||||
getAuthURL: func(context.Context, laosiji_app.GetAiMateURLReq) (laosiji_app.GetAiMateURLResp, error) {
|
||||
steps = append(steps, "auth")
|
||||
return laosiji_app.GetAiMateURLResp{AuthURL: "https://example.com/auth"}, nil
|
||||
},
|
||||
debitAndLog: func(uint64, int64, time.Time) error { return nil },
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
if _, err := getAuthURL(context.Background(), 99, deps); err != nil {
|
||||
t.Fatalf("getAuthURL() error = %v", err)
|
||||
}
|
||||
want := []string{"find-pending", "bring-out", "credit", "wallet", "auth"}
|
||||
if len(steps) != len(want) {
|
||||
t.Fatalf("steps = %v, want %v", steps, want)
|
||||
}
|
||||
for index := range want {
|
||||
if steps[index] != want[index] {
|
||||
t.Fatalf("steps = %v, want %v", steps, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettleDownCreditsConvertedBalance(t *testing.T) {
|
||||
originalConfig := appg.Conf
|
||||
appg.Conf = &appg.GlobalConfig{}
|
||||
appg.Conf.Base.Env = "prod"
|
||||
t.Cleanup(func() { appg.Conf = originalConfig })
|
||||
|
||||
var (
|
||||
gotAmount int64
|
||||
gotRemainder float64
|
||||
gotDesc string
|
||||
gotUsername string
|
||||
)
|
||||
deps := transferDependencies{
|
||||
findLatest: func(uint64, fundtransferlogmod.Category) (*fundtransferlogmod.FundTransferLog, error) {
|
||||
return &fundtransferlogmod.FundTransferLog{FundType: fundtransferlogmod.FundTypeIn}, nil
|
||||
},
|
||||
bringOut: func(_ context.Context, req laosiji_app.AiMateBringOutReq) (laosiji_app.AiMateBringOutResp, error) {
|
||||
gotUsername = req.Username
|
||||
return laosiji_app.AiMateBringOutResp{Balance: "1.23"}, nil
|
||||
},
|
||||
creditAndLog: func(_ uint64, amount int64, remainder float64, desc string, _ time.Time) error {
|
||||
gotAmount, gotRemainder, gotDesc = amount, remainder, desc
|
||||
return nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
if err := settleDown(context.Background(), 88, deps); err != nil {
|
||||
t.Fatalf("settleDown() error = %v", err)
|
||||
}
|
||||
if gotUsername != "JHA-204_88" {
|
||||
t.Fatalf("username = %q", gotUsername)
|
||||
}
|
||||
if gotAmount != 12 || gotRemainder != 0.03 || gotDesc != "AI女友下分" {
|
||||
t.Fatalf("credit amount=%d remainder=%v desc=%q", gotAmount, gotRemainder, gotDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceToWallet(t *testing.T) {
|
||||
tests := []struct {
|
||||
balance string
|
||||
amount int64
|
||||
remainder float64
|
||||
wantErr bool
|
||||
}{
|
||||
{balance: "0", amount: 0, remainder: 0},
|
||||
{balance: "1.20", amount: 12, remainder: 0},
|
||||
{balance: "1.29", amount: 12, remainder: 0.09},
|
||||
{balance: "-1", wantErr: true},
|
||||
{balance: "invalid", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
amount, remainder, err := balanceToWallet(test.balance)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("balanceToWallet(%q) error = %v", test.balance, err)
|
||||
}
|
||||
if amount != test.amount || remainder != test.remainder {
|
||||
t.Fatalf("balanceToWallet(%q) = (%d,%v), want (%d,%v)", test.balance, amount, remainder, test.amount, test.remainder)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user