473 lines
16 KiB
Go
473 lines
16 KiB
Go
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/models"
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/crypto/hkdf"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
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 {
|
|
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 != "" {
|
|
valid = bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(request.CurrentPassword)) == nil ||
|
|
VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code)
|
|
}
|
|
if !valid {
|
|
infra.Response.Error(ctx, errcode.ErrPassword)
|
|
return
|
|
}
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
|
|
if err := impl.DBService.Model(&wallet).Update("pay_password_hash", string(hash)).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, gin.H{"changed": true})
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
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,
|
|
}
|
|
if err := impl.DBService.Create(&order).Error; err != nil {
|
|
var existing models.WalletRechargeOrder
|
|
if impl.DBService.Where("request_no = ? AND owner_identity = ?", request.RequestNo, owner.Identity).First(&existing).Error != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
order = existing
|
|
}
|
|
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_identity = ?", ctx.Param("identity"), 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).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})
|
|
}
|
|
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)
|
|
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,
|
|
}
|
|
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})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
var bank models.WalletBank
|
|
if impl.DBService.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error != nil {
|
|
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
|
return
|
|
}
|
|
var count int64
|
|
impl.DBService.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count)
|
|
if count > 0 {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
if err := impl.DBService.Model(&bank).Update("status", StatusArchived).Error; err != nil {
|
|
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)
|
|
}
|
|
|
|
// VerifyPaymentPassword 校验支付密码,并在 Redis 中累计失败次数、短时锁定。
|
|
func VerifyPaymentPassword(ownerIdentity string, wallet models.WalletBasic, password string) bool {
|
|
lockKey := impl.RedisService.BuildKey("payment-password-lock", ownerIdentity)
|
|
var locked bool
|
|
if impl.RedisService.Get(lockKey, &locked) == nil && locked {
|
|
return false
|
|
}
|
|
if wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil {
|
|
_ = impl.RedisService.Delete(impl.RedisService.BuildKey("payment-password-failures", ownerIdentity))
|
|
return true
|
|
}
|
|
failureKey := impl.RedisService.BuildKey("payment-password-failures", ownerIdentity)
|
|
var failures int
|
|
_ = impl.RedisService.Get(failureKey, &failures)
|
|
failures++
|
|
_ = impl.RedisService.Set(failureKey, failures, 15*time.Minute)
|
|
if failures >= 5 {
|
|
_ = impl.RedisService.Set(lockKey, true, 15*time.Minute)
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|