Files
soul-yongping/soul-api/internal/handler/admin_withdrawals.go

440 lines
14 KiB
Go
Raw Normal View History

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminWithdrawalsAutoApproveGet GET /api/admin/withdrawals/auto-approve 获取自动审批开关状态
func AdminWithdrawalsAutoApproveGet(c *gin.Context) {
db := database.DB()
enabled := false
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var val map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil {
if v, ok := val["enableAutoWithdraw"].(bool); ok {
enabled = v
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": enabled})
}
// AdminWithdrawalsAutoApprovePut PUT /api/admin/withdrawals/auto-approve 设置自动审批开关
func AdminWithdrawalsAutoApprovePut(c *gin.Context) {
var body struct {
EnableAutoApprove bool `json:"enableAutoApprove"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
var refCfg model.SystemConfig
val := map[string]interface{}{
"distributorShare": float64(90), "minWithdrawAmount": float64(10), "bindingDays": float64(30),
"userDiscount": float64(5), "withdrawFee": float64(5), "enableAutoWithdraw": body.EnableAutoApprove,
"vipOrderShareVip": float64(20), "vipOrderShareNonVip": float64(10),
}
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil {
val["enableAutoWithdraw"] = body.EnableAutoApprove
}
}
valBytes, _ := json.Marshal(val)
desc := "分销 / 推广规则配置"
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err != nil {
refCfg = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc}
_ = db.Create(&refCfg)
} else {
refCfg.ConfigValue = valBytes
refCfg.Description = &desc
_ = db.Save(&refCfg)
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": body.EnableAutoApprove, "message": "已更新"})
}
// AdminWithdrawalsList GET /api/admin/withdrawals支持分页 page、pageSize筛选 status
func AdminWithdrawalsList(c *gin.Context) {
statusFilter := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
db := database.DB()
q := db.Model(&model.Withdrawal{})
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
}
var total int64
q.Count(&total)
var list []model.Withdrawal
query := db.Order("created_at DESC")
if statusFilter != "" && statusFilter != "all" {
query = query.Where("status = ?", statusFilter)
}
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})
return
}
userIds := make([]string, 0, len(list))
seen := make(map[string]bool)
for _, w := range list {
if !seen[w.UserID] {
seen[w.UserID] = true
userIds = append(userIds, w.UserID)
}
}
var users []model.User
if len(userIds) > 0 {
database.DB().Where("id IN ?", userIds).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
withdrawals := make([]gin.H, 0, len(list))
for _, w := range list {
u := userMap[w.UserID]
userName := "未知用户"
var userAvatar *string
account := "未绑定微信号"
if w.WechatID != nil && *w.WechatID != "" {
account = *w.WechatID
}
if u != nil {
if u.Nickname != nil {
userName = *u.Nickname
}
userAvatar = u.Avatar
if u.WechatID != nil && *u.WechatID != "" {
account = *u.WechatID
}
}
st := "pending"
if w.Status != nil {
st = *w.Status
if st == "success" {
st = "completed"
} else if st == "failed" {
st = "rejected"
} else if st == "pending_confirm" {
st = "pending_confirm"
}
}
userConfirmedAt := interface{}(nil)
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
}
avStr := ""
if userAvatar != nil {
avStr = resolveAvatarURL(*userAvatar)
}
// 备注:失败时显示 failReason/errorMessage否则显示用户 remark
remark := ""
if st == "rejected" || st == "failed" {
if w.FailReason != nil && *w.FailReason != "" {
remark = *w.FailReason
} else if w.ErrorMessage != nil && *w.ErrorMessage != "" {
remark = *w.ErrorMessage
}
}
if remark == "" && w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawals = append(withdrawals, gin.H{
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": avStr,
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
"method": "wechat", "account": account,
"userConfirmedAt": userConfirmedAt,
"remark": remark,
})
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
var pendingCount, successCount, failedCount int64
var pendingAmount, successAmount float64
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Count(&pendingCount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Select("COALESCE(SUM(amount), 0)").Scan(&pendingAmount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Count(&successCount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Select("COALESCE(SUM(amount), 0)").Scan(&successAmount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"failed", "rejected"}).Count(&failedCount)
c.JSON(http.StatusOK, gin.H{
"success": true, "withdrawals": withdrawals,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
"stats": gin.H{
"total": total, "pendingCount": pendingCount, "pendingAmount": pendingAmount,
"successCount": successCount, "successAmount": successAmount, "failedCount": failedCount,
},
})
}
// doApproveWithdrawal 执行提现审批逻辑(打款),供 AdminWithdrawalsAction 与自动审批共用
// 返回 (successMessage, error),成功时 err 为 nil
func doApproveWithdrawal(db *gorm.DB, id string) (string, error) {
now := time.Now()
var w model.Withdrawal
if err := db.Where("id = ?", id).First(&w).Error; err != nil {
return "", fmt.Errorf("提现记录不存在")
}
st := ""
if w.Status != nil {
st = *w.Status
}
if st != "pending" && st != "processing" && st != "pending_confirm" {
return "", fmt.Errorf("当前状态不允许批准")
}
openID := ""
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
openID = *w.WechatOpenid
}
if openID == "" {
var u model.User
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
openID = *u.OpenID
}
}
if openID == "" {
return "", fmt.Errorf("用户未绑定微信 openid无法打款")
}
_, totalCommission, withdrawn, pending, _ := computeAvailableWithdraw(db, w.UserID)
availableRaw := totalCommission - withdrawn - pending
if availableRaw < -0.01 {
return "", fmt.Errorf("用户当前可提现不足,无法批准")
}
remark := "提现"
if w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawFee := 0.0
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var refVal map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil {
if v, ok := refVal["withdrawFee"].(float64); ok {
withdrawFee = v / 100
}
}
}
actualAmount := w.Amount * (1 - withdrawFee)
if actualAmount < 0.01 {
actualAmount = 0.01
}
amountFen := int(actualAmount * 100)
if amountFen < 1 {
return "", fmt.Errorf("提现金额异常")
}
params := wechat.FundAppTransferParams{
OutBillNo: w.ID, OpenID: openID, Amount: amountFen, Remark: remark,
NotifyURL: "", TransferSceneId: "1005",
}
result, err := wechat.InitiateTransferByFundApp(params)
if err != nil {
errMsg := err.Error()
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
_ = db.Model(&w).Updates(map[string]interface{}{"status": "success", "processed_at": now}).Error
return "已标记为已打款。当前未接入微信转账,请线下打款。", nil
}
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed", "fail_reason": errMsg, "error_message": errMsg, "processed_at": now,
}).Error
return "", fmt.Errorf("%s", errMsg)
}
if result.OutBillNo == "" {
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed", "fail_reason": failMsg, "error_message": failMsg, "processed_at": now,
}).Error
return "", fmt.Errorf("%s", failMsg)
}
rowStatus := "processing"
if result.State == "WAIT_USER_CONFIRM" {
rowStatus = "pending_confirm"
}
upd := map[string]interface{}{
"status": rowStatus, "detail_no": result.OutBillNo, "batch_no": result.OutBillNo,
"batch_id": result.TransferBillNo, "processed_at": now,
}
if result.PackageInfo != "" {
upd["package_info"] = result.PackageInfo
}
if err := db.Model(&w).Updates(upd).Error; err != nil {
return "", fmt.Errorf("更新状态失败: %w", err)
}
if openID != "" {
go func() {
ctx := context.Background()
if e := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); e != nil {
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", id, e)
}
}()
}
return "已发起打款,微信处理中", nil
}
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
// approve先调微信转账接口打款成功则标为 processing失败则标为 failed 并返回错误。
// 若未初始化微信转账客户端,则仅将状态标为 success线下打款后批准
// reject直接标为 failed。
func AdminWithdrawalsAction(c *gin.Context) {
var body struct {
ID string `json:"id"`
Action string `json:"action"`
ErrorMessage string `json:"errorMessage"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
return
}
reason := body.ErrorMessage
if reason == "" {
reason = body.Reason
}
if reason == "" && body.Action == "reject" {
reason = "管理员拒绝"
}
db := database.DB()
now := time.Now()
switch body.Action {
case "reject":
err := db.Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
"status": "failed",
"error_message": reason,
"fail_reason": reason,
"processed_at": now,
}).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已拒绝"})
return
case "approve":
msg, err := doApproveWithdrawal(db, body.ID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
}
}
// AdminWithdrawalsSync POST /api/admin/withdrawals/sync 主动向微信查询转账结果并更新状态(无回调时的备选)
// body: { "id": "提现记录id" } 同步单条;不传 id 或 id 为空则同步所有 processing/pending_confirm
func AdminWithdrawalsSync(c *gin.Context) {
var body struct {
ID string `json:"id"`
}
_ = c.ShouldBindJSON(&body)
db := database.DB()
var list []model.Withdrawal
if body.ID != "" {
var w model.Withdrawal
if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"})
return
}
list = []model.Withdrawal{w}
} else {
if err := db.Where("status IN ?", []string{"processing", "pending_confirm"}).
Find(&list).Error; err != nil || len(list) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "暂无待同步记录", "synced": 0})
return
}
}
now := time.Now()
synced := 0
for _, w := range list {
batchNo := ""
detailNo := ""
if w.BatchNo != nil {
batchNo = *w.BatchNo
}
if w.DetailNo != nil {
detailNo = *w.DetailNo
}
if detailNo == "" {
continue
}
var status, failReason string
// FundApp 单笔batch_no == detail_no 时用商户单号查询
if batchNo == detailNo {
state, _, fail, err := wechat.QueryTransferByOutBill(detailNo)
if err != nil {
continue
}
status = state
failReason = fail
} else {
res, err := wechat.QueryTransfer(batchNo, detailNo)
if err != nil {
continue
}
if s, ok := res["detail_status"].(string); ok {
status = s
}
if s, ok := res["fail_reason"].(string); ok {
failReason = s
}
}
up := map[string]interface{}{"processed_at": now}
switch status {
case "SUCCESS":
up["status"] = "success"
case "FAIL":
up["status"] = "failed"
if failReason != "" {
up["fail_reason"] = failReason
}
default:
continue
}
if err := db.Model(&model.Withdrawal{}).Where("id = ?", w.ID).Updates(up).Error; err != nil {
continue
}
synced++
fmt.Printf("[AdminWithdrawals] 同步状态 id=%s -> %s\n", w.ID, up["status"])
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已向微信查询并更新",
"synced": synced,
"total": len(list),
})
}