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