Files
soul-yongping/soul-api/internal/handler/admin_withdrawals.go
卡若 5724fba877 feat: 小程序超级个体/个人资料/CKB获客;VIP列表展示过滤;管理端与API联调
- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go)
- 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken)
- 阅读页分享朋友圈复制与 toast 去重
- soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整
- 脚本:content_upload、miniprogram 上传辅助等

Made-with: Cursor
2026-03-22 08:34:28 +08:00

440 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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),
})
}