Files
junhong_cmp_fiber/internal/query/packagesilence/query.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

383 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
}
return items[0], 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
}
// ListOutboundRecords 按资产查询各世代出库记录(叠加资产数据范围)。
func (q *Query) ListOutboundRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetOutboundRecordResponse, error) {
if assetType == "card" {
assetType = constants.OutboundAssetTypeIotCard
}
table := "tb_iot_card"
if assetType == constants.OutboundAssetTypeDevice {
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.AssetOutboundRecord
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.AssetOutboundRecordResponse, 0, len(records))
for _, record := range records {
items = append(items, dto.AssetOutboundRecordResponse{
ID: record.ID, AssetType: record.AssetType, AssetID: record.AssetID, AssetIdentifier: record.AssetIdentifierSnapshot,
Generation: record.Generation, OutboundAt: record.OutboundAt, Source: record.Source, SourceName: outboundSourceName(record.Source),
OrderNo: record.OrderNo, ExchangeNo: record.ExchangeNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt,
})
}
return items, nil
}
func outboundSourceName(source string) string {
switch source {
case constants.OutboundSourceOrder:
return "首次购买套餐"
case constants.OutboundSourceExchange:
return "换货新资产"
case constants.OutboundSourceBackfill:
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
}