Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名 - 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段 - 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
16 KiB
Go
381 lines
16 KiB
Go
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|