后端提现增加

This commit is contained in:
yml
2026-06-06 10:08:39 +08:00
parent 082fd908e9
commit 8a2d2f2a18
15 changed files with 2763 additions and 0 deletions
@@ -0,0 +1,42 @@
package paymentaccount
import (
"time"
)
type PaymentAccountDTO struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
AccountType string `json:"account_type"`
AccountName string `json:"account_name"`
AccountNo string `json:"account_no"` // 脱敏显示
BankName string `json:"bank_name"`
BankBranch string `json:"bank_branch"`
CertificateURLs []string `json:"certificate_urls"`
IsDefault bool `json:"is_default"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreatePaymentAccountRequest struct {
AccountType string `json:"account_type" binding:"required,oneof=alipay wechat bank"`
AccountName string `json:"account_name" binding:"required"`
AccountNo string `json:"account_no" binding:"required"`
BankName string `json:"bank_name"`
BankBranch string `json:"bank_branch"`
CertificateURLs []string `json:"certificate_urls"`
}
type UpdatePaymentAccountRequest struct {
BankBranch string `json:"bank_branch"`
CertificateURLs []string `json:"certificate_urls"`
IsDefault *bool `json:"is_default"`
}
type PaginatedResult struct {
Items []PaymentAccountDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
@@ -0,0 +1,182 @@
package paymentaccount
import (
"hfb_sys/backend/pkg/response"
"strconv"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) List(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.service.List(userID, page, pageSize)
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) FindByID(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
account, err := h.service.FindByID(userID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, account)
}
func (h *Handler) Create(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
var req CreatePaymentAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
account, err := h.service.Create(userID, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, account)
}
func (h *Handler) Update(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req UpdatePaymentAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
account, err := h.service.Update(userID, id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, account)
}
func (h *Handler) Delete(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.Delete(userID, id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) SetDefault(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.SetDefault(userID, id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
// 辅助函数
func currentUserID(c *gin.Context) (uint64, bool) {
val, exists := c.Get("user_id")
if !exists {
return 0, false
}
userID, ok := val.(uint64)
return userID, ok
}
func parseID(c *gin.Context) (uint64, bool) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.BadRequest(c, "无效的ID")
return 0, false
}
return id, true
}
func writeError(c *gin.Context, err error) {
switch err {
case ErrAccountNotFound:
response.NotFound(c, "收款账号不存在")
case ErrAccountNameMismatch:
response.BadRequest(c, "账户名必须与实名认证姓名一致")
case ErrRealnameRequired:
response.BadRequest(c, "请先完成实名认证")
case ErrAccountLimit:
response.BadRequest(c, "收款账号数量已达上限(最多5个)")
case ErrCannotDeleteDefault:
response.BadRequest(c, "无法删除默认账号")
default:
response.InternalServerError(c, "操作失败")
}
}
@@ -0,0 +1,331 @@
package paymentaccount
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"strings"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// 加密密钥(生产环境应从配置文件读取)
const encryptionKey = "your-32-byte-secret-key-here!!" // 32字节
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) List(userID uint64, page, pageSize int) (*PaginatedResult, error) {
var total int64
if err := r.db.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND status = ?", userID, "active").
Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var accounts []model.UserPaymentAccount
if err := r.db.Where("user_id = ? AND status = ?", userID, "active").
Order("is_default DESC, created_at DESC").
Offset(offset).Limit(pageSize).
Find(&accounts).Error; err != nil {
return nil, err
}
items := make([]PaymentAccountDTO, 0, len(accounts))
for _, acc := range accounts {
dto, err := r.toDTO(acc)
if err != nil {
continue
}
items = append(items, *dto)
}
return &PaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (r *Repository) FindByID(userID, id uint64) (*PaymentAccountDTO, error) {
var account model.UserPaymentAccount
if err := r.db.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountNotFound
}
return nil, err
}
return r.toDTO(account)
}
func (r *Repository) Create(userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) {
// 加密账号
encryptedNo, err := encrypt(req.AccountNo)
if err != nil {
return nil, err
}
// 处理凭证URLs
var certURLs datatypes.JSON
if len(req.CertificateURLs) > 0 {
certURLs, _ = json.Marshal(req.CertificateURLs)
}
// 如果是第一个账号,自动设为默认
isDefault := false
var count int64
r.db.Model(&model.UserPaymentAccount{}).Where("user_id = ? AND status = ?", userID, "active").Count(&count)
if count == 0 {
isDefault = true
}
account := model.UserPaymentAccount{
UserID: userID,
AccountType: req.AccountType,
AccountName: req.AccountName,
AccountNo: encryptedNo,
BankName: req.BankName,
BankBranch: req.BankBranch,
CertificateURLs: certURLs,
IsDefault: isDefault,
Status: "active",
}
if err := r.db.Create(&account).Error; err != nil {
return nil, err
}
return r.FindByID(userID, account.ID)
}
func (r *Repository) Update(userID, id uint64, req UpdatePaymentAccountRequest) (*PaymentAccountDTO, error) {
var account model.UserPaymentAccount
if err := r.db.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountNotFound
}
return nil, err
}
updates := make(map[string]interface{})
if req.BankBranch != "" {
updates["bank_branch"] = req.BankBranch
}
if len(req.CertificateURLs) > 0 {
certURLs, _ := json.Marshal(req.CertificateURLs)
updates["certificate_urls"] = certURLs
}
if req.IsDefault != nil && *req.IsDefault {
// 先取消其他默认账号
r.db.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND id != ?", userID, id).
Update("is_default", false)
updates["is_default"] = true
}
if len(updates) > 0 {
if err := r.db.Model(&account).Updates(updates).Error; err != nil {
return nil, err
}
}
return r.FindByID(userID, id)
}
func (r *Repository) Delete(userID, id uint64) error {
var account model.UserPaymentAccount
if err := r.db.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAccountNotFound
}
return err
}
// 软删除
return r.db.Model(&account).Update("status", "disabled").Error
}
func (r *Repository) SetDefault(userID, id uint64) error {
// 验证账号存在
var account model.UserPaymentAccount
if err := r.db.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAccountNotFound
}
return err
}
return r.db.Transaction(func(tx *gorm.DB) error {
// 取消其他默认账号
if err := tx.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND id != ?", userID, id).
Update("is_default", false).Error; err != nil {
return err
}
// 设置当前为默认
return tx.Model(&account).Update("is_default", true).Error
})
}
func (r *Repository) CountByUser(userID uint64) (int64, error) {
var count int64
err := r.db.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND status = ?", userID, "active").
Count(&count).Error
return count, err
}
func (r *Repository) ValidateRealname(userID uint64, accountName string) error {
var user model.User
if err := r.db.First(&user, userID).Error; err != nil {
return err
}
if user.RealnameStatus != "verified" {
return ErrRealnameRequired
}
// 获取实名信息
var realname model.UserRealname
if err := r.db.Where("user_id = ? AND status = ?", userID, "success").
First(&realname).Error; err != nil {
return ErrRealnameRequired
}
// 验证姓名匹配(去除空格后比较)
if strings.ReplaceAll(realname.MaskedName, " ", "") != strings.ReplaceAll(accountName, " ", "") {
return ErrAccountNameMismatch
}
return nil
}
func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO, error) {
// 解密账号并脱敏
decrypted, err := decrypt(account.AccountNo)
if err != nil {
decrypted = account.AccountNo // 降级处理
}
maskedNo := maskAccountNo(decrypted, account.AccountType)
// 解析凭证URLs
var certURLs []string
if account.CertificateURLs != nil {
json.Unmarshal(account.CertificateURLs, &certURLs)
}
return &PaymentAccountDTO{
ID: account.ID,
UserID: account.UserID,
AccountType: account.AccountType,
AccountName: account.AccountName,
AccountNo: maskedNo,
BankName: account.BankName,
BankBranch: account.BankBranch,
CertificateURLs: certURLs,
IsDefault: account.IsDefault,
Status: account.Status,
CreatedAt: account.CreatedAt,
UpdatedAt: account.UpdatedAt,
}, nil
}
// 账号脱敏
func maskAccountNo(accountNo, accountType string) string {
length := len(accountNo)
if length <= 4 {
return accountNo
}
switch accountType {
case "alipay", "wechat":
// 手机号或邮箱
if length == 11 {
return accountNo[:3] + "****" + accountNo[7:]
}
return accountNo[:2] + "****" + accountNo[length-2:]
case "bank":
// 银行卡号
if length > 8 {
return accountNo[:4] + "****" + accountNo[length-4:]
}
return accountNo[:2] + "****" + accountNo[length-2:]
}
return accountNo
}
// AES加密
func encrypt(plainText string) (string, error) {
block, err := aes.NewCipher([]byte(encryptionKey))
if err != nil {
return "", err
}
plainBytes := []byte(plainText)
cipherBytes := make([]byte, aes.BlockSize+len(plainBytes))
iv := cipherBytes[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(cipherBytes[aes.BlockSize:], plainBytes)
return base64.StdEncoding.EncodeToString(cipherBytes), nil
}
// AES解密
func decrypt(cipherText string) (string, error) {
block, err := aes.NewCipher([]byte(encryptionKey))
if err != nil {
return "", err
}
cipherBytes, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
return "", err
}
if len(cipherBytes) < aes.BlockSize {
return "", errors.New("ciphertext too short")
}
iv := cipherBytes[:aes.BlockSize]
cipherBytes = cipherBytes[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(cipherBytes, cipherBytes)
return string(cipherBytes), nil
}
// 获取解密后的账号(仅供内部使用,如提现申请时)
func (r *Repository) GetDecryptedAccountNo(userID, id uint64) (string, error) {
var account model.UserPaymentAccount
if err := r.db.Where("id = ? AND user_id = ?", id, userID).First(&account).Error; err != nil {
return "", err
}
return decrypt(account.AccountNo)
}
@@ -0,0 +1,80 @@
package paymentaccount
import "errors"
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrAccountNotFound = errors.New("payment account not found")
ErrAccountNameMismatch = errors.New("account name must match realname")
ErrRealnameRequired = errors.New("realname verification required")
ErrAccountLimit = errors.New("maximum payment accounts reached")
ErrCannotDeleteDefault = errors.New("cannot delete default account")
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(userID uint64, page, pageSize int) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
return s.repo.List(userID, page, pageSize)
}
func (s *Service) FindByID(userID, id uint64) (*PaymentAccountDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindByID(userID, id)
}
func (s *Service) Create(userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
// 验证实名状态
if err := s.repo.ValidateRealname(userID, req.AccountName); err != nil {
return nil, err
}
// 检查账号数量限制(最多5个)
count, err := s.repo.CountByUser(userID)
if err != nil {
return nil, err
}
if count >= 5 {
return nil, ErrAccountLimit
}
return s.repo.Create(userID, req)
}
func (s *Service) Update(userID, id uint64, req UpdatePaymentAccountRequest) (*PaymentAccountDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Update(userID, id, req)
}
func (s *Service) Delete(userID, id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.Delete(userID, id)
}
func (s *Service) SetDefault(userID, id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.SetDefault(userID, id)
}