From d3face6f66af6ed41b2a73fb8db70780d20abefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sat, 7 Nov 2026 20:31:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BB=B6=E6=9C=9F=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E4=B8=8D=E5=85=B3=E9=97=AD=E3=80=81=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E7=BB=A7=E7=BB=AD=E6=94=AF=E4=BB=98=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E4=BD=9C=E5=BA=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支付宝查单把 ACQ.TRADE_NOT_EXIST 当成查单失败,恢复任务永远判为未知,订单停在待支付; 同一套餐再下单时旧单也关不掉,新单无法创建。改为按"渠道无此订单"处理,过支付截止后关闭 - 延期订单详情对未过期的待支付订单返回 qr_content,后台可展示二维码、H5 可继续支付 - 新增作废待支付订单:后台 POST /orders/:id/close、H5 POST /orders/:id/cancel;作废前先查单,已支付则按支付成功办理 --- .../packagesilence/order_cancel.go | 101 ++++++++++++++++++ internal/handler/admin/package_silence.go | 25 +++++ .../handler/app/client_package_silence.go | 25 +++++ .../payment/alipay_trade_not_exist_test.go | 31 ++++++ internal/infrastructure/payment/alipay_wap.go | 25 +++++ internal/model/dto/package_silence_dto.go | 6 ++ internal/query/packagesilence/query.go | 17 ++- internal/routes/package_silence.go | 17 ++- 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 internal/application/packagesilence/order_cancel.go create mode 100644 internal/infrastructure/payment/alipay_trade_not_exist_test.go diff --git a/internal/application/packagesilence/order_cancel.go b/internal/application/packagesilence/order_cancel.go new file mode 100644 index 00000000..8d17cb9e --- /dev/null +++ b/internal/application/packagesilence/order_cancel.go @@ -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 +} diff --git a/internal/handler/admin/package_silence.go b/internal/handler/admin/package_silence.go index ab3fbda4..07953a1e 100644 --- a/internal/handler/admin/package_silence.go +++ b/internal/handler/admin/package_silence.go @@ -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 diff --git a/internal/handler/app/client_package_silence.go b/internal/handler/app/client_package_silence.go index e4c7fb8e..c2ae039a 100644 --- a/internal/handler/app/client_package_silence.go +++ b/internal/handler/app/client_package_silence.go @@ -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 diff --git a/internal/infrastructure/payment/alipay_trade_not_exist_test.go b/internal/infrastructure/payment/alipay_trade_not_exist_test.go new file mode 100644 index 00000000..d1bbb7b6 --- /dev/null +++ b/internal/infrastructure/payment/alipay_trade_not_exist_test.go @@ -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) + } + }) + } +} diff --git a/internal/infrastructure/payment/alipay_wap.go b/internal/infrastructure/payment/alipay_wap.go index 104786ec..22faea76 100644 --- a/internal/infrastructure/payment/alipay_wap.go +++ b/internal/infrastructure/payment/alipay_wap.go @@ -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: diff --git a/internal/model/dto/package_silence_dto.go b/internal/model/dto/package_silence_dto.go index e4a009c1..299e2cff 100644 --- a/internal/model/dto/package_silence_dto.go +++ b/internal/model/dto/package_silence_dto.go @@ -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"` diff --git a/internal/query/packagesilence/query.go b/internal/query/packagesilence/query.go index ebce3fe1..957f4ae5 100644 --- a/internal/query/packagesilence/query.go +++ b/internal/query/packagesilence/query.go @@ -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 批量补齐延期订单的支付单快照与批次号(列表、详情与导出共用)。 diff --git a/internal/routes/package_silence.go b/internal/routes/package_silence.go index 6e373aa3..cfa43b8d 100644 --- a/internal/routes/package_silence.go +++ b/internal/routes/package_silence.go @@ -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,