342 lines
9.2 KiB
Go
342 lines
9.2 KiB
Go
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,
|
||
})
|
||
}
|