diff --git a/server/admin/internal/modules/prettyid/handler.go b/server/admin/internal/modules/prettyid/handler.go index 44fc65c4..f14b38bf 100644 --- a/server/admin/internal/modules/prettyid/handler.go +++ b/server/admin/internal/modules/prettyid/handler.go @@ -1,6 +1,10 @@ package prettyid import ( + "context" + "encoding/json" + "errors" + "math" "strconv" "strings" @@ -10,8 +14,22 @@ import ( "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 @@ -191,12 +209,12 @@ func (h *Handler) Grant(c *gin.Context) { response.BadRequest(c, "发放参数不正确") return } - targetUserID, ok := parseFlexibleInt64(req.TargetUserID) - if !ok { - response.BadRequest(c, "target_user_id 参数不正确") + targetUserID, targetKind, err := resolveGrantTargetUserID(c.Request.Context(), h.user, middleware.CurrentRequestID(c), req.TargetUserID) + if err != nil { + response.BadRequest(c, err.Error()) return } - // 后台发放允许 target_user_id 由 JS 以数字或字符串提交,这里转成 int64 后把业务校验交给 user-service。 + // 后台发放最终仍只把内部 user_id 传给 user-service;短号/靓号只作为后台输入别名,避免运营把 6 位短号误当成内部 ID 导致 NotFound。 result, err := h.user.AdminGrantPrettyDisplayID(c.Request.Context(), userclient.AdminGrantPrettyDisplayIDRequest{ RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", @@ -210,7 +228,7 @@ func (h *Handler) Grant(c *gin.Context) { 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)) + 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) } @@ -272,18 +290,86 @@ func optionalInt64Query(c *gin.Context, name string) (int64, bool) { return value, err == nil && value > 0 } -func parseFlexibleInt64(raw any) (int64, bool) { - // JSON 大整数在前端经常以字符串提交,数字提交时也必须是精确整数,不能接受小数或负数。 +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 != float64(int64(value)) { - return 0, false + if value <= 0 || value != math.Trunc(value) || value > float64(math.MaxInt64) { + return "", false } - return int64(value), true + 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: - parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) - return parsed, err == nil && parsed > 0 + text := strings.TrimSpace(value) + return text, text != "" default: - return 0, false + 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 +} diff --git a/server/admin/internal/modules/prettyid/handler_test.go b/server/admin/internal/modules/prettyid/handler_test.go new file mode 100644 index 00000000..c2c73249 --- /dev/null +++ b/server/admin/internal/modules/prettyid/handler_test.go @@ -0,0 +1,127 @@ +package prettyid + +import ( + "context" + "reflect" + "strconv" + "testing" + + "hyapp-admin-server/internal/integration/userclient" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type fakeGrantTargetResolver struct { + users map[int64]*userclient.User + displays map[string]*userclient.UserIdentity + calls []string +} + +func (f *fakeGrantTargetResolver) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) { + f.calls = append(f.calls, "get:"+strconv.FormatInt(req.UserID, 10)) + if user := f.users[req.UserID]; user != nil { + return user, nil + } + return nil, status.Error(codes.NotFound, "user not found") +} + +func (f *fakeGrantTargetResolver) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) { + f.calls = append(f.calls, "resolve:"+req.DisplayUserID) + if identity := f.displays[req.DisplayUserID]; identity != nil { + return identity, nil + } + return nil, status.Error(codes.NotFound, "display_user_id not found") +} + +func TestResolveGrantTargetUserIDUsesShortDisplayID(t *testing.T) { + resolver := &fakeGrantTargetResolver{ + users: map[int64]*userclient.User{}, + displays: map[string]*userclient.UserIdentity{ + "163001": {UserID: 312899006709637120, DisplayUserID: "163001"}, + }, + } + + userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "163001") + if err != nil { + t.Fatalf("resolveGrantTargetUserID returned error: %v", err) + } + if userID != 312899006709637120 || kind != "display_user_id" { + t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind) + } + if want := []string{"resolve:163001"}; !reflect.DeepEqual(resolver.calls, want) { + t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want) + } +} + +func TestResolveGrantTargetUserIDKeepsLongInternalID(t *testing.T) { + resolver := &fakeGrantTargetResolver{ + users: map[int64]*userclient.User{ + 312899006709637120: {UserID: 312899006709637120, DisplayUserID: "163001"}, + }, + displays: map[string]*userclient.UserIdentity{}, + } + + userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "312899006709637120") + if err != nil { + t.Fatalf("resolveGrantTargetUserID returned error: %v", err) + } + if userID != 312899006709637120 || kind != "user_id" { + t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind) + } + if want := []string{"get:312899006709637120"}; !reflect.DeepEqual(resolver.calls, want) { + t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want) + } +} + +func TestResolveGrantTargetUserIDFallsBackForLongDisplayID(t *testing.T) { + resolver := &fakeGrantTargetResolver{ + users: map[int64]*userclient.User{}, + displays: map[string]*userclient.UserIdentity{ + "312899006709637120": {UserID: 411, DisplayUserID: "312899006709637120"}, + }, + } + + userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "312899006709637120") + if err != nil { + t.Fatalf("resolveGrantTargetUserID returned error: %v", err) + } + if userID != 411 || kind != "display_user_id" { + t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind) + } + if want := []string{"get:312899006709637120", "resolve:312899006709637120"}; !reflect.DeepEqual(resolver.calls, want) { + t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want) + } +} + +func TestResolveGrantTargetUserIDAcceptsPrettyDisplayID(t *testing.T) { + resolver := &fakeGrantTargetResolver{ + users: map[int64]*userclient.User{}, + displays: map[string]*userclient.UserIdentity{ + "VIP2026": {UserID: 88, DisplayUserID: "VIP2026"}, + }, + } + + userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "VIP2026") + if err != nil { + t.Fatalf("resolveGrantTargetUserID returned error: %v", err) + } + if userID != 88 || kind != "display_user_id" { + t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind) + } + if want := []string{"resolve:VIP2026"}; !reflect.DeepEqual(resolver.calls, want) { + t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want) + } +} + +func TestResolveGrantTargetUserIDReportsMissingTarget(t *testing.T) { + resolver := &fakeGrantTargetResolver{ + users: map[int64]*userclient.User{}, + displays: map[string]*userclient.UserIdentity{}, + } + + _, _, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "163001") + if err != errGrantTargetNotFound { + t.Fatalf("error mismatch: got %v want %v", err, errGrantTargetNotFound) + } +}