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