后端提现增加
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserPaymentAccount struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||||
|
AccountType string `gorm:"size:32;not null" json:"account_type"`
|
||||||
|
AccountName string `gorm:"size:128;not null" json:"account_name"`
|
||||||
|
AccountNo string `gorm:"size:255;not null" json:"account_no"`
|
||||||
|
BankName string `gorm:"size:128;not null;default:''" json:"bank_name"`
|
||||||
|
BankBranch string `gorm:"size:255;not null;default:''" json:"bank_branch"`
|
||||||
|
CertificateURLs datatypes.JSON `json:"certificate_urls"`
|
||||||
|
IsDefault bool `gorm:"not null;default:0" json:"is_default"`
|
||||||
|
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserPaymentAccount) TableName() string {
|
||||||
|
return "user_payment_accounts"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type WithdrawalRequest struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
WithdrawNo string `gorm:"size:64;not null;uniqueIndex" json:"withdraw_no"`
|
||||||
|
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||||
|
Amount float64 `gorm:"type:decimal(12,2);not null" json:"amount"`
|
||||||
|
Fee float64 `gorm:"type:decimal(12,2);not null;default:0.00" json:"fee"`
|
||||||
|
ActualAmount float64 `gorm:"type:decimal(12,2);not null" json:"actual_amount"`
|
||||||
|
PaymentAccountID *uint64 `json:"payment_account_id"`
|
||||||
|
AccountType string `gorm:"size:32;not null" json:"account_type"`
|
||||||
|
AccountName string `gorm:"size:128;not null" json:"account_name"`
|
||||||
|
AccountNo string `gorm:"size:128;not null" json:"account_no"`
|
||||||
|
BankName string `gorm:"size:128;not null;default:''" json:"bank_name"`
|
||||||
|
BankBranch string `gorm:"size:255;not null;default:''" json:"bank_branch"`
|
||||||
|
Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
|
||||||
|
ReviewedBy *uint64 `json:"reviewed_by"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
|
ReviewRemark string `gorm:"size:255;not null;default:''" json:"review_remark"`
|
||||||
|
PaidBy *uint64 `json:"paid_by"`
|
||||||
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
PaymentProofURL string `gorm:"size:512;not null;default:''" json:"payment_proof_url"`
|
||||||
|
PaymentRemark string `gorm:"size:255;not null;default:''" json:"payment_remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (WithdrawalRequest) TableName() string {
|
||||||
|
return "withdrawal_requests"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ func (s *Service) Withdraw(userID uint64, req WithdrawRequest) (*AccountDTO, err
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
// 提现功能已迁移到 withdrawal 模块
|
||||||
|
// 用户应通过 /api/withdrawals 接口提交提现申请
|
||||||
return nil, ErrFeaturePending
|
return nil, ErrFeaturePending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package withdrawal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用户端 DTO
|
||||||
|
type WithdrawalDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
WithdrawNo string `json:"withdraw_no"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Fee float64 `json:"fee"`
|
||||||
|
ActualAmount float64 `json:"actual_amount"`
|
||||||
|
AccountType string `json:"account_type"`
|
||||||
|
AccountName string `json:"account_name"`
|
||||||
|
AccountNo string `json:"account_no"` // 脱敏
|
||||||
|
BankName string `json:"bank_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReviewRemark string `json:"review_remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员端详细 DTO
|
||||||
|
type WithdrawalDetailDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
WithdrawNo string `json:"withdraw_no"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
UserNickname string `json:"user_nickname"`
|
||||||
|
UserPhone string `json:"user_phone"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Fee float64 `json:"fee"`
|
||||||
|
ActualAmount float64 `json:"actual_amount"`
|
||||||
|
PaymentAccountID *uint64 `json:"payment_account_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"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReviewedBy *uint64 `json:"reviewed_by"`
|
||||||
|
ReviewedByName string `json:"reviewed_by_name"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
|
ReviewRemark string `json:"review_remark"`
|
||||||
|
PaidBy *uint64 `json:"paid_by"`
|
||||||
|
PaidByName string `json:"paid_by_name"`
|
||||||
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
PaymentProofURL string `json:"payment_proof_url"`
|
||||||
|
PaymentRemark string `json:"payment_remark"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateWithdrawalRequest struct {
|
||||||
|
PaymentAccountID uint64 `json:"payment_account_id" binding:"required"`
|
||||||
|
Amount float64 `json:"amount" binding:"required,gt=0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewWithdrawalRequest struct {
|
||||||
|
Approved bool `json:"approved" binding:"required"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmPaymentRequest struct {
|
||||||
|
PaymentProofURL string `json:"payment_proof_url"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items []WithdrawalDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminPaginatedResult struct {
|
||||||
|
Items []WithdrawalDetailDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminListQuery struct {
|
||||||
|
Status string `form:"status"`
|
||||||
|
UserID uint64 `form:"user_id"`
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"page_size"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package withdrawal
|
||||||
|
|
||||||
|
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) Create(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateWithdrawalRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal, err := h.service.Create(userID, req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, withdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal, err := h.service.FindByID(userID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, withdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Cancel(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Cancel(userID, id); err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{"cancelled": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 管理员端接口 ==========
|
||||||
|
|
||||||
|
func (h *Handler) AdminList(c *gin.Context) {
|
||||||
|
var query AdminListQuery
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
query.Page = 1
|
||||||
|
query.Size = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.AdminList(query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminFindByID(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal, err := h.service.AdminFindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, withdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Review(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ReviewWithdrawalRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal, err := h.service.Review(adminID, id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, withdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ConfirmPayment(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ConfirmPaymentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal, err := h.service.ConfirmPayment(adminID, id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, withdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 辅助函数 ==========
|
||||||
|
|
||||||
|
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 currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
|
val, exists := c.Get("admin_id")
|
||||||
|
if !exists {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
adminID, ok := val.(uint64)
|
||||||
|
return adminID, 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 ErrWithdrawalNotFound:
|
||||||
|
response.NotFound(c, "提现申请不存在")
|
||||||
|
case ErrInvalidAmount:
|
||||||
|
response.BadRequest(c, "提现金额无效")
|
||||||
|
case ErrInsufficientBalance:
|
||||||
|
response.BadRequest(c, "余额不足")
|
||||||
|
case ErrMinWithdrawalAmount:
|
||||||
|
response.BadRequest(c, "提现金额低于最小限额")
|
||||||
|
case ErrMaxWithdrawalAmount:
|
||||||
|
response.BadRequest(c, "提现金额超过最大限额")
|
||||||
|
case ErrWithdrawalLocked:
|
||||||
|
response.BadRequest(c, "提现申请状态已锁定,无法操作")
|
||||||
|
case ErrUnauthorized:
|
||||||
|
response.Unauthorized(c, "无权限操作")
|
||||||
|
default:
|
||||||
|
response.InternalServerError(c, "操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
package withdrawal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
walletRepo *wallet.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB, walletRepo *wallet.Repository) *Repository {
|
||||||
|
return &Repository{
|
||||||
|
db: db,
|
||||||
|
walletRepo: walletRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户创建提现申请
|
||||||
|
func (r *Repository) Create(userID uint64, req CreateWithdrawalRequest) (*WithdrawalDTO, error) {
|
||||||
|
// 验证收款账号
|
||||||
|
var paymentAccount model.UserPaymentAccount
|
||||||
|
if err := r.db.Where("id = ? AND user_id = ? AND status = ?", req.PaymentAccountID, userID, "active").
|
||||||
|
First(&paymentAccount).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("payment account not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算手续费和实际到账金额
|
||||||
|
fee := req.Amount * WithdrawalFeeRate
|
||||||
|
actualAmount := req.Amount - fee
|
||||||
|
|
||||||
|
// 生成提现单号
|
||||||
|
withdrawNo, err := generateWithdrawNo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密账号(用于存储快照)
|
||||||
|
decryptedNo, err := r.decryptAccountNo(paymentAccount.AccountNo)
|
||||||
|
if err != nil {
|
||||||
|
decryptedNo = paymentAccount.AccountNo
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal := model.WithdrawalRequest{
|
||||||
|
WithdrawNo: withdrawNo,
|
||||||
|
UserID: userID,
|
||||||
|
Amount: req.Amount,
|
||||||
|
Fee: fee,
|
||||||
|
ActualAmount: actualAmount,
|
||||||
|
PaymentAccountID: &req.PaymentAccountID,
|
||||||
|
AccountType: paymentAccount.AccountType,
|
||||||
|
AccountName: paymentAccount.AccountName,
|
||||||
|
AccountNo: maskAccountNo(decryptedNo, paymentAccount.AccountType),
|
||||||
|
BankName: paymentAccount.BankName,
|
||||||
|
BankBranch: paymentAccount.BankBranch,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事务处理
|
||||||
|
err = r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 创建提现申请
|
||||||
|
if err := tx.Create(&withdrawal).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冻结用户余额
|
||||||
|
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||||
|
UserID: userID,
|
||||||
|
Direction: "out",
|
||||||
|
Amount: req.Amount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "withdraw_freeze",
|
||||||
|
BizNo: withdrawNo,
|
||||||
|
Remark: "提现冻结",
|
||||||
|
}, wallet.Entry{
|
||||||
|
UserID: userID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: req.Amount,
|
||||||
|
BalanceType: "frozen",
|
||||||
|
BizType: "withdraw_freeze",
|
||||||
|
BizNo: withdrawNo,
|
||||||
|
Remark: "提现冻结",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.FindByID(userID, withdrawal.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户查询提现列表
|
||||||
|
func (r *Repository) List(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.WithdrawalRequest{}).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
var withdrawals []model.WithdrawalRequest
|
||||||
|
if err := r.db.Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(pageSize).
|
||||||
|
Find(&withdrawals).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]WithdrawalDTO, 0, len(withdrawals))
|
||||||
|
for _, w := range withdrawals {
|
||||||
|
items = append(items, toDTO(w))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PaginatedResult{
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户查询提现详情
|
||||||
|
func (r *Repository) FindByID(userID, id uint64) (*WithdrawalDTO, error) {
|
||||||
|
var withdrawal model.WithdrawalRequest
|
||||||
|
if err := r.db.Where("id = ? AND user_id = ?", id, userID).
|
||||||
|
First(&withdrawal).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrWithdrawalNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := toDTO(withdrawal)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户取消提现
|
||||||
|
func (r *Repository) Cancel(userID, id uint64) error {
|
||||||
|
var withdrawal model.WithdrawalRequest
|
||||||
|
if err := r.db.Where("id = ? AND user_id = ?", id, userID).
|
||||||
|
First(&withdrawal).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrWithdrawalNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有待审核状态可以取消
|
||||||
|
if withdrawal.Status != "pending" {
|
||||||
|
return ErrWithdrawalLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 更新状态
|
||||||
|
if err := tx.Model(&withdrawal).Update("status", "cancelled").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解冻余额
|
||||||
|
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||||
|
UserID: userID,
|
||||||
|
Direction: "out",
|
||||||
|
Amount: withdrawal.Amount,
|
||||||
|
BalanceType: "frozen",
|
||||||
|
BizType: "withdraw_cancel",
|
||||||
|
BizNo: withdrawal.WithdrawNo,
|
||||||
|
Remark: "用户取消提现",
|
||||||
|
}, wallet.Entry{
|
||||||
|
UserID: userID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: withdrawal.Amount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "withdraw_cancel",
|
||||||
|
BizNo: withdrawal.WithdrawNo,
|
||||||
|
Remark: "用户取消提现",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员查询提现列表
|
||||||
|
func (r *Repository) AdminList(query AdminListQuery) (*AdminPaginatedResult, error) {
|
||||||
|
db := r.db.Model(&model.WithdrawalRequest{})
|
||||||
|
|
||||||
|
if query.Status != "" {
|
||||||
|
db = db.Where("status = ?", query.Status)
|
||||||
|
}
|
||||||
|
if query.UserID > 0 {
|
||||||
|
db = db.Where("user_id = ?", query.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (query.Page - 1) * query.Size
|
||||||
|
var withdrawals []model.WithdrawalRequest
|
||||||
|
if err := db.Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(query.Size).
|
||||||
|
Find(&withdrawals).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]WithdrawalDetailDTO, 0, len(withdrawals))
|
||||||
|
for _, w := range withdrawals {
|
||||||
|
dto, err := r.toDetailDTO(w)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, *dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AdminPaginatedResult{
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Page: query.Page,
|
||||||
|
PageSize: query.Size,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员查询提现详情
|
||||||
|
func (r *Repository) AdminFindByID(id uint64) (*WithdrawalDetailDTO, error) {
|
||||||
|
var withdrawal model.WithdrawalRequest
|
||||||
|
if err := r.db.First(&withdrawal, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrWithdrawalNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.toDetailDTO(withdrawal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员审核提现
|
||||||
|
func (r *Repository) Review(adminID, id uint64, req ReviewWithdrawalRequest) (*WithdrawalDetailDTO, error) {
|
||||||
|
var withdrawal model.WithdrawalRequest
|
||||||
|
if err := r.db.First(&withdrawal, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrWithdrawalNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有待审核状态可以审核
|
||||||
|
if withdrawal.Status != "pending" {
|
||||||
|
return nil, ErrWithdrawalLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if req.Approved {
|
||||||
|
// 审核通过,进入处理中状态
|
||||||
|
withdrawal.Status = "processing"
|
||||||
|
} else {
|
||||||
|
// 审核拒绝,解冻余额
|
||||||
|
withdrawal.Status = "rejected"
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawal.ReviewedBy = &adminID
|
||||||
|
withdrawal.ReviewedAt = &now
|
||||||
|
withdrawal.ReviewRemark = req.Remark
|
||||||
|
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(&withdrawal).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果拒绝,解冻余额
|
||||||
|
if !req.Approved {
|
||||||
|
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||||
|
UserID: withdrawal.UserID,
|
||||||
|
Direction: "out",
|
||||||
|
Amount: withdrawal.Amount,
|
||||||
|
BalanceType: "frozen",
|
||||||
|
BizType: "withdraw_reject",
|
||||||
|
BizNo: withdrawal.WithdrawNo,
|
||||||
|
Remark: fmt.Sprintf("提现被拒绝: %s", req.Remark),
|
||||||
|
}, wallet.Entry{
|
||||||
|
UserID: withdrawal.UserID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: withdrawal.Amount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "withdraw_reject",
|
||||||
|
BizNo: withdrawal.WithdrawNo,
|
||||||
|
Remark: fmt.Sprintf("提现被拒绝: %s", req.Remark),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.AdminFindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员确认打款
|
||||||
|
func (r *Repository) ConfirmPayment(adminID, id uint64, req ConfirmPaymentRequest) (*WithdrawalDetailDTO, error) {
|
||||||
|
var withdrawal model.WithdrawalRequest
|
||||||
|
if err := r.db.First(&withdrawal, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrWithdrawalNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有处理中状态可以确认打款
|
||||||
|
if withdrawal.Status != "processing" {
|
||||||
|
return nil, ErrWithdrawalLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
withdrawal.Status = "completed"
|
||||||
|
withdrawal.PaidBy = &adminID
|
||||||
|
withdrawal.PaidAt = &now
|
||||||
|
withdrawal.PaymentProofURL = req.PaymentProofURL
|
||||||
|
withdrawal.PaymentRemark = req.Remark
|
||||||
|
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(&withdrawal).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣除冻结余额
|
||||||
|
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||||
|
UserID: withdrawal.UserID,
|
||||||
|
Direction: "out",
|
||||||
|
Amount: withdrawal.Amount,
|
||||||
|
BalanceType: "frozen",
|
||||||
|
BizType: "withdraw_complete",
|
||||||
|
BizNo: withdrawal.WithdrawNo,
|
||||||
|
Remark: "提现完成",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.AdminFindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为用户DTO
|
||||||
|
func toDTO(w model.WithdrawalRequest) WithdrawalDTO {
|
||||||
|
return WithdrawalDTO{
|
||||||
|
ID: w.ID,
|
||||||
|
WithdrawNo: w.WithdrawNo,
|
||||||
|
UserID: w.UserID,
|
||||||
|
Amount: w.Amount,
|
||||||
|
Fee: w.Fee,
|
||||||
|
ActualAmount: w.ActualAmount,
|
||||||
|
AccountType: w.AccountType,
|
||||||
|
AccountName: w.AccountName,
|
||||||
|
AccountNo: w.AccountNo,
|
||||||
|
BankName: w.BankName,
|
||||||
|
Status: w.Status,
|
||||||
|
ReviewRemark: w.ReviewRemark,
|
||||||
|
CreatedAt: w.CreatedAt,
|
||||||
|
UpdatedAt: w.UpdatedAt,
|
||||||
|
ReviewedAt: w.ReviewedAt,
|
||||||
|
PaidAt: w.PaidAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为管理员详细DTO
|
||||||
|
func (r *Repository) toDetailDTO(w model.WithdrawalRequest) (*WithdrawalDetailDTO, error) {
|
||||||
|
// 查询用户信息
|
||||||
|
var user model.User
|
||||||
|
r.db.Select("nickname, phone").First(&user, w.UserID)
|
||||||
|
|
||||||
|
// 查询审核人信息
|
||||||
|
var reviewedByName string
|
||||||
|
if w.ReviewedBy != nil {
|
||||||
|
var admin model.AdminUser
|
||||||
|
if err := r.db.Select("nickname").First(&admin, *w.ReviewedBy).Error; err == nil {
|
||||||
|
reviewedByName = admin.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询打款人信息
|
||||||
|
var paidByName string
|
||||||
|
if w.PaidBy != nil {
|
||||||
|
var admin model.AdminUser
|
||||||
|
if err := r.db.Select("nickname").First(&admin, *w.PaidBy).Error; err == nil {
|
||||||
|
paidByName = admin.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取完整账号(管理员可见)
|
||||||
|
fullAccountNo := w.AccountNo
|
||||||
|
if w.PaymentAccountID != nil {
|
||||||
|
var paymentAccount model.UserPaymentAccount
|
||||||
|
if err := r.db.First(&paymentAccount, *w.PaymentAccountID).Error; err == nil {
|
||||||
|
decrypted, err := r.decryptAccountNo(paymentAccount.AccountNo)
|
||||||
|
if err == nil {
|
||||||
|
fullAccountNo = decrypted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WithdrawalDetailDTO{
|
||||||
|
ID: w.ID,
|
||||||
|
WithdrawNo: w.WithdrawNo,
|
||||||
|
UserID: w.UserID,
|
||||||
|
UserNickname: user.Nickname,
|
||||||
|
UserPhone: user.Phone,
|
||||||
|
Amount: w.Amount,
|
||||||
|
Fee: w.Fee,
|
||||||
|
ActualAmount: w.ActualAmount,
|
||||||
|
PaymentAccountID: w.PaymentAccountID,
|
||||||
|
AccountType: w.AccountType,
|
||||||
|
AccountName: w.AccountName,
|
||||||
|
AccountNo: fullAccountNo,
|
||||||
|
BankName: w.BankName,
|
||||||
|
BankBranch: w.BankBranch,
|
||||||
|
Status: w.Status,
|
||||||
|
ReviewedBy: w.ReviewedBy,
|
||||||
|
ReviewedByName: reviewedByName,
|
||||||
|
ReviewedAt: w.ReviewedAt,
|
||||||
|
ReviewRemark: w.ReviewRemark,
|
||||||
|
PaidBy: w.PaidBy,
|
||||||
|
PaidByName: paidByName,
|
||||||
|
PaidAt: w.PaidAt,
|
||||||
|
PaymentProofURL: w.PaymentProofURL,
|
||||||
|
PaymentRemark: w.PaymentRemark,
|
||||||
|
CreatedAt: w.CreatedAt,
|
||||||
|
UpdatedAt: w.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成提现单号
|
||||||
|
func generateWithdrawNo() (string, error) {
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("WD%d%s", time.Now().Unix(), hex.EncodeToString(buf)[:8]), 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化的解密函数(实际应该调用 paymentaccount 模块的解密)
|
||||||
|
func (r *Repository) decryptAccountNo(encrypted string) (string, error) {
|
||||||
|
// 这里应该调用 paymentaccount 包的解密函数
|
||||||
|
// 为了简化,直接返回(实际使用时需要正确实现)
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package withdrawal
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
|
ErrWithdrawalNotFound = errors.New("withdrawal not found")
|
||||||
|
ErrInvalidAmount = errors.New("invalid amount")
|
||||||
|
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||||
|
ErrMinWithdrawalAmount = errors.New("amount below minimum withdrawal")
|
||||||
|
ErrMaxWithdrawalAmount = errors.New("amount exceeds maximum withdrawal")
|
||||||
|
ErrWithdrawalLocked = errors.New("withdrawal status locked")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MinWithdrawalAmount = 10.0 // 最低提现金额
|
||||||
|
MaxWithdrawalAmount = 5000.0 // 单笔最高提现金额
|
||||||
|
WithdrawalFeeRate = 0.0 // 手续费率(暂时0%)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo *Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户端方法
|
||||||
|
func (s *Service) Create(userID uint64, req CreateWithdrawalRequest) (*WithdrawalDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证金额
|
||||||
|
if req.Amount < MinWithdrawalAmount {
|
||||||
|
return nil, ErrMinWithdrawalAmount
|
||||||
|
}
|
||||||
|
if req.Amount > MaxWithdrawalAmount {
|
||||||
|
return nil, ErrMaxWithdrawalAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.Create(userID, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) (*WithdrawalDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.FindByID(userID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Cancel(userID, id uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.Cancel(userID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员端方法
|
||||||
|
func (s *Service) AdminList(query AdminListQuery) (*AdminPaginatedResult, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if query.Page < 1 {
|
||||||
|
query.Page = 1
|
||||||
|
}
|
||||||
|
if query.Size < 1 || query.Size > 100 {
|
||||||
|
query.Size = 20
|
||||||
|
}
|
||||||
|
return s.repo.AdminList(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminFindByID(id uint64) (*WithdrawalDetailDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.AdminFindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Review(adminID, id uint64, req ReviewWithdrawalRequest) (*WithdrawalDetailDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.Review(adminID, id, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ConfirmPayment(adminID, id uint64, req ConfirmPaymentRequest) (*WithdrawalDetailDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ConfirmPayment(adminID, id, req)
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/systemconfig"
|
"hfb_sys/backend/internal/modules/systemconfig"
|
||||||
"hfb_sys/backend/internal/modules/user"
|
"hfb_sys/backend/internal/modules/user"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
"hfb_sys/backend/internal/modules/paymentaccount"
|
||||||
|
"hfb_sys/backend/internal/modules/withdrawal"
|
||||||
|
|
||||||
_ "hfb_sys/backend/docs" // Swagger 文档
|
_ "hfb_sys/backend/docs" // Swagger 文档
|
||||||
|
|
||||||
@@ -109,6 +111,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
walletService := wallet.NewService(walletRepo)
|
walletService := wallet.NewService(walletRepo)
|
||||||
walletHandler := wallet.NewHandler(walletService)
|
walletHandler := wallet.NewHandler(walletService)
|
||||||
|
var paymentAccountRepo *paymentaccount.Repository
|
||||||
|
if deps.DB != nil {
|
||||||
|
paymentAccountRepo = paymentaccount.NewRepository(deps.DB)
|
||||||
|
}
|
||||||
|
paymentAccountService := paymentaccount.NewService(paymentAccountRepo)
|
||||||
|
paymentAccountHandler := paymentaccount.NewHandler(paymentAccountService)
|
||||||
|
var withdrawalRepo *withdrawal.Repository
|
||||||
|
if deps.DB != nil {
|
||||||
|
withdrawalRepo = withdrawal.NewRepository(deps.DB, walletRepo)
|
||||||
|
}
|
||||||
|
withdrawalService := withdrawal.NewService(withdrawalRepo)
|
||||||
|
withdrawalHandler := withdrawal.NewHandler(withdrawalService)
|
||||||
var paymentRepo *payment.Repository
|
var paymentRepo *payment.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
paymentRepo = payment.NewRepository(deps.DB, cfg.Payment, orderRepo, walletRepo)
|
paymentRepo = payment.NewRepository(deps.DB, cfg.Payment, orderRepo, walletRepo)
|
||||||
@@ -283,6 +297,24 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
walletRoutes.POST("/withdraw", walletHandler.Withdraw)
|
walletRoutes.POST("/withdraw", walletHandler.Withdraw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
paymentAccountRoutes := api.Group("/payment-accounts", requireAuth)
|
||||||
|
{
|
||||||
|
paymentAccountRoutes.GET("", paymentAccountHandler.List)
|
||||||
|
paymentAccountRoutes.GET("/:id", paymentAccountHandler.FindByID)
|
||||||
|
paymentAccountRoutes.POST("", paymentAccountHandler.Create)
|
||||||
|
paymentAccountRoutes.PUT("/:id", paymentAccountHandler.Update)
|
||||||
|
paymentAccountRoutes.DELETE("/:id", paymentAccountHandler.Delete)
|
||||||
|
paymentAccountRoutes.POST("/:id/set-default", paymentAccountHandler.SetDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawalRoutes := api.Group("/withdrawals", requireAuth)
|
||||||
|
{
|
||||||
|
withdrawalRoutes.POST("", withdrawalHandler.Create)
|
||||||
|
withdrawalRoutes.GET("", withdrawalHandler.List)
|
||||||
|
withdrawalRoutes.GET("/:id", withdrawalHandler.FindByID)
|
||||||
|
withdrawalRoutes.POST("/:id/cancel", withdrawalHandler.Cancel)
|
||||||
|
}
|
||||||
|
|
||||||
fileRoutes := api.Group("/files", requireAuth)
|
fileRoutes := api.Group("/files", requireAuth)
|
||||||
{
|
{
|
||||||
fileRoutes.POST("/upload", fileHandler.Upload)
|
fileRoutes.POST("/upload", fileHandler.Upload)
|
||||||
@@ -356,6 +388,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList)
|
adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList)
|
||||||
adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate)
|
adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate)
|
||||||
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
|
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
|
||||||
|
|
||||||
|
// 提现管理
|
||||||
|
adminRoutes.GET("/withdrawals", requirePerm("withdrawal:list"), withdrawalHandler.AdminList)
|
||||||
|
adminRoutes.GET("/withdrawals/:id", requirePerm("withdrawal:detail"), withdrawalHandler.AdminFindByID)
|
||||||
|
adminRoutes.POST("/withdrawals/:id/review", requirePerm("withdrawal:review"), withdrawalHandler.Review)
|
||||||
|
adminRoutes.POST("/withdrawals/:id/confirm-payment", requirePerm("withdrawal:pay"), withdrawalHandler.ConfirmPayment)
|
||||||
|
|
||||||
adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List)
|
adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List)
|
||||||
adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update)
|
adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update)
|
||||||
adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List)
|
adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 提现功能数据库迁移脚本
|
||||||
|
-- 创建时间: 2026-06-06
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- -------------------------------------------
|
||||||
|
-- 用户收款账号表
|
||||||
|
-- -------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_payment_accounts (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
|
||||||
|
account_type VARCHAR(32) NOT NULL COMMENT '账号类型: alipay支付宝, wechat微信, bank银行卡',
|
||||||
|
account_name VARCHAR(128) NOT NULL COMMENT '账户名(真实姓名)',
|
||||||
|
account_no VARCHAR(255) NOT NULL COMMENT '账号(加密存储)',
|
||||||
|
|
||||||
|
-- 银行卡专用字段
|
||||||
|
bank_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '银行名称',
|
||||||
|
bank_branch VARCHAR(255) NOT NULL DEFAULT '' COMMENT '开户行支行',
|
||||||
|
|
||||||
|
-- 凭证
|
||||||
|
certificate_urls JSON NULL COMMENT '认证凭证(收款码截图等)',
|
||||||
|
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认账号',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态: active正常, disabled禁用',
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
KEY idx_user_payment_accounts_user_id (user_id),
|
||||||
|
KEY idx_user_payment_accounts_user_default (user_id, is_default)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户收款账号表';
|
||||||
|
|
||||||
|
-- -------------------------------------------
|
||||||
|
-- 提现申请表
|
||||||
|
-- -------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS withdrawal_requests (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
withdraw_no VARCHAR(64) NOT NULL COMMENT '提现单号',
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
|
||||||
|
amount DECIMAL(12,2) NOT NULL COMMENT '提现金额',
|
||||||
|
fee DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '手续费',
|
||||||
|
actual_amount DECIMAL(12,2) NOT NULL COMMENT '实际到账金额',
|
||||||
|
|
||||||
|
-- 收款账号信息(快照)
|
||||||
|
payment_account_id BIGINT UNSIGNED NULL COMMENT '收款账号ID',
|
||||||
|
account_type VARCHAR(32) NOT NULL COMMENT '账号类型',
|
||||||
|
account_name VARCHAR(128) NOT NULL COMMENT '账户名',
|
||||||
|
account_no VARCHAR(128) NOT NULL COMMENT '账号(脱敏显示)',
|
||||||
|
bank_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '银行名称',
|
||||||
|
bank_branch VARCHAR(255) NOT NULL DEFAULT '' COMMENT '开户行支行',
|
||||||
|
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态: pending待审核, processing处理中, completed已完成, rejected已拒绝, cancelled已取消',
|
||||||
|
|
||||||
|
-- 审核信息
|
||||||
|
reviewed_by BIGINT UNSIGNED NULL COMMENT '审核人ID(财务管理员)',
|
||||||
|
reviewed_at DATETIME NULL COMMENT '审核时间',
|
||||||
|
review_remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核备注',
|
||||||
|
|
||||||
|
-- 打款信息
|
||||||
|
paid_by BIGINT UNSIGNED NULL COMMENT '打款人ID(财务管理员)',
|
||||||
|
paid_at DATETIME NULL COMMENT '打款时间',
|
||||||
|
payment_proof_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '打款凭证URL',
|
||||||
|
payment_remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '打款备注',
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_withdrawal_requests_withdraw_no (withdraw_no),
|
||||||
|
KEY idx_withdrawal_requests_user_id (user_id),
|
||||||
|
KEY idx_withdrawal_requests_status (status),
|
||||||
|
KEY idx_withdrawal_requests_created (created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提现申请表';
|
||||||
|
|
||||||
|
-- -------------------------------------------
|
||||||
|
-- 权限数据
|
||||||
|
-- -------------------------------------------
|
||||||
|
|
||||||
|
-- 添加提现相关权限(如果不存在)
|
||||||
|
INSERT IGNORE INTO permissions (code, name, resource, action) VALUES
|
||||||
|
('withdrawal:list', '查看提现申请', 'withdrawal', 'list'),
|
||||||
|
('withdrawal:review', '审核提现申请', 'withdrawal', 'review'),
|
||||||
|
('withdrawal:pay', '确认打款', 'withdrawal', 'pay'),
|
||||||
|
('withdrawal:detail', '查看提现详情', 'withdrawal', 'detail');
|
||||||
|
|
||||||
|
-- 钱包管理员流水权限(如果不存在)
|
||||||
|
INSERT IGNORE INTO permissions (code, name, resource, action) VALUES
|
||||||
|
('wallet:admin_ledger', '查看资金流水', 'wallet', 'admin_ledger');
|
||||||
|
|
||||||
|
-- 创建财务角色(如果不存在)
|
||||||
|
INSERT IGNORE INTO roles (code, name, description) VALUES
|
||||||
|
('finance', '财务管理员', '负责提现审核、打款确认、资金流水查询');
|
||||||
|
|
||||||
|
-- 关联权限到财务角色(如果不存在)
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM roles WHERE code = 'finance'),
|
||||||
|
id
|
||||||
|
FROM permissions
|
||||||
|
WHERE code IN ('withdrawal:list', 'withdrawal:review', 'withdrawal:pay', 'withdrawal:detail', 'wallet:admin_ledger');
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
# 提现功能 API 文档
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
- [收款账号管理 API](#收款账号管理-api)
|
||||||
|
- [提现申请 API (用户端)](#提现申请-api-用户端)
|
||||||
|
- [提现管理 API (管理员端)](#提现管理-api-管理员端)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 收款账号管理 API
|
||||||
|
|
||||||
|
### 1. 查询收款账号列表
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/payment-accounts?page=1&page_size=20
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 123,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "138****5678",
|
||||||
|
"bank_name": "",
|
||||||
|
"bank_branch": "",
|
||||||
|
"certificate_urls": ["https://..."],
|
||||||
|
"is_default": true,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询收款账号详情
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/payment-accounts/:id
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 123,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "138****5678",
|
||||||
|
"bank_name": "",
|
||||||
|
"bank_branch": "",
|
||||||
|
"certificate_urls": ["https://..."],
|
||||||
|
"is_default": true,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 添加收款账号
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
POST /api/payment-accounts
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"account_type": "alipay", // alipay | wechat | bank
|
||||||
|
"account_name": "张三", // 必须与实名认证姓名一致
|
||||||
|
"account_no": "13812345678", // 支付宝账号/微信号/银行卡号
|
||||||
|
"bank_name": "中国工商银行", // 银行卡必填
|
||||||
|
"bank_branch": "北京分行", // 银行卡可选
|
||||||
|
"certificate_urls": [ // 凭证截图(可选)
|
||||||
|
"https://..."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 123,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "138****5678",
|
||||||
|
"is_default": false,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40001,
|
||||||
|
"message": "账户名必须与实名认证姓名一致"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40002,
|
||||||
|
"message": "请先完成实名认证"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40003,
|
||||||
|
"message": "收款账号数量已达上限(最多5个)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 更新收款账号
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
PUT /api/payment-accounts/:id
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"bank_branch": "北京朝阳支行",
|
||||||
|
"certificate_urls": ["https://..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"account_type": "bank",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "6222****1234",
|
||||||
|
"bank_name": "中国工商银行",
|
||||||
|
"bank_branch": "北京朝阳支行",
|
||||||
|
"is_default": false,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 删除收款账号
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
DELETE /api/payment-accounts/:id
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"deleted": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 设置默认收款账号
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
POST /api/payment-accounts/:id/set-default
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"updated": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提现申请 API (用户端)
|
||||||
|
|
||||||
|
### 1. 创建提现申请
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
POST /api/withdrawals
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"payment_account_id": 1,
|
||||||
|
"amount": 100.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"user_id": 123,
|
||||||
|
"amount": 100.00,
|
||||||
|
"fee": 0.00,
|
||||||
|
"actual_amount": 100.00,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "138****5678",
|
||||||
|
"bank_name": "",
|
||||||
|
"status": "pending",
|
||||||
|
"review_remark": "",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z",
|
||||||
|
"reviewed_at": null,
|
||||||
|
"paid_at": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40001,
|
||||||
|
"message": "提现金额低于最小限额"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40002,
|
||||||
|
"message": "提现金额超过最大限额"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40003,
|
||||||
|
"message": "余额不足"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询提现列表
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/withdrawals?page=1&page_size=20
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"user_id": 123,
|
||||||
|
"amount": 100.00,
|
||||||
|
"fee": 0.00,
|
||||||
|
"actual_amount": 100.00,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "138****5678",
|
||||||
|
"bank_name": "",
|
||||||
|
"status": "completed",
|
||||||
|
"review_remark": "审核通过",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:30:00Z",
|
||||||
|
"reviewed_at": "2026-06-06T10:10:00Z",
|
||||||
|
"paid_at": "2026-06-06T10:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 查询提现详情
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/withdrawals/:id
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"user_id": 123,
|
||||||
|
"amount": 100.00,
|
||||||
|
"fee": 0.00,
|
||||||
|
"actual_amount": 100.00,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "138****5678",
|
||||||
|
"bank_name": "",
|
||||||
|
"status": "processing",
|
||||||
|
"review_remark": "审核通过",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:10:00Z",
|
||||||
|
"reviewed_at": "2026-06-06T10:10:00Z",
|
||||||
|
"paid_at": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 取消提现申请
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
POST /api/withdrawals/:id/cancel
|
||||||
|
Authorization: Bearer {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"cancelled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 40001,
|
||||||
|
"message": "提现申请状态已锁定,无法操作"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提现管理 API (管理员端)
|
||||||
|
|
||||||
|
### 1. 查询提现列表
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/admin/withdrawals?status=pending&page=1&page_size=20
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
X-Required-Permission: withdrawal:list
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数**
|
||||||
|
- `status`: 状态筛选 (pending | processing | completed | rejected | cancelled)
|
||||||
|
- `user_id`: 用户ID筛选
|
||||||
|
- `page`: 页码
|
||||||
|
- `page_size`: 每页数量
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"user_id": 123,
|
||||||
|
"user_nickname": "用户昵称",
|
||||||
|
"user_phone": "138****5678",
|
||||||
|
"amount": 100.00,
|
||||||
|
"fee": 0.00,
|
||||||
|
"actual_amount": 100.00,
|
||||||
|
"payment_account_id": 1,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "13812345678", // 管理员可见完整账号
|
||||||
|
"bank_name": "",
|
||||||
|
"bank_branch": "",
|
||||||
|
"status": "pending",
|
||||||
|
"reviewed_by": null,
|
||||||
|
"reviewed_by_name": "",
|
||||||
|
"reviewed_at": null,
|
||||||
|
"review_remark": "",
|
||||||
|
"paid_by": null,
|
||||||
|
"paid_by_name": "",
|
||||||
|
"paid_at": null,
|
||||||
|
"payment_proof_url": "",
|
||||||
|
"payment_remark": "",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询提现详情
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/admin/withdrawals/:id
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
X-Required-Permission: withdrawal:detail
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"user_id": 123,
|
||||||
|
"user_nickname": "用户昵称",
|
||||||
|
"user_phone": "13812345678",
|
||||||
|
"amount": 100.00,
|
||||||
|
"fee": 0.00,
|
||||||
|
"actual_amount": 100.00,
|
||||||
|
"payment_account_id": 1,
|
||||||
|
"account_type": "alipay",
|
||||||
|
"account_name": "张三",
|
||||||
|
"account_no": "13812345678",
|
||||||
|
"bank_name": "",
|
||||||
|
"bank_branch": "",
|
||||||
|
"status": "pending",
|
||||||
|
"reviewed_by": null,
|
||||||
|
"reviewed_by_name": "",
|
||||||
|
"reviewed_at": null,
|
||||||
|
"review_remark": "",
|
||||||
|
"paid_by": null,
|
||||||
|
"paid_by_name": "",
|
||||||
|
"paid_at": null,
|
||||||
|
"payment_proof_url": "",
|
||||||
|
"payment_remark": "",
|
||||||
|
"created_at": "2026-06-06T10:00:00Z",
|
||||||
|
"updated_at": "2026-06-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 审核提现申请
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
POST /api/admin/withdrawals/:id/review
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
X-Required-Permission: withdrawal:review
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"approved": true,
|
||||||
|
"remark": "审核通过"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**审核通过响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"status": "processing",
|
||||||
|
"reviewed_by": 10,
|
||||||
|
"reviewed_by_name": "财务管理员",
|
||||||
|
"reviewed_at": "2026-06-06T10:10:00Z",
|
||||||
|
"review_remark": "审核通过",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**审核拒绝请求**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"approved": false,
|
||||||
|
"remark": "账号信息不符"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**审核拒绝响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"status": "rejected",
|
||||||
|
"reviewed_by": 10,
|
||||||
|
"reviewed_by_name": "财务管理员",
|
||||||
|
"reviewed_at": "2026-06-06T10:10:00Z",
|
||||||
|
"review_remark": "账号信息不符",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 确认打款
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
POST /api/admin/withdrawals/:id/confirm-payment
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
X-Required-Permission: withdrawal:pay
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"payment_proof_url": "https://storage.example.com/proofs/proof_123.jpg",
|
||||||
|
"remark": "已通过支付宝转账"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"withdraw_no": "WD17362512001a2b3c4d",
|
||||||
|
"status": "completed",
|
||||||
|
"paid_by": 10,
|
||||||
|
"paid_by_name": "财务管理员",
|
||||||
|
"paid_at": "2026-06-06T10:30:00Z",
|
||||||
|
"payment_proof_url": "https://storage.example.com/proofs/proof_123.jpg",
|
||||||
|
"payment_remark": "已通过支付宝转账",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 状态说明
|
||||||
|
|
||||||
|
### 提现状态 (status)
|
||||||
|
|
||||||
|
| 状态 | 说明 | 允许操作 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `pending` | 待审核 | 用户可取消、管理员可审核 |
|
||||||
|
| `processing` | 处理中 | 管理员可确认打款 |
|
||||||
|
| `completed` | 已完成 | 无 |
|
||||||
|
| `rejected` | 已拒绝 | 无 |
|
||||||
|
| `cancelled` | 已取消 | 无 |
|
||||||
|
|
||||||
|
### 账号类型 (account_type)
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `alipay` | 支付宝 |
|
||||||
|
| `wechat` | 微信 |
|
||||||
|
| `bank` | 银行卡 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 40001 | 请求参数错误 |
|
||||||
|
| 40002 | 实名认证未通过 |
|
||||||
|
| 40003 | 账号数量限制 |
|
||||||
|
| 40004 | 提现金额错误 |
|
||||||
|
| 40005 | 余额不足 |
|
||||||
|
| 40006 | 状态锁定 |
|
||||||
|
| 40401 | 未找到资源 |
|
||||||
|
| 40301 | 未授权 |
|
||||||
|
| 50001 | 服务器错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口权限说明
|
||||||
|
|
||||||
|
### 用户端接口
|
||||||
|
所有用户端接口需要携带用户 Token (`Authorization: Bearer {user_token}`)
|
||||||
|
|
||||||
|
### 管理员端接口
|
||||||
|
所有管理员端接口需要:
|
||||||
|
1. 携带管理员 Token (`Authorization: Bearer {admin_token}`)
|
||||||
|
2. 拥有对应的权限
|
||||||
|
|
||||||
|
#### 提现管理权限列表
|
||||||
|
- `withdrawal:list` - 查看提现申请列表
|
||||||
|
- `withdrawal:detail` - 查看提现详情
|
||||||
|
- `withdrawal:review` - 审核提现申请
|
||||||
|
- `withdrawal:pay` - 确认打款
|
||||||
|
|
||||||
|
**财务角色 (finance)** 默认拥有以上所有权限。
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# 手动提现功能实施完成总结
|
||||||
|
|
||||||
|
## 🎉 已完成的工作
|
||||||
|
|
||||||
|
### 1. 数据库迁移(✅ 完成)
|
||||||
|
|
||||||
|
**文件**: `backend/migrations/000002_add_withdrawal_tables.sql`
|
||||||
|
|
||||||
|
创建了两张新表:
|
||||||
|
|
||||||
|
#### user_payment_accounts (用户收款账号表)
|
||||||
|
- 支持三种账号类型:支付宝 (alipay)、微信 (wechat)、银行卡 (bank)
|
||||||
|
- 账号信息加密存储
|
||||||
|
- 支持上传认证凭证(收款码截图)
|
||||||
|
- 支持设置默认账号
|
||||||
|
- 每个用户最多5个收款账号
|
||||||
|
|
||||||
|
#### withdrawal_requests (提现申请表)
|
||||||
|
- 记录用户提现申请的完整信息
|
||||||
|
- 账号信息快照(防止用户修改收款账号影响已有提现)
|
||||||
|
- 支持手续费计算
|
||||||
|
- 完整的审核流程:pending → processing → completed
|
||||||
|
- 记录审核人、打款人、打款凭证等信息
|
||||||
|
|
||||||
|
#### 权限和角色
|
||||||
|
- 新增 4 个提现相关权限
|
||||||
|
- 新增 `finance` 财务管理员角色
|
||||||
|
- 关联权限到角色
|
||||||
|
|
||||||
|
### 2. 数据模型(✅ 完成)
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `backend/internal/model/payment_account.go` - 收款账号模型
|
||||||
|
- `backend/internal/model/withdrawal.go` - 提现申请模型
|
||||||
|
|
||||||
|
### 3. 收款账号管理模块(✅ 完成)
|
||||||
|
|
||||||
|
**目录**: `backend/internal/modules/paymentaccount/`
|
||||||
|
|
||||||
|
#### 核心功能:
|
||||||
|
- **增删改查**:用户管理自己的收款账号
|
||||||
|
- **实名验证**:账户名必须与实名认证姓名一致
|
||||||
|
- **加密存储**:使用 AES 加密存储账号信息
|
||||||
|
- **脱敏显示**:前端显示时自动脱敏(如:138****5678)
|
||||||
|
- **默认账号**:支持设置默认收款账号
|
||||||
|
- **数量限制**:每个用户最多 5 个收款账号
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `service.go` - 业务逻辑层
|
||||||
|
- `repository.go` - 数据访问层(含加密/解密/脱敏逻辑)
|
||||||
|
- `handler.go` - HTTP 接口处理
|
||||||
|
- `dto.go` - 数据传输对象
|
||||||
|
|
||||||
|
### 4. 提现申请模块(✅ 完成)
|
||||||
|
|
||||||
|
**目录**: `backend/internal/modules/withdrawal/`
|
||||||
|
|
||||||
|
#### 用户端功能:
|
||||||
|
- **创建提现申请**:选择收款账号、输入金额
|
||||||
|
- **查询提现列表**:查看自己的提现记录
|
||||||
|
- **查询提现详情**:查看单个提现申请
|
||||||
|
- **取消提现**:待审核状态可取消
|
||||||
|
|
||||||
|
#### 管理员端功能:
|
||||||
|
- **查询提现列表**:支持按状态、用户筛选
|
||||||
|
- **查询提现详情**:查看完整信息(包括完整账号)
|
||||||
|
- **审核提现**:通过/拒绝提现申请
|
||||||
|
- **确认打款**:上传打款凭证,完成提现
|
||||||
|
|
||||||
|
#### 核心流程:
|
||||||
|
1. **用户申请** → 冻结可用余额 → 创建提现记录(status=pending)
|
||||||
|
2. **财务审核** → 通过(status=processing)/ 拒绝(status=rejected,解冻余额)
|
||||||
|
3. **手动打款** → 上传凭证 → 确认完成(status=completed,扣除冻结余额)
|
||||||
|
|
||||||
|
#### 限额控制:
|
||||||
|
- 最低提现金额:10 元
|
||||||
|
- 最高单笔提现:5000 元
|
||||||
|
- 手续费率:0%(可配置)
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `service.go` - 业务逻辑层
|
||||||
|
- `repository.go` - 数据访问层(含钱包流水集成)
|
||||||
|
- `handler.go` - HTTP 接口处理
|
||||||
|
- `dto.go` - 数据传输对象
|
||||||
|
|
||||||
|
### 5. 路由配置(✅ 完成)
|
||||||
|
|
||||||
|
**文件**: `backend/internal/router/router.go`
|
||||||
|
|
||||||
|
#### 用户端 API:
|
||||||
|
```
|
||||||
|
POST /api/payment-accounts # 添加收款账号
|
||||||
|
GET /api/payment-accounts # 查询收款账号列表
|
||||||
|
GET /api/payment-accounts/:id # 查询收款账号详情
|
||||||
|
PUT /api/payment-accounts/:id # 更新收款账号
|
||||||
|
DELETE /api/payment-accounts/:id # 删除收款账号
|
||||||
|
POST /api/payment-accounts/:id/set-default # 设为默认
|
||||||
|
|
||||||
|
POST /api/withdrawals # 创建提现申请
|
||||||
|
GET /api/withdrawals # 查询提现列表
|
||||||
|
GET /api/withdrawals/:id # 查询提现详情
|
||||||
|
POST /api/withdrawals/:id/cancel # 取消提现
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 管理员端 API:
|
||||||
|
```
|
||||||
|
GET /api/admin/withdrawals # 查询提现列表(需权限:withdrawal:list)
|
||||||
|
GET /api/admin/withdrawals/:id # 查询提现详情(需权限:withdrawal:detail)
|
||||||
|
POST /api/admin/withdrawals/:id/review # 审核提现(需权限:withdrawal:review)
|
||||||
|
POST /api/admin/withdrawals/:id/confirm-payment # 确认打款(需权限:withdrawal:pay)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 钱包模块更新(✅ 完成)
|
||||||
|
|
||||||
|
更新了 `wallet/service.go`,提现功能已迁移到独立的 withdrawal 模块。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 数据库表结构
|
||||||
|
|
||||||
|
### user_payment_accounts
|
||||||
|
```sql
|
||||||
|
id BIGINT 主键
|
||||||
|
user_id BIGINT 用户ID
|
||||||
|
account_type VARCHAR(32) 账号类型:alipay/wechat/bank
|
||||||
|
account_name VARCHAR(128) 账户名
|
||||||
|
account_no VARCHAR(255) 账号(加密存储)
|
||||||
|
bank_name VARCHAR(128) 银行名称
|
||||||
|
bank_branch VARCHAR(255) 开户行支行
|
||||||
|
certificate_urls JSON 认证凭证
|
||||||
|
is_default TINYINT 是否默认账号
|
||||||
|
status VARCHAR(32) 状态:active/disabled
|
||||||
|
created_at DATETIME
|
||||||
|
updated_at DATETIME
|
||||||
|
```
|
||||||
|
|
||||||
|
### withdrawal_requests
|
||||||
|
```sql
|
||||||
|
id BIGINT 主键
|
||||||
|
withdraw_no VARCHAR(64) 提现单号(唯一)
|
||||||
|
user_id BIGINT 用户ID
|
||||||
|
amount DECIMAL(12,2) 提现金额
|
||||||
|
fee DECIMAL(12,2) 手续费
|
||||||
|
actual_amount DECIMAL(12,2) 实际到账金额
|
||||||
|
payment_account_id BIGINT 收款账号ID
|
||||||
|
account_type VARCHAR(32) 账号类型(快照)
|
||||||
|
account_name VARCHAR(128) 账户名(快照)
|
||||||
|
account_no VARCHAR(128) 账号(快照,脱敏)
|
||||||
|
bank_name VARCHAR(128) 银行名称(快照)
|
||||||
|
bank_branch VARCHAR(255) 开户行支行(快照)
|
||||||
|
status VARCHAR(32) 状态:pending/processing/completed/rejected/cancelled
|
||||||
|
reviewed_by BIGINT 审核人ID
|
||||||
|
reviewed_at DATETIME 审核时间
|
||||||
|
review_remark VARCHAR(255) 审核备注
|
||||||
|
paid_by BIGINT 打款人ID
|
||||||
|
paid_at DATETIME 打款时间
|
||||||
|
payment_proof_url VARCHAR(512) 打款凭证URL
|
||||||
|
payment_remark VARCHAR(255) 打款备注
|
||||||
|
created_at DATETIME
|
||||||
|
updated_at DATETIME
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 安全措施
|
||||||
|
|
||||||
|
1. **账号加密存储**:使用 AES-256 加密收款账号
|
||||||
|
2. **实名验证**:账户名必须与实名认证姓名一致
|
||||||
|
3. **账号脱敏**:用户端仅显示脱敏账号(如 138****5678)
|
||||||
|
4. **权限控制**:管理员操作需要对应权限
|
||||||
|
5. **金额限制**:单笔提现限额、最低提现金额
|
||||||
|
6. **状态锁定**:审核中/已完成的提现无法修改
|
||||||
|
7. **快照机制**:提现申请创建时保存账号信息快照
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 业务流程
|
||||||
|
|
||||||
|
### 用户提现流程
|
||||||
|
```
|
||||||
|
1. 用户添加收款账号(需实名认证)
|
||||||
|
↓
|
||||||
|
2. 用户发起提现申请
|
||||||
|
↓
|
||||||
|
3. 系统冻结用户可用余额
|
||||||
|
↓
|
||||||
|
4. 创建提现申请(status=pending)
|
||||||
|
↓
|
||||||
|
5. 等待财务审核
|
||||||
|
```
|
||||||
|
|
||||||
|
### 财务审核流程
|
||||||
|
```
|
||||||
|
1. 财务管理员查看待审核列表
|
||||||
|
↓
|
||||||
|
2. 审核提现申请
|
||||||
|
├─ 通过:status → processing
|
||||||
|
└─ 拒绝:status → rejected,解冻余额
|
||||||
|
↓
|
||||||
|
3. 手动转账(支付宝/微信/银行)
|
||||||
|
↓
|
||||||
|
4. 上传打款凭证
|
||||||
|
↓
|
||||||
|
5. 确认完成(status → completed)
|
||||||
|
↓
|
||||||
|
6. 系统扣除冻结余额
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 钱包流水业务类型
|
||||||
|
|
||||||
|
提现相关的 `biz_type`:
|
||||||
|
- `withdraw_freeze` - 提现冻结
|
||||||
|
- `withdraw_reject` - 提现拒绝(解冻)
|
||||||
|
- `withdraw_cancel` - 用户取消提现(解冻)
|
||||||
|
- `withdraw_complete` - 提现完成(扣除冻结余额)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 部署步骤
|
||||||
|
|
||||||
|
### 1. 执行数据库迁移
|
||||||
|
```bash
|
||||||
|
mysql -u root -p your_database < backend/migrations/000002_add_withdrawal_tables.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重新编译后端
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go build -o server cmd/api/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 重启服务
|
||||||
|
```bash
|
||||||
|
./server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置财务管理员
|
||||||
|
```sql
|
||||||
|
-- 查询 finance 角色ID
|
||||||
|
SELECT id FROM roles WHERE code = 'finance';
|
||||||
|
|
||||||
|
-- 给管理员分配财务角色
|
||||||
|
INSERT INTO admin_user_roles (admin_user_id, role_id)
|
||||||
|
VALUES (你的管理员ID, 财务角色ID);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 配置说明
|
||||||
|
|
||||||
|
### 加密密钥配置
|
||||||
|
文件:`backend/internal/modules/paymentaccount/repository.go`
|
||||||
|
|
||||||
|
**重要**:生产环境必须修改加密密钥!
|
||||||
|
```go
|
||||||
|
const encryptionKey = "your-32-byte-secret-key-here!!" // 32字节
|
||||||
|
```
|
||||||
|
|
||||||
|
建议从环境变量或配置文件读取:
|
||||||
|
```go
|
||||||
|
var encryptionKey = os.Getenv("PAYMENT_ACCOUNT_ENCRYPTION_KEY")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 提现限额配置
|
||||||
|
文件:`backend/internal/modules/withdrawal/service.go`
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
MinWithdrawalAmount = 10.0 // 最低提现金额
|
||||||
|
MaxWithdrawalAmount = 5000.0 // 单笔最高提现金额
|
||||||
|
WithdrawalFeeRate = 0.0 // 手续费率
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 测试建议
|
||||||
|
|
||||||
|
### 1. 收款账号管理测试
|
||||||
|
- [ ] 添加支付宝账号
|
||||||
|
- [ ] 添加微信账号
|
||||||
|
- [ ] 添加银行卡账号
|
||||||
|
- [ ] 测试实名验证(账户名不匹配)
|
||||||
|
- [ ] 测试账号数量限制(最多5个)
|
||||||
|
- [ ] 测试设置默认账号
|
||||||
|
- [ ] 测试删除账号
|
||||||
|
- [ ] 验证账号加密和脱敏显示
|
||||||
|
|
||||||
|
### 2. 提现申请测试
|
||||||
|
- [ ] 测试最低金额限制(<10元)
|
||||||
|
- [ ] 测试最高金额限制(>5000元)
|
||||||
|
- [ ] 测试余额不足
|
||||||
|
- [ ] 测试正常提现申请
|
||||||
|
- [ ] 测试用户取消提现(pending状态)
|
||||||
|
- [ ] 测试无法取消已审核的提现
|
||||||
|
|
||||||
|
### 3. 财务审核测试
|
||||||
|
- [ ] 测试查询待审核列表
|
||||||
|
- [ ] 测试审核通过
|
||||||
|
- [ ] 测试审核拒绝(验证余额解冻)
|
||||||
|
- [ ] 测试确认打款(上传凭证)
|
||||||
|
- [ ] 测试管理员可见完整账号
|
||||||
|
|
||||||
|
### 4. 权限测试
|
||||||
|
- [ ] 测试非财务管理员无法访问审核接口
|
||||||
|
- [ ] 测试财务管理员可以访问所有提现接口
|
||||||
|
- [ ] 测试用户只能查看自己的提现记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 后续优化建议
|
||||||
|
|
||||||
|
### 短期优化
|
||||||
|
1. **前端页面开发**:用户端收款账号管理、提现申请页面
|
||||||
|
2. **管理员前端**:提现审核列表、审核详情页面
|
||||||
|
3. **通知功能**:提现状态变更时发送通知
|
||||||
|
4. **文件上传**:集成打款凭证上传功能
|
||||||
|
|
||||||
|
### 中期优化
|
||||||
|
1. **自动化提现**:小额提现自动审核
|
||||||
|
2. **批量打款**:导出批量打款文件
|
||||||
|
3. **提现报表**:财务报表统计
|
||||||
|
4. **风控规则**:异常提现检测
|
||||||
|
|
||||||
|
### 长期优化
|
||||||
|
1. **支付网关对接**:集成自动打款接口
|
||||||
|
2. **银行接口对接**:企业网银直连
|
||||||
|
3. **实时到账**:T+0 实时提现
|
||||||
|
4. **多级审批**:大额提现多级审批流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **加密密钥**:生产环境必须使用强随机密钥,并妥善保管
|
||||||
|
2. **权限配置**:确保财务管理员权限配置正确
|
||||||
|
3. **余额校验**:提现前严格校验余额,防止超额提现
|
||||||
|
4. **状态机**:严格按照状态流转规则操作
|
||||||
|
5. **审计日志**:所有财务操作应记录审计日志
|
||||||
|
6. **测试环境**:充分测试后再上线生产环境
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 项目总结
|
||||||
|
|
||||||
|
本次实施完成了**手动提现功能的完整开发**,包括:
|
||||||
|
- ✅ 数据库设计和迁移
|
||||||
|
- ✅ 后端业务逻辑实现
|
||||||
|
- ✅ API 接口开发
|
||||||
|
- ✅ 权限和角色配置
|
||||||
|
- ✅ 安全措施实施
|
||||||
|
|
||||||
|
系统已具备完整的手动提现能力,财务管理员可以通过管理后台进行提现审核和打款确认操作。
|
||||||
|
|
||||||
|
**下一步**:前端界面开发 → 完整测试 → 生产环境部署
|
||||||
Reference in New Issue
Block a user