fix: 延期订单超时不关闭、无法继续支付、不能作废
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 支付宝查单把 ACQ.TRADE_NOT_EXIST 当成查单失败,恢复任务永远判为未知,订单停在待支付; 同一套餐再下单时旧单也关不掉,新单无法创建。改为按"渠道无此订单"处理,过支付截止后关闭 - 延期订单详情对未过期的待支付订单返回 qr_content,后台可展示二维码、H5 可继续支付 - 新增作废待支付订单:后台 POST /orders/:id/close、H5 POST /orders/:id/cancel;作废前先查单,已支付则按支付成功办理
This commit is contained in:
101
internal/application/packagesilence/order_cancel.go
Normal file
101
internal/application/packagesilence/order_cancel.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package packagesilence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CancelOrderCommand 是作废待支付延期订单的命令:Scope.CustomerID 非 0 为 H5 客户作废自己的订单,为 0 为后台运营作废。
|
||||
type CancelOrderCommand struct {
|
||||
OrderID uint
|
||||
Scope Scope
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CancelPendingOrder 作废未支付的在线延期订单,作废后同一套餐可以重新下单。
|
||||
//
|
||||
// 作废前先按冻结商户查单:渠道已支付则按支付成功处理(照常延期)并拒绝作废;
|
||||
// 其余情况(未支付、渠道无此订单、查单失败)直接关闭。关闭后才到达的支付会进入异常并由恢复任务原路退款,不会吞款。
|
||||
func (s *ExtensionService) CancelPendingOrder(ctx context.Context, command CancelOrderCommand) (*dto.SilenceExtensionOrderResponse, error) {
|
||||
if command.Scope.CustomerID == 0 {
|
||||
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
order, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, command.OrderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "延期订单不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
|
||||
}
|
||||
if command.Scope.CustomerID != 0 && (order.CustomerID == nil || *order.CustomerID != command.Scope.CustomerID) {
|
||||
return nil, errors.New(errors.CodeNotFound, "延期订单不存在")
|
||||
}
|
||||
if order.Status != constants.SilenceOrderStatusPendingPayment {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "只有待支付的延期订单可以作废")
|
||||
}
|
||||
|
||||
paid, err := s.confirmIfPaid(ctx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if paid {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "该订单已支付,已按支付成功办理延期,不能作废")
|
||||
}
|
||||
|
||||
reason := "手动作废"
|
||||
if command.Scope.CustomerID != 0 {
|
||||
reason = "客户作废"
|
||||
}
|
||||
if extra := strings.TrimSpace(command.Reason); extra != "" {
|
||||
reason += ":" + extra
|
||||
}
|
||||
if err := s.closeOrder(ctx, order.ID, reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, order.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
|
||||
}
|
||||
return s.orderResponse(ctx, current, false)
|
||||
}
|
||||
|
||||
// confirmIfPaid 查询订单下待支付的支付单;渠道已支付时走支付确认并返回 true。查单失败不阻止作废。
|
||||
func (s *ExtensionService) confirmIfPaid(ctx context.Context, order *model.PackageSilenceExtensionOrder) (bool, error) {
|
||||
if order.PaymentMethod == model.PaymentByOffline {
|
||||
return false, nil
|
||||
}
|
||||
var payments []model.Payment
|
||||
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
|
||||
Order("id DESC").Find(&payments).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
|
||||
}
|
||||
for index := range payments {
|
||||
payment := &payments[index]
|
||||
state, query, err := s.queryPayment(ctx, payment)
|
||||
if err != nil || state != agentrechargeApp.OnlinePaymentStatePaid {
|
||||
continue
|
||||
}
|
||||
paidAt := s.now()
|
||||
if query.PaidAt != nil {
|
||||
paidAt = *query.PaidAt
|
||||
}
|
||||
if err := s.ConfirmPayment(ctx, PaymentConfirmCommand{
|
||||
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: query.ThirdPartyTradeNo, Amount: query.Amount, PaidAt: paidAt,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -259,6 +259,31 @@ func (h *PackageSilenceHandler) GetOrder(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// CloseOrder 作废待支付的延期订单。POST /api/admin/package-silence/orders/:id/close
|
||||
func (h *PackageSilenceHandler) CloseOrder(c *fiber.Ctx) error {
|
||||
id, err := silencePathID(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var req dto.SilenceOrderCancelRequest
|
||||
if len(c.Body()) > 0 {
|
||||
if err := h.bindBody(c, "作废参数不合法", &req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 先按数据范围查详情:看不到的订单不能作废
|
||||
if _, err := h.query.GetOrder(c.UserContext(), id, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.extensions.CancelPendingOrder(c.UserContext(), packagesilenceapp.CancelOrderCommand{
|
||||
OrderID: id, Reason: req.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// SubmitSpecial 提交特殊延期(单个 / 批量)。POST /api/admin/package-silence/special
|
||||
func (h *PackageSilenceHandler) SubmitSpecial(c *fiber.Ctx) error {
|
||||
var req dto.SilenceSpecialRequest
|
||||
|
||||
@@ -145,6 +145,31 @@ func (h *ClientPackageSilenceHandler) GetOrder(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// CancelOrder 客户作废自己未支付的延期订单。POST /api/c/v1/package-silence/orders/:id/cancel
|
||||
func (h *ClientPackageSilenceHandler) CancelOrder(c *fiber.Ctx) error {
|
||||
id, err := c.ParamsInt("id")
|
||||
if err != nil || id <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var req dto.SilenceOrderCancelRequest
|
||||
if len(c.Body()) > 0 {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
}
|
||||
ctx, customerID, err := h.customerContext(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.extensions.CancelPendingOrder(ctx, packagesilenceapp.CancelOrderCommand{
|
||||
OrderID: uint(id), Scope: packagesilenceapp.Scope{CustomerID: customerID}, Reason: req.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListRecords 套餐沉默期流水。GET /api/c/v1/package-silence/records
|
||||
func (h *ClientPackageSilenceHandler) ListRecords(c *fiber.Ctx) error {
|
||||
var req dto.SilenceRecordListRequest
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
sdkalipay "github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
func TestAlipayTradeNotExist(t *testing.T) {
|
||||
notExist := sdkalipay.Error{Code: sdkalipay.Code("40004"), SubCode: alipayTradeNotExistCode}
|
||||
tests := []struct {
|
||||
name string
|
||||
response *sdkalipay.TradeQueryRsp
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "失败响应且交易不存在", response: &sdkalipay.TradeQueryRsp{Error: notExist}, want: true},
|
||||
{name: "SDK 以错误返回交易不存在", err: fmt.Errorf("wrap: %w", ¬Exist), want: true},
|
||||
{name: "其他业务失败", response: &sdkalipay.TradeQueryRsp{Error: sdkalipay.Error{Code: sdkalipay.Code("40004"), SubCode: "ACQ.SYSTEM_ERROR"}}, want: false},
|
||||
{name: "成功响应", response: &sdkalipay.TradeQueryRsp{Error: sdkalipay.Error{Code: sdkalipay.CodeSuccess}, TradeStatus: sdkalipay.TradeStatusWaitBuyerPay}, want: false},
|
||||
{name: "网络错误", err: fmt.Errorf("dial tcp: timeout"), want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := alipayTradeNotExist(tt.response, tt.err); got != tt.want {
|
||||
t.Fatalf("alipayTradeNotExist() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -54,6 +55,18 @@ func (a *AlipayWapAdapter) Query(ctx context.Context, request agentrecharge.Onli
|
||||
}
|
||||
startedAt := time.Now()
|
||||
response, callErr := client.TradeQuery(ctx, sdkalipay.TradeQuery{OutTradeNo: request.PaymentNo})
|
||||
// 客户没打开过支付链接时支付宝侧没有这笔交易(ACQ.TRADE_NOT_EXIST):按"渠道没有这笔订单"返回,
|
||||
// 恢复任务在过支付截止后据此关闭订单;不能当成查单失败,否则订单永远停在待支付。
|
||||
if alipayTradeNotExist(response, callErr) {
|
||||
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess, ProviderCode: alipayTradeNotExistCode,
|
||||
ResponseSummary: map[string]any{"state": agentrecharge.OnlinePaymentStateNotFound},
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
}); err != nil {
|
||||
return agentrecharge.OnlinePaymentQueryResult{}, err
|
||||
}
|
||||
return agentrecharge.OnlinePaymentQueryResult{State: agentrecharge.OnlinePaymentStateNotFound}, nil
|
||||
}
|
||||
if callErr != nil {
|
||||
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
|
||||
}
|
||||
@@ -128,6 +141,18 @@ func (a *AlipayWapAdapter) completeUnknown(ctx context.Context, integrationID st
|
||||
return apperrors.Wrap(apperrors.CodeTimeout, cause, "支付宝支付请求结果未知")
|
||||
}
|
||||
|
||||
// alipayTradeNotExistCode 是支付宝查单"交易不存在"的业务子码。
|
||||
const alipayTradeNotExistCode = "ACQ.TRADE_NOT_EXIST"
|
||||
|
||||
// alipayTradeNotExist 判断查单结果是否为"交易不存在":SDK 可能以失败响应返回,也可能以 *Error 返回。
|
||||
func alipayTradeNotExist(response *sdkalipay.TradeQueryRsp, callErr error) bool {
|
||||
if response != nil && response.IsFailure() && response.SubCode == alipayTradeNotExistCode {
|
||||
return true
|
||||
}
|
||||
var sdkErr *sdkalipay.Error
|
||||
return stderrors.As(callErr, &sdkErr) && sdkErr != nil && sdkErr.SubCode == alipayTradeNotExistCode
|
||||
}
|
||||
|
||||
func mapAlipayTradeState(state sdkalipay.TradeStatus) string {
|
||||
switch state {
|
||||
case sdkalipay.TradeStatusSuccess, sdkalipay.TradeStatusFinished:
|
||||
|
||||
@@ -410,6 +410,12 @@ type SilenceIDPath struct {
|
||||
ID uint `json:"id" params:"id" path:"id" validate:"required,min=1" required:"true" description:"ID"`
|
||||
}
|
||||
|
||||
// SilenceOrderCancelRequest 作废待支付延期订单请求。
|
||||
type SilenceOrderCancelRequest struct {
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required,min=1" required:"true" description:"延期订单 ID"`
|
||||
Reason string `json:"reason" validate:"omitempty,max=200" maxLength:"200" description:"作废原因(选填)"`
|
||||
}
|
||||
|
||||
// SilenceRecordListRequest 沉默期流水查询请求。
|
||||
type SilenceRecordListRequest struct {
|
||||
PackageUsageID uint `json:"package_usage_id" query:"package_usage_id" validate:"required,min=1" required:"true" description:"套餐记录 ID"`
|
||||
|
||||
@@ -125,7 +125,22 @@ func (q *Query) GetOrder(ctx context.Context, id uint, customerID uint) (*dto.Si
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items[0], nil
|
||||
item := items[0]
|
||||
// 待支付且未过支付截止的订单返回支付内容,前端据此展示二维码 / 继续支付;列表不返回。
|
||||
if order.Status == constants.SilenceOrderStatusPendingPayment && (order.ExpireAt == nil || time.Now().Before(*order.ExpireAt)) {
|
||||
var payment model.Payment
|
||||
err := q.db.WithContext(ctx).Select("payment_no", "qr_content").
|
||||
Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
|
||||
Order("id DESC").First(&payment).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
|
||||
}
|
||||
if err == nil {
|
||||
item.QRContent = payment.QRContent
|
||||
item.PaymentNo = payment.PaymentNo
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// OrderItems 批量补齐延期订单的支付单快照与批次号(列表、详情与导出共用)。
|
||||
|
||||
@@ -77,7 +77,14 @@ func registerPackageSilenceRoutes(router fiber.Router, handler *admin.PackageSil
|
||||
Tags: extTags, Input: new(dto.SilenceOrderListRequest), Output: new(dto.SilenceOrderPageResult), Auth: true,
|
||||
})
|
||||
Register(group, doc, path, "GET", "/orders/:id", handler.GetOrder, RouteSpec{
|
||||
Summary: "延期订单详情", Tags: extTags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceExtensionOrderResponse), Auth: true,
|
||||
Summary: "延期订单详情", Description: "待支付且未过支付截止的订单返回 qr_content,可继续扫码支付。",
|
||||
Tags: extTags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceExtensionOrderResponse), Auth: true,
|
||||
})
|
||||
Register(group, doc, path, "POST", "/orders/:id/close", handler.CloseOrder, RouteSpec{
|
||||
Summary: "作废待支付延期订单",
|
||||
Description: "需 package_silence:extend 权限,只能作废数据范围内待支付的在线订单。作废前先查单:渠道已支付则按支付成功办理延期并拒绝作废;" +
|
||||
"作废后同一套餐可重新下单,作废后才到达的支付进入异常并原路退款。",
|
||||
Tags: extTags, Input: new(dto.SilenceOrderCancelRequest), Output: new(dto.SilenceExtensionOrderResponse), Auth: true,
|
||||
})
|
||||
Register(group, doc, path, "POST", "/special", handler.SubmitSpecial, RouteSpec{
|
||||
Summary: "提交特殊延期(单个 / 批量)",
|
||||
@@ -130,7 +137,13 @@ func registerPersonalPackageSilenceRoutes(router fiber.Router, handler *apphandl
|
||||
Summary: "我的延期订单", Tags: tags, Input: new(dto.SilenceOrderListRequest), Output: new(dto.SilenceOrderPageResult), Auth: true,
|
||||
})
|
||||
Register(group, doc, path, "GET", "/orders/:id", handler.GetOrder, RouteSpec{
|
||||
Summary: "我的延期订单详情", Tags: tags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceExtensionOrderResponse), Auth: true,
|
||||
Summary: "我的延期订单详情", Description: "待支付且未过支付截止的订单返回 qr_content,可继续支付。",
|
||||
Tags: tags, Input: new(dto.SilenceIDPath), Output: new(dto.SilenceExtensionOrderResponse), Auth: true,
|
||||
})
|
||||
Register(group, doc, path, "POST", "/orders/:id/cancel", handler.CancelOrder, RouteSpec{
|
||||
Summary: "作废我的待支付延期订单",
|
||||
Description: "只能作废自己的待支付订单。作废前先查单:已支付则按支付成功办理延期并拒绝作废;作废后可重新下单。",
|
||||
Tags: tags, Input: new(dto.SilenceOrderCancelRequest), Output: new(dto.SilenceExtensionOrderResponse), Auth: true,
|
||||
})
|
||||
Register(group, doc, path, "GET", "/records", handler.ListRecords, RouteSpec{
|
||||
Summary: "套餐沉默期流水", Tags: tags, Input: new(dto.SilenceRecordListRequest), Output: new([]dto.SilenceRecordResponse), Auth: true,
|
||||
|
||||
Reference in New Issue
Block a user