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