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 ListWalletPayment(ctx *gin.Context) { listWalletPage[models.WalletPayment](ctx, "", nil) } func GetWalletPayment(ctx *gin.Context) { getWalletByIdentity[models.WalletPayment](ctx) } func ListWalletRecord(ctx *gin.Context) { listWalletPage[models.WalletRecord](ctx, "", nil) } func GetWalletRecord(ctx *gin.Context) { getWalletByIdentity[models.WalletRecord](ctx) } func ListWalletRefund(ctx *gin.Context) { listWalletPage[models.WalletRefund](ctx, "", nil) } func GetWalletRefund(ctx *gin.Context) { getWalletByIdentity[models.WalletRefund](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 { result := tx.Model(&models.WalletBasic{}). Where("id = ? AND withdrawal_balance <= ?", application.WalletBasicID, math.MaxInt64-application.Amount). Update("withdrawal_balance", gorm.Expr("withdrawal_balance + ?", application.Amount)) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return errors.New("withdrawal balance overflow") } } 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") } 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, }).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) }