package prettyid import ( "context" "encoding/json" "errors" "io" "math" "strconv" "strings" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" "github.com/gin-gonic/gin" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) const internalUserIDMinDigits = 15 var ( errGrantTargetInvalid = errors.New("请输入用户 ID 或短号") errGrantTargetNotFound = errors.New("用户不存在") ) type grantTargetResolver interface { GetUser(ctx context.Context, req userclient.GetUserRequest) (*userclient.User, error) ResolveDisplayUserID(ctx context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) } type Handler struct { user userclient.Client audit shared.OperationLogger } func New(user userclient.Client, audit shared.OperationLogger) *Handler { return &Handler{user: user, audit: audit} } type poolRequest struct { Name string `json:"name"` LevelTrack string `json:"level_track"` MinLevel int32 `json:"min_level"` MaxLevel int32 `json:"max_level"` RuleType string `json:"rule_type"` RuleConfigJSON string `json:"rule_config_json"` Status string `json:"status"` SortOrder int32 `json:"sort_order"` } type generateRequest struct { RuleType string `json:"rule_type"` RuleConfigJSON string `json:"rule_config_json"` Count int32 `json:"count"` } type grantRequest struct { TargetUserID any `json:"target_user_id"` DisplayUserID string `json:"display_user_id"` DurationMs int64 `json:"duration_ms"` Reason string `json:"reason"` } type statusRequest struct { Status string `json:"status"` Reason string `json:"reason"` } type recycleRequest struct { Reason string `json:"reason"` } func (h *Handler) ListPools(c *gin.Context) { page, pageSize, ok := pageQuery(c) if !ok { response.BadRequest(c, "分页参数不正确") return } // admin-server 只解析 HTTP 查询参数和当前管理员身份,池状态、等级轨道合法性由 user-service 统一校验。 items, total, err := h.user.ListPrettyDisplayIDPools(c.Request.Context(), userclient.ListPrettyDisplayIDPoolsRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", Status: strings.TrimSpace(c.Query("status")), LevelTrack: strings.TrimSpace(c.Query("level_track")), Page: page, PageSize: pageSize, }) if err != nil { response.BadRequest(c, err.Error()) return } response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize}) } func (h *Handler) CreatePool(c *gin.Context) { var req poolRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "靓号池参数不正确") return } // 创建池时操作者从后台登录态读取,不能信任请求体里的管理员 ID。 pool, err := h.user.CreatePrettyDisplayIDPool(c.Request.Context(), userclient.PrettyDisplayIDPoolRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", Name: req.Name, LevelTrack: req.LevelTrack, MinLevel: req.MinLevel, MaxLevel: req.MaxLevel, RuleType: req.RuleType, RuleConfigJSON: req.RuleConfigJSON, Status: req.Status, SortOrder: req.SortOrder, OperatorAdminID: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLogWithResourceID(c, h.audit, "create-pretty-id-pool", "pretty_display_id_pools", pool.PoolID, "success", "name="+pool.Name) response.Created(c, pool) } func (h *Handler) UpdatePool(c *gin.Context) { var req poolRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "靓号池参数不正确") return } // pool_id 来自路径,body 只承载可编辑字段,避免前端误传 ID 导致更新目标不一致。 poolID := strings.TrimSpace(c.Param("pool_id")) pool, err := h.user.UpdatePrettyDisplayIDPool(c.Request.Context(), userclient.PrettyDisplayIDPoolRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", PoolID: poolID, Name: req.Name, LevelTrack: req.LevelTrack, MinLevel: req.MinLevel, MaxLevel: req.MaxLevel, RuleType: req.RuleType, RuleConfigJSON: req.RuleConfigJSON, Status: req.Status, SortOrder: req.SortOrder, OperatorAdminID: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLogWithResourceID(c, h.audit, "update-pretty-id-pool", "pretty_display_id_pools", pool.PoolID, "success", "status="+pool.Status) response.OK(c, pool) } func (h *Handler) Generate(c *gin.Context) { var req generateRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "生成参数不正确") return } // 生成只从路径读取 pool_id,请求体里的规则和数量会继续交给 user-service 校验并写批次审计。 poolID := strings.TrimSpace(c.Param("pool_id")) batch, err := h.user.GeneratePrettyDisplayIDs(c.Request.Context(), userclient.GeneratePrettyDisplayIDsRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", PoolID: poolID, RuleType: req.RuleType, RuleConfigJSON: req.RuleConfigJSON, Count: req.Count, OperatorAdminID: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLogWithResourceID(c, h.audit, "generate-pretty-ids", "pretty_display_id_generation_batches", batch.BatchID, "success", "pool_id="+poolID) response.Created(c, batch) } func (h *Handler) ListIDs(c *gin.Context) { page, pageSize, ok := pageQuery(c) if !ok { response.BadRequest(c, "分页参数不正确") return } assignedUserID, ok := optionalInt64Query(c, "assigned_user_id") if !ok { response.BadRequest(c, "assigned_user_id 参数不正确") return } // 列表筛选保持轻量透传,空字符串表示不过滤,assigned_user_id 只在正整数时作为精确条件。 items, total, err := h.user.ListPrettyDisplayIDs(c.Request.Context(), userclient.ListPrettyDisplayIDsRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", PoolID: strings.TrimSpace(c.Query("pool_id")), Source: strings.TrimSpace(c.Query("source")), Status: strings.TrimSpace(c.Query("status")), Keyword: strings.TrimSpace(c.Query("keyword")), AssignedUserID: assignedUserID, Page: page, PageSize: pageSize, }) if err != nil { response.BadRequest(c, err.Error()) return } if err := attachAssignedUsers(c.Request.Context(), h.user, middleware.CurrentRequestID(c), items); err != nil { response.BadRequest(c, err.Error()) return } response.OK(c, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize}) } type assignedUserBatchGetter interface { BatchGetUsers(ctx context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) } func attachAssignedUsers(ctx context.Context, getter assignedUserBatchGetter, requestID string, items []*userclient.PrettyDisplayID) error { if getter == nil || len(items) == 0 { return nil } userIDs := make([]int64, 0, len(items)) seen := make(map[int64]struct{}, len(items)) for _, item := range items { if item == nil || item.AssignedUserID <= 0 { continue } if _, ok := seen[item.AssignedUserID]; ok { continue } // pretty_display_ids 用 0 表示未占用;这里只批量补真实正数 user_id,避免前端把哨兵值渲染成用户。 seen[item.AssignedUserID] = struct{}{} userIDs = append(userIDs, item.AssignedUserID) } if len(userIDs) == 0 { return nil } users, err := getter.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{ RequestID: requestID, Caller: "admin-server", UserIDs: userIDs, }) if err != nil { return err } for _, item := range items { if item == nil || item.AssignedUserID <= 0 { continue } // BatchGetUsers 对缺失用户不返回占位对象;保持 nil 让前端显示空态,不用内部 user_id 冒充昵称或短号。 item.AssignedUser = users[item.AssignedUserID] } return nil } func (h *Handler) Grant(c *gin.Context) { var req grantRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "发放参数不正确") return } targetUserID, targetKind, err := resolveGrantTargetUserID(c.Request.Context(), h.user, middleware.CurrentRequestID(c), req.TargetUserID) if err != nil { response.BadRequest(c, err.Error()) return } // 后台发放最终仍只把内部 user_id 传给 user-service;短号/靓号只作为后台输入别名,避免运营把 6 位短号误当成内部 ID 导致 NotFound。 result, err := h.user.AdminGrantPrettyDisplayID(c.Request.Context(), userclient.AdminGrantPrettyDisplayIDRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", TargetUserID: targetUserID, DisplayUserID: req.DisplayUserID, DurationMs: req.DurationMs, Reason: req.Reason, OperatorAdminID: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLogWithResourceID(c, h.audit, "grant-pretty-id", "pretty_display_ids", result.PrettyID, "success", "target_user_id="+strconv.FormatInt(targetUserID, 10)+" target_kind="+targetKind) response.Created(c, result) } func (h *Handler) SetStatus(c *gin.Context) { var req statusRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "状态参数不正确") return } // 状态修改以路径 pretty_id 为准,请求体只允许目标状态和原因,防止改错号码。 prettyID := strings.TrimSpace(c.Param("pretty_id")) item, err := h.user.SetPrettyDisplayIDStatus(c.Request.Context(), userclient.SetPrettyDisplayIDStatusRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", PrettyID: prettyID, Status: req.Status, Reason: req.Reason, OperatorAdminID: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLogWithResourceID(c, h.audit, "set-pretty-id-status", "pretty_display_ids", prettyID, "success", "status="+item.Status) response.OK(c, item) } func (h *Handler) Recycle(c *gin.Context) { var req recycleRequest if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { response.BadRequest(c, "回收参数不正确") return } // 回收只能以路径 pretty_id 定位,不能相信前端传用户 ID 或 lease ID;真实占用关系由 user-service 事务重新锁表校验。 prettyID := strings.TrimSpace(c.Param("pretty_id")) item, err := h.user.RecyclePrettyDisplayID(c.Request.Context(), userclient.RecyclePrettyDisplayIDRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", PrettyID: prettyID, Reason: strings.TrimSpace(req.Reason), OperatorAdminID: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLogWithResourceID(c, h.audit, "recycle-pretty-id", "pretty_display_ids", prettyID, "success", "status="+item.Status) response.OK(c, item) } func pageQuery(c *gin.Context) (int32, int32, bool) { // 分页参数在 admin-server 先收敛到 1..100,避免后台页面传错值造成 user-service 大分页查询。 page := int32(1) pageSize := int32(20) if raw := strings.TrimSpace(c.Query("page")); raw != "" { value, err := strconv.ParseInt(raw, 10, 32) if err != nil || value <= 0 { return 0, 0, false } page = int32(value) } if raw := strings.TrimSpace(c.Query("page_size")); raw != "" { value, err := strconv.ParseInt(raw, 10, 32) if err != nil || value <= 0 { return 0, 0, false } pageSize = int32(value) } if pageSize > 100 { pageSize = 100 } return page, pageSize, true } func optionalInt64Query(c *gin.Context, name string) (int64, bool) { // 空查询表示不筛选,非空必须是正整数,避免把 0 当成真实用户 ID 传给列表接口。 raw := strings.TrimSpace(c.Query(name)) if raw == "" { return 0, true } value, err := strconv.ParseInt(raw, 10, 64) return value, err == nil && value > 0 } func resolveGrantTargetUserID(ctx context.Context, resolver grantTargetResolver, requestID string, raw any) (int64, string, error) { keyword, ok := grantTargetKeyword(raw) if !ok || resolver == nil { return 0, "", errGrantTargetInvalid } numericID, numericOK := parsePositiveInt64(keyword) // 内部 user_id 是雪花长整型,运营常用的短 ID 是 6 位左右;短输入优先走展示号解析,长输入优先保持旧的内部 ID 语义。 userIDChecked := false if numericOK && len(strings.TrimLeft(keyword, "0")) >= internalUserIDMinDigits { userIDChecked = true if found, err := grantTargetByUserID(ctx, resolver, requestID, numericID); err != nil { return 0, "", err } else if found { return numericID, "user_id", nil } } if identity, err := resolver.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{ RequestID: requestID, Caller: "admin-server", DisplayUserID: keyword, }); err != nil { if !isGRPCNotFound(err) { return 0, "", err } } else if identity != nil && identity.UserID > 0 { return identity.UserID, "display_user_id", nil } if numericOK && !userIDChecked { if found, err := grantTargetByUserID(ctx, resolver, requestID, numericID); err != nil { return 0, "", err } else if found { return numericID, "user_id", nil } } return 0, "", errGrantTargetNotFound } func grantTargetByUserID(ctx context.Context, resolver grantTargetResolver, requestID string, userID int64) (bool, error) { // GetUser 只用于确认内部 ID 存在;真实发放仍在 AdminGrantPrettyDisplayID 事务里完成,避免这里承担写语义。 user, err := resolver.GetUser(ctx, userclient.GetUserRequest{ RequestID: requestID, Caller: "admin-server", UserID: userID, }) if err != nil { if isGRPCNotFound(err) { return false, nil } return false, err } return user != nil && user.UserID > 0, nil } func grantTargetKeyword(raw any) (string, bool) { // JSON 入口可能收到字符串、数字或测试里的 json.Number;统一转成精确文本后再决定按内部 ID 还是短号解析。 switch value := raw.(type) { case float64: if value <= 0 || value != math.Trunc(value) || value > float64(math.MaxInt64) { return "", false } return strconv.FormatInt(int64(value), 10), true case json.Number: text := strings.TrimSpace(value.String()) if _, ok := parsePositiveInt64(text); !ok { return "", false } return text, true case string: text := strings.TrimSpace(value) return text, text != "" default: return "", false } } func parsePositiveInt64(text string) (int64, bool) { parsed, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) return parsed, err == nil && parsed > 0 } func isGRPCNotFound(err error) bool { return status.Code(err) == codes.NotFound }