package exporter import ( "context" "fmt" "strconv" "time" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/pkg/constants" ) const ( deviceExportBaseHeaderCount = 6 deviceExportCardGroupSize = 5 deviceExportTailHeaderCount = 5 deviceExportMinCardGroups = 1 ) // DeviceDataSource 设备导出数据源。 type DeviceDataSource struct { db *gorm.DB } // NewDeviceDataSource 创建设备导出数据源。 func NewDeviceDataSource(db *gorm.DB) *DeviceDataSource { return &DeviceDataSource{db: db} } // Scene 返回导出场景编码。 func (s *DeviceDataSource) Scene() string { return constants.ExportTaskSceneDevice } // Count 统计设备导出行数。 func (s *DeviceDataSource) Count(ctx context.Context, params ExportParams) (int, error) { var total int64 query := s.applyFilters(s.baseQuery(ctx), params) if err := query.Count(&total).Error; err != nil { return 0, err } return int(total), nil } // Headers 返回设备导出表头。 func (s *DeviceDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { cardGroups, err := s.resolveCardGroupCount(ctx, params) if err != nil { return nil, err } return buildDeviceExportHeaders(cardGroups), nil } // Fetch 按 offset/limit 查询设备导出数据。 func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { return [][]string{}, nil } cardGroups := cardGroupCountFromHeaders(params.ResolvedHeaders) if cardGroups <= 0 { resolved, err := s.resolveCardGroupCount(ctx, params) if err != nil { return nil, err } cardGroups = resolved } devices, err := s.fetchDevices(ctx, params, offset, limit) if err != nil { return nil, err } if len(devices) == 0 { return [][]string{}, nil } deviceIDs := make([]uint, 0, len(devices)) for _, item := range devices { deviceIDs = append(deviceIDs, item.ID) } cardMap, err := s.fetchBoundCards(ctx, deviceIDs) if err != nil { return nil, err } packageMap, err := s.fetchActivePackages(ctx, deviceIDs) if err != nil { return nil, err } rows := make([][]string, 0, len(devices)) for _, item := range devices { rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], cardGroups)) } return rows, nil } func (s *DeviceDataSource) baseQuery(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx). Table("tb_device AS d"). Where("d.deleted_at IS NULL") } // applyFilters 应用与 /api/admin/devices 一致的筛选条件。 // 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。 func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "d.shop_id") if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok { query = query.Where("d.virtual_no LIKE ?", "%"+virtualNo+"%") } if imei, ok := filterString(params.Filters, "imei"); ok { query = query.Where("d.imei LIKE ?", "%"+imei+"%") } if keyword, ok := filterString(params.Filters, "keyword"); ok { like := "%" + keyword + "%" query = query.Where("d.virtual_no LIKE ? OR d.imei LIKE ?", like, like) } if deviceName, ok := filterString(params.Filters, "device_name"); ok { query = query.Where("d.device_name LIKE ?", "%"+deviceName+"%") } if status, ok := filterInt(params.Filters, "status"); ok && status > 0 { query = query.Where("d.status = ?", status) } if activationStatus, ok := filterInt(params.Filters, "activation_status"); ok { query = s.applyActivationStatusFilter(query, activationStatus) } if shopIDs, ok := filterUintSlice(params.Filters, "shop_ids"); ok { if len(shopIDs) == 0 { query = query.Where("1 = 0") } else { query = query.Where("d.shop_id IN ?", shopIDs) } } else if shopID, ok := filterInt(params.Filters, "shop_id"); ok { if shopID == 0 { query = query.Where("1 = 0") } else if shopID > 0 { query = query.Where("d.shop_id = ?", shopID) } } if batchNo, ok := filterString(params.Filters, "batch_no"); ok { query = query.Where("d.batch_no = ?", batchNo) } if deviceType, ok := filterString(params.Filters, "device_type"); ok { query = query.Where("d.device_type = ?", deviceType) } if manufacturer, ok := filterString(params.Filters, "manufacturer"); ok { query = query.Where("d.manufacturer LIKE ?", "%"+manufacturer+"%") } if start, ok := filterTime(params.Filters, "created_at_start"); ok { query = query.Where("d.created_at >= ?", start) } if end, ok := filterTime(params.Filters, "created_at_end"); ok { query = query.Where("d.created_at <= ?", end) } if seriesID, ok := filterUint(params.Filters, "series_id"); ok { query = query.Where("d.series_id = ?", seriesID) } if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok { query = s.applyHasActivePackageFilter(query, hasActivePackage) } return query } // applyHasActivePackageFilter 按设备是否有生效中的主套餐过滤。 func (s *DeviceDataSource) applyHasActivePackageFilter(query *gorm.DB, hasActive bool) *gorm.DB { subQuery := `SELECT 1 FROM tb_package_usage AS pu WHERE pu.device_id = d.id AND pu.status = ? AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL` if hasActive { return query.Where("EXISTS ("+subQuery+")", constants.PackageUsageStatusActive) } return query.Where("NOT EXISTS ("+subQuery+")", constants.PackageUsageStatusActive) } func (s *DeviceDataSource) applyActivationStatusFilter(query *gorm.DB, activationStatus int) *gorm.DB { activeCondition := `EXISTS ( SELECT 1 FROM tb_package_usage AS pu JOIN tb_device_sim_binding AS b ON b.device_id = pu.device_id AND b.bind_status = ? AND b.deleted_at IS NULL JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.real_name_status = ? AND c.deleted_at IS NULL WHERE pu.device_id = d.id AND pu.status = ? AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL )` args := []any{ constants.BindStatusBound, constants.RealNameStatusVerified, constants.PackageUsageStatusActive, } if activationStatus == constants.ActivationStatusActive { return query.Where(activeCondition, args...) } if activationStatus == constants.ActivationStatusInactive { return query.Where("NOT "+activeCondition, args...) } return query } func (s *DeviceDataSource) resolveCardGroupCount(ctx context.Context, params ExportParams) (int, error) { var count int64 query := s.applyFilters(s.baseQuery(ctx), params). Select("COALESCE(MAX(bound_cards.card_count), 0)"). Joins(` LEFT JOIN LATERAL ( SELECT COUNT(*) AS card_count FROM tb_device_sim_binding AS b WHERE b.device_id = d.id AND b.bind_status = ? AND b.deleted_at IS NULL ) AS bound_cards ON TRUE `, constants.BindStatusBound) if err := query.Scan(&count).Error; err != nil { return 0, err } if count < deviceExportMinCardGroups { return deviceExportMinCardGroups, nil } return int(count), nil } func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams, offset, limit int) ([]deviceExportRow, error) { var devices []deviceExportRow query := s.applyFilters(s.baseQuery(ctx), params). Joins("LEFT JOIN tb_shop AS sh ON sh.id = d.shop_id"). Joins("LEFT JOIN tb_package_series AS ps ON ps.id = d.series_id AND ps.deleted_at IS NULL"). Joins(` LEFT JOIN tb_asset_wallet AS aw ON aw.resource_type = ? AND aw.resource_id = d.id AND aw.deleted_at IS NULL `, constants.AssetWalletResourceTypeDevice). Select(` d.id, d.virtual_no, d.imei, d.device_name, d.device_model, COALESCE(sh.shop_name, '') AS shop_name, COALESCE(ps.series_name, '') AS series_name, COALESCE(aw.balance, 0) AS wallet_balance `). Order("d.id ASC"). Limit(limit). Offset(offset) if err := query.Scan(&devices).Error; err != nil { return nil, err } return devices, nil } func (s *DeviceDataSource) fetchBoundCards(ctx context.Context, deviceIDs []uint) (map[uint][]deviceExportCardRow, error) { result := make(map[uint][]deviceExportCardRow, len(deviceIDs)) if len(deviceIDs) == 0 { return result, nil } var cards []deviceExportCardRow if err := s.db.WithContext(ctx). Table("tb_device_sim_binding AS b"). Select(` b.device_id, b.slot_position, c.iccid, c.msisdn, c.carrier_name, c.current_month_usage_mb, c.network_status `). Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL"). Where("b.device_id IN ?", deviceIDs). Where("b.bind_status = ?", constants.BindStatusBound). Where("b.deleted_at IS NULL"). Order("b.device_id ASC, b.slot_position ASC, b.id ASC"). Scan(&cards).Error; err != nil { return nil, err } for _, card := range cards { result[card.DeviceID] = append(result[card.DeviceID], card) } return result, nil } func (s *DeviceDataSource) fetchActivePackages(ctx context.Context, deviceIDs []uint) (map[uint]deviceExportPackageRow, error) { result := make(map[uint]deviceExportPackageRow, len(deviceIDs)) if len(deviceIDs) == 0 { return result, nil } var packages []deviceExportPackageRow if err := s.db.WithContext(ctx). Table("tb_package_usage AS pu"). Select(` pu.device_id, COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name, pu.activated_at, pu.expires_at `). Joins("LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL"). Where("pu.device_id IN ?", deviceIDs). Where("pu.status = ?", constants.PackageUsageStatusActive). Where("pu.master_usage_id IS NULL"). Where("pu.deleted_at IS NULL"). Order("pu.device_id ASC, pu.priority ASC, pu.activated_at ASC, pu.id ASC"). Scan(&packages).Error; err != nil { return nil, err } for _, item := range packages { if _, exists := result[item.DeviceID]; exists { continue } result[item.DeviceID] = item } return result, nil } type deviceExportRow struct { ID uint `gorm:"column:id"` VirtualNo string `gorm:"column:virtual_no"` IMEI string `gorm:"column:imei"` DeviceName string `gorm:"column:device_name"` DeviceModel string `gorm:"column:device_model"` ShopName string `gorm:"column:shop_name"` SeriesName string `gorm:"column:series_name"` WalletBalance int64 `gorm:"column:wallet_balance"` } type deviceExportCardRow struct { DeviceID uint `gorm:"column:device_id"` SlotPosition int `gorm:"column:slot_position"` ICCID string `gorm:"column:iccid"` MSISDN string `gorm:"column:msisdn"` CarrierName string `gorm:"column:carrier_name"` CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb"` NetworkStatus int `gorm:"column:network_status"` } type deviceExportPackageRow struct { DeviceID uint `gorm:"column:device_id"` PackageName string `gorm:"column:package_name"` ActivatedAt *time.Time `gorm:"column:activated_at"` ExpiresAt *time.Time `gorm:"column:expires_at"` } func buildDeviceExportHeaders(cardGroups int) []string { if cardGroups < deviceExportMinCardGroups { cardGroups = deviceExportMinCardGroups } headers := []string{"设备号", "IMEI", "设备名称", "店铺名称", "套餐系列", "设备型号"} for i := 1; i <= cardGroups; i++ { headers = append(headers, fmt.Sprintf("ICCID%d", i), fmt.Sprintf("接入号%d", i), fmt.Sprintf("ICCID%d对应的运营商", i), fmt.Sprintf("ICCID%d使用的流量", i), fmt.Sprintf("卡状态%d", i), ) } headers = append(headers, "设备合计", "套餐的生效时间", "套餐的到期时间", "当前套餐", "钱包余额", ) return headers } func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, cardGroups int) []string { if cardGroups < deviceExportMinCardGroups { cardGroups = deviceExportMinCardGroups } row := []string{ item.VirtualNo, item.IMEI, item.DeviceName, item.ShopName, item.SeriesName, item.DeviceModel, } totalUsage := 0.0 for i := 0; i < cardGroups; i++ { if i >= len(cards) { row = append(row, "", "", "", "", "") continue } card := cards[i] totalUsage += card.CurrentMonthUsageMB row = append(row, card.ICCID, card.MSISDN, card.CarrierName, formatMB(card.CurrentMonthUsageMB), constants.GetNetworkStatusName(card.NetworkStatus), ) } // 如果表头列数被历史任务固定得比当前绑定卡少,仍保证合计覆盖当前查到的全部卡。 for i := cardGroups; i < len(cards); i++ { totalUsage += cards[i].CurrentMonthUsageMB } row = append(row, formatMB(totalUsage), formatOptionalTime(pkg.ActivatedAt), formatOptionalTime(pkg.ExpiresAt), pkg.PackageName, formatMoneyYuan(item.WalletBalance), ) return row } func cardGroupCountFromHeaders(headers []string) int { if len(headers) < deviceExportBaseHeaderCount+deviceExportTailHeaderCount { return 0 } cardColumnCount := len(headers) - deviceExportBaseHeaderCount - deviceExportTailHeaderCount if cardColumnCount <= 0 || cardColumnCount%deviceExportCardGroupSize != 0 { return 0 } return cardColumnCount / deviceExportCardGroupSize } func formatMB(value float64) string { return strconv.FormatFloat(value, 'f', 2, 64) } func formatMoneyYuan(cents int64) string { sign := "" if cents < 0 { sign = "-" cents = -cents } return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100) }