From 064961471b3ee5426ca0312500b69acb1175bd1a Mon Sep 17 00:00:00 2001 From: Break Date: Mon, 15 Jun 2026 16:43:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/exporter/datasource.go | 7 +- internal/exporter/device_scene.go | 413 ++++++++++++++++++++++++++++-- internal/exporter/query_params.go | 5 + 3 files changed, 395 insertions(+), 30 deletions(-) diff --git a/internal/exporter/datasource.go b/internal/exporter/datasource.go index 5d5d1c8..a57e7c7 100644 --- a/internal/exporter/datasource.go +++ b/internal/exporter/datasource.go @@ -14,7 +14,8 @@ type DataSource interface { // ExportParams 导出任务执行参数。 // Filters 来自任务 query_json.filters,权限字段来自任务创建时的快照。 type ExportParams struct { - Filters map[string]any - ScopeShopIDs []uint - UserType int + Filters map[string]any + ScopeShopIDs []uint + UserType int + ResolvedHeaders []string } diff --git a/internal/exporter/device_scene.go b/internal/exporter/device_scene.go index af04798..77c5ffc 100644 --- a/internal/exporter/device_scene.go +++ b/internal/exporter/device_scene.go @@ -2,14 +2,22 @@ package exporter import ( "context" + "fmt" "strconv" + "time" "gorm.io/gorm" - "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" ) +const ( + deviceExportBaseHeaderCount = 6 + deviceExportCardGroupSize = 5 + deviceExportTailHeaderCount = 5 + deviceExportMinCardGroups = 1 +) + // DeviceDataSource 设备导出数据源。 type DeviceDataSource struct { db *gorm.DB @@ -28,7 +36,7 @@ func (s *DeviceDataSource) Scene() string { // Count 统计设备导出行数。 func (s *DeviceDataSource) Count(ctx context.Context, params ExportParams) (int, error) { var total int64 - query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Device{}), params) + query := s.applyFilters(s.baseQuery(ctx), params) if err := query.Count(&total).Error; err != nil { return 0, err } @@ -37,7 +45,11 @@ func (s *DeviceDataSource) Count(ctx context.Context, params ExportParams) (int, // Headers 返回设备导出表头。 func (s *DeviceDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { - return []string{"ID", "设备虚拟号", "设备名称", "设备型号", "设备类型", "状态", "店铺ID", "创建时间"}, nil + cardGroups, err := s.resolveCardGroupCount(ctx, params) + if err != nil { + return nil, err + } + return buildDeviceExportHeaders(cardGroups), nil } // Fetch 按 offset/limit 查询设备导出数据。 @@ -46,45 +58,392 @@ func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offse return [][]string{}, nil } - var devices []model.Device - query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Device{}), params). - Order("id ASC"). - Limit(limit). - Offset(offset) - if err := query.Find(&devices).Error; err != 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, []string{ - strconv.FormatUint(uint64(item.ID), 10), - item.VirtualNo, - item.DeviceName, - item.DeviceModel, - item.DeviceType, - strconv.Itoa(item.Status), - formatOptionalUint(item.ShopID), - item.CreatedAt.Format(exportTimeLayout), - }) + rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], cardGroups)) } return rows, nil } -func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { - query = applyExportShopScope(query, params, "shop_id") +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") +} - if status, ok := filterInt(params.Filters, "status"); ok { - query = query.Where("status = ?", status) +// 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 shopID, ok := filterUint(params.Filters, "shop_id"); ok { - query = query.Where("shop_id = ?", shopID) + 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("created_at >= ?", start) + query = query.Where("d.created_at >= ?", start) } if end, ok := filterTime(params.Filters, "created_at_end"); ok { - query = query.Where("created_at <= ?", end) + 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.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"` + Status int `gorm:"column: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.GetIotCardStatusName(card.Status), + ) + } + // 如果表头列数被历史任务固定得比当前绑定卡少,仍保证合计覆盖当前查到的全部卡。 + 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) +} diff --git a/internal/exporter/query_params.go b/internal/exporter/query_params.go index e3dacd4..f9d4c59 100644 --- a/internal/exporter/query_params.go +++ b/internal/exporter/query_params.go @@ -27,6 +27,7 @@ func ParseExportParams(task *model.ExportTask) ExportParams { if filters, ok := raw["filters"].(map[string]any); ok { params.Filters = filters } + params.ResolvedHeaders = headersFromRawQuery(raw) return params } @@ -41,6 +42,10 @@ func ResolvedHeadersFromTask(task *model.ExportTask) []string { return nil } + return headersFromRawQuery(raw) +} + +func headersFromRawQuery(raw map[string]any) []string { values, ok := raw["resolved_headers"].([]any) if !ok { return nil