package exportcenter import ( "context" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" ) func snapshot(ctx context.Context) (*model.ExportIdentitySnapshot, error) { user := contexts.GetUser(ctx) if user == nil || user.ID <= 0 { return nil, gerror.New("登录状态已失效,请重新登录") } snap := &model.ExportIdentitySnapshot{ UserId: user.ID, DeptId: user.DeptId, TenantId: user.TenantId, Roles: user.Roles, AccessCodeList: user.AccessCodeList, } if v := contexts.GetDataValue(ctx, "currentRoleId"); v != nil { snap.CurrentRoleId = gconv.String(v) } if v := contexts.GetDataValue(ctx, "currentRoleDataScope"); v != nil { snap.CurrentRoleDataScope = gconv.String(v) } return snap, nil } // Submit 提交一次导出任务:预校验行数、落任务与执行日志、投递后台执行。 func Submit(ctx context.Context, req *Request) (res *SubmitResult, err error) { if req == nil || req.Tags == nil || req.Fetch == nil { return nil, gerror.New("导出配置不完整") } if req.BizKey == "" || req.BizName == "" { return nil, gerror.New("导出业务标识不能为空") } // 提交前占用执行/排队容量,避免高峰期无限创建等待执行的 goroutine。 select { case slots <- struct{}{}: default: return nil, gerror.New("导出任务排队已满,请稍后重试") } ownershipTransferred := false defer func() { if !ownershipTransferred { <-slots } }() snap, err := snapshot(ctx) if err != nil { return nil, failSubmit(ctx, "snapshot", req.BizKey, err) } // 预校验:只取 1 行,拿到筛选条件下的总行数。 // 业务侧可能返回 -1 表示「提交阶段还无法确定条数」(如按号段/按公司圈卡, // 需要后台实际查库才知道命中多少),此时放行,最终条数以实际写出为准。 // total=0 也放行:空结果同样要产出文件(至少带表头与失败原因列), // 让用户能拿到「查了但没命中」的凭据,而不是只看到一句报错。 _, total, err := req.Fetch(ctx, 1, 1) if err != nil { return nil, failSubmit(ctx, "count", req.BizKey, gerror.Wrap(err, "统计导出数据失败")) } if total > MaxRows { return nil, gerror.Newf("本次导出 %d 行,超过单次上限 %d 行,请缩小筛选范围", total, MaxRows) } // 未知条数不写入任务记录,避免 -1 出现在列表与执行日志里。 recordedTotal := total if recordedTotal < 0 { recordedTotal = 0 } taskNo, err := batchimport.NextBatchNo(consts.BatchNoPrefixExport) if err != nil { return nil, failSubmit(ctx, "batch_no", req.BizKey, gerror.Wrap(err, "生成任务编号失败")) } // 列清单直接影响导出内容,留痕便于排查「导出列与页面不一致」。 if len(req.Columns) == 0 { req.Columns = columnsFromRequest(ctx) } g.Log().Infof(ctx, "[exportcenter] 提交导出 biz=%s user=%d columns=%d", req.BizKey, snap.UserId, len(req.Columns)) now := time.Now() expire := now.AddDate(0, 0, ExpireDays) fileName := taskNo + ".xlsx" batchInfo := gjson.New(g.Map{ "exportBizKey": req.BizKey, "exportBizName": req.BizName, "permCode": req.PermCode, }) var taskId int64 err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { id, insertErr := dao.TExportTask.Ctx(ctx).TX(tx).Data(g.Map{ dao.TExportTask.Columns().TaskNo: taskNo, dao.TExportTask.Columns().BizKey: req.BizKey, dao.TExportTask.Columns().BizName: req.BizName, dao.TExportTask.Columns().PermCode: req.PermCode, dao.TExportTask.Columns().QueryParams: gjson.New(req.QueryParam), dao.TExportTask.Columns().IdentitySnapshot: gjson.New(snap), dao.TExportTask.Columns().Status: consts.DownloadStatusPending, dao.TExportTask.Columns().TotalCount: recordedTotal, dao.TExportTask.Columns().FileName: fileName, dao.TExportTask.Columns().OwnerUserId: snap.UserId, dao.TExportTask.Columns().DeptId: snap.DeptId, dao.TExportTask.Columns().TenantId: snap.TenantId, dao.TExportTask.Columns().CreatedDept: snap.DeptId, dao.TExportTask.Columns().CreatedBy: snap.UserId, dao.TExportTask.Columns().CreatedAt: now, dao.TExportTask.Columns().ExpireTime: expire, }).InsertAndGetId() if insertErr != nil { return gerror.Wrap(insertErr, "创建导出任务失败") } taskId = id _, insertErr = dao.BatchMain.Ctx(ctx).TX(tx).Data(g.Map{ dao.BatchMain.Columns().BatchNo: taskNo, dao.BatchMain.Columns().BatchType: consts.BatchTypeExport, dao.BatchMain.Columns().BatchStatus: consts.BatchStatusNormal, dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusCreated, dao.BatchMain.Columns().SourceChannel: batchmetadata.Console, dao.BatchMain.Columns().ExecutionMode: batchmetadata.Bulk, dao.BatchMain.Columns().RunResult: consts.BatchRunResultPending, dao.BatchMain.Columns().TotalCount: recordedTotal, dao.BatchMain.Columns().BatchInfo: batchInfo, dao.BatchMain.Columns().TenantId: snap.TenantId, dao.BatchMain.Columns().CreatedDept: snap.DeptId, // dept_id 必须与 created_dept 一致。 // FilterAuth 对 batch_main 优先用 dept_id 过滤,留空(NUL)会让提交人自己也看不到 // 这条执行日志(只有全部数据权限的管理员能看到),表现为「导出了但日志里没有」。 dao.BatchMain.Columns().DeptId: snap.DeptId, dao.BatchMain.Columns().CreatedBy: snap.UserId, dao.BatchMain.Columns().CreatedAt: now, }).Insert() if insertErr != nil { return gerror.Wrap(insertErr, "写导出执行日志失败") } return nil }) if err != nil { return nil, failSubmit(ctx, "persist", req.BizKey, err) } go run(taskNo, req) ownershipTransferred = true return &SubmitResult{TaskId: taskId, TaskNo: taskNo, Total: total}, nil } // failSubmit 统一记录提交失败的环节与原因。 // 框架会把未带错误码的 error 统一转成「内部服务器错误」,真实原因只能从日志看, // 所以这里必须把 step 打出来,否则线上无法定位。 func failSubmit(ctx context.Context, step, bizKey string, err error) error { g.Log().Errorf(ctx, "[exportcenter] 提交导出失败 step=%s biz=%s err=%+v", step, bizKey, err) return err }