fix: 延期订单超时不关闭、无法继续支付、不能作废
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:
邹进
2026-11-07 20:31:45 +09:00
parent 226a15573a
commit d3face6f66
8 changed files with 244 additions and 3 deletions

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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", &notExist), 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)
}
})
}
}

View File

@@ -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:

View File

@@ -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"`

View File

@@ -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 批量补齐延期订单的支付单快照与批次号(列表、详情与导出共用)。

View File

@@ -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,