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