2026-06-30 20:50:50 +08:00

450 lines
15 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 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
}