package batchresult import ( "context" "crypto/md5" "encoding/hex" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/xuri/excelize/v2" ) const configOssURLPathKey = "sys.oss.urlPath" type Row struct { RowNo int ObjectCode string TargetCode string PackageCode string EffectiveMode string ICCID string MSISDN string IMSI string TargetDeptName string StatusBefore int TargetStatus int Operation string ApnName string Success bool Reason string UpstreamCode int OrderNum string } type Request struct { BatchNo string BatchType int TenantID string Rows []Row } type Artifact struct { FileName string OriginalName string FileSuffix string Path string LocalPath string URL string Size int64 MD5 string } type Generator interface { Create(ctx context.Context, request Request) (Artifact, error) } type generator struct{} func New() Generator { return &generator{} } func (gtr *generator) Create(ctx context.Context, request Request) (Artifact, error) { if request.BatchNo == "" || filepath.Base(request.BatchNo) != request.BatchNo { return Artifact{}, gerror.New("invalid batch number") } uploadPath := g.Cfg().MustGet(ctx, "system.upload.local.path").String() if uploadPath == "" { return Artifact{}, gerror.New("system.upload.local.path is empty") } tenantID := request.TenantID if tenantID == "" { tenantID = "default" } resultDir, err := resultDir(request.BatchType) if err != nil { return Artifact{}, err } objectDir := filepath.Join(tenantID, "user", "file", time.Now().Format("20060102"), resultDir) physicalUploadPath := resultPhysicalUploadRoot(uploadPath) directory := filepath.Join(physicalUploadPath, objectDir) if err = os.MkdirAll(directory, 0o755); err != nil { return Artifact{}, gerror.Wrap(err, "create batch result directory") } fileName := request.BatchNo + "-result.xlsx" filePath := filepath.Join(directory, fileName) logicalPath := filepath.Join(uploadPath, objectDir, fileName) if err = writeWorkbook(filePath, request); err != nil { return Artifact{}, err } artifact, err := inspectArtifact(ctx, filePath, logicalPath, fileName, tenantID) if err != nil { _ = os.Remove(filePath) return Artifact{}, err } return artifact, nil } func resultPhysicalUploadRoot(uploadPath string) string { uploadPath = strings.TrimSpace(uploadPath) for _, envName := range []string{"IOTLCM_WORKER_UPLOAD_ROOT", "IOTLCM_UPLOAD_ROOT"} { if value := strings.TrimSpace(os.Getenv(envName)); value != "" { return value } } for _, candidate := range uploadRootEquivalentCandidates(uploadPath) { if samePath(candidate, uploadPath) { continue } for _, resolved := range resolvedRootCandidates(candidate) { if info, err := os.Stat(resolved); err == nil && info.IsDir() { return resolved } } } return uploadPath } func uploadRootEquivalentCandidates(root string) []string { root = strings.TrimSpace(root) if root == "" { return nil } candidates := []string{root} normalized := filepath.ToSlash(filepath.Clean(root)) if normalized == "resource/upload" || strings.HasSuffix(normalized, "/resource/upload") { prefix := strings.TrimSuffix(normalized, "resource/upload") candidates = appendUniqueString(candidates, filepath.FromSlash(prefix+"upload")) } return candidates } func resolvedRootCandidates(root string) []string { root = strings.TrimSpace(root) if root == "" { return nil } if filepath.IsAbs(root) { return []string{root} } candidates := []string{root} for _, base := range localBaseDirs() { candidates = appendUniqueString(candidates, filepath.Join(base, root)) } return candidates } func localBaseDirs() []string { var dirs []string if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" { dirs = appendUniqueString(dirs, wd) } if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" { dirs = appendUniqueString(dirs, filepath.Dir(exe)) } return dirs } func samePath(a, b string) bool { return filepath.Clean(strings.TrimSpace(a)) == filepath.Clean(strings.TrimSpace(b)) } func appendUniqueString(values []string, value string) []string { value = strings.TrimSpace(value) if value == "" { return values } for _, existing := range values { if samePath(existing, value) { return values } } return append(values, value) } func resultDir(batchType int) (string, error) { if batchType == 11 { return filepath.Join("sim-card-add-package", "result"), nil } if batchType == 12 { return filepath.Join("sim-pool-add-package", "result"), nil } switch batchType { case 1: return filepath.Join("sim-card-inbound", "result"), nil case 3: return filepath.Join("sim-card-allocate", "result"), nil case 4: return filepath.Join("sim-card-renew-change-prepare", "result"), nil case 101: return filepath.Join("sim-card-renew", "result"), nil case 102: return filepath.Join("sim-card-change", "result"), nil case 103: return filepath.Join("sim-card-start", "result"), nil case 104: return filepath.Join("sim-card-stop", "result"), nil case 105: return filepath.Join("sim-card-restart", "result"), nil case 106: return filepath.Join("sim-card-cancel", "result"), nil case 107: return filepath.Join("sim-card-data-cut", "result"), nil case 108: return filepath.Join("sim-card-unsub", "result"), nil case 109: return filepath.Join("sim-card-data-start", "result"), nil case 110: return filepath.Join("sim-card-data-stop", "result"), nil case 40: return filepath.Join("sim-warning-config", "result"), nil case 41: return filepath.Join("sim-card-traffic-cutoff-config", "result"), nil default: return "", gerror.Newf("unsupported batch type %d", batchType) } } func writeWorkbook(path string, request Request) error { file := excelize.NewFile() defer func() { _ = file.Close() }() const resultSheet = "处理结果" defaultSheet := file.GetSheetName(0) if err := file.SetSheetName(defaultSheet, resultSheet); err != nil { return gerror.Wrap(err, "name result sheet") } if request.BatchType == 1 { return writeInboundWorkbook(file, resultSheet, path, request) } if request.BatchType == 3 { return writeAllocateWorkbook(file, resultSheet, path, request) } if request.BatchType == 11 || request.BatchType == 12 { return writeAddPackageWorkbook(file, resultSheet, path, request) } if request.BatchType == 106 { return writeCancelWorkbook(file, resultSheet, path, request) } if request.BatchType == 40 || request.BatchType == 41 { return writeWarningConfigWorkbook(file, resultSheet, path, request) } if isDataServiceBatch(request.BatchType) { return writeDataServiceWorkbook(file, resultSheet, path, request) } return writeLifecycleWorkbook(file, resultSheet, path, request) } func writeAllocateWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "目标客户", "执行结果", "失败原因"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write allocate result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.TargetDeptName, status, row.Reason} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate allocate result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write allocate result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format allocate result workbook") } if err := file.SetColWidth(sheet, "B", "E", 24); err != nil { return gerror.Wrap(err, "format allocate result workbook") } if err := file.SetColWidth(sheet, "F", "G", 24); err != nil { return gerror.Wrap(err, "format allocate result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save allocate result workbook") } return nil } func writeWarningConfigWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "对象编码", "执行结果", "失败原因"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write warning config result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } objectCode := row.ObjectCode if objectCode == "" { objectCode = row.ICCID } values := []interface{}{row.RowNo, objectCode, status, row.Reason} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate warning config result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write warning config result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format warning config result workbook") } if err := file.SetColWidth(sheet, "B", "B", 28); err != nil { return gerror.Wrap(err, "format warning config result workbook") } if err := file.SetColWidth(sheet, "C", "D", 24); err != nil { return gerror.Wrap(err, "format warning config result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save warning config result workbook") } return nil } func writeAddPackageWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "目标编码", "加油包编码", "生效方式", "执行结果", "失败原因", "订单号"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write add package result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } targetCode := row.ObjectCode if targetCode == "" { targetCode = row.TargetCode } values := []interface{}{row.RowNo, targetCode, row.PackageCode, addPackageEffectiveModeText(row.EffectiveMode), status, row.Reason, row.OrderNum} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate add package result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write add package result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format add package result workbook") } if err := file.SetColWidth(sheet, "B", "D", 24); err != nil { return gerror.Wrap(err, "format add package result workbook") } if err := file.SetColWidth(sheet, "E", "G", 22); err != nil { return gerror.Wrap(err, "format add package result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save add package result workbook") } return nil } func addPackageEffectiveModeText(mode string) string { switch strings.TrimSpace(mode) { case "", "current_month", "immediate", "this_month", "本月生效", "本月", "当月": return "本月生效" case "next_month", "nextMonth", "次月生效", "次月", "下月", "下月生效": return "次月生效" default: return strings.TrimSpace(mode) } } func isDataServiceBatch(batchType int) bool { return batchType == 107 || batchType == 109 || batchType == 110 } func writeInboundWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "ICCID", "执行结果", "失败原因"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write inbound result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } values := []interface{}{row.RowNo, row.ICCID, status, row.Reason} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate inbound result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write inbound result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format inbound result workbook") } if err := file.SetColWidth(sheet, "B", "B", 24); err != nil { return gerror.Wrap(err, "format inbound result workbook") } if err := file.SetColWidth(sheet, "C", "D", 24); err != nil { return gerror.Wrap(err, "format inbound result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save inbound result workbook") } return nil } func writeLifecycleWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "执行结果", "失败原因", "上游状态码", "上游订单号"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write lifecycle result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, status, row.Reason, row.UpstreamCode, row.OrderNum} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate lifecycle result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write lifecycle result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format lifecycle result workbook") } if err := file.SetColWidth(sheet, "B", "D", 24); err != nil { return gerror.Wrap(err, "format lifecycle result workbook") } if err := file.SetColWidth(sheet, "E", "H", 20); err != nil { return gerror.Wrap(err, "format lifecycle result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save lifecycle result workbook") } return nil } func writeCancelWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "原卡状态", "目标卡状态", "执行结果", "失败原因"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write cancel result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.StatusBefore, row.TargetStatus, status, row.Reason} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate cancel result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write cancel result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format cancel result workbook") } if err := file.SetColWidth(sheet, "B", "D", 24); err != nil { return gerror.Wrap(err, "format cancel result workbook") } if err := file.SetColWidth(sheet, "E", "H", 20); err != nil { return gerror.Wrap(err, "format cancel result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save cancel result workbook") } return nil } func writeDataServiceWorkbook(file *excelize.File, sheet, path string, request Request) error { headers := []interface{}{"行号", "ICCID", "MSISDN", "IMSI", "操作", "APN", "执行结果", "失败原因", "上游状态码", "上游订单号"} if err := file.SetSheetRow(sheet, "A1", &headers); err != nil { return gerror.Wrap(err, "write data service result headers") } for index, row := range request.Rows { status := "失败" if row.Success { status = "成功" } values := []interface{}{row.RowNo, row.ICCID, row.MSISDN, row.IMSI, row.Operation, row.ApnName, status, row.Reason, row.UpstreamCode, row.OrderNum} cell, err := excelize.CoordinatesToCellName(1, index+2) if err != nil { return gerror.Wrap(err, "locate data service result row") } if err = file.SetSheetRow(sheet, cell, &values); err != nil { return gerror.Wrap(err, "write data service result row") } } if err := file.SetColWidth(sheet, "A", "A", 10); err != nil { return gerror.Wrap(err, "format data service result workbook") } if err := file.SetColWidth(sheet, "B", "D", 24); err != nil { return gerror.Wrap(err, "format data service result workbook") } if err := file.SetColWidth(sheet, "E", "J", 20); err != nil { return gerror.Wrap(err, "format data service result workbook") } if err := file.SaveAs(path); err != nil { return gerror.Wrap(err, "save data service result workbook") } return nil } func inspectArtifact(ctx context.Context, localPath, logicalPath, fileName string, tenantID string) (Artifact, error) { file, err := os.Open(localPath) if err != nil { return Artifact{}, gerror.Wrap(err, "open result file") } defer file.Close() hash := md5.New() size, err := io.Copy(hash, file) if err != nil { return Artifact{}, gerror.Wrap(err, "hash result file") } return Artifact{ FileName: fileName, OriginalName: fileName, FileSuffix: "xlsx", Path: logicalPath, LocalPath: localPath, URL: localUploadURL(ctx, logicalPath, tenantID), Size: size, MD5: hex.EncodeToString(hash.Sum(nil)), }, nil } func RemoveLocalFile(artifact Artifact) { path := strings.TrimSpace(artifact.LocalPath) if path == "" { path = strings.TrimSpace(artifact.Path) } if path != "" { _ = os.Remove(path) } } func localUploadURL(ctx context.Context, filePath string, tenantID string) string { filePath = strings.TrimSpace(filepath.ToSlash(filepath.Clean(filePath))) if filePath == "" { return "" } if configValue := loadUploadURLPathConfig(ctx, tenantID); configValue != "" { if strings.Contains(configValue, "|") { split := strings.SplitN(configValue, "|", 2) from := strings.TrimSpace(filepath.ToSlash(split[0])) to := strings.TrimSpace(filepath.ToSlash(split[1])) if suffix, ok := localUploadMappedSuffix(filePath, from); ok { return joinUploadURL(to, suffix) } } else { if suffix := localUploadRelativePath(filePath); suffix != "" { return joinUploadURL(configValue, suffix) } return joinUploadURL(configValue, filePath) } } if suffix := localUploadRelativePath(filePath); suffix != "" { return joinUploadURL("/upload", suffix) } return filePath } func loadUploadURLPathConfig(ctx context.Context, tenantID string) (configValue string) { defer func() { if recover() != nil { configValue = "" } }() for _, tenant := range []string{strings.TrimSpace(tenantID), "000000"} { if tenant == "" { continue } var row struct { ConfigValue string `orm:"config_value"` } err := g.DB().Model("sys_config").Safe().Ctx(ctx). Fields("config_value"). Where("tenant_id", tenant). Where("config_key", configOssURLPathKey). Limit(1). Scan(&row) if err == nil && strings.TrimSpace(row.ConfigValue) != "" { return strings.TrimSpace(row.ConfigValue) } } return "" } func localUploadMappedSuffix(filePath string, from string) (string, bool) { from = strings.Trim(strings.TrimSpace(filepath.ToSlash(from)), "/") if from == "" { return "", false } normalized := strings.TrimSpace(filepath.ToSlash(filePath)) marker := "/" + from + "/" if idx := strings.Index(normalized, marker); idx >= 0 { return strings.TrimPrefix(normalized[idx+len(marker):], "/"), true } prefix := from + "/" if strings.HasPrefix(normalized, prefix) { return strings.TrimPrefix(normalized[len(prefix):], "/"), true } if strings.Trim(normalized, "/") == from { return "", true } return "", false } func localUploadRelativePath(filePath string) string { normalized := filepath.ToSlash(filepath.Clean(strings.TrimSpace(filePath))) for _, marker := range []string{ "resource/upload/", "/resource/upload/", "/app/resource/upload/", "/app/upload/", "upload/", "/upload/", } { if idx := strings.Index(normalized, marker); idx >= 0 { return strings.TrimPrefix(normalized[idx+len(marker):], "/") } } return "" } func joinUploadURL(base string, suffix string) string { base = strings.TrimRight(strings.TrimSpace(filepath.ToSlash(base)), "/") suffix = strings.TrimLeft(strings.TrimSpace(filepath.ToSlash(suffix)), "/") if base == "" { base = "/upload" } if suffix == "" { return base } return base + "/" + suffix } func TaskName(batchType int) string { if batchType == 11 { return "单卡加油包结果" } if batchType == 12 { return "流量池加油包结果" } switch batchType { case 1: return "SIM卡批量入库结果" case 3: return "卡划拨结果" case 4: return "卡续费/变更预处理结果" case 101: return "卡续费结果" case 102: return "卡变更套餐结果" case 103: return "SIM卡批量复机结果" case 104: return "SIM卡批量停机结果" case 105: return "SIM卡批量重启结果" case 106: return "卡销户结果" case 108: return "SIM卡批量退订结果" case 107: return "SIM卡达量断网结果" case 109: return "卡开数据结果" case 110: return "卡停数据结果" case 40: return "SIM卡预警配置结果" case 41: return "达量断网配置结果" default: return fmt.Sprintf("SIM卡批量任务%d结果", batchType) } }