2026-02-09 14:33:41 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-02-09 19:26:19 +08:00
|
|
|
|
"fmt"
|
2026-02-09 14:33:41 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"soul-api/internal/database"
|
|
|
|
|
|
"soul-api/internal/model"
|
2026-02-09 19:26:19 +08:00
|
|
|
|
"soul-api/internal/wechat"
|
2026-02-09 14:33:41 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// AdminWithdrawalsList GET /api/admin/withdrawals
|
|
|
|
|
|
func AdminWithdrawalsList(c *gin.Context) {
|
|
|
|
|
|
statusFilter := c.Query("status")
|
|
|
|
|
|
var list []model.Withdrawal
|
|
|
|
|
|
q := database.DB().Order("created_at DESC").Limit(100)
|
|
|
|
|
|
if statusFilter != "" {
|
|
|
|
|
|
q = q.Where("status = ?", statusFilter)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := q.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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "withdrawals": withdrawals, "stats": gin.H{"total": len(withdrawals)}})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
|
2026-02-09 19:26:19 +08:00
|
|
|
|
// approve:先调微信转账接口打款,成功则标为 processing,失败则标为 failed 并返回错误。
|
|
|
|
|
|
// 若未初始化微信转账客户端,则仅将状态标为 success(线下打款后批准)。
|
|
|
|
|
|
// reject:直接标为 failed。
|
2026-02-09 14:33:41 +08:00
|
|
|
|
func AdminWithdrawalsAction(c *gin.Context) {
|
|
|
|
|
|
var body struct {
|
2026-02-09 19:26:19 +08:00
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Action string `json:"action"`
|
2026-02-09 14:33:41 +08:00
|
|
|
|
ErrorMessage string `json:"errorMessage"`
|
2026-02-09 19:26:19 +08:00
|
|
|
|
Reason string `json:"reason"`
|
2026-02-09 14:33:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 = "管理员拒绝"
|
|
|
|
|
|
}
|
2026-02-09 19:26:19 +08:00
|
|
|
|
|
|
|
|
|
|
db := database.DB()
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
|
2026-02-09 14:33:41 +08:00
|
|
|
|
switch body.Action {
|
|
|
|
|
|
case "reject":
|
2026-02-09 19:26:19 +08:00
|
|
|
|
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": "已拒绝"})
|
2026-02-09 14:33:41 +08:00
|
|
|
|
return
|
2026-02-09 19:26:19 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 15:03:31 +08:00
|
|
|
|
// 调用微信转账接口(FundApp 单笔发起转账,与 商家转账.md 示例一致)
|
2026-02-09 19:26:19 +08:00
|
|
|
|
remark := "提现"
|
|
|
|
|
|
if w.Remark != nil && *w.Remark != "" {
|
|
|
|
|
|
remark = *w.Remark
|
|
|
|
|
|
}
|
|
|
|
|
|
amountFen := int(w.Amount * 100)
|
|
|
|
|
|
if amountFen < 1 {
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现金额异常"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-10 15:03:31 +08:00
|
|
|
|
outBillNo := w.ID // 商户单号,回调时 out_bill_no 即此值,用于更新该条提现
|
|
|
|
|
|
params := wechat.FundAppTransferParams{
|
|
|
|
|
|
OutBillNo: outBillNo,
|
|
|
|
|
|
OpenID: openID,
|
|
|
|
|
|
Amount: amountFen,
|
|
|
|
|
|
Remark: remark,
|
|
|
|
|
|
NotifyURL: "", // 由 wechat 包从配置读取 WechatTransferURL
|
|
|
|
|
|
TransferSceneId: "1005",
|
2026-02-09 19:26:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 15:03:31 +08:00
|
|
|
|
result, err := wechat.InitiateTransferByFundApp(params)
|
2026-02-09 19:26:19 +08:00
|
|
|
|
if err != nil {
|
2026-02-10 10:39:39 +08:00
|
|
|
|
errMsg := err.Error()
|
2026-02-10 15:03:31 +08:00
|
|
|
|
fmt.Printf("[AdminWithdrawals] 发起转账失败 id=%s: %s\n", body.ID, errMsg)
|
|
|
|
|
|
// 未初始化或未配置转账:仅标记为已打款并提示线下处理
|
2026-02-10 10:39:39 +08:00
|
|
|
|
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
|
2026-02-09 19:26:19 +08:00
|
|
|
|
_ = db.Model(&w).Updates(map[string]interface{}{
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"processed_at": now,
|
|
|
|
|
|
}).Error
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "已标记为已打款。当前未接入微信转账,请线下打款。",
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-10 15:03:31 +08:00
|
|
|
|
// 微信接口报错或其它失败:把微信/具体原因返回给管理端展示,不返回「微信处理中」
|
2026-02-10 10:39:39 +08:00
|
|
|
|
failMsg := errMsg
|
2026-02-09 19:26:19 +08:00
|
|
|
|
_ = db.Model(&w).Updates(map[string]interface{}{
|
|
|
|
|
|
"status": "failed",
|
|
|
|
|
|
"fail_reason": failMsg,
|
|
|
|
|
|
"error_message": failMsg,
|
|
|
|
|
|
"processed_at": now,
|
|
|
|
|
|
}).Error
|
2026-02-10 15:03:31 +08:00
|
|
|
|
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
|
2026-02-09 19:26:19 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
|
"success": false,
|
|
|
|
|
|
"error": "发起打款失败",
|
|
|
|
|
|
"message": failMsg,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 15:03:31 +08:00
|
|
|
|
// 打款已受理(FundApp 单笔),更新为处理中并保存商户单号、微信转账单号
|
|
|
|
|
|
fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s\n", body.ID, result.OutBillNo, result.TransferBillNo)
|
2026-02-09 19:26:19 +08:00
|
|
|
|
processingStatus := "processing"
|
2026-02-09 21:48:26 +08:00
|
|
|
|
if err := db.Model(&w).Updates(map[string]interface{}{
|
2026-02-09 19:26:19 +08:00
|
|
|
|
"status": processingStatus,
|
2026-02-10 15:03:31 +08:00
|
|
|
|
"detail_no": result.OutBillNo, // 回调用 out_bill_no 匹配此字段
|
|
|
|
|
|
"batch_no": result.OutBillNo, // 单笔无批次,存同一单号便于查询
|
|
|
|
|
|
"batch_id": result.TransferBillNo,
|
2026-02-09 19:26:19 +08:00
|
|
|
|
"processed_at": now,
|
2026-02-09 21:48:26 +08:00
|
|
|
|
}).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
|
|
|
|
|
|
}
|
2026-02-09 19:26:19 +08:00
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "已发起打款,微信处理中",
|
2026-02-09 21:29:52 +08:00
|
|
|
|
"data": gin.H{
|
2026-02-10 15:03:31 +08:00
|
|
|
|
"out_bill_no": result.OutBillNo,
|
|
|
|
|
|
"transfer_bill_no": result.TransferBillNo,
|
2026-02-09 21:29:52 +08:00
|
|
|
|
},
|
2026-02-09 19:26:19 +08:00
|
|
|
|
})
|
2026-02-09 14:33:41 +08:00
|
|
|
|
return
|
2026-02-09 19:26:19 +08:00
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
|
2026-02-09 14:33:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-10 14:15:32 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-02-10 15:03:31 +08:00
|
|
|
|
if detailNo == "" {
|
2026-02-10 14:15:32 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-02-10 15:03:31 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-02-10 14:15:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
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),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|