432 lines
15 KiB
Go
432 lines
15 KiB
Go
package wallet
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.apinb.com/bsm-sdk/core/errcode"
|
|
"git.apinb.com/bsm-sdk/core/infra"
|
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
var walletOwnerModels = map[string]any{
|
|
"user": &models.UserAccount{},
|
|
"staff": &models.StaffAccount{},
|
|
"delivery": &models.DeliveryBasic{},
|
|
"gas": &models.GasBasic{},
|
|
}
|
|
|
|
func ListWalletBasic(ctx *gin.Context) {
|
|
ownerType := strings.TrimSpace(ctx.Query("owner_type"))
|
|
if ownerType != "" && ownerType != "user" && ownerType != "staff" && ownerType != "delivery" && ownerType != "gas" && ownerType != "platform" {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
ownerIdentities := strings.Split(strings.TrimSpace(ctx.Query("owner_identities")), ",")
|
|
if len(ownerIdentities) == 1 && ownerIdentities[0] == "" {
|
|
ownerIdentities = nil
|
|
}
|
|
if len(ownerIdentities) > 100 {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
listWalletPage[models.WalletBasic](ctx, ownerType, ownerIdentities)
|
|
}
|
|
func GetWalletBasic(ctx *gin.Context) { getWalletByIdentity[models.WalletBasic](ctx) }
|
|
func ListWalletBank(ctx *gin.Context) { listWalletPage[models.WalletBank](ctx, "", nil) }
|
|
func GetWalletBank(ctx *gin.Context) { getWalletByIdentity[models.WalletBank](ctx) }
|
|
func ListPaymentOrder(ctx *gin.Context) { listWalletPage[models.PaymentOrder](ctx, "", nil) }
|
|
func GetPaymentOrder(ctx *gin.Context) { getWalletByIdentity[models.PaymentOrder](ctx) }
|
|
func ListWalletRecord(ctx *gin.Context) { listWalletPage[models.WalletRecord](ctx, "", nil) }
|
|
func GetWalletRecord(ctx *gin.Context) { getWalletByIdentity[models.WalletRecord](ctx) }
|
|
func ListPaymentRefund(ctx *gin.Context) { listWalletPage[models.PaymentRefund](ctx, "", nil) }
|
|
func GetPaymentRefund(ctx *gin.Context) { getWalletByIdentity[models.PaymentRefund](ctx) }
|
|
func ListWalletApplyCash(ctx *gin.Context) { listWalletPage[models.WalletApplyCash](ctx, "", nil) }
|
|
func GetWalletApplyCash(ctx *gin.Context) { getWalletByIdentity[models.WalletApplyCash](ctx) }
|
|
|
|
func listWalletPage[T any](ctx *gin.Context, ownerType string, ownerIdentities []string) {
|
|
page, size := common.PageSize(ctx)
|
|
var list []T
|
|
var total int64
|
|
model := new(T)
|
|
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(model)), model)
|
|
if ownerType != "" {
|
|
query = query.Where("owner_type = ?", ownerType)
|
|
}
|
|
if len(ownerIdentities) > 0 {
|
|
query = query.Where("owner_identity IN ?", ownerIdentities)
|
|
}
|
|
if err := query.Count(&total).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
response, err := common.PublicResourceResponse(list)
|
|
if err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
protectWalletResponse(response, false)
|
|
infra.Response.Success(ctx, gin.H{"total": total, "list": common.ProtectPreciseLocation(ctx, model, response)})
|
|
}
|
|
|
|
func getWalletByIdentity[T any](ctx *gin.Context) {
|
|
var data T
|
|
if err := common.ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {
|
|
common.RespondRecordError(ctx, err)
|
|
return
|
|
}
|
|
response, err := common.PublicResourceResponse(data)
|
|
if err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
protectWalletResponse(response, true)
|
|
infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, new(T), response))
|
|
}
|
|
|
|
func protectWalletResponse(value any, includeDetails bool) {
|
|
switch data := value.(type) {
|
|
case map[string]any:
|
|
for _, key := range []string{"alipay_id", "alipay_name", "wxpay_id", "wxpay_name"} {
|
|
if text, ok := data[key].(string); ok && text != "" {
|
|
data[key] = maskWalletAccount(text)
|
|
}
|
|
}
|
|
if last4, ok := data["card_no_last4"].(string); ok && last4 != "" {
|
|
data["card_no_masked"] = "****" + last4
|
|
delete(data, "card_no_last4")
|
|
}
|
|
if owner, ok := data["card_owner"].(string); ok && owner != "" {
|
|
data["card_owner"] = common.MaskPersonalNameValue(owner)
|
|
}
|
|
if !includeDetails {
|
|
delete(data, "args")
|
|
delete(data, "callback_msg")
|
|
delete(data, "order_info")
|
|
delete(data, "result")
|
|
}
|
|
for _, item := range data {
|
|
protectWalletResponse(item, includeDetails)
|
|
}
|
|
case []any:
|
|
for _, item := range data {
|
|
protectWalletResponse(item, includeDetails)
|
|
}
|
|
}
|
|
}
|
|
|
|
func maskWalletAccount(value string) string {
|
|
runes := []rune(value)
|
|
if len(runes) <= 2 {
|
|
return strings.Repeat("*", len(runes))
|
|
}
|
|
return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1])
|
|
}
|
|
|
|
func GetOrCreateOwnerWallet(ctx *gin.Context) {
|
|
ownerType := strings.ToLower(strings.TrimSpace(ctx.Param("owner_type")))
|
|
ownerIdentity := strings.TrimSpace(ctx.Param("owner_identity"))
|
|
ownerID, err := resolveWalletOwner(ownerType, ownerIdentity)
|
|
if err != nil {
|
|
common.RespondRecordError(ctx, err)
|
|
return
|
|
}
|
|
var wallet models.WalletBasic
|
|
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Where("owner_type = ? AND owner_identity = ?", ownerType, ownerIdentity).First(&wallet).Error
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
candidate := models.WalletBasic{
|
|
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
|
OwnerType: ownerType, OwnerID: ownerID, OwnerIdentity: ownerIdentity,
|
|
}
|
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&candidate).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Where("owner_type = ? AND owner_identity = ?", ownerType, ownerIdentity).First(&wallet).Error
|
|
})
|
|
if err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
response, err := common.PublicResourceResponse(wallet)
|
|
if err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, common.ProtectPreciseLocation(ctx, &models.WalletBasic{}, response))
|
|
}
|
|
|
|
func resolveWalletOwner(ownerType, ownerIdentity string) (uint64, error) {
|
|
if ownerType == "platform" {
|
|
if ownerIdentity != "heqi" {
|
|
return 0, errors.New("invalid platform owner")
|
|
}
|
|
return 0, nil
|
|
}
|
|
model := walletOwnerModels[ownerType]
|
|
if model == nil || ownerIdentity == "" {
|
|
return 0, errors.New("invalid wallet owner")
|
|
}
|
|
return common.ResolveIdentityID(model, ownerIdentity, true)
|
|
}
|
|
|
|
func UpdateWalletBasicStatus(ctx *gin.Context) {
|
|
var request struct {
|
|
Status int `json:"status" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&request); err != nil ||
|
|
(request.Status != common.StatusEnable && request.Status != common.StatusDisable && request.Status != common.StatusFrozen) {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
common.UpdateAllowedByIdentity(ctx, &models.WalletBasic{}, gin.H{"status": request.Status}, []string{"status"})
|
|
}
|
|
|
|
func RechargeWalletBasic(ctx *gin.Context) {
|
|
if !common.RequirePlatformRoot(ctx) {
|
|
return
|
|
}
|
|
var request struct {
|
|
RequestNo string `json:"request_no" binding:"required,max=128"`
|
|
Amount int64 `json:"amount" binding:"required"`
|
|
Withdrawable bool `json:"withdrawable"`
|
|
Reason string `json:"reason" binding:"required,max=1000"`
|
|
Remark string `json:"remark" binding:"max=2000"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&request); err != nil ||
|
|
request.Amount <= 0 || request.Amount > config.Spec.Wallet.ManualRechargeMaxAmount ||
|
|
strings.TrimSpace(request.RequestNo) == "" || strings.TrimSpace(request.Reason) == "" {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
operatorIdentity, operatorName := common.PlatformOperator(ctx)
|
|
var record models.WalletRecord
|
|
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("request_no = ?", request.RequestNo).First(&record).Error; err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
var wallet models.WalletBasic
|
|
if err := tx.Where("identity = ?", ctx.Param("identity")).First(&wallet).Error; err != nil {
|
|
return err
|
|
}
|
|
updates := map[string]any{"balance": gorm.Expr("balance + ?", request.Amount)}
|
|
query := tx.Model(&models.WalletBasic{}).
|
|
Where("id = ? AND status = ? AND balance <= ?", wallet.ID, common.StatusEnable, math.MaxInt64-request.Amount)
|
|
if request.Withdrawable {
|
|
updates["withdrawal_balance"] = gorm.Expr("withdrawal_balance + ?", request.Amount)
|
|
query = query.Where("withdrawal_balance <= ?", math.MaxInt64-request.Amount)
|
|
}
|
|
result := query.Updates(updates)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return errors.New("wallet is disabled or balance overflow")
|
|
}
|
|
if err := tx.Where("id = ?", wallet.ID).First(&wallet).Error; err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
record = models.WalletRecord{
|
|
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
|
WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: request.RequestNo,
|
|
Direction: "income", TradeType: "recharge", Amount: request.Amount,
|
|
BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
|
|
InTradeNo: request.RequestNo, PayChannel: "manual",
|
|
OperatorIdentity: operatorIdentity, OperatorName: operatorName,
|
|
Ymd: dateNumber(now, "20060102"), Ym: dateNumber(now, "200601"),
|
|
Remark: strings.TrimSpace(request.Reason + " " + request.Remark),
|
|
}
|
|
return tx.Create(&record).Error
|
|
})
|
|
if err != nil {
|
|
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&record).Error; lookupErr == nil {
|
|
response, responseErr := common.PublicResourceResponse(record)
|
|
if responseErr != nil {
|
|
infra.Response.Error(ctx, responseErr)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, response)
|
|
return
|
|
}
|
|
common.RespondRecordError(ctx, err)
|
|
return
|
|
}
|
|
response, err := common.PublicResourceResponse(record)
|
|
if err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, response)
|
|
}
|
|
|
|
func ApproveWalletApplyCash(ctx *gin.Context) {
|
|
reviewWalletApplyCash(ctx, common.StatusApproved)
|
|
}
|
|
|
|
func RejectWalletApplyCash(ctx *gin.Context) {
|
|
reviewWalletApplyCash(ctx, common.StatusRejected)
|
|
}
|
|
|
|
func reviewWalletApplyCash(ctx *gin.Context, targetStatus int) {
|
|
var request struct {
|
|
Reason string `json:"reason" binding:"required,max=2000"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.Reason) == "" {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
operatorIdentity, operatorName := common.PlatformOperator(ctx)
|
|
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
|
var application models.WalletApplyCash
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusEnable).First(&application).Error; err != nil {
|
|
return err
|
|
}
|
|
if application.ApplyStatus == targetStatus {
|
|
return nil
|
|
}
|
|
if application.ApplyStatus != common.StatusPending {
|
|
return errors.New("cash application is not pending")
|
|
}
|
|
now := time.Now()
|
|
if targetStatus == common.StatusRejected {
|
|
wallet, err := common.LockWalletForUpdate(tx, application.WalletBasicID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
released, err := common.ReleaseWithdrawalApplication(&wallet, application)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if released {
|
|
if err := common.SaveWalletBalances(tx, wallet); err != nil {
|
|
return err
|
|
}
|
|
record := common.NewWalletBalanceRecord(
|
|
wallet,
|
|
"withdrawal-reject:"+application.Identity,
|
|
"income",
|
|
"withdrawal_release",
|
|
application.Amount,
|
|
application.CashNo,
|
|
"",
|
|
application.Channel,
|
|
operatorIdentity,
|
|
operatorName,
|
|
request.Reason,
|
|
)
|
|
if err := tx.Create(&record).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return tx.Model(&application).Updates(map[string]any{
|
|
"apply_status": targetStatus, "reviewer_identity": operatorIdentity, "reviewer_name": operatorName,
|
|
"reviewed_at": &now, "review_reason": request.Reason,
|
|
}).Error
|
|
})
|
|
if err != nil {
|
|
common.RespondRecordError(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, gin.H{"updated": true, "apply_status": targetStatus})
|
|
}
|
|
|
|
// CompleteWalletApplyCash records the external payout result after approval.
|
|
func CompleteWalletApplyCash(ctx *gin.Context) {
|
|
var request struct {
|
|
TradeNo string `json:"trade_no" binding:"required,max=128"`
|
|
CallbackMsg string `json:"callback_msg" binding:"max=4000"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.TradeNo) == "" {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
|
var application models.WalletApplyCash
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusEnable).
|
|
First(&application).Error; err != nil {
|
|
return err
|
|
}
|
|
if application.ApplyStatus == common.StatusCompleted {
|
|
if application.TradeNo == request.TradeNo {
|
|
return nil
|
|
}
|
|
return errors.New("cash application already completed")
|
|
}
|
|
if application.ApplyStatus != common.StatusApproved {
|
|
return errors.New("cash application is not approved")
|
|
}
|
|
if !application.BalanceReserved {
|
|
wallet, err := common.LockWalletForUpdate(tx, application.WalletBasicID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed, err := common.SettleLegacyWithdrawal(&wallet, application)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !changed {
|
|
return errors.New("legacy withdrawal settlement did not change wallet")
|
|
}
|
|
if err := common.SaveWalletBalances(tx, wallet); err != nil {
|
|
return err
|
|
}
|
|
operatorIdentity, operatorName := common.PlatformOperator(ctx)
|
|
record := common.NewWalletBalanceRecord(
|
|
wallet,
|
|
"withdrawal-complete:"+application.Identity,
|
|
"expense",
|
|
"withdrawal_complete",
|
|
application.Amount,
|
|
"",
|
|
request.TradeNo,
|
|
application.Channel,
|
|
operatorIdentity,
|
|
operatorName,
|
|
"历史提现申请完成时补记总余额",
|
|
)
|
|
if err := tx.Create(&record).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
now := time.Now()
|
|
return tx.Model(&application).Updates(map[string]any{
|
|
"apply_status": common.StatusCompleted, "trade_no": strings.TrimSpace(request.TradeNo),
|
|
"callback_msg": request.CallbackMsg, "completed_at": &now, "balance_reserved": true,
|
|
}).Error
|
|
})
|
|
if err != nil {
|
|
common.RespondRecordError(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, gin.H{"updated": true, "apply_status": common.StatusCompleted})
|
|
}
|
|
|
|
func dateNumber(value time.Time, layout string) int32 {
|
|
number, _ := strconv.ParseInt(value.Format(layout), 10, 32)
|
|
return int32(number)
|
|
}
|