272 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""
}