Files
platforms/backend/api/internal/logic/common/client_wallet.go
czl231 3ef33b531d 已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
2026-09-13 00:57:32 +08:00

554 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}