Files
junhong_cmp_fiber/internal/exporter/device_scene.go
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
2026-09-15 11:54:56 +08:00

473 lines
14 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 exporter
import (
"context"
"fmt"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const (
deviceExportBaseHeaderCount = 6
deviceExportCardGroupSize = 5
// deviceExportTailHeaderCount 是当前尾部固定列数(含新增的「关联手机号」列)。
deviceExportTailHeaderCount = 6
// deviceExportLegacyTailHeaderCount 是新增尾部列之前的固定列数,仅供历史任务表头反解回退使用。
deviceExportLegacyTailHeaderCount = 5
deviceExportMinCardGroups = 1
// deviceExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。
deviceExportAssociatedPhoneHeader = "关联手机号"
)
// 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
}
associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeDevice, 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], associatedPhones[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,
"设备合计",
"套餐的生效时间",
"套餐的到期时间",
"当前套餐",
"钱包余额",
deviceExportAssociatedPhoneHeader,
)
return headers
}
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, 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),
associatedPhone,
)
return row
}
// cardGroupCountFromHeaders 从已持久化的表头反解卡组列数。
// 先按当前尾列数判定;不整除时回退到新增尾部列之前的尾列数再判定,
// 否则历史任务凭 ResolvedHeaders 重导出时列组数会被算成 0缺失全部卡列。
func cardGroupCountFromHeaders(headers []string) int {
if count := cardGroupCountWithTail(headers, deviceExportTailHeaderCount); count > 0 {
return count
}
return cardGroupCountWithTail(headers, deviceExportLegacyTailHeaderCount)
}
// cardGroupCountWithTail 按指定尾部固定列数反解卡组列数,不整除即无法确定列组。
func cardGroupCountWithTail(headers []string, tailHeaderCount int) int {
if len(headers) < deviceExportBaseHeaderCount+tailHeaderCount {
return 0
}
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - tailHeaderCount
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)
}