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

342 lines
9.2 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 (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat/transferv3"
"github.com/gin-gonic/gin"
)
// getTransferV3Client 从 config 创建文档 V3 转账 Client独立于 PowerWeChat
func getTransferV3Client() (*transferv3.Client, error) {
cfg := config.Get()
if cfg == nil {
return nil, fmt.Errorf("config not loaded")
}
key, err := transferv3.LoadPrivateKeyFromPath(cfg.WechatKeyPath)
if err != nil {
return nil, fmt.Errorf("load private key: %w", err)
}
return transferv3.NewClient(cfg.WechatMchID, cfg.WechatAppID, cfg.WechatSerialNo, key), nil
}
// WithdrawV3Initiate POST /api/v3/withdraw/initiate 根据文档发起商家转账到零钱V3 独立实现)
// body: { "withdrawal_id": "xxx" },需先存在 pending 的提现记录
func WithdrawV3Initiate(c *gin.Context) {
var req struct {
WithdrawalID string `json:"withdrawal_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"})
return
}
db := database.DB()
var w model.Withdrawal
if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"})
return
}
st := ""
if w.Status != nil {
st = *w.Status
}
if st != "pending" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "仅支持 pending 状态发起"})
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.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定 openid"})
return
}
cfg := config.Get()
if cfg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "配置未加载"})
return
}
outBatchNo := fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
outDetailNo := fmt.Sprintf("WDD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
amountFen := int(w.Amount * 100)
if amountFen < 1 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额异常"})
return
}
batchRemark := fmt.Sprintf("提现 %.2f 元", w.Amount)
if len([]rune(batchRemark)) > 32 {
batchRemark = "用户提现"
}
body := map[string]interface{}{
"appid": cfg.WechatAppID,
"out_batch_no": outBatchNo,
"batch_name": "用户提现",
"batch_remark": batchRemark,
"total_amount": amountFen,
"total_num": 1,
"transfer_scene_id": "1005",
"transfer_detail_list": []map[string]interface{}{
{
"out_detail_no": outDetailNo,
"transfer_amount": amountFen,
"transfer_remark": "提现",
"openid": openID,
},
},
}
if cfg.WechatTransferURL != "" {
body["notify_url"] = cfg.WechatTransferURL
}
bodyBytes, _ := json.Marshal(body)
client, err := getTransferV3Client()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
respBody, statusCode, err := client.PostBatches(bodyBytes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
if statusCode < 200 || statusCode >= 300 {
var errResp struct {
Code string `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(respBody, &errResp)
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errResp.Message,
"code": errResp.Code,
})
return
}
var respData struct {
OutBatchNo string `json:"out_batch_no"`
BatchID string `json:"batch_id"`
CreateTime string `json:"create_time"`
BatchStatus string `json:"batch_status"`
}
_ = json.Unmarshal(respBody, &respData)
now := time.Now()
processingStatus := "processing"
_ = db.Model(&w).Updates(map[string]interface{}{
"status": processingStatus,
"batch_no": outBatchNo,
"detail_no": outDetailNo,
"batch_id": respData.BatchID,
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已发起打款,微信处理中",
"data": gin.H{
"out_batch_no": outBatchNo,
"batch_id": respData.BatchID,
"batch_status": respData.BatchStatus,
},
})
}
// WithdrawV3Notify POST /api/v3/withdraw/notify 文档 V3 转账结果回调(验签可选,解密后更新状态)
func WithdrawV3Notify(c *gin.Context) {
rawBody, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "body read error"})
return
}
var envelope map[string]interface{}
if err := json.Unmarshal(rawBody, &envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "invalid json"})
return
}
resource, _ := envelope["resource"].(map[string]interface{})
if resource == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "no resource"})
return
}
ciphertext, _ := resource["ciphertext"].(string)
nonceStr, _ := resource["nonce"].(string)
assoc, _ := resource["associated_data"].(string)
if ciphertext == "" || nonceStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "missing ciphertext/nonce"})
return
}
if assoc == "" {
assoc = "mch_payment"
}
cfg := config.Get()
if cfg == nil || len(cfg.WechatAPIv3Key) != 32 {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "config or apiv3 key invalid"})
return
}
decrypted, err := transferv3.DecryptResourceJSON(ciphertext, nonceStr, assoc, []byte(cfg.WechatAPIv3Key))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "decrypt failed"})
return
}
outBillNo, _ := decrypted["out_bill_no"].(string)
state, _ := decrypted["state"].(string)
failReason, _ := decrypted["fail_reason"].(string)
if outBillNo == "" {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
return
}
db := database.DB()
var w model.Withdrawal
if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
return
}
cur := ""
if w.Status != nil {
cur = *w.Status
}
if cur != "processing" && cur != "pending_confirm" {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
return
}
now := time.Now()
up := map[string]interface{}{"processed_at": now}
switch state {
case "SUCCESS":
up["status"] = "success"
case "FAIL", "CANCELLED":
up["status"] = "failed"
if failReason != "" {
up["fail_reason"] = failReason
}
default:
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
return
}
if err := db.Model(&w).Updates(up).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "update failed"})
return
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
}
// WithdrawV3Query POST /api/v3/withdraw/query 主动查询转账结果并更新(文档:按商户批次/明细单号查询)
// body: { "withdrawal_id": "xxx" }
func WithdrawV3Query(c *gin.Context) {
var req struct {
WithdrawalID string `json:"withdrawal_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"})
return
}
db := database.DB()
var w model.Withdrawal
if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"})
return
}
batchNo := ""
detailNo := ""
if w.BatchNo != nil {
batchNo = *w.BatchNo
}
if w.DetailNo != nil {
detailNo = *w.DetailNo
}
if batchNo == "" || detailNo == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未发起过微信转账"})
return
}
client, err := getTransferV3Client()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
respBody, statusCode, err := client.GetTransferDetail(batchNo, detailNo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
if statusCode != 200 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": string(respBody),
})
return
}
var detail struct {
DetailStatus string `json:"detail_status"`
FailReason string `json:"fail_reason"`
}
_ = json.Unmarshal(respBody, &detail)
now := time.Now()
up := map[string]interface{}{"processed_at": now}
switch strings.ToUpper(detail.DetailStatus) {
case "SUCCESS":
up["status"] = "success"
case "FAIL":
up["status"] = "failed"
if detail.FailReason != "" {
up["fail_reason"] = detail.FailReason
}
default:
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "查询成功,状态未终态",
"detail_status": detail.DetailStatus,
})
return
}
if err := db.Model(&w).Updates(up).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已同步状态",
"detail_status": detail.DetailStatus,
})
}