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

342 lines
9.2 KiB
Go
Raw Normal View History

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