Files
junhong_cmp_fiber/internal/query/packagesilence/query.go
邹进 d3face6f66
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
fix: 延期订单超时不关闭、无法继续支付、不能作废
- 支付宝查单把 ACQ.TRADE_NOT_EXIST 当成查单失败,恢复任务永远判为未知,订单停在待支付;
  同一套餐再下单时旧单也关不掉,新单无法创建。改为按"渠道无此订单"处理,过支付截止后关闭
- 延期订单详情对未过期的待支付订单返回 qr_content,后台可展示二维码、H5 可继续支付
- 新增作废待支付订单:后台 POST /orders/:id/close、H5 POST /orders/:id/cancel;作废前先查单,已支付则按支付成功办理
2026-11-07 20:31:45 +09:00

396 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package packagesilence 提供沉默期延期订单、批次、即将过期 / 已过期套餐与首次购买套餐记录的只读查询。
package packagesilence
import (
"context"
"strings"
"time"
"gorm.io/gorm"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// Query 是沉默期只读查询。
type Query struct {
db *gorm.DB
now func() time.Time
}
// NewQuery 创建沉默期只读查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, now: time.Now}
}
// OrderFilter 是延期订单筛选(后台列表、导出与 H5 我的订单共用)。
type OrderFilter struct {
Request dto.SilenceOrderListRequest
CustomerID uint // H5:只看自己的订单
}
// OrderQuery 构造延期订单查询(叠加店铺数据范围)。
func (q *Query) OrderQuery(ctx context.Context, filter OrderFilter) (*gorm.DB, error) {
req := filter.Request
query := q.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).Table("tb_package_silence_extension_order AS o")
if filter.CustomerID > 0 {
query = query.Where("o.customer_id = ?", filter.CustomerID)
} else {
query = applyStrictShop(ctx, query, "o.shop_id")
}
if req.PackageUsageID != nil {
query = query.Where("o.package_usage_id = ?", *req.PackageUsageID)
}
if req.PackageID != nil {
query = query.Where("o.package_id = ?", *req.PackageID)
}
if value := strings.TrimSpace(req.AssetIdentifier); value != "" {
query = query.Where("o.asset_identifier_snapshot = ?", value)
}
if value := strings.TrimSpace(req.OrderNo); value != "" {
query = query.Where("o.order_no = ?", value)
}
if req.Status != "" {
query = query.Where("o.status = ?", req.Status)
}
if req.Source != "" {
query = query.Where("o.source = ?", req.Source)
}
if req.PaymentMethod != "" {
query = query.Where("o.payment_method = ?", req.PaymentMethod)
}
if value := strings.TrimSpace(req.MerchantName); value != "" {
query = query.Where("EXISTS (SELECT 1 FROM tb_payment p WHERE p.id = o.paid_payment_id AND p.merchant_name_snapshot LIKE ?)", "%"+value+"%")
}
if value := strings.TrimSpace(req.ThirdPartyTradeNo); value != "" {
query = query.Where("(o.offline_trade_no = ? OR EXISTS (SELECT 1 FROM tb_payment p WHERE p.id = o.paid_payment_id AND p.third_party_trade_no = ?))", value, value)
}
if req.PaidStart != "" {
start, err := time.Parse(time.RFC3339, req.PaidStart)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "支付时间起格式应为 RFC3339")
}
query = query.Where("o.paid_at >= ?", start)
}
if req.PaidEnd != "" {
end, err := time.Parse(time.RFC3339, req.PaidEnd)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "支付时间止格式应为 RFC3339")
}
query = query.Where("o.paid_at <= ?", end)
}
return query, nil
}
// ListOrders 分页查询延期订单;支付方式、收款商户、交易流水号取已支付或已退款支付单快照。
func (q *Query) ListOrders(ctx context.Context, filter OrderFilter) (*dto.SilenceOrderPageResult, error) {
page, pageSize := normalizePage(filter.Request.Page, filter.Request.PageSize)
query, err := q.OrderQuery(ctx, filter)
if err != nil {
return nil, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计延期订单失败")
}
var orders []model.PackageSilenceExtensionOrder
if err := query.Select("o.*").Order("o.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&orders).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
items, err := q.OrderItems(ctx, orders)
if err != nil {
return nil, err
}
return &dto.SilenceOrderPageResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// GetOrder 查询延期订单详情(叠加数据范围或客户归属)。
func (q *Query) GetOrder(ctx context.Context, id uint, customerID uint) (*dto.SilenceExtensionOrderResponse, error) {
query, err := q.OrderQuery(ctx, OrderFilter{CustomerID: customerID})
if err != nil {
return nil, err
}
var order model.PackageSilenceExtensionOrder
if err := query.Select("o.*").Where("o.id = ?", id).First(&order).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
items, err := q.OrderItems(ctx, []model.PackageSilenceExtensionOrder{order})
if err != nil {
return nil, err
}
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 批量补齐延期订单的支付单快照与批次号(列表、详情与导出共用)。
func (q *Query) OrderItems(ctx context.Context, orders []model.PackageSilenceExtensionOrder) ([]*dto.SilenceExtensionOrderResponse, error) {
paymentIDs := make([]uint, 0, len(orders))
batchIDs := make([]uint, 0, len(orders))
for index := range orders {
if orders[index].PaidPaymentID != nil {
paymentIDs = append(paymentIDs, *orders[index].PaidPaymentID)
}
batchIDs = append(batchIDs, orders[index].BatchID)
}
payments := map[uint]model.Payment{}
if len(paymentIDs) > 0 {
var rows []model.Payment
if err := q.db.WithContext(ctx).Where("id IN ?", paymentIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
for _, row := range rows {
payments[row.ID] = row
}
}
batchNos := map[uint]string{}
if len(batchIDs) > 0 {
var rows []model.PackageSilenceExtensionBatch
if err := q.db.WithContext(ctx).Select("id", "batch_no").Where("id IN ?", batchIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
for _, row := range rows {
batchNos[row.ID] = row.BatchNo
}
}
items := make([]*dto.SilenceExtensionOrderResponse, 0, len(orders))
for index := range orders {
order := &orders[index]
item := packagesilenceapp.OrderDTO(order)
item.BatchNo = batchNos[order.BatchID]
if order.PaidPaymentID != nil {
if payment, ok := payments[*order.PaidPaymentID]; ok {
item.PaymentMerchantName = payment.MerchantNameSnapshot
item.ThirdPartyTradeNo = payment.ThirdPartyTradeNo
item.PaymentNo = payment.PaymentNo
}
}
items = append(items, item)
}
return items, nil
}
// ListBatches 分页查询延期批次(平台与有数据范围的账号按批次店铺标签过滤)。
func (q *Query) ListBatches(ctx context.Context, req dto.SilenceBatchListRequest) (*dto.SilenceBatchPageResult, error) {
page, pageSize := normalizePage(req.Page, req.PageSize)
query := applyStrictShop(ctx, q.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).Table("tb_package_silence_extension_batch AS b"), "b.shop_id_tag")
if value := strings.TrimSpace(req.BatchNo); value != "" {
query = query.Where("b.batch_no = ?", value)
}
if req.Path != "" {
query = query.Where("b.path = ?", req.Path)
}
if req.ExtensionType != "" {
query = query.Where("b.extension_type = ?", req.ExtensionType)
}
if req.Status != "" {
query = query.Where("b.status = ?", req.Status)
}
if req.HasException != nil && *req.HasException {
query = query.Where("EXISTS (SELECT 1 FROM tb_package_silence_extension e WHERE e.batch_id = b.id AND e.status = ?)", constants.SilenceExtensionStatusException)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计延期批次失败")
}
var batches []model.PackageSilenceExtensionBatch
if err := query.Select("b.*").Order("b.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&batches).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
items := make([]*dto.SilenceBatchResponse, 0, len(batches))
for index := range batches {
items = append(items, packagesilenceapp.BatchDTO(&batches[index]))
}
return &dto.SilenceBatchPageResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// usageRow 是即将过期 / 已过期套餐查询行。
type usageRow struct {
model.PackageUsage
CardICCID string `gorm:"column:card_iccid"`
DeviceNo string `gorm:"column:device_no"`
AssetShopID *uint `gorm:"column:asset_shop_id"`
AssetShopNam string `gorm:"column:asset_shop_name"`
}
// UsageQuery 构造即将过期 / 已过期套餐查询(叠加资产店铺数据范围)。
func (q *Query) UsageQuery(ctx context.Context, req dto.SilenceUsageListRequest) (*gorm.DB, error) {
now := q.now()
query := q.db.WithContext(ctx).Table("tb_package_usage AS u").
Joins("LEFT JOIN tb_iot_card AS c ON c.id = u.iot_card_id AND COALESCE(u.device_id, 0) = 0").
Joins("LEFT JOIN tb_device AS d ON d.id = u.device_id AND COALESCE(u.device_id, 0) > 0").
Joins("LEFT JOIN tb_shop AS s ON s.id = COALESCE(d.shop_id, c.shop_id)").
Where("u.deleted_at IS NULL AND u.status = ? AND u.master_usage_id IS NULL", constants.PackageUsageStatusPending)
switch req.View {
case "expired":
query = query.Where("(u.silence_status = ? OR (u.silence_status = ? AND u.silence_deadline_at < ? AND u.silence_adjust_pending = FALSE))",
constants.SilenceStatusExpired, constants.SilenceStatusWaiting, now)
case "all":
query = query.Where("u.silence_status IN ?", []string{constants.SilenceStatusWaiting, constants.SilenceStatusExpired})
default:
within := req.WithinDays
if within <= 0 {
within = constants.SilenceExpiringSoonDaysDefault
}
query = query.Where("u.silence_status = ? AND u.silence_deadline_at >= ? AND u.silence_deadline_at < ?",
constants.SilenceStatusWaiting, now, now.Add(time.Duration(within)*24*time.Hour))
}
if value := strings.TrimSpace(req.AssetIdentifier); value != "" {
query = query.Where("(c.iccid = ? OR c.virtual_no = ? OR d.virtual_no = ? OR d.imei = ?)", value, value, value, value)
}
if req.PackageID != nil {
query = query.Where("u.package_id = ?", *req.PackageID)
}
if req.ShopID != nil {
query = query.Where("COALESCE(d.shop_id, c.shop_id) = ?", *req.ShopID)
}
if req.DeadlineStart != "" {
start, err := time.Parse(time.RFC3339, req.DeadlineStart)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "截止时间起格式应为 RFC3339")
}
query = query.Where("u.silence_deadline_at >= ?", start)
}
if req.DeadlineEnd != "" {
end, err := time.Parse(time.RFC3339, req.DeadlineEnd)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "截止时间止格式应为 RFC3339")
}
query = query.Where("u.silence_deadline_at <= ?", end)
}
return applyStrictShop(ctx, query, "COALESCE(d.shop_id, c.shop_id)"), nil
}
// ListUsages 分页查询即将过期 / 已过期套餐。
func (q *Query) ListUsages(ctx context.Context, req dto.SilenceUsageListRequest) (*dto.SilenceUsagePageResult, error) {
page, pageSize := normalizePage(req.Page, req.PageSize)
query, err := q.UsageQuery(ctx, req)
if err != nil {
return nil, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计沉默期套餐失败")
}
rows, err := q.FetchUsages(ctx, query, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
return &dto.SilenceUsagePageResult{Items: rows, Total: total, Page: page, PageSize: pageSize}, nil
}
// FetchUsages 按偏移读取即将过期 / 已过期套餐(列表与导出共用)。
func (q *Query) FetchUsages(ctx context.Context, query *gorm.DB, offset, limit int) ([]*dto.SilenceUsageItem, error) {
var rows []usageRow
if err := query.Select("u.*, c.iccid AS card_iccid, COALESCE(NULLIF(d.virtual_no, ''), d.imei) AS device_no, COALESCE(d.shop_id, c.shop_id) AS asset_shop_id, COALESCE(s.shop_name, '') AS asset_shop_name").
Order("u.silence_deadline_at ASC, u.id ASC").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期套餐失败")
}
now := q.now()
items := make([]*dto.SilenceUsageItem, 0, len(rows))
for index := range rows {
row := &rows[index]
assetType, assetID, identifier := constants.SilenceAssetTypeIotCard, row.IotCardID, row.CardICCID
if row.DeviceID > 0 {
assetType, assetID, identifier = constants.SilenceAssetTypeDevice, row.DeviceID, row.DeviceNo
}
items = append(items, &dto.SilenceUsageItem{
PackageUsageID: row.ID, OrderNo: row.OrderNo, PackageID: row.PackageID, PackageName: row.PackageName,
AssetType: assetType, AssetID: assetID, AssetIdentifier: identifier, ShopID: row.AssetShopID,
ShopName: row.AssetShopNam, CreatedAt: row.CreatedAt, Silence: packagesilenceapp.BuildView(&row.PackageUsage, now),
})
}
return items, nil
}
// ListFirstPurchaseRecords 按资产查询各世代首次购买套餐记录(叠加资产数据范围)。
func (q *Query) ListFirstPurchaseRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetFirstPurchaseRecordResponse, error) {
if assetType == "card" {
assetType = constants.FirstPurchaseAssetTypeIotCard
}
table := "tb_iot_card"
if assetType == constants.FirstPurchaseAssetTypeDevice {
table = "tb_device"
}
var count int64
if err := middleware.ApplyStrictShopFilter(ctx, q.db.WithContext(ctx).Table(table).Where("id = ? AND deleted_at IS NULL", assetID)).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败")
}
if count == 0 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
var records []model.AssetFirstPurchaseRecord
if err := q.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID).Order("generation DESC").Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询首次购买套餐记录失败")
}
items := make([]dto.AssetFirstPurchaseRecordResponse, 0, len(records))
for _, record := range records {
items = append(items, dto.AssetFirstPurchaseRecordResponse{
ID: record.ID, AssetType: record.AssetType, AssetID: record.AssetID, AssetIdentifier: record.AssetIdentifierSnapshot,
Generation: record.Generation, FirstPurchaseAt: record.FirstPurchaseAt, Source: record.Source, SourceName: firstPurchaseSourceName(record.Source),
OrderNo: record.OrderNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt,
})
}
return items, nil
}
func firstPurchaseSourceName(source string) string {
switch source {
case constants.FirstPurchaseSourceOrder:
return "首次购买套餐"
case constants.FirstPurchaseSourceBackfill:
return "历史回填"
default:
return source
}
}
// applyStrictShop 按严格店铺数据范围过滤:超管与平台不限;代理只看自己及下级店铺;其他账号无数据。
func applyStrictShop(ctx context.Context, query *gorm.DB, column string) *gorm.DB {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return query
case constants.UserTypeAgent:
if shopIDs := middleware.GetSubordinateShopIDs(ctx); len(shopIDs) > 0 {
return query.Where(column+" IN ?", shopIDs)
}
if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 {
return query.Where(column+" = ?", shopID)
}
}
return query.Where("1 = 0")
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}