package exporter import ( "context" "strconv" "time" "gorm.io/gorm" packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" "github.com/break/junhong_cmp_fiber/pkg/constants" ) // IotCardDataSource IoT 卡导出数据源。 type IotCardDataSource struct { db *gorm.DB expiryQ *packageexpiryquery.Query } // NewIotCardDataSource 创建 IoT 卡导出数据源。 func NewIotCardDataSource(db *gorm.DB) *IotCardDataSource { return &IotCardDataSource{db: db, expiryQ: packageexpiryquery.NewQuery(db)} } // Scene 返回导出场景编码。 func (s *IotCardDataSource) Scene() string { return constants.ExportTaskSceneIotCard } // Count 统计 IoT 卡导出行数。 func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int, error) { var total int64 query := s.applyFilters(ctx, s.baseQuery(ctx), params) if err := query.Count(&total).Error; err != nil { return 0, err } return int(total), nil } // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { // 「关联手机号」「套餐到期时间」「使用流量(GB)」「剩余流量(GB)」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { return [][]string{}, nil } var items []iotCardExportRow query := s.applyFilters(ctx, s.baseQuery(ctx), params). Select(` c.id, c.iccid, c.msisdn, c.device_virtual_no, c.carrier_name, COALESCE(sh.shop_name, '') AS shop_name, COALESCE(d.device_name, '') AS device_name, c.real_name_status, c.first_realname_at, c.network_status, COALESCE(pkg.package_name, '') AS package_name, COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb, COALESCE(pkg.data_limit_mb, 0) AS data_limit_mb `). Order("c.id ASC"). Limit(limit). Offset(offset) if err := query.Scan(&items).Error; err != nil { return nil, err } cardIDs := make([]uint, 0, len(items)) for _, item := range items { cardIDs = append(cardIDs, item.ID) } associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeIotCard, cardIDs) if err != nil { return nil, err } includeExpiry, includeGB := iotCardExportTailColumns(params.ResolvedHeaders) var finalExpiries map[uint]string if includeExpiry { finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs) if err != nil { return nil, err } } rows := make([][]string, 0, len(items)) for _, item := range items { remainingMB := remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB) row := []string{ item.ICCID, item.MSISDN, item.DeviceVirtualNo, item.CarrierName, item.ShopName, item.DeviceName, formatRealNameVerified(item.RealNameStatus), formatOptionalTime(item.FirstRealnameAt), constants.GetNetworkStatusName(item.NetworkStatus), item.PackageName, strconv.FormatInt(item.DataUsageMB, 10), strconv.FormatInt(remainingMB, 10), associatedPhones[item.ID], } if includeExpiry { row = append(row, finalExpiries[item.ID]) } if includeGB { row = append(row, formatMB(float64(item.DataUsageMB)/deviceExportMBPerGB), formatMB(float64(remainingMB)/deviceExportMBPerGB)) } rows = append(rows, row) } return rows, nil } // loadFinalExpiries 按分页批量推算卡的最终套餐到期时间,只有可精确推算时输出。 func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uint) (map[uint]string, error) { estimates, err := s.expiryQ.ResolveBatch(ctx, constants.AssetTypeIotCard, cardIDs) if err != nil { return nil, err } result := make(map[uint]string, len(estimates)) for cardID, estimate := range estimates { if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact { continue } result[cardID] = formatOptionalTime(estimate.EstimatedFinalExpiresAt) } return result, nil } // iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「剩余流量(GB)」输出到期列与 GB 列, // 末列「套餐到期时间」只输出到期列,更早的表头两者都不输出;无冻结表头按当前版本输出。 func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB bool) { if len(headers) == 0 { return true, true } switch headers[len(headers)-1] { case iotCardExportRemainingGBHeader: return true, true case iotCardExportFinalExpiryHeader: return true, false default: return false, false } } // baseQuery 构造卡导出的基础查询。 // 绑定设备名称从当前有效绑定中取一条,避免多绑定历史记录放大导出行数。 func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx). Table("tb_iot_card AS c"). Joins("LEFT JOIN tb_shop AS sh ON sh.id = c.shop_id"). Joins(` LEFT JOIN LATERAL ( SELECT d.device_name FROM tb_device_sim_binding AS b JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL WHERE b.iot_card_id = c.id AND b.bind_status = ? AND b.deleted_at IS NULL ORDER BY b.is_current DESC, b.id DESC LIMIT 1 ) AS d ON TRUE `, constants.BindStatusBound). Joins(` LEFT JOIN LATERAL ( SELECT COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name, pu.data_usage_mb, pu.data_limit_mb FROM tb_package_usage AS pu LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL WHERE pu.iot_card_id = c.id AND pu.status = ? AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL ORDER BY pu.priority ASC, pu.activated_at ASC, pu.id ASC LIMIT 1 ) AS pkg ON TRUE `, constants.PackageUsageStatusActive). Where("c.deleted_at IS NULL") } // applyFilters 应用与 /api/admin/iot-cards/standalone 一致的筛选条件。 // 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。 func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "c.shop_id") if isStandalone, ok := filterOptionalBool(params.Filters, "is_standalone"); ok { query = query.Where("c.is_standalone = ?", isStandalone) } if status, ok := filterInt(params.Filters, "status"); ok && status > 0 { query = query.Where("c.status = ?", status) } if carrierID, ok := filterUint(params.Filters, "carrier_id"); ok { query = query.Where("c.carrier_id = ?", carrierID) } if shopIDs, ok := filterUintSlice(params.Filters, "shop_ids"); ok { if len(shopIDs) == 0 { query = query.Where("1 = 0") } else { query = query.Where("c.shop_id IN ?", shopIDs) } } else if shopID, ok := filterInt(params.Filters, "shop_id"); ok { if shopID <= 0 { query = query.Where("1 = 0") } else { query = query.Where("c.shop_id = ?", shopID) } } if iccid, ok := filterString(params.Filters, "iccid"); ok { query = query.Where("c.iccid LIKE ?", "%"+iccid+"%") } if msisdn, ok := filterString(params.Filters, "msisdn"); ok { query = query.Where("c.msisdn LIKE ?", "%"+msisdn+"%") } if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok { query = query.Where("c.virtual_no LIKE ?", "%"+virtualNo+"%") } if keyword, ok := filterString(params.Filters, "keyword"); ok { query = query.Where("c.iccid LIKE ? OR c.virtual_no LIKE ?", "%"+keyword+"%", "%"+keyword+"%") } if batchNo, ok := filterString(params.Filters, "batch_no"); ok { query = query.Where("c.batch_no = ?", batchNo) } if packageID, ok := filterUint(params.Filters, "package_id"); ok { query = query.Where("c.id IN (?)", s.db.WithContext(ctx).Table("tb_package_usage"). Select("iot_card_id"). Where("package_id = ? AND deleted_at IS NULL", packageID)) } if isDistributed, ok := filterOptionalBool(params.Filters, "is_distributed"); ok { if isDistributed { query = query.Where("c.shop_id IS NOT NULL") } else { query = query.Where("c.shop_id IS NULL") } } if iccidStart, ok := filterString(params.Filters, "iccid_start"); ok { query = query.Where("c.iccid >= ?", iccidStart) } if iccidEnd, ok := filterString(params.Filters, "iccid_end"); ok { query = query.Where("c.iccid <= ?", iccidEnd) } if isReplaced, ok := filterOptionalBool(params.Filters, "is_replaced"); ok { subQuery := s.db.WithContext(ctx).Table("tb_exchange_order"). Select("old_asset_id"). Where("old_asset_type = ? AND status IN ? AND deleted_at IS NULL", constants.ExchangeAssetTypeIotCard, []int{constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted}, ) if isReplaced { query = query.Where("c.id IN (?)", subQuery) } else { query = query.Where("c.id NOT IN (?)", subQuery) } } if seriesID, ok := filterUint(params.Filters, "series_id"); ok { query = query.Where("c.series_id = ?", seriesID) } if carrierName, ok := filterString(params.Filters, "carrier_name"); ok { query = query.Where("c.carrier_name LIKE ?", "%"+carrierName+"%") } if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok { subQuery := s.db.WithContext(ctx).Table("tb_package_usage AS pu"). Select("1"). Where("pu.iot_card_id = c.id AND pu.status = ? AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL", constants.PackageUsageStatusActive) if hasActivePackage { query = query.Where("EXISTS (?)", subQuery) } else { query = query.Where("NOT EXISTS (?)", subQuery) } } return query } // iotCardExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。 const iotCardExportAssociatedPhoneHeader = "关联手机号" // iotCardExportFinalExpiryHeader 是「套餐到期时间」列的表头,位于两列 GB 流量之前。 const iotCardExportFinalExpiryHeader = "套餐到期时间" // iotCardExportUsageGBHeader、iotCardExportRemainingGBHeader 是 GB 流量列的表头,固定位于导出最后两列。 const ( iotCardExportUsageGBHeader = "使用流量(GB)" iotCardExportRemainingGBHeader = "剩余流量(GB)" ) type iotCardExportRow struct { ID uint `gorm:"column:id"` ICCID string `gorm:"column:iccid"` MSISDN string `gorm:"column:msisdn"` DeviceVirtualNo string `gorm:"column:device_virtual_no"` CarrierName string `gorm:"column:carrier_name"` ShopName string `gorm:"column:shop_name"` DeviceName string `gorm:"column:device_name"` RealNameStatus int `gorm:"column:real_name_status"` FirstRealnameAt *time.Time `gorm:"column:first_realname_at"` NetworkStatus int `gorm:"column:network_status"` PackageName string `gorm:"column:package_name"` DataUsageMB int64 `gorm:"column:data_usage_mb"` DataLimitMB int64 `gorm:"column:data_limit_mb"` } func remainingPackageDataMB(limit, used int64) int64 { remaining := limit - used if remaining < 0 { return 0 } return remaining } func formatRealNameVerified(status int) string { switch status { case constants.RealNameStatusVerified: return "是" case constants.RealNameStatusNotVerified: return "否" default: return "未知" } }