package appuser import ( "context" "encoding/csv" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" "github.com/gin-gonic/gin" ) const AppUserExportJobType = "app-user-export" type appUserExportJobStore interface { CreateJob(job *model.AdminJob) error } type appUserExportPayload struct { AppCode string `json:"appCode"` Query listQuery `json:"query"` } type appUserExportJobResponse struct { JobID uint `json:"jobId"` Status string `json:"status"` SnapshotAtMs int64 `json:"snapshotAtMs"` } type appUserExportResult struct { JobID uint `json:"jobId"` Rows int64 `json:"rows"` Artifact string `json:"artifact"` DownloadPath string `json:"downloadPath"` SnapshotAtMs int64 `json:"snapshotAtMs"` } func (s *Service) CreateExportJob(ctx context.Context, actor shared.Actor, query listQuery) (*appUserExportJobResponse, error) { if s.jobStore == nil || !s.jobsConfig.Enabled { return nil, errors.New("admin jobs are not enabled") } query = normalizeListQuery(query) query = stableAppUserExportQuery(query) query.Page = 1 query.PageSize = 100 query.SnapshotAtMs = nowMillis() payload := appUserExportPayload{ AppCode: appctx.FromContext(ctx), Query: query, } body, err := json.Marshal(payload) if err != nil { return nil, err } job := model.AdminJob{ Type: AppUserExportJobType, Status: model.JobStatusPending, PayloadJSON: string(body), MaxAttempts: s.jobsConfig.MaxAttempts, CreatedBy: actor.UserID, CreatedByName: strings.TrimSpace(actor.Username), } if job.MaxAttempts <= 0 { job.MaxAttempts = 3 } if err := s.jobStore.CreateJob(&job); err != nil { return nil, err } return &appUserExportJobResponse{JobID: job.ID, Status: job.Status, SnapshotAtMs: query.SnapshotAtMs}, nil } // HandleExportJob 使用创建任务时固化的 App 与筛选条件逐页读取;任务执行期间不会重新解释前端 query。 func (s *Service) HandleExportJob(ctx context.Context, job *model.AdminJob) (string, string, error) { if job == nil || job.ID == 0 { return "", "", errors.New("job is required") } var payload appUserExportPayload if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil { return "", "", err } payload.AppCode = strings.TrimSpace(payload.AppCode) payload.Query = normalizeListQuery(payload.Query) payload.Query = stableAppUserExportQuery(payload.Query) if payload.AppCode == "" || payload.Query.SnapshotAtMs <= 0 { return "", "", ErrInvalidArgument } artifactDir := strings.TrimSpace(s.jobsConfig.ArtifactDir) if artifactDir == "" { artifactDir = "storage/exports" } if err := os.MkdirAll(artifactDir, 0o750); err != nil { return "", "", err } file, err := os.CreateTemp(artifactDir, fmt.Sprintf("app-user-export-%d-*.csv", job.ID)) if err != nil { return "", "", err } artifactName := filepath.Base(file.Name()) succeeded := false defer func() { _ = file.Close() if !succeeded { _ = os.Remove(file.Name()) } }() // UTF-8 BOM 让运营直接使用 Excel 打开中文昵称、国家和身份时不出现乱码。 if _, err := file.WriteString("\xEF\xBB\xBF"); err != nil { return "", "", err } writer := csv.NewWriter(file) if err := writer.Write(appUserExportHeaders()); err != nil { return "", "", err } exportCtx := appctx.WithContext(ctx, payload.AppCode) query := payload.Query query.Page = 1 query.PageSize = 100 var exported int64 for { items, _, err := s.ListUsers(exportCtx, query) if err != nil { return "", "", err } for i := range items { if err := writer.Write(appUserExportRow(items[i])); err != nil { return "", "", err } } exported += int64(len(items)) if len(items) < query.PageSize { break } last := items[len(items)-1] cursorUserID, err := strconv.ParseInt(last.UserID, 10, 64) if err != nil || cursorUserID <= 0 || last.CreatedAtMs <= 0 { return "", "", errors.New("app user export cursor is invalid") } // 使用 (created_at_ms,user_id) keyset 而不是 OFFSET;任务运行期间新增用户或已读用户变更筛选字段时, // 后续批次仍不会位移、重复或跳过尚未读取的稳定主键区间。 query.ExportCursorCreatedAtMs = last.CreatedAtMs query.ExportCursorUserID = cursorUserID } writer.Flush() if err := writer.Error(); err != nil { return "", "", err } if err := file.Sync(); err != nil { return "", "", err } if err := file.Close(); err != nil { return "", "", err } succeeded = true result := appUserExportResult{ JobID: job.ID, Rows: exported, Artifact: artifactName, DownloadPath: fmt.Sprintf("/api/v1/jobs/%d/artifact", job.ID), SnapshotAtMs: payload.Query.SnapshotAtMs, } resultJSON, err := json.Marshal(result) if err != nil { return "", "", err } return string(resultJSON), artifactName, nil } func stableAppUserExportQuery(query listQuery) listQuery { // 导出顺序不属于筛选语义,统一 created_at/user_id 倒序才能和固定 keyset 游标一致。 // 这也绕开 coin 投影的全量内存排序,避免每一批重复读取形成 O(批次数×用户数)。 query.SortBy = "created_at" query.SortDirection = "desc" return query } func (h *Handler) CreateExport(c *gin.Context) { job, err := h.service.CreateExportJob(c.Request.Context(), shared.ActorFromContext(c), parseListQuery(c)) if err != nil { response.ServerError(c, "创建 App 用户导出任务失败") return } writeAppUserAuditLog(c, h.audit, "export-app-users", "app_user_exports", int64(job.JobID), "success", fmt.Sprintf("snapshot_at_ms=%d", job.SnapshotAtMs)) response.OK(c, job) } func (h *Handler) HandleExportJob(ctx context.Context, job *model.AdminJob) (string, string, error) { return h.service.HandleExportJob(ctx, job) } func appUserExportHeaders() []string { return []string{ "用户ID", "短ID", "靓号", "昵称", "头像", "性别", "生日", "年龄", "国家", "区域", "VIP等级", "VIP到期时间(ms)", "富豪真实等级", "富豪展示等级", "富豪临时目标", "富豪到期时间(ms)", "魅力真实等级", "魅力展示等级", "魅力临时目标", "魅力到期时间(ms)", "游戏等级", "身份", "注册设备", "金币", "钻石", "累计充值(USD分)", "状态", "封禁类型", "封禁到期时间(ms)", "封禁原因", "注册时间(ms)", "最近活跃时间(ms)", "最近成功登录时间(ms)", "最新操作人", "最新操作", "最新操作时间(ms)", } } func appUserExportRow(item AppUser) []string { age := "" if item.Age != nil { age = strconv.FormatInt(int64(*item.Age), 10) } banType := "" if item.Ban.Active { if item.Ban.Permanent { banType = "永久" } else { banType = "限时" } } operatorName := "" operatorAction := "" if item.LastOperator != nil { operatorName = item.LastOperator.Name operatorAction = item.LastOperator.Action } row := []string{ item.UserID, item.DefaultDisplayUserID, firstNonEmptyString(item.PrettyDisplayUserID, item.PrettyID), item.Username, item.Avatar, item.Gender, item.Birth, age, firstNonEmptyString(item.CountryDisplayName, item.CountryName, item.Country), item.RegionName, strconv.FormatInt(int64(item.VIP.Level), 10), strconv.FormatInt(item.VIP.ExpiresAtMs, 10), strconv.FormatInt(int64(item.Levels.Wealth.RealLevel), 10), strconv.FormatInt(int64(item.Levels.Wealth.DisplayLevel), 10), strconv.FormatInt(int64(item.Levels.Wealth.TemporaryTargetLevel), 10), strconv.FormatInt(item.Levels.Wealth.ExpiresAtMs, 10), strconv.FormatInt(int64(item.Levels.Charm.RealLevel), 10), strconv.FormatInt(int64(item.Levels.Charm.DisplayLevel), 10), strconv.FormatInt(int64(item.Levels.Charm.TemporaryTargetLevel), 10), strconv.FormatInt(item.Levels.Charm.ExpiresAtMs, 10), strconv.FormatInt(int64(item.Levels.Game.DisplayLevel), 10), strings.Join(item.Roles, "|"), item.RegisterDevice, strconv.FormatInt(item.Coin, 10), strconv.FormatInt(item.Diamond, 10), strconv.FormatInt(item.CumulativeRechargeUSDMinor, 10), item.Status, banType, strconv.FormatInt(item.Ban.ExpiresAtMs, 10), item.Ban.Reason, strconv.FormatInt(item.CreatedAtMs, 10), strconv.FormatInt(item.LastActiveAtMs, 10), strconv.FormatInt(item.LastLoginAtMs, 10), operatorName, operatorAction, strconv.FormatInt(item.LastOperatedAtMs, 10), } for index := range row { row[index] = spreadsheetSafeCSVCell(row[index]) } return row } func spreadsheetSafeCSVCell(value string) string { trimmed := strings.TrimLeft(value, " \t\r\n") if trimmed == "" { return value } switch trimmed[0] { case '=', '+', '-', '@': // Excel/Sheets 会把这些前缀解释为公式;前置单引号只影响表格解释,不丢失原始文本内容。 return "'" + value default: return value } } func firstNonEmptyString(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" }