package simcardquery import ( "context" "math" "strings" "time" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/xuri/excelize/v2" ) // 导出列白名单。 // // 只放行白名单内的字段:前端传什么就导出什么,但传了名单外的字段一律忽略, // 避免误把内部字段(如 id、tenantId)带进对客户的导出文件。 var baseColumnWhitelist = map[string]model.ExportColumn{ "iccid": { Field: "iccid", Label: "ICCID", }, "msisdn": { Field: "msisdn", Label: "MSISDN", }, "imsi": { Field: "imsi", Label: "IMSI", }, "imei": { Field: "imei", Label: "IMEI", }, "usedFlowMb": { Field: "usedFlowMb", Label: "当月用量(MB)", }, "remainFlowMb": { Field: "remainFlowMb", Label: "当月剩余用量(MB)", }, "packageTotalMb": { Field: "packageTotalMb", Label: "套餐总流量(MB)", }, "cardStatus": { Field: "cardStatus", Label: "卡状态", DictType: "t_boss_card_status", }, "netModel": { Field: "netModel", Label: "网络类型", DictType: "t_net_model", }, "operators": { Field: "operators", Label: "运营商", }, "activateAt": { Field: "activateAt", Label: "激活时间", }, "outStockTime": { Field: "outStockTime", Label: "出库时间", }, "expireAt": { Field: "expireAt", Label: "到期时间", }, "outboundBatch": { Field: "outboundBatch", Label: "出库批次", }, "inboundBatch": { Field: "inboundBatch", Label: "入库批次", }, "packageName": { Field: "packageName", Label: "当前套餐名称", }, "deptName": { Field: "deptName", Label: "所属企业", }, "remark": { Field: "remark", Label: "备注", }, "extraRemark": { Field: "extraRemark", Label: "其他备注", }, } // baseDefaultOrder 未勾选列时的默认导出顺序,与卡信息列表的常用列保持一致。 var baseDefaultOrder = []string{ "iccid", "msisdn", "imsi", "imei", "usedFlowMb", "remainFlowMb", "packageTotalMb", "cardStatus", "netModel", "operators", "activateAt", "outStockTime", "expireAt", "outboundBatch", "inboundBatch", "packageName", "deptName", "remark", "extraRemark", } // 查询结果与失败原因固定追加在末尾,保证「部分成功」时每条失败可追溯。 const ( queryResultField = "queryResult" failReasonField = "failReason" ) func baseDefaultTags() []string { tags := make([]string, 0, len(baseDefaultOrder)+2) for _, field := range baseDefaultOrder { tags = append(tags, baseColumnWhitelist[field].Label) } return append(tags, "查询结果", "失败原因") } // baseMandatoryOrder 三码固定导出列。 // 这三列是对账的锚点,文件里没有 ICCID/MSISDN/IMSI 就无法定位到卡, // 因此既不允许前端取消勾选,也不允许服务端被传参绕过。 var baseMandatoryOrder = []string{"iccid", "msisdn", "imsi"} // resolveBaseColumns 归一化前端传入的导出列:三码强制在前,其余去重后按白名单追加,并固定追加结果列。 func resolveBaseColumns(in *model.SimCardQueryExportParam) ([]model.ExportColumn, error) { requested := decodeExportColumns(in) columns := make([]model.ExportColumn, 0, len(requested)+5) seen := make(map[string]struct{}, len(requested)+len(baseMandatoryOrder)) for _, field := range baseMandatoryOrder { seen[field] = struct{}{} columns = append(columns, baseColumnWhitelist[field]) } for _, column := range requested { field := strings.TrimSpace(column.Field) allowed, ok := baseColumnWhitelist[field] if !ok { continue } if _, dup := seen[field]; dup { continue } seen[field] = struct{}{} // 表头以服务端白名单为准,忽略前端自定义文案,避免导出列与页面口径不一致。 columns = append(columns, allowed) } // 未勾选任何可选列时,把剩余默认列补齐;三码已在前面,不会重复。 if len(columns) == len(baseMandatoryOrder) { for _, field := range baseDefaultOrder { if _, dup := seen[field]; dup { continue } seen[field] = struct{}{} columns = append(columns, baseColumnWhitelist[field]) } } // 结果列不允许被去掉:部分成功时用户必须能看到每条失败的原因。 columns = append(columns, model.ExportColumn{Field: queryResultField, Label: "查询结果"}, model.ExportColumn{Field: failReasonField, Label: "失败原因"}, ) return columns, nil } // decodeExportColumns 解析前端传入的导出列。 // 优先解析 JSON 字符串(表单提交时数组套对象会被拆散),失败再退回对象数组。 func decodeExportColumns(in *model.SimCardQueryExportParam) []model.ExportColumn { if in == nil { return nil } if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" { cols := make([]model.ExportColumn, 0) if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 { return cols } } return in.ExportColumns } // baseQuerySnapshot 落库的筛选条件快照。 // 只留模式、条数和前若干条样本,不存全量号码,避免 query_params 膨胀。 func baseQuerySnapshot(in *model.SimCardQueryExportParam, inputRows int) g.Map { snapshot := g.Map{ "mode": in.Mode, "inputRows": inputRows, } switch in.Mode { case model.SimCardQueryModeRange: snapshot["dimension"] = in.Dimension snapshot["startNum"] = in.StartNum snapshot["endNum"] = in.EndNum case model.SimCardQueryModeInput: snapshot["batchInputPreview"] = previewText(in.BatchInput, 5) } return snapshot } func previewText(text string, limit int) []string { lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") out := make([]string, 0, limit) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } out = append(out, line) if len(out) >= limit { break } } return out } // roundMb 统一保留 3 位小数,与库表 decimal(18,3) 精度一致。 func roundMb(value float64) float64 { return math.Round(value*1000) / 1000 } // usageHistoryWhitelist 月历史用量查询允许勾选的固定列。 // 月份列是动态生成的,直接按月份值作 Field,不走白名单。 var usageHistoryWhitelist = map[string]model.ExportColumn{ "iccid": {Field: "iccid", Label: "ICCID"}, "msisdn": {Field: "msisdn", Label: "MSISDN"}, "imsi": {Field: "imsi", Label: "IMSI"}, "outStockTime": {Field: "outStockTime", Label: "出库时间"}, "expireAt": {Field: "expireAt", Label: "到期时间"}, } // usageHistoryOptionalFields 月历史用量里可勾选的固定列(三码固定导出,不在此列)。 var usageHistoryOptionalFields = []string{"outStockTime", "expireAt"} // resolveUsageHistoryColumns 生成月历史用量导出列。 // 三码固定在最前且不可取消;月份列固定跟随导出口径,不允许前端自行增删。 func resolveUsageHistoryColumns(in *model.SimCardUsageHistoryExportParam, months []string) []model.ExportColumn { requested := decodeExportColumns(&in.SimCardQueryExportParam) allowed := make(map[string]struct{}, len(requested)) for _, column := range requested { field := strings.TrimSpace(column.Field) if _, ok := usageHistoryWhitelist[field]; ok { allowed[field] = struct{}{} } } columns := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)+len(months)+2) for _, field := range baseMandatoryOrder { columns = append(columns, usageHistoryWhitelist[field]) } // 未勾选任何可选列时按默认全出,命中筛选时按固定顺序保留。 if len(allowed) == 0 { for _, field := range usageHistoryOptionalFields { columns = append(columns, usageHistoryWhitelist[field]) } } else { for _, field := range usageHistoryOptionalFields { if _, ok := allowed[field]; ok { columns = append(columns, usageHistoryWhitelist[field]) } } } for _, month := range months { columns = append(columns, model.ExportColumn{Field: month, Label: month}) } columns = append(columns, model.ExportColumn{Field: queryResultField, Label: "查询结果"}, model.ExportColumn{Field: failReasonField, Label: "失败原因"}, ) return columns } func usageHistoryTags(months []string) []string { tags := make([]string, 0, len(usageHistoryFixedFields)+len(months)+2) for _, field := range usageHistoryFixedFields { tags = append(tags, usageHistoryWhitelist[field].Label) } tags = append(tags, months...) return append(tags, "查询结果", "失败原因") } func usageHistorySnapshot(in *model.SimCardUsageHistoryExportParam, months []string, rows int) g.Map { snapshot := g.Map{ "mode": in.Mode, "months": months, "inputRows": rows, } if in.Mode == model.SimCardQueryModeDept { snapshot["deptId"] = in.DeptId } return snapshot } // UsageHistoryTemplate 生成月历史用量查询模板(与卡基础信息查询同一份模板口径)。 func (s *Service) UsageHistoryTemplate(ctx context.Context) (*excelize.File, string, error) { return s.BaseTemplate(ctx) } // BaseColumnOptions 卡基础信息查询可选导出列,供前端渲染勾选框。 func (s *Service) BaseColumnOptions() []model.ExportColumn { options := make([]model.ExportColumn, 0, len(baseDefaultOrder)) for _, field := range baseDefaultOrder { options = append(options, baseColumnWhitelist[field]) } return options } // UsageHistoryColumnOptions 月历史用量查询可选固定列(不含动态月份列)。 func (s *Service) UsageHistoryColumnOptions() []model.ExportColumn { options := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)) for _, field := range usageHistoryFixedFields { options = append(options, usageHistoryWhitelist[field]) } return options } // UsageHistoryMonths 当前导出口径的月份清单,前端用它预览列顺序。 func (s *Service) UsageHistoryMonths(now time.Time) []string { return LastCompleteMonths(now, usageHistoryMonths) } // BaseTemplate 生成卡基础信息查询模板。 // 三列都允许为空,但至少填一个;表头与导入解析的 RequiredHeaders 保持一致。 func (s *Service) BaseTemplate(ctx context.Context) (*excelize.File, string, error) { f := excelize.NewFile() sheet := "卡基础信息查询" index, err := f.NewSheet(sheet) if err != nil { return nil, "", gerror.Wrap(err, "创建模板工作表失败") } f.SetActiveSheet(index) if err := f.DeleteSheet("Sheet1"); err != nil { return nil, "", gerror.Wrap(err, "清理默认工作表失败") } headers := []string{"ICCID", "MSISDN", "IMSI"} for i, header := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 1) if err = f.SetCellValue(sheet, cell, header); err != nil { return nil, "", gerror.Wrap(err, "写入模板表头失败") } } if err = f.SetColWidth(sheet, "A", "C", 26); err != nil { return nil, "", gerror.Wrap(err, "设置模板列宽失败") } if err = iot_excelize.ApplyHeaderColumnsTextStyle(f); err != nil { g.Log().Warningf(ctx, "apply template style failed: %v", err) } return f, "卡基础信息查询模板", nil }