272 lines
8.9 KiB
Go
272 lines
8.9 KiB
Go
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 ""
|
||
}
|