2026-02-25 11:52:11 +08:00
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
2026-02-25 16:26:13 +08:00
"strconv"
2026-02-25 11:52:11 +08:00
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
2026-02-25 16:26:13 +08:00
// AdminWithdrawalsList GET /api/admin/withdrawals( 支持分页 page、pageSize, 筛选 status)
2026-02-25 11:52:11 +08:00
func AdminWithdrawalsList ( c * gin . Context ) {
statusFilter := c . Query ( "status" )
2026-02-25 16:26:13 +08:00
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" {
2026-02-25 11:52:11 +08:00
q = q . Where ( "status = ?" , statusFilter )
}
2026-02-25 16:26:13 +08:00
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 {
2026-02-25 11:52:11 +08:00
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" )
}
withdrawals = append ( withdrawals , gin . H {
"id" : w . ID , "userId" : w . UserID , "userName" : userName , "userAvatar" : userAvatar ,
"amount" : w . Amount , "status" : st , "createdAt" : w . CreatedAt ,
"method" : "wechat" , "account" : account ,
"userConfirmedAt" : userConfirmedAt ,
} )
}
2026-02-25 16:26:13 +08:00
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 ,
} ,
} )
2026-02-25 11:52:11 +08:00
}
// 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" :
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
}
st := ""
if w . Status != nil {
st = * w . Status
}
if st != "pending" && st != "processing" && st != "pending_confirm" {
c . JSON ( http . StatusOK , gin . H { "success" : false , "error" : "当前状态不允许批准" } )
return
}
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 == "" {
c . JSON ( http . StatusOK , gin . H { "success" : false , "error" : "用户未绑定微信 openid, 无法打款" } )
return
}
// 批准前二次校验可提现金额,与申请时口径一致,防止退款/冲正后超额打款
available , _ , _ , _ , _ := computeAvailableWithdraw ( db , w . UserID )
if w . Amount > available {
c . JSON ( http . StatusOK , gin . H {
"success" : false ,
"error" : "用户当前可提现不足,无法批准" ,
"message" : fmt . Sprintf ( "用户当前可提现 ¥%.2f,本笔申请 ¥%.2f,可能因退款/冲正导致。请核对后再批或联系用户。" , available , w . Amount ) ,
} )
return
}
// 调用微信转账接口: 按提现手续费扣除后打款, 例如申请100元、手续费5%则实际打款95元
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 {
c . JSON ( http . StatusOK , gin . H { "success" : false , "error" : "提现金额异常" } )
return
}
outBillNo := w . ID // 商户单号,回调时 out_bill_no 即此值,用于更新该条提现
params := wechat . FundAppTransferParams {
OutBillNo : outBillNo ,
OpenID : openID ,
Amount : amountFen ,
Remark : remark ,
NotifyURL : "" , // 由 wechat 包从配置读取 WechatTransferURL
TransferSceneId : "1005" ,
}
result , err := wechat . InitiateTransferByFundApp ( params )
if err != nil {
errMsg := err . Error ( )
fmt . Printf ( "[AdminWithdrawals] 发起转账失败 id=%s: %s\n" , body . ID , errMsg )
// 未初始化或未配置转账:仅标记为已打款并提示线下处理
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
_ = db . Model ( & w ) . Updates ( map [ string ] interface { } {
"status" : "success" ,
"processed_at" : now ,
} ) . Error
c . JSON ( http . StatusOK , gin . H {
"success" : true ,
"message" : "已标记为已打款。当前未接入微信转账,请线下打款。" ,
} )
return
}
// 微信接口报错或其它失败:把微信/具体原因返回给管理端展示,不返回「微信处理中」
failMsg := errMsg
_ = db . Model ( & w ) . Updates ( map [ string ] interface { } {
"status" : "failed" ,
"fail_reason" : failMsg ,
"error_message" : failMsg ,
"processed_at" : now ,
} ) . Error
c . JSON ( http . StatusOK , gin . H {
"success" : false ,
"error" : "发起打款失败" ,
"message" : failMsg , // 管理端直接展示微信报错信息(如 IP 白名单、参数错误等)
} )
return
}
// 防护:微信未返回商户单号时也按失败返回,避免管理端显示「已发起打款」却无单号
if result . OutBillNo == "" {
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
_ = db . Model ( & w ) . Updates ( map [ string ] interface { } {
"status" : "failed" ,
"fail_reason" : failMsg ,
"error_message" : failMsg ,
"processed_at" : now ,
} ) . Error
c . JSON ( http . StatusOK , gin . H {
"success" : false ,
"error" : "发起打款失败" ,
"message" : failMsg ,
} )
return
}
// 打款已受理( 微信同步返回) , 立即落库: 商户单号、微信单号、package_info、按 state 设 status( 不依赖回调)
fmt . Printf ( "[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s state=%s\n" , body . ID , result . OutBillNo , result . TransferBillNo , result . State )
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 {
fmt . Printf ( "[AdminWithdrawals] 更新提现状态失败 id=%s: %v\n" , body . ID , err )
c . JSON ( http . StatusOK , gin . H { "success" : false , "error" : "更新状态失败: " + err . Error ( ) } )
return
}
// 发起转账成功后发订阅消息(异步,失败不影响接口返回)
if openID != "" {
go func ( ) {
ctx := context . Background ( )
if err := wechat . SendWithdrawSubscribeMessage ( ctx , openID , w . Amount , true ) ; err != nil {
fmt . Printf ( "[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n" , body . ID , err )
}
} ( )
}
c . JSON ( http . StatusOK , gin . H {
"success" : true ,
"message" : "已发起打款,微信处理中" ,
"data" : gin . H {
"out_bill_no" : result . OutBillNo ,
"transfer_bill_no" : result . TransferBillNo ,
} ,
} )
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 ) ,
} )
}