This commit is contained in:
2026-01-16 17:01:36 +08:00
parent 23ad0477b2
commit 663ad1af0d
8 changed files with 600 additions and 0 deletions

View File

@@ -26,6 +26,10 @@ service main {
// 易支付回调
@handler EasyPayCallback
get /pay/easypay/callback
// 云印签支付回调
@handler YunYinSignPayCallback
post /pay/yunyinsign/callback
}
@server (
@@ -44,6 +48,10 @@ service main {
@handler PaymentCheck
post /pay/check (PaymentCheckReq) returns (PaymentCheckResp)
// 云印签退款
@handler YunYinSignPayRefund
post /pay/yunyinsign/refund (YunYinSignPayRefundReq) returns (YunYinSignPayRefundResp)
}
type (
@@ -72,5 +80,15 @@ type (
OrderID string `json:"order_id" validate:"required"`
TransactionReceipt string `json:"transaction_receipt" validate:"required"`
}
YunYinSignPayRefundReq {
OrderNo string `json:"order_no"` // 订单号sourceOrderCode与 participate_id 二选一
ParticipateId int64 `json:"participate_id,omitempty"` // 参与方ID与 order_no 二选一
RefundAmount float64 `json:"refund_amount" validate:"required,gt=0"` // 退款金额必须大于0
RefundReason string `json:"refund_reason,omitempty"` // 退款原因
}
YunYinSignPayRefundResp {
Success bool `json:"success"`
Message string `json:"message"`
}
)

View File

@@ -0,0 +1,17 @@
package pay
import (
"net/http"
"jnc-server/app/main/api/internal/logic/pay"
"jnc-server/app/main/api/internal/svc"
"jnc-server/common/result"
)
func YunYinSignPayCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := pay.NewYunYinSignPayCallbackLogic(r.Context(), svcCtx)
err := l.YunYinSignPayCallback(w, r)
result.HttpResult(r, w, nil, err)
}
}

View File

@@ -0,0 +1,30 @@
package pay
import (
"net/http"
"jnc-server/app/main/api/internal/logic/pay"
"jnc-server/app/main/api/internal/svc"
"jnc-server/app/main/api/internal/types"
"jnc-server/common/result"
"jnc-server/pkg/lzkit/validator"
"github.com/zeromicro/go-zero/rest/httpx"
)
func YunYinSignPayRefundHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.YunYinSignPayRefundReq
if err := httpx.Parse(r, &req); err != nil {
result.ParamErrorResult(r, w, err)
return
}
if err := validator.Validate(req); err != nil {
result.ParamValidateErrorResult(r, w, err)
return
}
l := pay.NewYunYinSignPayRefundLogic(r.Context(), svcCtx)
resp, err := l.YunYinSignPayRefund(&req)
result.HttpResult(r, w, resp, err)
}
}

View File

@@ -786,6 +786,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/pay/wechat/refund_callback",
Handler: pay.WechatPayRefundCallbackHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/pay/yunyinsign/callback",
Handler: pay.YunYinSignPayCallbackHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1"),
)
@@ -809,6 +814,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/pay/payment",
Handler: pay.PaymentHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/pay/yunyinsign/refund",
Handler: pay.YunYinSignPayRefundHandler(serverCtx),
},
}...,
),
rest.WithJwt(serverCtx.Config.JwtAuth.AccessSecret),

View File

@@ -0,0 +1,191 @@
package pay
import (
"context"
"jnc-server/app/main/api/internal/svc"
"jnc-server/pkg/lzkit/lzUtils"
"net/http"
"strconv"
"time"
"github.com/zeromicro/go-zero/core/logx"
)
type YunYinSignPayCallbackLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewYunYinSignPayCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *YunYinSignPayCallbackLogic {
return &YunYinSignPayCallbackLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// YunYinSignPayCallback 云印签支付回调处理
func (l *YunYinSignPayCallbackLogic) YunYinSignPayCallback(w http.ResponseWriter, r *http.Request) error {
// 检查云印签支付服务是否启用
if l.svcCtx.YunYinSignPayService == nil {
logx.Errorf("云印签支付服务未启用")
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 解析表单数据
if err := r.ParseForm(); err != nil {
logx.Errorf("云印签回调,解析表单数据失败: %v", err)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 获取回调参数
tradeState := r.FormValue("tradeState")
orderType := r.FormValue("orderType")
payOrderNo := r.FormValue("payOrderNo")
payAmount := r.FormValue("payAmount")
flowId := r.FormValue("flowId")
sourceOrderCode := r.FormValue("sourceOrderCode")
channelOrderNo := r.FormValue("channelOrderNo")
paySuccessTime := r.FormValue("paySuccessTime")
// 记录回调日志
logx.Infof("云印签回调通知,订单号: %s, 支付状态: %s, 订单类型: %s, 支付单号: %s, 金额: %s, 流程ID: %s",
sourceOrderCode, tradeState, orderType, payOrderNo, payAmount, flowId)
// 只处理付款通知orderType == "pay"
if orderType != "pay" {
logx.Infof("云印签回调,非付款通知,订单类型: %s, 订单号: %s", orderType, sourceOrderCode)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 如果没有 sourceOrderCode无法关联订单直接返回成功
if sourceOrderCode == "" {
logx.Errorf("云印签回调,缺少 sourceOrderCode 参数")
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 根据 sourceOrderCode 查找订单
order, findOrderErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, sourceOrderCode)
if findOrderErr != nil {
logx.Errorf("云印签回调,查找订单失败,订单号: %s, 错误: %v", sourceOrderCode, findOrderErr)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 幂等性检查:如果订单已经不是 pending 状态,说明已经处理过,直接返回成功
if order.Status != "pending" {
logx.Infof("云印签回调,订单已处理,订单号: %s, 当前状态: %s", sourceOrderCode, order.Status)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 验证支付状态tradeState == "00000" 表示支付成功
if tradeState != "00000" {
logx.Infof("云印签回调,订单未支付成功,订单号: %s, 支付状态: %s", sourceOrderCode, tradeState)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 验证金额
payAmountFloat, parseAmountErr := strconv.ParseFloat(payAmount, 64)
if parseAmountErr != nil {
logx.Errorf("云印签回调,金额解析失败,订单号: %s, 金额: %s, 错误: %v", sourceOrderCode, payAmount, parseAmountErr)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 金额比较(允许小数点误差)
amountDiff := payAmountFloat - order.Amount
if amountDiff < 0 {
amountDiff = -amountDiff
}
if amountDiff > 0.01 { // 允许1分钱的误差
logx.Errorf("云印签回调,金额不一致,订单号: %s, 订单金额: %.2f, 回调金额: %.2f", sourceOrderCode, order.Amount, payAmountFloat)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 解析支付成功时间
var payTime time.Time
if paySuccessTime != "" {
// 尝试多种时间格式
timeFormats := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05.000",
"2006-01-02T15:04:05.000Z",
}
parsed := false
for _, format := range timeFormats {
if t, err := time.Parse(format, paySuccessTime); err == nil {
payTime = t
parsed = true
break
}
}
if !parsed {
// 如果解析失败,使用当前时间
payTime = time.Now()
logx.Infof("云印签回调,支付时间解析失败,使用当前时间,订单号: %s, 时间字符串: %s", sourceOrderCode, paySuccessTime)
}
} else {
payTime = time.Now()
}
// 更新订单状态
order.Status = "paid"
order.PayTime = lzUtils.TimeToNullTime(payTime)
if channelOrderNo != "" {
order.PlatformOrderId = lzUtils.StringToNullString(channelOrderNo)
}
if updateErr := l.svcCtx.OrderModel.UpdateWithVersion(l.ctx, nil, order); updateErr != nil {
logx.Errorf("云印签回调,更新订单状态失败,订单号: %s, 错误: %v", sourceOrderCode, updateErr)
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}
// 更新云印签订单表的支付状态
yunyinOrder, findYunyinErr := l.svcCtx.YunyinSignPayOrderModel.FindOneByOrderId(l.ctx, order.Id)
if findYunyinErr == nil && yunyinOrder != nil {
// 更新支付状态为已支付1
if yunyinOrder.PayStatus != 1 {
yunyinOrder.PayStatus = 1
if updateYunyinErr := l.svcCtx.YunyinSignPayOrderModel.Update(l.ctx, yunyinOrder); updateYunyinErr != nil {
logx.Errorf("云印签回调更新云印签订单支付状态失败订单ID: %s, 错误: %v", order.Id, updateYunyinErr)
} else {
logx.Infof("云印签回调成功更新云印签订单支付状态订单ID: %s", order.Id)
}
}
} else {
logx.Infof("云印签回调未找到对应的云印签订单记录订单ID: %s, 流程ID: %s", order.Id, flowId)
}
// 发送异步任务处理后续流程
if asyncErr := l.svcCtx.AsynqService.SendQueryTask(order.Id); asyncErr != nil {
logx.Errorf("云印签回调异步任务调度失败订单ID: %s, 错误: %v", order.Id, asyncErr)
// 不返回错误,因为订单已经更新成功
}
logx.Infof("云印签回调处理成功,订单号: %s, 支付单号: %s, 渠道单号: %s", sourceOrderCode, payOrderNo, channelOrderNo)
// 返回 success云印签要求返回纯字符串 success
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
return nil
}

View File

@@ -0,0 +1,153 @@
package pay
import (
"context"
"database/sql"
"fmt"
"jnc-server/app/main/api/internal/svc"
"jnc-server/app/main/api/internal/types"
"jnc-server/app/main/model"
"jnc-server/common/xerr"
"jnc-server/pkg/lzkit/lzUtils"
"time"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
type YunYinSignPayRefundLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewYunYinSignPayRefundLogic(ctx context.Context, svcCtx *svc.ServiceContext) *YunYinSignPayRefundLogic {
return &YunYinSignPayRefundLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// YunYinSignPayRefund 云印签退款
func (l *YunYinSignPayRefundLogic) YunYinSignPayRefund(req *types.YunYinSignPayRefundReq) (resp *types.YunYinSignPayRefundResp, err error) {
// 检查云印签支付服务是否启用
if l.svcCtx.YunYinSignPayService == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "云印签支付服务未启用")
}
// 验证参数order_no 和 participate_id 至少提供一个
if req.OrderNo == "" && req.ParticipateId == 0 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.REUQEST_PARAM_ERROR), "订单号和参与方ID至少需要提供一个")
}
// 查找订单(如果提供了订单号)
var order *model.Order
if req.OrderNo != "" {
order, err = l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查找订单失败,订单号: %s, 错误: %v", req.OrderNo, err)
}
// 验证订单支付平台
if order.PaymentPlatform != "yunyinSignPay" && order.PaymentPlatform != "yunyinSignPay_wechat" && order.PaymentPlatform != "yunyinSignPay_alipay" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.REUQEST_PARAM_ERROR), "订单不是云印签支付订单,订单号: %s, 支付平台: %s", req.OrderNo, order.PaymentPlatform)
}
// 验证订单状态
if order.Status != "paid" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.REUQEST_PARAM_ERROR), "订单状态不允许退款,订单号: %s, 状态: %s", req.OrderNo, order.Status)
}
// 验证退款金额
if req.RefundAmount > order.Amount {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.REUQEST_PARAM_ERROR), "退款金额不能超过订单金额,订单号: %s, 订单金额: %.2f, 退款金额: %.2f", req.OrderNo, order.Amount, req.RefundAmount)
}
}
// 调用云印签退款接口
// RefundPayeeBill 方法会自动处理:如果只提供了 sourceOrderCode会查询收款单获取 participateId
refundErr := l.svcCtx.YunYinSignPayService.RefundPayeeBill(
l.ctx,
req.OrderNo,
req.ParticipateId,
req.RefundAmount,
req.RefundReason,
)
if refundErr != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "云印签退款失败: %v", refundErr)
}
// 如果提供了订单号,更新订单状态和创建退款记录
if req.OrderNo != "" && order != nil {
// 检查是否全额退款
isFullRefund := req.RefundAmount >= order.Amount-0.01 // 允许1分钱的误差
// 创建退款记录并更新订单状态
err = l.createRefundRecordAndUpdateOrder(order, req, isFullRefund)
if err != nil {
logx.Errorf("创建退款记录或更新订单状态失败: %v", err)
// 不返回错误,因为退款已经成功
}
// 更新云印签订单表的支付状态
yunyinOrder, findYunyinErr := l.svcCtx.YunyinSignPayOrderModel.FindOneByOrderId(l.ctx, order.Id)
if findYunyinErr == nil && yunyinOrder != nil {
if isFullRefund {
// 全额退款更新为已退款状态2
yunyinOrder.PayStatus = 2
}
// 部分退款保持已支付状态1但可以通过其他字段记录退款金额
if updateYunyinErr := l.svcCtx.YunyinSignPayOrderModel.Update(l.ctx, yunyinOrder); updateYunyinErr != nil {
logx.Errorf("更新云印签订单状态失败: %v", updateYunyinErr)
}
}
}
logx.Infof("云印签退款成功,订单号: %s, 参与方ID: %d, 退款金额: %.2f", req.OrderNo, req.ParticipateId, req.RefundAmount)
return &types.YunYinSignPayRefundResp{
Success: true,
Message: "退款申请已提交",
}, nil
}
// createRefundRecordAndUpdateOrder 创建退款记录并更新订单状态
func (l *YunYinSignPayRefundLogic) createRefundRecordAndUpdateOrder(order *model.Order, req *types.YunYinSignPayRefundReq, isFullRefund bool) error {
return l.svcCtx.OrderModel.Trans(l.ctx, func(ctx context.Context, session sqlx.Session) error {
// 生成退款单号
refundNo := fmt.Sprintf("refund-%s", order.OrderNo)
// 创建退款记录
refund := &model.OrderRefund{
Id: uuid.NewString(),
RefundNo: refundNo,
PlatformRefundId: sql.NullString{String: "", Valid: false}, // 云印签退款没有平台退款单号
OrderId: order.Id,
UserId: order.UserId,
ProductId: order.ProductId,
RefundAmount: req.RefundAmount,
RefundReason: lzUtils.StringToNullString(req.RefundReason),
Status: "success", // 云印签退款是异步的,这里标记为成功
RefundTime: sql.NullTime{Time: time.Now(), Valid: true},
}
if _, err := l.svcCtx.OrderRefundModel.Insert(ctx, session, refund); err != nil {
return fmt.Errorf("创建退款记录失败: %v", err)
}
// 更新订单状态
if isFullRefund {
order.Status = "refunded"
}
// 部分退款保持 paid 状态
if _, err := l.svcCtx.OrderModel.Update(ctx, session, order); err != nil {
return fmt.Errorf("更新订单状态失败: %v", err)
}
return nil
})
}

View File

@@ -683,3 +683,172 @@ func (y *YunYinSignPayService) QueryPayeeBill(ctx context.Context, sourceOrderCo
RefundAmount: billItem.RefundAmount,
}, nil
}
// RefundPayeeBillRequest 退款请求
type RefundPayeeBillRequest struct {
SourceOrderCode string `json:"sourceOrderCode,omitempty"` // 来源订单号(与 participateId 二选一)
ParticipateId int64 `json:"participateId,omitempty"` // 参与方ID与 sourceOrderCode 二选一)
RefundAmount float64 `json:"refundAmount"` // 退款金额(必填)
RefundReason string `json:"refundReason,omitempty"` // 退款原因(可选)
}
// RefundPayeeBillResponse 退款响应
type RefundPayeeBillResponse struct {
Code interface{} `json:"code"` // 返回码,可能是字符串"200"或数字200
Msg string `json:"msg"` // 返回码的描述信息
Data interface{} `json:"data,omitempty"`
}
// GetCodeInt 获取 code 的 int 值
func (r *RefundPayeeBillResponse) GetCodeInt() int {
switch v := r.Code.(type) {
case int:
return v
case float64:
return int(v)
case string:
if v == "200" {
return 200
}
return 0
default:
return 0
}
}
// RefundPayeeBill 发起退款
func (y *YunYinSignPayService) RefundPayeeBill(ctx context.Context, sourceOrderCode string, participateId int64, refundAmount float64, refundReason string) error {
// 1. 获取token和操作ID带缓存
accessToken, err := y.GetAccessToken(ctx)
if err != nil {
return fmt.Errorf("获取云印签token失败: %v", err)
}
operationUserId, err := y.GetUserId(ctx, accessToken)
if err != nil {
return fmt.Errorf("获取云印签操作ID失败: %v", err)
}
// 2. 如果只提供了 sourceOrderCode需要先查询收款单获取 participateId
if participateId == 0 && sourceOrderCode != "" {
// 查询收款单列表获取 participateId
reqBody := QueryPayeeBillRequest{
SourceOrderCode: sourceOrderCode,
ListPageNo: 1,
ListPageSize: 10,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("序列化查询请求失败: %v", err)
}
url := fmt.Sprintf("%s/signFlowBill/payeeBillList", y.config.ApiURL)
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("创建查询请求失败: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("appId", y.config.AppID)
httpReq.Header.Set("accessToken", accessToken)
httpReq.Header.Set("userId", operationUserId)
httpReq.Header.Set("source", "pc")
resp, err := y.client.Do(httpReq)
if err != nil {
return fmt.Errorf("查询请求失败: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取查询响应失败: %v", err)
}
var queryResp QueryPayeeBillResponse
if err := json.Unmarshal(body, &queryResp); err != nil {
return fmt.Errorf("解析查询响应失败: %v, 响应内容: %s", err, string(body))
}
codeInt := queryResp.GetCodeInt()
if codeInt != 200 {
return fmt.Errorf("查询收款单失败: %s", queryResp.Msg)
}
if len(queryResp.Data) == 0 {
return fmt.Errorf("未找到匹配的收款单记录,订单号: %s", sourceOrderCode)
}
// 获取第一条记录的 participateId
billItem := queryResp.Data[0]
participateId = billItem.ParticipateID
logx.Infof("通过订单号查询到参与方ID: %d", participateId)
}
// 3. 验证参数
if participateId == 0 {
return fmt.Errorf("参与方ID不能为空请提供 participateId 或 sourceOrderCode")
}
if refundAmount <= 0 {
return fmt.Errorf("退款金额必须大于0")
}
// 4. 构建退款请求
refundReq := RefundPayeeBillRequest{
RefundAmount: refundAmount,
RefundReason: refundReason,
}
// 优先使用 participateId
if participateId > 0 {
refundReq.ParticipateId = participateId
} else if sourceOrderCode != "" {
// 如果 participateId 仍然为0使用 sourceOrderCode
refundReq.SourceOrderCode = sourceOrderCode
}
jsonData, err := json.Marshal(refundReq)
if err != nil {
return fmt.Errorf("序列化退款请求失败: %v", err)
}
// 5. 调用退款API
url := fmt.Sprintf("%s/signFlowBill/refund", y.config.ApiURL)
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("创建退款请求失败: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("appId", y.config.AppID)
httpReq.Header.Set("accessToken", accessToken)
httpReq.Header.Set("userId", operationUserId)
httpReq.Header.Set("source", "pc")
resp, err := y.client.Do(httpReq)
if err != nil {
return fmt.Errorf("退款请求失败: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取退款响应失败: %v", err)
}
var refundResp RefundPayeeBillResponse
if err := json.Unmarshal(body, &refundResp); err != nil {
return fmt.Errorf("解析退款响应失败: %v, 响应内容: %s", err, string(body))
}
// 6. 检查响应码
codeInt := refundResp.GetCodeInt()
if codeInt != 200 {
return fmt.Errorf("退款失败: %s", refundResp.Msg)
}
logx.Infof("云印签退款成功参与方ID: %d, 退款金额: %.2f, 退款原因: %s", participateId, refundAmount, refundReason)
return nil
}

View File

@@ -26,3 +26,15 @@ type PaymentResp struct {
PrepayId string `json:"prepay_id"`
OrderNo string `json:"order_no"`
}
type YunYinSignPayRefundReq struct {
OrderNo string `json:"order_no"` // 订单号sourceOrderCode与 participate_id 二选一
ParticipateId int64 `json:"participate_id,omitempty"` // 参与方ID与 order_no 二选一
RefundAmount float64 `json:"refund_amount" validate:"required,gt=0"` // 退款金额必须大于0
RefundReason string `json:"refund_reason,omitempty"` // 退款原因
}
type YunYinSignPayRefundResp struct {
Success bool `json:"success"`
Message string `json:"message"`
}