This commit is contained in:
20
internal/exporter/datasource.go
Normal file
20
internal/exporter/datasource.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package exporter
|
||||
|
||||
import "context"
|
||||
|
||||
// DataSource 导出场景数据源接口。
|
||||
// 场景实现只负责统计、表头和按 offset/limit 拉取数据,分片调度由导出框架统一处理。
|
||||
type DataSource interface {
|
||||
Scene() string
|
||||
Count(ctx context.Context, params ExportParams) (int, error)
|
||||
Headers(ctx context.Context, params ExportParams) ([]string, error)
|
||||
Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error)
|
||||
}
|
||||
|
||||
// ExportParams 导出任务执行参数。
|
||||
// Filters 来自任务 query_json.filters,权限字段来自任务创建时的快照。
|
||||
type ExportParams struct {
|
||||
Filters map[string]any
|
||||
ScopeShopIDs []uint
|
||||
UserType int
|
||||
}
|
||||
@@ -10,78 +10,53 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// DeviceSceneStrategy 设备导出场景策略。
|
||||
type DeviceSceneStrategy struct {
|
||||
// DeviceDataSource 设备导出数据源。
|
||||
type DeviceDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDeviceSceneStrategy 创建设备场景策略。
|
||||
func NewDeviceSceneStrategy(db *gorm.DB) *DeviceSceneStrategy {
|
||||
return &DeviceSceneStrategy{db: db}
|
||||
// NewDeviceDataSource 创建设备导出数据源。
|
||||
func NewDeviceDataSource(db *gorm.DB) *DeviceDataSource {
|
||||
return &DeviceDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 场景编码。
|
||||
func (s *DeviceSceneStrategy) Scene() string {
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *DeviceDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneDevice
|
||||
}
|
||||
|
||||
// Headers 导出表头。
|
||||
func (s *DeviceSceneStrategy) Headers() []string {
|
||||
return []string{"ID", "设备虚拟号", "设备名称", "设备型号", "设备类型", "状态", "店铺ID", "创建时间"}
|
||||
// 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)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// NextShardBoundary 获取下一段分片边界。
|
||||
func (s *DeviceSceneStrategy) NextShardBoundary(ctx context.Context, task *model.ExportTask, afterID uint64, limit int) (*ShardBoundary, error) {
|
||||
// Headers 返回设备导出表头。
|
||||
func (s *DeviceDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
|
||||
return []string{"ID", "设备虚拟号", "设备名称", "设备型号", "设备类型", "状态", "店铺ID", "创建时间"}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询设备导出数据。
|
||||
func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = constants.ExportDefaultShardSize
|
||||
}
|
||||
|
||||
var ids []uint64
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&model.Device{}).
|
||||
Where("id > ?", afterID).
|
||||
Order("id ASC").
|
||||
Limit(limit)
|
||||
query = applyShopScopeForTask(query, task)
|
||||
|
||||
if err := query.Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &ShardBoundary{
|
||||
StartID: afterID,
|
||||
EndID: ids[len(ids)-1],
|
||||
RowCount: len(ids),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryRows 查询分片内数据行。
|
||||
func (s *DeviceSceneStrategy) QueryRows(ctx context.Context, task *model.ExportTask, startID, endID uint64) ([][]string, error) {
|
||||
if endID == 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var devices []model.Device
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&model.Device{}).
|
||||
Where("id > ? AND id <= ?", startID, endID).
|
||||
Order("id ASC")
|
||||
query = applyShopScopeForTask(query, task)
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(devices))
|
||||
for _, item := range devices {
|
||||
shopID := ""
|
||||
if item.ShopID != nil {
|
||||
shopID = strconv.FormatUint(uint64(*item.ShopID), 10)
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatUint(uint64(item.ID), 10),
|
||||
item.VirtualNo,
|
||||
@@ -89,10 +64,27 @@ func (s *DeviceSceneStrategy) QueryRows(ctx context.Context, task *model.ExportT
|
||||
item.DeviceModel,
|
||||
item.DeviceType,
|
||||
strconv.Itoa(item.Status),
|
||||
shopID,
|
||||
item.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
formatOptionalUint(item.ShopID),
|
||||
item.CreatedAt.Format(exportTimeLayout),
|
||||
})
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
||||
query = applyExportShopScope(query, params, "shop_id")
|
||||
|
||||
if status, ok := filterInt(params.Filters, "status"); ok {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
||||
query = query.Where("shop_id = ?", shopID)
|
||||
}
|
||||
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
|
||||
query = query.Where("created_at >= ?", start)
|
||||
}
|
||||
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
|
||||
query = query.Where("created_at <= ?", end)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
270
internal/exporter/filter_helpers.go
Normal file
270
internal/exporter/filter_helpers.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
const exportTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
func applyExportShopScope(query *gorm.DB, params ExportParams, column string) *gorm.DB {
|
||||
switch params.UserType {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
return query
|
||||
case constants.UserTypeAgent:
|
||||
if len(params.ScopeShopIDs) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where(column+" IN ?", params.ScopeShopIDs)
|
||||
default:
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
func filterInt(filters map[string]any, key string) (int, bool) {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case uint:
|
||||
return int(v), true
|
||||
case uint64:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func filterUint(filters map[string]any, key string) (uint, bool) {
|
||||
value, ok := filterInt(filters, key)
|
||||
if !ok || value <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint(value), true
|
||||
}
|
||||
|
||||
func filterString(filters map[string]any, key string) (string, bool) {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return "", false
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
|
||||
func filterBool(filters map[string]any, key string) bool {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
return err == nil && parsed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// filterOptionalBool 解析可选布尔筛选项。
|
||||
// 返回第二个值标识调用方是否明确传入该筛选项,避免 false 被误判为未传。
|
||||
func filterOptionalBool(filters map[string]any, key string) (bool, bool) {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return false, false
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return parsed, true
|
||||
case int:
|
||||
return v != 0, true
|
||||
case int64:
|
||||
return v != 0, true
|
||||
case uint:
|
||||
return v != 0, true
|
||||
case uint64:
|
||||
return v != 0, true
|
||||
case float64:
|
||||
return v != 0, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// filterUintSlice 解析前端 JSON 或查询参数中的 uint 数组筛选项。
|
||||
// 空数组按未传处理;数组存在但过滤后为空时由调用方决定是否返回空结果。
|
||||
func filterUintSlice(filters map[string]any, key string) ([]uint, bool) {
|
||||
value, ok := filters[key]
|
||||
if !ok || value == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case []uint:
|
||||
if len(v) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return normalizeUintSlice(v), true
|
||||
case []int:
|
||||
if len(v) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
result := make([]uint, 0, len(v))
|
||||
for _, item := range v {
|
||||
if item > 0 {
|
||||
result = append(result, uint(item))
|
||||
}
|
||||
}
|
||||
return normalizeUintSlice(result), true
|
||||
case []float64:
|
||||
if len(v) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
result := make([]uint, 0, len(v))
|
||||
for _, item := range v {
|
||||
if item > 0 {
|
||||
result = append(result, uint(item))
|
||||
}
|
||||
}
|
||||
return normalizeUintSlice(result), true
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
result := make([]uint, 0, len(v))
|
||||
for _, item := range v {
|
||||
switch typed := item.(type) {
|
||||
case int:
|
||||
if typed > 0 {
|
||||
result = append(result, uint(typed))
|
||||
}
|
||||
case int64:
|
||||
if typed > 0 {
|
||||
result = append(result, uint(typed))
|
||||
}
|
||||
case uint:
|
||||
if typed > 0 {
|
||||
result = append(result, typed)
|
||||
}
|
||||
case uint64:
|
||||
if typed > 0 {
|
||||
result = append(result, uint(typed))
|
||||
}
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
result = append(result, uint(typed))
|
||||
}
|
||||
case string:
|
||||
if parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 {
|
||||
result = append(result, uint(parsed))
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeUintSlice(result), true
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
result := make([]uint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
|
||||
if err == nil && parsed > 0 {
|
||||
result = append(result, uint(parsed))
|
||||
}
|
||||
}
|
||||
return normalizeUintSlice(result), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeUintSlice 去除无效值和重复 ID,保留原始顺序。
|
||||
func normalizeUintSlice(values []uint) []uint {
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func filterTime(filters map[string]any, key string) (time.Time, bool) {
|
||||
text, ok := filterString(filters, key)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.ParseInLocation(layout, text, time.Local)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func formatOptionalUint(value *uint) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(uint64(*value), 10)
|
||||
}
|
||||
|
||||
func formatOptionalTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.Format(exportTimeLayout)
|
||||
}
|
||||
@@ -2,97 +2,219 @@ package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// IotCardSceneStrategy IoT 卡导出场景策略。
|
||||
type IotCardSceneStrategy struct {
|
||||
// IotCardDataSource IoT 卡导出数据源。
|
||||
type IotCardDataSource struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewIotCardSceneStrategy 创建 IoT 卡场景策略。
|
||||
func NewIotCardSceneStrategy(db *gorm.DB) *IotCardSceneStrategy {
|
||||
return &IotCardSceneStrategy{db: db}
|
||||
// NewIotCardDataSource 创建 IoT 卡导出数据源。
|
||||
func NewIotCardDataSource(db *gorm.DB) *IotCardDataSource {
|
||||
return &IotCardDataSource{db: db}
|
||||
}
|
||||
|
||||
// Scene 场景编码。
|
||||
func (s *IotCardSceneStrategy) Scene() string {
|
||||
// Scene 返回导出场景编码。
|
||||
func (s *IotCardDataSource) Scene() string {
|
||||
return constants.ExportTaskSceneIotCard
|
||||
}
|
||||
|
||||
// Headers 导出表头。
|
||||
func (s *IotCardSceneStrategy) Headers() []string {
|
||||
return []string{"ID", "ICCID", "MSISDN", "虚拟号", "运营商名称", "状态", "店铺ID", "创建时间"}
|
||||
// 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
|
||||
}
|
||||
|
||||
// NextShardBoundary 获取下一段分片边界。
|
||||
func (s *IotCardSceneStrategy) NextShardBoundary(ctx context.Context, task *model.ExportTask, afterID uint64, limit int) (*ShardBoundary, error) {
|
||||
// Headers 返回 IoT 卡导出表头。
|
||||
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
|
||||
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态"}, nil
|
||||
}
|
||||
|
||||
// Fetch 按 offset/limit 查询 IoT 卡导出数据。
|
||||
func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = constants.ExportDefaultShardSize
|
||||
}
|
||||
|
||||
var ids []uint64
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&model.IotCard{}).
|
||||
Where("id > ?", afterID).
|
||||
Order("id ASC").
|
||||
Limit(limit)
|
||||
query = applyShopScopeForTask(query, task)
|
||||
|
||||
if err := query.Pluck("id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &ShardBoundary{
|
||||
StartID: afterID,
|
||||
EndID: ids[len(ids)-1],
|
||||
RowCount: len(ids),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryRows 查询分片内数据行。
|
||||
func (s *IotCardSceneStrategy) QueryRows(ctx context.Context, task *model.ExportTask, startID, endID uint64) ([][]string, error) {
|
||||
if endID == 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
var cards []model.IotCard
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&model.IotCard{}).
|
||||
Where("id > ? AND id <= ?", startID, endID).
|
||||
Order("id ASC")
|
||||
query = applyShopScopeForTask(query, task)
|
||||
|
||||
if err := query.Find(&cards).Error; err != nil {
|
||||
var items []iotCardExportRow
|
||||
query := s.applyFilters(ctx, s.baseQuery(ctx), params).
|
||||
Select(`
|
||||
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
|
||||
`).
|
||||
Order("c.id ASC").
|
||||
Limit(limit).
|
||||
Offset(offset)
|
||||
if err := query.Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(cards))
|
||||
for _, item := range cards {
|
||||
shopID := ""
|
||||
if item.ShopID != nil {
|
||||
shopID = strconv.FormatUint(uint64(*item.ShopID), 10)
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatUint(uint64(item.ID), 10),
|
||||
item.ICCID,
|
||||
item.MSISDN,
|
||||
item.VirtualNo,
|
||||
item.DeviceVirtualNo,
|
||||
item.CarrierName,
|
||||
strconv.Itoa(item.Status),
|
||||
shopID,
|
||||
item.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
item.ShopName,
|
||||
item.DeviceName,
|
||||
formatRealNameVerified(item.RealNameStatus),
|
||||
formatOptionalTime(item.FirstRealnameAt),
|
||||
constants.GetNetworkStatusName(item.NetworkStatus),
|
||||
})
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
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
|
||||
}
|
||||
|
||||
type iotCardExportRow struct {
|
||||
ICCID string
|
||||
MSISDN string
|
||||
DeviceVirtualNo string
|
||||
CarrierName string
|
||||
ShopName string
|
||||
DeviceName string
|
||||
RealNameStatus int
|
||||
FirstRealnameAt *time.Time
|
||||
NetworkStatus int
|
||||
}
|
||||
|
||||
func formatRealNameVerified(status int) string {
|
||||
switch status {
|
||||
case constants.RealNameStatusVerified:
|
||||
return "是"
|
||||
case constants.RealNameStatusNotVerified:
|
||||
return "否"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
56
internal/exporter/query_params.go
Normal file
56
internal/exporter/query_params.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"github.com/bytedance/sonic"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// ParseExportParams 从导出任务快照解析数据源执行参数。
|
||||
func ParseExportParams(task *model.ExportTask) ExportParams {
|
||||
params := ExportParams{Filters: make(map[string]any)}
|
||||
if task == nil {
|
||||
return params
|
||||
}
|
||||
|
||||
params.ScopeShopIDs = append(params.ScopeShopIDs, task.ScopeShopIDs...)
|
||||
params.UserType = task.CreatorUserType
|
||||
|
||||
var raw map[string]any
|
||||
if len(task.QueryJSON) == 0 {
|
||||
return params
|
||||
}
|
||||
if err := sonic.Unmarshal(task.QueryJSON, &raw); err != nil {
|
||||
return params
|
||||
}
|
||||
|
||||
if filters, ok := raw["filters"].(map[string]any); ok {
|
||||
params.Filters = filters
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// ResolvedHeadersFromTask 从任务 query_json 读取 dispatch 阶段固定的表头。
|
||||
func ResolvedHeadersFromTask(task *model.ExportTask) []string {
|
||||
if task == nil || len(task.QueryJSON) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := sonic.Unmarshal(task.QueryJSON, &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
values, ok := raw["resolved_headers"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if text, ok := value.(string); ok {
|
||||
headers = append(headers, text)
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -1,71 +1,54 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// ShardBoundary 分片边界信息。
|
||||
type ShardBoundary struct {
|
||||
StartID uint64
|
||||
EndID uint64
|
||||
RowCount int
|
||||
}
|
||||
|
||||
// SceneStrategy 导出场景策略接口。
|
||||
type SceneStrategy interface {
|
||||
Scene() string
|
||||
Headers() []string
|
||||
NextShardBoundary(ctx context.Context, task *model.ExportTask, afterID uint64, limit int) (*ShardBoundary, error)
|
||||
QueryRows(ctx context.Context, task *model.ExportTask, startID, endID uint64) ([][]string, error)
|
||||
}
|
||||
|
||||
// Registry 场景策略注册中心。
|
||||
// Registry 导出数据源注册中心。
|
||||
type Registry struct {
|
||||
strategies map[string]SceneStrategy
|
||||
sources map[string]DataSource
|
||||
}
|
||||
|
||||
// NewRegistry 创建场景策略注册中心。
|
||||
func NewRegistry(strategies ...SceneStrategy) *Registry {
|
||||
m := make(map[string]SceneStrategy, len(strategies))
|
||||
for _, strategy := range strategies {
|
||||
if strategy == nil {
|
||||
// NewRegistry 创建导出数据源注册中心。
|
||||
func NewRegistry(sources ...DataSource) *Registry {
|
||||
m := make(map[string]DataSource, len(sources))
|
||||
for _, source := range sources {
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
m[strategy.Scene()] = strategy
|
||||
m[source.Scene()] = source
|
||||
}
|
||||
return &Registry{strategies: m}
|
||||
return &Registry{sources: m}
|
||||
}
|
||||
|
||||
// NewDefaultRegistry 创建默认场景策略注册中心。
|
||||
// NewDefaultRegistry 创建默认导出数据源注册中心。
|
||||
func NewDefaultRegistry(db *gorm.DB) *Registry {
|
||||
return NewRegistry(
|
||||
NewDeviceSceneStrategy(db),
|
||||
NewIotCardSceneStrategy(db),
|
||||
NewDeviceDataSource(db),
|
||||
NewIotCardDataSource(db),
|
||||
)
|
||||
}
|
||||
|
||||
// Get 获取指定场景策略。
|
||||
func (r *Registry) Get(scene string) (SceneStrategy, bool) {
|
||||
strategy, ok := r.strategies[scene]
|
||||
return strategy, ok
|
||||
// Get 获取指定场景数据源。
|
||||
func (r *Registry) Get(scene string) (DataSource, bool) {
|
||||
source, ok := r.sources[scene]
|
||||
return source, ok
|
||||
}
|
||||
|
||||
// IsSupported 判断场景是否支持。
|
||||
func (r *Registry) IsSupported(scene string) bool {
|
||||
_, ok := r.strategies[scene]
|
||||
_, ok := r.sources[scene]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Scenes 返回当前支持的场景列表。
|
||||
func (r *Registry) Scenes() []string {
|
||||
result := make([]string, 0, len(r.strategies))
|
||||
for scene := range r.strategies {
|
||||
result := make([]string, 0, len(r.sources))
|
||||
for scene := range r.sources {
|
||||
result = append(result, scene)
|
||||
}
|
||||
sort.Strings(result)
|
||||
|
||||
@@ -1,26 +1 @@
|
||||
package exporter
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func applyShopScopeForTask(query *gorm.DB, task *model.ExportTask) *gorm.DB {
|
||||
if task == nil {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
|
||||
switch task.CreatorUserType {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
return query
|
||||
case constants.UserTypeAgent:
|
||||
if len(task.ScopeShopIDs) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("shop_id IN ?", []uint(task.ScopeShopIDs))
|
||||
default:
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user