package common import ( "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "io" "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/payment" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/hkdf" "gorm.io/gorm" "gorm.io/gorm/clause" "gorm.io/gorm/logger" ) type walletOwner struct { Type, Identity, Phone string ID uint64 } func currentOwner(ctx *gin.Context, client string) (walletOwner, bool) { if client == "user_app" { account, ok := UserAccount(ctx) return walletOwner{Type: "user", Identity: account.Identity, Phone: account.Phone, ID: account.ID}, ok } account, ok := StaffAccount(ctx) return walletOwner{Type: "staff", Identity: account.Identity, Phone: account.Phone, ID: account.ID}, ok } func ensureWallet(tx *gorm.DB, owner walletOwner) (models.WalletBasic, error) { var wallet models.WalletBasic err := tx.Where("owner_type = ? AND owner_identity = ?", owner.Type, owner.Identity).First(&wallet).Error if err == nil { return wallet, nil } if err != gorm.ErrRecordNotFound { return wallet, err } wallet = models.WalletBasic{ Entity: NewEntity(StatusEnable), OwnerType: owner.Type, OwnerID: owner.ID, OwnerIdentity: owner.Identity, } if err := tx.Create(&wallet).Error; err != nil { return wallet, err } return wallet, nil } // GetWallet 延迟创建并返回当前主体钱包。 func GetWallet(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{ "identity": wallet.Identity, "balance": wallet.Balance, "withdrawal_balance": wallet.WithdrawalBalance, "payment_password_set": wallet.PayPasswordHash != "", }) } } // SetPaymentPassword 设置或重置六位数字支付密码。 func SetPaymentPassword(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } var request struct { NewPassword string `json:"new_password" binding:"required,len=6,numeric"` CurrentPassword string `json:"current_password"` Code string `json:"code"` RequestIdentity string `json:"request_identity"` } if ctx.ShouldBindJSON(&request) != nil || !paymentPasswordDigits.MatchString(request.NewPassword) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } valid := wallet.PayPasswordHash == "" && VerifyCode(client, owner.Phone, "set_payment_password", request.RequestIdentity, request.Code) if wallet.PayPasswordHash != "" { // 验证码找回不尝试空旧密码,避免把找回动作计入支付密码输错次数。 if request.CurrentPassword != "" { valid = VerifyPaymentPassword(owner.Identity, wallet, request.CurrentPassword) } if !valid { valid = VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code) } } if !valid { infra.Response.Error(ctx, errcode.ErrPassword) return } hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost) if err != nil { infra.Response.Error(ctx, err) return } // 条件更新避免两个已验证请求互相覆盖;散列不得进入SQL日志。 result := impl.DBService.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Model(&wallet). Where("pay_password_hash = ?", wallet.PayPasswordHash).Update("pay_password_hash", string(hash)) if result.Error != nil { infra.Response.Error(ctx, result.Error) return } if result.RowsAffected != 1 { infra.Response.Error(ctx, errcode.ErrPassword) return } // 经过旧密码或手机验证码验证后,解除本次主体的既有密码锁;失败不否认已完成的改密。 cleared := impl.RedisService != nil && impl.RedisService.Client != nil && impl.RedisService.Client.Del(impl.RedisService.Ctx, impl.RedisService.BuildKey("payment-password-lock", owner.Identity), impl.RedisService.BuildKey("payment-password-failures", owner.Identity)).Err() == nil infra.Response.Success(ctx, gin.H{"changed": true, "lock_cleared": cleared}) } } // CreateRecharge 创建待支付充值订单,不直接增加余额。 func CreateRecharge(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } var request struct { Amount int64 `json:"amount" binding:"required,gt=0"` Channel string `json:"channel" binding:"required,oneof=mock wechat alipay"` RequestNo string `json:"request_no" binding:"required"` PayType string `json:"pay_type"` OpenID string `json:"openid"` } if ctx.ShouldBindJSON(&request) != nil || request.Amount > config.Spec.Global.ManualRechargeMaxAmount || (request.Channel == "mock" && !config.Spec.Global.MockPaymentEnabled) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } order := models.WalletRechargeOrder{ Entity: NewEntity(StatusEnable), RechargeStatus: 10, WalletBasicID: wallet.ID, RechargeNo: RecordNo("RC"), RequestNo: request.RequestNo, Amount: request.Amount, Channel: request.Channel, OwnerType: owner.Type, OwnerIdentity: owner.Identity, } created := impl.DBService.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "request_no"}}, DoNothing: true}).Create(&order) if created.Error != nil { infra.Response.Error(ctx, created.Error) return } if created.RowsAffected == 0 { var existing models.WalletRechargeOrder if impl.DBService.Where("request_no = ? AND owner_type = ? AND owner_identity = ?", request.RequestNo, owner.Type, owner.Identity).First(&existing).Error != nil { infra.Response.Error(ctx, ErrRechargeRequestConflict) return } if existing.Amount != request.Amount || existing.Channel != request.Channel { infra.Response.Error(ctx, ErrRechargeRequestConflict) return } order = existing } if request.Channel != "mock" { payOrder, payErr := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "recharge", BusinessIdentity: order.Identity, UserIdentity: owner.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气钱包充值 " + order.RechargeNo, OpenID: request.OpenID, Amount: order.Amount}) if payErr != nil { infra.Response.Error(ctx, payErr) return } response := payment.PublicResponse(payOrder) response["recharge_identity"] = order.Identity response["recharge_no"] = order.RechargeNo response["recharge_status"] = order.RechargeStatus infra.Response.Success(ctx, response) return } infra.Response.Success(ctx, ResourceResponse(order)) } } // ConfirmMockRecharge 模拟支付回调,生产关闭;事务内只入账一次。 func ConfirmMockRecharge(client string) gin.HandlerFunc { return func(ctx *gin.Context) { if !config.Spec.Global.MockPaymentEnabled { infra.Response.Error(ctx, errcode.ErrPermissionDenied) return } owner, ok := currentOwner(ctx, client) if !ok { return } var response models.WalletRechargeOrder err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("identity = ? AND owner_type = ? AND owner_identity = ?", ctx.Param("identity"), owner.Type, owner.Identity).First(&response).Error; err != nil { return err } if response.RechargeStatus == 23 { return nil } if response.RechargeStatus != 10 || response.Channel != "mock" { return gorm.ErrInvalidData } var wallet models.WalletBasic if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, response.WalletBasicID).Error; err != nil { return err } wallet.Balance += response.Amount if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil { return err } now := time.Now() if err := tx.Model(&response).Updates(map[string]any{"recharge_status": 23, "completed_at": &now}).Error; err != nil { return err } date := now.In(time.Local) return tx.Create(&models.WalletRecord{ Entity: NewEntity(StatusEnable), WalletBasicID: wallet.ID, RecordNo: RecordNo("WR"), RequestNo: "recharge:" + response.Identity, Direction: "income", TradeType: "recharge", Amount: response.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, InTradeNo: response.RechargeNo, PayChannel: "mock", OperatorIdentity: owner.Identity, Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month())), }).Error }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"confirmed": true}) } } // ListWalletRecords 返回当前钱包不可变流水。 func ListWalletRecords(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } var list []models.WalletRecord if err := impl.DBService.Where("wallet_basic_id = ?", wallet.ID).Order("created_at desc").Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, ResourceResponse(list)) } } // ListBanks 仅返回银行卡掩码和非敏感字段。 func ListBanks(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } var banks []models.WalletBank if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived). Order("is_default desc, created_at desc").Find(&banks).Error; err != nil { infra.Response.Error(ctx, err) return } list := make([]gin.H, 0, len(banks)) for _, bank := range banks { list = append(list, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "bank_name": bank.BankName, "card_owner": bank.CardOwner, "bank_type": bank.BankType, "is_default": bank.IsDefault}) } infra.Response.Success(ctx, list) } } // BindBank 加密保存银行卡;支付渠道绑定标识在首期保持为空。 func BindBank(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } var request struct { CardNo string `json:"card_no" binding:"required,min=12,max=32"` BankName string `json:"bank_name" binding:"required,max=128"` CardOwner string `json:"card_owner" binding:"required,max=128"` IDCard string `json:"id_card" binding:"required"` Phone string `json:"phone" binding:"required"` BankType string `json:"bank_type"` Bank string `json:"bank"` PaymentPassword string `json:"payment_password"` Code string `json:"code"` RequestIdentity string `json:"request_identity"` } if ctx.ShouldBindJSON(&request) != nil || !ValidPhone(request.Phone) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } if !walletProof(wallet, client, owner.Phone, "bind_bank", request.PaymentPassword, request.RequestIdentity, request.Code) { infra.Response.Error(ctx, errcode.ErrPassword) return } cardCipher, fingerprint, err := protectField(request.CardNo) if err != nil { infra.Response.Error(ctx, err) return } idCipher, _, _ := protectField(request.IDCard) phoneCipher, _, _ := protectField(request.Phone) var activeCount int64 if err := impl.DBService.Model(&models.WalletBank{}). Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).Count(&activeCount).Error; err != nil { infra.Response.Error(ctx, err) return } bank := models.WalletBank{ Entity: NewEntity(StatusEnable), WalletBasicID: wallet.ID, CardNoCiphertext: cardCipher, CardFingerprint: fingerprint, CardNoLast4: request.CardNo[len(request.CardNo)-4:], BankName: request.BankName, CardOwner: request.CardOwner, IDCardCiphertext: idCipher, PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank, IsDefault: activeCount == 0, } if err := impl.DBService.Create(&bank).Error; err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "is_default": bank.IsDefault}) } } // SetDefaultBank 将本人已绑定卡设为默认到账卡,其他卡同时清除默认状态。 func SetDefaultBank(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } err = impl.DBService.Transaction(func(tx *gorm.DB) error { var bank models.WalletBank if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", ctx.Param("identity"), wallet.ID, StatusEnable).First(&bank).Error; err != nil { return err } if err := tx.Model(&models.WalletBank{}).Where("wallet_basic_id = ?", wallet.ID).Update("is_default", false).Error; err != nil { return err } return tx.Model(&bank).Update("is_default", true).Error }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"changed": true}) } } // UnbindBank 归档银行卡;存在待处理提现时拒绝。 func UnbindBank(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } var request struct { PaymentPassword string `json:"payment_password"` Code string `json:"code"` RequestIdentity string `json:"request_identity"` } if ctx.ShouldBindJSON(&request) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil || !walletProof(wallet, client, owner.Phone, "unbind_bank", request.PaymentPassword, request.RequestIdentity, request.Code) { infra.Response.Error(ctx, errcode.ErrPassword) return } err = impl.DBService.Transaction(func(tx *gorm.DB) error { var bank models.WalletBank if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error; err != nil { return err } var count int64 if err := tx.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count).Error; err != nil { return err } if count > 0 { return gorm.ErrInvalidData } if err := tx.Model(&bank).Updates(map[string]any{"status": StatusArchived, "is_default": false}).Error; err != nil { return err } if !bank.IsDefault { return nil } var next models.WalletBank if err := tx.Where("wallet_basic_id = ? AND status = ?", wallet.ID, StatusEnable).Order("created_at desc").First(&next).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil } return err } return tx.Model(&next).Update("is_default", true).Error }) if err != nil { if err == gorm.ErrInvalidData { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"archived": true}) } } // CreateWithdrawal 创建提现申请并预扣可提现余额。 func CreateWithdrawal(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } var request struct { BankIdentity string `json:"bank_identity" binding:"required"` Amount int64 `json:"amount" binding:"required,gt=0"` RequestNo string `json:"request_no" binding:"required"` PaymentPassword string `json:"payment_password" binding:"required"` Remark string `json:"remark"` } if ctx.ShouldBindJSON(&request) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil || !VerifyPaymentPassword(owner.Identity, wallet, request.PaymentPassword) { infra.Response.Error(ctx, errcode.ErrPassword) return } var apply models.WalletApplyCash err = impl.DBService.Transaction(func(tx *gorm.DB) error { var bank models.WalletBank if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", request.BankIdentity, wallet.ID, StatusEnable).First(&bank).Error; err != nil { return err } var createErr error apply, _, createErr = CreateReservedWithdrawal(tx, WalletWithdrawalInput{ WalletBasicID: wallet.ID, WalletBankID: bank.ID, RequestNo: request.RequestNo, CashNo: RecordNo("WD"), Amount: request.Amount, Channel: "bank", Remark: request.Remark, OperatorIdentity: owner.Identity, }) return createErr }) if err != nil { var existing models.WalletApplyCash if impl.DBService.Where("request_no = ? AND wallet_basic_id = ?", request.RequestNo, wallet.ID).First(&existing).Error != nil { infra.Response.Error(ctx, err) return } apply = existing } infra.Response.Success(ctx, ResourceResponse(apply)) } } // ListWithdrawals 返回当前钱包提现申请。 func ListWithdrawals(client string) gin.HandlerFunc { return func(ctx *gin.Context) { owner, ok := currentOwner(ctx, client) if !ok { return } wallet, err := ensureWallet(impl.DBService, owner) if err != nil { infra.Response.Error(ctx, err) return } var list []models.WalletApplyCash if err := impl.DBService.Where("wallet_basic_id = ?", wallet.ID).Order("created_at desc").Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, ResourceResponse(list)) } } func walletProof(wallet models.WalletBasic, client, phone, purpose, password, requestIdentity, code string) bool { return wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil || VerifyCode(client, phone, purpose, requestIdentity, code) } func protectField(value string) (string, string, error) { reader := hkdf.New(sha256.New, []byte(config.Spec.Global.FieldEncryptionKey), nil, []byte("heqi-wallet-field-v1")) key := make([]byte, 64) if _, err := io.ReadFull(reader, key); err != nil { return "", "", err } block, err := aes.NewCipher(key[:32]) if err != nil { return "", "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", "", err } sealed := gcm.Seal(nil, nonce, []byte(strings.TrimSpace(value)), nil) mac := hmac.New(sha256.New, key[32:]) _, _ = mac.Write([]byte(strings.TrimSpace(value))) return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), hex.EncodeToString(mac.Sum(nil)), nil }