From 3f465e4490230f22da9a1d7c8d6e2a221d52bcef Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 30 Jun 2026 20:50:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=82=80=E8=AF=B7=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E7=94=A8=E6=88=B7id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/多App租户架构.md | 4 +- .../internal/integration/userclient/pretty.go | 1 + .../internal/modules/prettyid/handler.go | 46 ++++ .../internal/modules/prettyid/handler_test.go | 66 ++++++ .../transport/http/game_handler_test.go | 76 +++++++ .../transport/http/gameapi/game_handler.go | 155 +++++++++++++ .../transport/http/gameapi/handler.go | 4 + .../internal/transport/http/middleware.go | 17 +- .../http/my_page_overview_handler_test.go | 3 +- .../internal/transport/http/response_test.go | 40 +++- .../internal/transport/http/router.go | 1 + .../transport/http/userapi/user_handler.go | 2 + .../deploy/mysql/initdb/001_user_service.sql | 40 +++- .../internal/service/auth/service.go | 2 +- .../internal/service/auth/service_test.go | 40 +++- .../storage/mysql/app/repository_test.go | 12 + .../storage/mysql/auth/display_sequence.go | 205 +++++++++++++++--- .../mysql/auth/display_sequence_test.go | 77 +++++++ 18 files changed, 735 insertions(+), 56 deletions(-) create mode 100644 services/user-service/internal/storage/mysql/auth/display_sequence_test.go diff --git a/docs/多App租户架构.md b/docs/多App租户架构.md index 5b860003..bb3ba558 100644 --- a/docs/多App租户架构.md +++ b/docs/多App租户架构.md @@ -10,7 +10,9 @@ | package_name | platform | app_code | status | | --- | --- | --- | --- | -| `com.org.laluparty` | `android` | `lalu` | `active` | +| `com.org.laluparty` | 任意或空 | `lalu` | `active` | +| `com.app.huwaa` | 任意或空 | `huwaa` | `active` | +| `com.chat.fami` | 任意或空 | `fami` | `active` | 新增 App 时,只新增 `apps` 注册表记录和对应 App 的基础配置/种子数据,不新建一套服务,也不复制业务代码。 diff --git a/server/admin/internal/integration/userclient/pretty.go b/server/admin/internal/integration/userclient/pretty.go index 30db59ab..5f64e37b 100644 --- a/server/admin/internal/integration/userclient/pretty.go +++ b/server/admin/internal/integration/userclient/pretty.go @@ -31,6 +31,7 @@ type PrettyDisplayID struct { DisplayUserID string `json:"display_user_id"` Status string `json:"status"` AssignedUserID int64 `json:"assigned_user_id,string"` + AssignedUser *User `json:"assigned_user,omitempty"` AssignedLeaseID string `json:"assigned_lease_id"` AssignedAtMs int64 `json:"assigned_at_ms"` ReleasedAtMs int64 `json:"released_at_ms"` diff --git a/server/admin/internal/modules/prettyid/handler.go b/server/admin/internal/modules/prettyid/handler.go index cec26859..c95ac4ee 100644 --- a/server/admin/internal/modules/prettyid/handler.go +++ b/server/admin/internal/modules/prettyid/handler.go @@ -205,9 +205,55 @@ func (h *Handler) ListIDs(c *gin.Context) { 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 { diff --git a/server/admin/internal/modules/prettyid/handler_test.go b/server/admin/internal/modules/prettyid/handler_test.go index c2c73249..b0ebba02 100644 --- a/server/admin/internal/modules/prettyid/handler_test.go +++ b/server/admin/internal/modules/prettyid/handler_test.go @@ -2,6 +2,7 @@ package prettyid import ( "context" + "errors" "reflect" "strconv" "testing" @@ -18,6 +19,20 @@ type fakeGrantTargetResolver struct { calls []string } +type fakeAssignedUserBatchGetter struct { + calls [][]int64 + err error + users map[int64]*userclient.User +} + +func (f *fakeAssignedUserBatchGetter) BatchGetUsers(_ context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) { + f.calls = append(f.calls, append([]int64(nil), req.UserIDs...)) + if f.err != nil { + return nil, f.err + } + return f.users, nil +} + 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 { @@ -34,6 +49,57 @@ func (f *fakeGrantTargetResolver) ResolveDisplayUserID(_ context.Context, req us return nil, status.Error(codes.NotFound, "display_user_id not found") } +func TestAttachAssignedUsersAddsSnapshotsAndSkipsZero(t *testing.T) { + getter := &fakeAssignedUserBatchGetter{ + users: map[int64]*userclient.User{ + 42: { + Avatar: "https://cdn.example/u42.png", + DefaultDisplayUserID: "100042", + DisplayUserID: "VIP42", + PrettyDisplayUserID: "VIP42", + UserID: 42, + Username: "owner42", + }, + }, + } + items := []*userclient.PrettyDisplayID{ + {PrettyID: "available", AssignedUserID: 0}, + {PrettyID: "assigned-42", AssignedUserID: 42}, + {PrettyID: "assigned-42-dup", AssignedUserID: 42}, + {PrettyID: "stale-77", AssignedUserID: 77}, + } + + if err := attachAssignedUsers(context.Background(), getter, "req", items); err != nil { + t.Fatalf("attachAssignedUsers returned error: %v", err) + } + + if want := [][]int64{{42, 77}}; !reflect.DeepEqual(getter.calls, want) { + t.Fatalf("calls mismatch: got %v want %v", getter.calls, want) + } + if items[0].AssignedUser != nil { + t.Fatalf("zero assigned user should stay empty: %+v", items[0].AssignedUser) + } + if items[1].AssignedUser == nil || items[1].AssignedUser.Username != "owner42" || items[1].AssignedUser.DefaultDisplayUserID != "100042" { + t.Fatalf("assigned snapshot mismatch: %+v", items[1].AssignedUser) + } + if items[2].AssignedUser == nil || items[2].AssignedUser.UserID != 42 { + t.Fatalf("duplicate assigned id should reuse snapshot: %+v", items[2].AssignedUser) + } + if items[3].AssignedUser != nil { + t.Fatalf("missing batch user should stay empty: %+v", items[3].AssignedUser) + } +} + +func TestAttachAssignedUsersReturnsBatchError(t *testing.T) { + wantErr := errors.New("batch failed") + err := attachAssignedUsers(context.Background(), &fakeAssignedUserBatchGetter{err: wantErr}, "req", []*userclient.PrettyDisplayID{ + {PrettyID: "assigned", AssignedUserID: 42}, + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error mismatch: got %v want %v", err, wantErr) + } +} + func TestResolveGrantTargetUserIDUsesShortDisplayID(t *testing.T) { resolver := &fakeGrantTargetResolver{ users: map[int64]*userclient.User{}, diff --git a/services/gateway-service/internal/transport/http/game_handler_test.go b/services/gateway-service/internal/transport/http/game_handler_test.go index 5863b808..6b293754 100644 --- a/services/gateway-service/internal/transport/http/game_handler_test.go +++ b/services/gateway-service/internal/transport/http/game_handler_test.go @@ -573,6 +573,82 @@ func TestZGameFixedCallbackRouteIsRawPublicResponse(t *testing.T) { } } +func TestGameCallbackUsesVerifiedTokenAppCodeFromBodyQueryAndHeader(t *testing.T) { + token := signGatewayTokenWithAppCode(t, "secret", 42, "yumi") + tests := []struct { + name string + path string + body string + headerName string + }{ + { + name: "body js_code", + path: "/api/v1/zgame/callback/api/server/login?nonce=1", + body: `{"open_id":"420001","js_code":"` + token + `"}`, + }, + { + name: "query access_token", + path: "/api/v1/game-callbacks/demo/change_coin?nonce=1&access_token=" + url.QueryEscape(token), + body: `{"provider_order_id":"p1"}`, + }, + { + name: "header access token", + path: "/api/v1/game-callbacks/demo/change_coin?nonce=1", + body: `{"provider_order_id":"p1"}`, + headerName: "X-Hyapp-Access-Token", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetGameClient(gameClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewReader([]byte(tt.body))) + request.Header.Set("X-App-Code", "lalu") + if tt.headerName != "" { + request.Header.Set(tt.headerName, token) + } + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if gameClient.lastCallback == nil { + t.Fatalf("callback was not forwarded") + } + if got := gameClient.lastCallback.GetMeta().GetAppCode(); got != "yumi" { + t.Fatalf("callback app_code must come from verified token: got %q", got) + } + if got := gameClient.lastCallback.GetMeta().GetActorUserId(); got != 42 { + t.Fatalf("callback actor user must come from verified token: got %d", got) + } + }) + } +} + +func TestGameCallbackRejectsInvalidHyappToken(t *testing.T) { + invalidToken := signGatewayTokenWithAppCode(t, "other-secret", 42, "yumi") + gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetGameClient(gameClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/game-callbacks/demo/change_coin?nonce=1", bytes.NewReader([]byte(`{"token":"`+invalidToken+`"}`))) + request.Header.Set("X-App-Code", "lalu") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("invalid callback token must be rejected: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if gameClient.lastCallback != nil { + t.Fatalf("invalid token callback must not reach game-service: %+v", gameClient.lastCallback) + } +} + func TestHotgameCompatFallbacksToHyappReyouCallback(t *testing.T) { body := `{"gameId":"101","uid":"420001","token":"hyapp-token","sign":"s"}` gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}} diff --git a/services/gateway-service/internal/transport/http/gameapi/game_handler.go b/services/gateway-service/internal/transport/http/gameapi/game_handler.go index 93fde676..c482fee7 100644 --- a/services/gateway-service/internal/transport/http/gameapi/game_handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/game_handler.go @@ -785,6 +785,12 @@ func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *htt http.Error(writer, "upstream service error", http.StatusBadGateway) return } + scopedRequest, ok := h.withVerifiedCallbackToken(request, raw) + if !ok { + http.Error(writer, "unauthorized", http.StatusUnauthorized) + return + } + request = scopedRequest resp, err := h.gameClient.HandleCallback(request.Context(), &gamev1.CallbackRequest{ Meta: gameMeta(request), PlatformCode: strings.TrimSpace(platformCode), @@ -808,6 +814,155 @@ func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *htt _, _ = writer.Write(resp.GetRawBody()) } +func (h *Handler) withVerifiedCallbackToken(request *http.Request, raw []byte) (*http.Request, bool) { + jwtCandidates := callbackJWTTokenCandidates(callbackTokenCandidates(request, raw)) + if len(jwtCandidates) == 0 { + // 回调 body 里常见的 code、js_code、token 可能是三方业务码或老平台 session;不是 HyApp JWT 时不参与归因, + // 避免把 code=0、chatapp3 sign token 或游戏方随机串误判为当前 App 用户。 + return request, true + } + if h.jwtVerifier == nil { + return request, false + } + for _, token := range jwtCandidates { + claims, err := h.jwtVerifier.Verify("Bearer " + token) + if err != nil { + continue + } + // 游戏回调是公网入口,callback 自带 app_code/header 都不能作为归因事实;只有 gateway 验过签的 HyApp access token + // 才能覆盖 request context,后续 gameMeta 会把 token 内 app_code/user_id 透传到 game-service RequestMeta。 + return request.WithContext(auth.WithClaims(request.Context(), claims)), true + } + return request, false +} + +func callbackJWTTokenCandidates(candidates []string) []string { + jwtCandidates := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + token := normalizeCallbackTokenValue(candidate) + if !callbackTokenLooksLikeJWT(token) { + continue + } + if _, ok := seen[token]; ok { + continue + } + seen[token] = struct{}{} + jwtCandidates = append(jwtCandidates, token) + } + return jwtCandidates +} + +func callbackTokenCandidates(request *http.Request, raw []byte) []string { + candidates := make([]string, 0, 8) + seen := make(map[string]struct{}, 8) + add := func(value string) { + token := normalizeCallbackTokenValue(value) + if token == "" { + return + } + if _, ok := seen[token]; ok { + return + } + seen[token] = struct{}{} + candidates = append(candidates, token) + } + + add(request.Header.Get("Authorization")) + for _, headerName := range []string{hyappAccessTokenHeader, "X-Access-Token", "X-Token", "X-Session-Token", "X-Game-Token"} { + add(request.Header.Get(headerName)) + } + for key, values := range request.URL.Query() { + if !callbackTokenFieldName(key) { + continue + } + for _, value := range values { + add(value) + } + } + appendCallbackBodyTokenCandidates(raw, add) + return candidates +} + +func appendCallbackBodyTokenCandidates(raw []byte, add func(string)) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return + } + var payload any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return + } + walkCallbackTokenJSON("", payload, add) +} + +func walkCallbackTokenJSON(fieldName string, value any, add func(string)) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + walkCallbackTokenJSON(key, child, add) + } + case []any: + for _, child := range typed { + walkCallbackTokenJSON(fieldName, child, add) + } + case string: + if callbackTokenFieldName(fieldName) { + add(typed) + } + } +} + +var callbackTokenKeyReplacer = strings.NewReplacer("_", "", "-", "", " ", "") + +var callbackTokenFieldNames = map[string]struct{}{ + "authorization": {}, + "token": {}, + "accesstoken": {}, + "hyapptoken": {}, + "hyappaccesstoken": {}, + "authtoken": {}, + "session": {}, + "sessionid": {}, + "sessiontoken": {}, + "jscode": {}, + "authcode": {}, + "platauthcode": {}, + "plattoken": {}, + "sstoken": {}, + "useraccesstoken": {}, + "userauthorization": {}, +} + +func callbackTokenFieldName(fieldName string) bool { + normalized := callbackTokenKeyReplacer.Replace(strings.ToLower(strings.TrimSpace(fieldName))) + _, ok := callbackTokenFieldNames[normalized] + return ok +} + +func normalizeCallbackTokenValue(value string) string { + token := strings.TrimSpace(value) + if len(token) >= len("Bearer ") && strings.EqualFold(token[:len("Bearer ")], "Bearer ") { + token = strings.TrimSpace(token[len("Bearer "):]) + } + return strings.Trim(strings.TrimSpace(token), `"'`) +} + +func callbackTokenLooksLikeJWT(token string) bool { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if strings.TrimSpace(part) == "" { + return false + } + } + return true +} + func gameMeta(request *http.Request) *gamev1.RequestMeta { return &gamev1.RequestMeta{ RequestId: httpkit.RequestIDFromContext(request.Context()), diff --git a/services/gateway-service/internal/transport/http/gameapi/handler.go b/services/gateway-service/internal/transport/http/gameapi/handler.go index f3128257..fa369629 100644 --- a/services/gateway-service/internal/transport/http/gameapi/handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/handler.go @@ -3,6 +3,7 @@ package gameapi import ( "net/http" + "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/transport/http/httproutes" ) @@ -15,6 +16,7 @@ type Handler struct { userProfileClient client.UserProfileClient walletClient client.WalletClient statisticsClient client.StatisticsClient + jwtVerifier *auth.Verifier // 热游固定回调地址先进入 hyapp gateway;chatapp3 老 token 需要原样转给 chatapp3,hyapp token 继续落到 game-service。 hotgameCompatHTTPClient *http.Client } @@ -25,6 +27,7 @@ type Config struct { UserProfileClient client.UserProfileClient WalletClient client.WalletClient StatisticsClient client.StatisticsClient + JWTVerifier *auth.Verifier } func New(config Config) *Handler { @@ -34,6 +37,7 @@ func New(config Config) *Handler { userProfileClient: config.UserProfileClient, walletClient: config.WalletClient, statisticsClient: config.StatisticsClient, + jwtVerifier: config.JWTVerifier, hotgameCompatHTTPClient: hotgameCompatHTTPClient(), } } diff --git a/services/gateway-service/internal/transport/http/middleware.go b/services/gateway-service/internal/transport/http/middleware.go index f0c19234..91447584 100644 --- a/services/gateway-service/internal/transport/http/middleware.go +++ b/services/gateway-service/internal/transport/http/middleware.go @@ -135,14 +135,25 @@ func (h *Handler) resolveRequestAppCode(ctx context.Context, request *http.Reque } } - if strings.EqualFold(packageName, "com.org.laluparty") { - // 开发阶段先保留用户明确给出的包名映射;生产仍以 user-service apps 表为准。 - return "lalu", nil + if appCode := fallbackAppCodeByPackage(packageName); appCode != "" { + return appCode, nil } return appcode.Normalize(explicitAppCode), nil } +func fallbackAppCodeByPackage(packageName string) string { + // 注册表 client 不可用时只兜底内置 App;生产主路径仍以 user-service apps 表校验为准。 + switch strings.ToLower(strings.TrimSpace(packageName)) { + case "com.org.laluparty": + return "lalu" + case "com.chat.fami": + return "fami" + default: + return "" + } +} + func firstHeader(request *http.Request, names ...string) string { for _, name := range names { value := strings.TrimSpace(request.Header.Get(name)) diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go index ce1a697e..3d2c9614 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go @@ -25,6 +25,7 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) RegionId: 10, ProfileCompleted: true, OnboardingStatus: "completed", + CreatedAtMs: 1_770_000_000_000, ProfileBgImg: "https://cdn.example/overview-profile-bg.png", }}, statsResp: &userv1.GetMyProfileStatsResponse{Stats: &userv1.UserProfileStats{ @@ -84,7 +85,7 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) } profile := data["profile"].(map[string]any) - if profile["user_id"] != "42" || profile["username"] != "hy" || profile["region_id"] != float64(10) || profile["profile_bg_img"] != "https://cdn.example/overview-profile-bg.png" { + if profile["user_id"] != "42" || profile["username"] != "hy" || profile["region_id"] != float64(10) || profile["created_at_ms"] != float64(1_770_000_000_000) || profile["profile_bg_img"] != "https://cdn.example/overview-profile-bg.png" { t.Fatalf("profile data mismatch: %+v", profile) } stats := data["profile_stats"].(map[string]any) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 94c8bf8f..3cc8a3c1 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -4204,6 +4204,29 @@ func TestAppBootstrapIsPublicAndLightweight(t *testing.T) { } } +func TestAppBootstrapResolvesFamiPackageFallback(t *testing.T) { + router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil) + request.Header.Set("X-Request-ID", "req-bootstrap-fami") + request.Header.Set("X-App-Package", "com.chat.fami") + request.Header.Set("X-App-Platform", "android") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + if response.Code != httpkit.CodeOK || !ok || data["app_code"] != "fami" { + t.Fatalf("fami bootstrap app_code mismatch: %+v", response) + } +} + type captureAppConfigReader struct { appconfig.StaticReader bannerQuery appconfig.BannerQuery @@ -6203,6 +6226,7 @@ func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) { Gender: "female", ProfileCompleted: true, OnboardingStatus: "completed", + CreatedAtMs: 1_770_000_000_000, ProfileBgImg: "https://cdn.example/profile-bg.png", }}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) @@ -6225,7 +6249,7 @@ func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) - if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" { + if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" || data["created_at_ms"] != float64(1_770_000_000_000) || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" { t.Fatalf("profile response mismatch: %+v", response) } } @@ -10063,12 +10087,24 @@ func signGatewayTokenWithProfile(t *testing.T, secret string, userID int64, prof return signGatewayTokenWithExpiresAt(t, secret, userID, profileCompleted, time.Now().Add(time.Hour)) } +func signGatewayTokenWithAppCode(t *testing.T, secret string, userID int64, appCode string) string { + t.Helper() + + return signGatewayTokenWithAppCodeAndExpiresAt(t, secret, userID, appCode, true, time.Now().Add(time.Hour)) +} + func signGatewayTokenWithExpiresAt(t *testing.T, secret string, userID int64, profileCompleted bool, expiresAt time.Time) string { t.Helper() + return signGatewayTokenWithAppCodeAndExpiresAt(t, secret, userID, "lalu", profileCompleted, expiresAt) +} + +func signGatewayTokenWithAppCodeAndExpiresAt(t *testing.T, secret string, userID int64, appCode string, profileCompleted bool, expiresAt time.Time) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": strconv.FormatInt(userID, 10), - "app_code": "lalu", + "app_code": appCode, "sid": "sess-test", "profile_completed": profileCompleted, "onboarding_status": map[bool]string{true: "completed", false: "profile_required"}[profileCompleted], diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 20fa0814..4abc0982 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -100,6 +100,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { UserProfileClient: h.userProfileClient, WalletClient: h.walletClient, StatisticsClient: h.statisticsClient, + JWTVerifier: jwtVerifier, }) resourceAPI := resourceapi.New(resourceapi.Config{ WalletClient: h.walletClient, diff --git a/services/gateway-service/internal/transport/http/userapi/user_handler.go b/services/gateway-service/internal/transport/http/userapi/user_handler.go index 02af80c9..0d060441 100644 --- a/services/gateway-service/internal/transport/http/userapi/user_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/user_handler.go @@ -61,6 +61,7 @@ type userProfileData struct { ProfileCompleted bool `json:"profile_completed"` ProfileCompletedAtMs int64 `json:"profile_completed_at_ms"` OnboardingStatus string `json:"onboarding_status"` + CreatedAtMs int64 `json:"created_at_ms"` UpdatedAtMs int64 `json:"updated_at_ms"` ProfileBgImg string `json:"profile_bg_img"` ContactInfo string `json:"contact_info,omitempty"` @@ -695,6 +696,7 @@ func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData ProfileCompleted: user.GetProfileCompleted(), ProfileCompletedAtMs: user.GetProfileCompletedAtMs(), OnboardingStatus: user.GetOnboardingStatus(), + CreatedAtMs: user.GetCreatedAtMs(), UpdatedAtMs: user.GetUpdatedAtMs(), ProfileBgImg: user.GetProfileBgImg(), ContactInfo: user.GetContactInfo(), diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 3e2d7cce..f0f7adab 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS apps ( INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms) VALUES ('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0), - ('huwaa', 'Huwaa', 'com.app.huwaa', '', 'active', 0, 0) + ('huwaa', 'Huwaa', 'com.app.huwaa', '', 'active', 0, 0), + ('fami', 'Fami', 'com.chat.fami', '', 'active', 0, 0) ON DUPLICATE KEY UPDATE app_name = VALUES(app_name), package_name = VALUES(package_name), @@ -212,17 +213,6 @@ CREATE TABLE IF NOT EXISTS user_reports ( KEY idx_user_reports_request (app_code, request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户举报表'; -CREATE TABLE IF NOT EXISTS display_user_id_sequences ( - app_code VARCHAR(32) NOT NULL PRIMARY KEY COMMENT '应用编码,用于多租户隔离', - next_value BIGINT NOT NULL COMMENT '下一个值', - updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='展示用户 ID 序列表'; - -INSERT INTO display_user_id_sequences (app_code, next_value, updated_at_ms) -VALUES ('lalu', 163000, 0) -ON DUPLICATE KEY UPDATE - next_value = next_value; - CREATE TABLE IF NOT EXISTS countries ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', country_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '国家 ID', @@ -975,6 +965,32 @@ CREATE TABLE IF NOT EXISTS user_display_user_ids ( KEY idx_user_display_user_ids_user_id (app_code, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户展示 ID 表'; +CREATE TABLE IF NOT EXISTS display_user_id_sequences ( + app_code VARCHAR(32) NOT NULL PRIMARY KEY COMMENT '应用编码,用于多租户隔离', + next_value BIGINT NOT NULL DEFAULT 0 COMMENT '下一个内部序号;对外展示号由置换算法生成', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='展示用户 ID 内部序列表'; + +INSERT INTO display_user_id_sequences (app_code, next_value, updated_at_ms) +SELECT + a.app_code, + GREATEST(COALESCE(du.occupied_count, 0), COALESCE(u.user_count, 0)), + 0 +FROM apps a +LEFT JOIN ( + SELECT app_code, COUNT(*) AS occupied_count + FROM user_display_user_ids + WHERE live_display_user_id IS NOT NULL + GROUP BY app_code +) du ON du.app_code = a.app_code +LEFT JOIN ( + SELECT app_code, COUNT(*) AS user_count + FROM users + GROUP BY app_code +) u ON u.app_code = a.app_code +ON DUPLICATE KEY UPDATE + next_value = GREATEST(next_value, VALUES(next_value)); + CREATE TABLE IF NOT EXISTS pretty_display_user_id_leases ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', lease_id VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '租约 ID', diff --git a/services/user-service/internal/service/auth/service.go b/services/user-service/internal/service/auth/service.go index 29610c6e..82a1153a 100644 --- a/services/user-service/internal/service/auth/service.go +++ b/services/user-service/internal/service/auth/service.go @@ -36,7 +36,7 @@ const ( // AuthRepository 是认证用例的持久化边界。 // 跨表写入仍按用例保留事务入口,例如三方首次登录要一次性创建用户、默认短号、三方身份和 session。 type AuthRepository interface { - // AllocateDisplayUserID 分配当前 App 下的下一个默认短号。 + // AllocateDisplayUserID 分配当前 App 下的随机外观默认短号;最终唯一性由创建事务里的展示号事实表兜底。 AllocateDisplayUserID(ctx context.Context, appCode string) (string, error) // SetPassword 为已有用户首次写入密码身份。 SetPassword(ctx context.Context, account authdomain.PasswordAccount) error diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index 44f04fce..d844fe8f 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -166,6 +166,18 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6 }, options...) } +func assertSevenDigitDisplayUserID(t *testing.T, displayUserID string) { + t.Helper() + if len(displayUserID) != 7 || displayUserID[0] < '1' || displayUserID[0] > '9' { + t.Fatalf("display_user_id must be 7 digits and start with 1-9, got %q", displayUserID) + } + for _, digit := range displayUserID { + if digit < '0' || digit > '9' { + t.Fatalf("display_user_id must contain digits only, got %q", displayUserID) + } + } +} + func assertUserRegisteredOutboxCount(t *testing.T, repository *mysqltest.Repository, userID int64, want int) { t.Helper() var count int @@ -276,7 +288,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } - if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" { + displayUserID := thirdToken.DisplayUserID + assertSevenDigitDisplayUserID(t, displayUserID) + if !isNewUser || thirdToken.UserID != 900001 || displayUserID == "" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" { t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser) } if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { @@ -284,7 +298,7 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { } assertUserRegisteredOutboxCount(t, repository, thirdToken.UserID, 0) - if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { + if _, err := svc.LoginPassword(ctx, displayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 未设置密码前不能通过短号密码登录,且错误必须统一 AUTH_FAILED。 t.Fatalf("expected AUTH_FAILED before password is set, got %v", err) } @@ -297,15 +311,15 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { t.Fatalf("expected PASSWORD_ALREADY_SET, got %v", err) } - loginToken, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{RequestID: "req-login"}) + loginToken, err := svc.LoginPassword(ctx, displayUserID, "secret-pass", "ios", authservice.Meta{RequestID: "req-login"}) if err != nil { t.Fatalf("LoginPassword failed: %v", err) } - if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != "163000" || loginToken.RefreshToken == thirdToken.RefreshToken { + if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != displayUserID || loginToken.RefreshToken == thirdToken.RefreshToken { t.Fatalf("unexpected login token: %+v", loginToken) } - if _, err := svc.LoginPassword(ctx, "163000", "wrong", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { + if _, err := svc.LoginPassword(ctx, displayUserID, "wrong", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { t.Fatalf("expected AUTH_FAILED for wrong password, got %v", err) } @@ -315,7 +329,7 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { if err != nil { t.Fatalf("RefreshToken failed: %v", err) } - if refreshToken.RefreshToken == loginToken.RefreshToken || refreshToken.DisplayUserID != "163000" { + if refreshToken.RefreshToken == loginToken.RefreshToken || refreshToken.DisplayUserID != displayUserID { t.Fatalf("refresh token must rotate") } @@ -389,6 +403,7 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) { if err != nil { t.Fatalf("QuickCreateAccount failed: %v", err) } + assertSevenDigitDisplayUserID(t, token.DisplayUserID) if token.UserID != 900011 || token.DisplayUserID == "" || token.AccessToken == "" || token.RefreshToken == "" { t.Fatalf("unexpected quick token: %+v", token) } @@ -440,6 +455,7 @@ func TestQuickCreateGameRobotCannotLoginOrCreateSession(t *testing.T) { if err != nil { t.Fatalf("QuickCreateAccount robot failed: %v", err) } + assertSevenDigitDisplayUserID(t, token.DisplayUserID) if token.UserID != 900012 || token.DisplayUserID == "" || token.AccessToken != "" || token.RefreshToken != "" || token.SessionID != "" { t.Fatalf("robot token must expose identity without login credentials: %+v", token) } @@ -1088,7 +1104,8 @@ func TestThirdPartyLoginOrRegister(t *testing.T) { if err != nil { t.Fatalf("first LoginThirdParty failed: %v", err) } - if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" { + assertSevenDigitDisplayUserID(t, firstToken.DisplayUserID) + if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID == "" { t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) } if firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { @@ -1503,8 +1520,8 @@ func TestThirdPartyRegisterValidatesSQLBackedLengths(t *testing.T) { } } -func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) { - // 默认短号候选冲突时,三方注册应在有限次数内重试下一个候选。 +func TestThirdPartyRegisterAvoidsOccupiedDisplayUserID(t *testing.T) { + // 默认短号随机生成前先查历史占用;创建事务仍用唯一索引处理并发撞号。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") @@ -1522,8 +1539,9 @@ func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) { if err != nil { t.Fatalf("GetUserIdentity failed: %v", err) } - if identity.DisplayUserID != "163001" { - t.Fatalf("display_user_id retry mismatch: %+v", identity) + assertSevenDigitDisplayUserID(t, identity.DisplayUserID) + if identity.DisplayUserID == "163000" { + t.Fatalf("display_user_id must not reuse occupied value: %+v", identity) } } diff --git a/services/user-service/internal/storage/mysql/app/repository_test.go b/services/user-service/internal/storage/mysql/app/repository_test.go index 0ae74116..c8b7af95 100644 --- a/services/user-service/internal/storage/mysql/app/repository_test.go +++ b/services/user-service/internal/storage/mysql/app/repository_test.go @@ -32,6 +32,18 @@ func TestResolveAppUsesWildcardPlatformForPackageOnlyRequests(t *testing.T) { } } +func TestResolveAppSupportsFamiPackage(t *testing.T) { + repo := mysqltest.NewRepository(t).Repository.AppRepository() + + resolved, err := repo.ResolveApp(context.Background(), "", "com.chat.fami", "android") + if err != nil { + t.Fatalf("ResolveApp with fami package failed: %v", err) + } + if resolved.AppCode != "fami" || resolved.PackageName != "com.chat.fami" || resolved.Status != "active" { + t.Fatalf("fami app mismatch: %+v", resolved) + } +} + func TestResolveAppRejectsUnsupportedPackage(t *testing.T) { repo := mysqltest.NewRepository(t).Repository.AppRepository() diff --git a/services/user-service/internal/storage/mysql/auth/display_sequence.go b/services/user-service/internal/storage/mysql/auth/display_sequence.go index 34e74a38..61db3342 100644 --- a/services/user-service/internal/storage/mysql/auth/display_sequence.go +++ b/services/user-service/internal/storage/mysql/auth/display_sequence.go @@ -4,49 +4,204 @@ import ( "context" "database/sql" "fmt" + "hash/fnv" "time" "hyapp/pkg/appcode" + "hyapp/pkg/xerr" ) -const firstDisplayUserID int64 = 163000 +const ( + displayUserIDMin int64 = 1000000 + displayUserIDSpace int64 = 9000000 + displayUserIDPermutationBase uint32 = 3000 + displayUserIDPermutationRound = 7 + displayUserIDAllocateAttempts = 4096 +) -// AllocateDisplayUserID 从数据库序列分配当前 App 下的默认短号。 -// 这里不用内存计数,避免服务重启后从 163000 重新发号导致连续冲突。 +var displayUserIDPermutationKeys = [displayUserIDPermutationRound]uint32{ + 0x243f6a88, + 0x85a308d3, + 0x13198a2e, + 0x03707344, + 0xa4093822, + 0x299f31d0, + 0x082efa98, +} + +type displayUserIDQueryer interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// AllocateDisplayUserID 为当前 App 分配 7 位随机外观默认短号。 +// 数据库只保存递增内部序号;对外短号由 3000x3000 Feistel 置换得到,所以同一 App 内序号不重复就不会产出重复短号。 +// 旧库里可能已有随机号或人工种子号,因此分配时仍用唯一索引点查跳过已占用值,最终创建事务再用唯一键兜住并发窗口。 func (r *Repository) AllocateDisplayUserID(ctx context.Context, appCode string) (string, error) { + normalized := appcode.Normalize(appCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback() - normalized := appcode.Normalize(appCode) - var next int64 - err = tx.QueryRowContext(ctx, ` + nowMs := time.Now().UTC().UnixMilli() + nextValue, err := r.lockDisplayUserIDSequence(ctx, tx, normalized, nowMs) + if err != nil { + return "", err + } + + for attempt := 0; attempt < displayUserIDAllocateAttempts; attempt++ { + if nextValue < 0 || nextValue >= displayUserIDSpace { + return "", xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id space exhausted") + } + candidate, err := displayUserIDFromOrdinal(normalized, nextValue) + if err != nil { + return "", err + } + occupied, err := displayUserIDOccupied(ctx, tx, normalized, candidate) + if err != nil { + return "", err + } + nextValue++ + if occupied { + // 历史随机号、测试种子号或人工保留号可能已经占住当前置换结果;推进内部序号继续找下一个可用值。 + continue + } + if _, err := tx.ExecContext(ctx, ` + UPDATE display_user_id_sequences + SET next_value = ?, updated_at_ms = ? + WHERE app_code = ? + `, nextValue, nowMs, normalized); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + + return candidate, nil + } + + return "", xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed") +} + +func (r *Repository) lockDisplayUserIDSequence(ctx context.Context, tx *sql.Tx, appCode string, nowMs int64) (int64, error) { + var nextValue int64 + err := tx.QueryRowContext(ctx, ` SELECT next_value FROM display_user_id_sequences WHERE app_code = ? FOR UPDATE - `, normalized).Scan(&next) - if err == sql.ErrNoRows { - next = firstDisplayUserID - _, err = tx.ExecContext(ctx, ` - INSERT INTO display_user_id_sequences (app_code, next_value, updated_at_ms) - VALUES (?, ?, ?) - `, normalized, next+1, time.Now().UTC().UnixMilli()) - } else if err == nil { - _, err = tx.ExecContext(ctx, ` - UPDATE display_user_id_sequences - SET next_value = ?, updated_at_ms = ? - WHERE app_code = ? - `, next+1, time.Now().UTC().UnixMilli(), normalized) + `, appCode).Scan(&nextValue) + if err == nil { + return nextValue, nil } - if err != nil { - return "", err - } - if err := tx.Commit(); err != nil { - return "", err + if err != sql.ErrNoRows { + return 0, err } - return fmt.Sprintf("%d", next), nil + initialValue, err := initialDisplayUserIDSequenceValue(ctx, tx, appCode) + if err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO display_user_id_sequences (app_code, next_value, updated_at_ms) + VALUES (?, ?, ?) + `, appCode, initialValue, nowMs); err != nil { + return 0, err + } + if err := tx.QueryRowContext(ctx, ` + SELECT next_value + FROM display_user_id_sequences + WHERE app_code = ? + FOR UPDATE + `, appCode).Scan(&nextValue); err != nil { + return 0, err + } + + return nextValue, nil +} + +func initialDisplayUserIDSequenceValue(ctx context.Context, queryer displayUserIDQueryer, appCode string) (int64, error) { + // 老库补表时用现有占用量作为内部序号起点;这不是唯一性来源,只是减少迁移后从 0 开始撞历史号的概率。 + var initialValue int64 + err := queryer.QueryRowContext(ctx, ` + SELECT GREATEST( + (SELECT COUNT(*) FROM user_display_user_ids WHERE app_code = ? AND live_display_user_id IS NOT NULL), + (SELECT COUNT(*) FROM users WHERE app_code = ?) + ) + `, appCode, appCode).Scan(&initialValue) + if err != nil { + return 0, err + } + + return initialValue, nil +} + +func displayUserIDFromOrdinal(appCode string, ordinal int64) (string, error) { + if ordinal < 0 || ordinal >= displayUserIDSpace { + return "", xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id space exhausted") + } + + return fmt.Sprintf("%07d", displayUserIDMin+int64(permuteDisplayUserIDOrdinal(appCode, uint32(ordinal)))), nil +} + +func permuteDisplayUserIDOrdinal(appCode string, ordinal uint32) uint32 { + // 7 位账号空间是 9,000,000 = 3000 * 3000;把内部序号拆成两个 3000 进制半区后做 Feistel 置换。 + // 每一轮都是可逆映射,因此整个函数是 0..8,999,999 上的一一置换,不会像随机取号一样产生生日碰撞。 + left := ordinal / displayUserIDPermutationBase + right := ordinal % displayUserIDPermutationBase + appHash := displayUserIDAppHash(appCode) + for round := 0; round < displayUserIDPermutationRound; round++ { + nextLeft := right + nextRight := (left + displayUserIDRoundValue(right, appHash, round)) % displayUserIDPermutationBase + left, right = nextLeft, nextRight + } + + return left*displayUserIDPermutationBase + right +} + +func displayUserIDRoundValue(right uint32, appHash uint32, round int) uint32 { + // 轮函数只需要伪随机分布,不需要可逆;Feistel 结构负责整体可逆性。 + x := right + appHash + displayUserIDPermutationKeys[round] + uint32(round)*0x9e3779b9 + x ^= x >> 15 + x *= 0x2c1b3c6d + x ^= x >> 12 + x *= 0x297a2d39 + x ^= x >> 15 + return x % displayUserIDPermutationBase +} + +func displayUserIDAppHash(appCode string) uint32 { + hash := fnv.New32a() + _, _ = hash.Write([]byte(appcode.Normalize(appCode))) + return hash.Sum32() +} + +func displayUserIDOccupied(ctx context.Context, queryer displayUserIDQueryer, appCode string, displayUserID string) (bool, error) { + var exists int + err := queryer.QueryRowContext(ctx, ` + SELECT 1 + FROM ( + SELECT 1 + FROM user_display_user_ids + WHERE app_code = ? AND live_display_user_id = ? + UNION ALL + SELECT 1 + FROM users + WHERE app_code = ? AND default_display_user_id = ? + UNION ALL + SELECT 1 + FROM users + WHERE app_code = ? AND current_display_user_id = ? + ) occupied_display_user_ids + LIMIT 1 + `, appCode, displayUserID, appCode, displayUserID, appCode, displayUserID).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + + return true, nil } diff --git a/services/user-service/internal/storage/mysql/auth/display_sequence_test.go b/services/user-service/internal/storage/mysql/auth/display_sequence_test.go new file mode 100644 index 00000000..a5c0ade7 --- /dev/null +++ b/services/user-service/internal/storage/mysql/auth/display_sequence_test.go @@ -0,0 +1,77 @@ +package auth + +import ( + "context" + "testing" + + "hyapp/services/user-service/internal/testutil/mysqlschema" +) + +func TestDisplayUserIDPermutationProducesUniqueSevenDigitValues(t *testing.T) { + // 内部序号经过 3000x3000 Feistel 置换后仍是一一映射;这里抽样覆盖格式和前段序号不重复。 + seen := make(map[string]struct{}, 10000) + for ordinal := int64(0); ordinal < 10000; ordinal++ { + displayUserID, err := displayUserIDFromOrdinal("lalu", ordinal) + if err != nil { + t.Fatalf("displayUserIDFromOrdinal(%d) failed: %v", ordinal, err) + } + if len(displayUserID) != 7 || displayUserID[0] < '1' || displayUserID[0] > '9' { + t.Fatalf("display_user_id must be 7 digits and start with 1-9, got %q", displayUserID) + } + if _, ok := seen[displayUserID]; ok { + t.Fatalf("display_user_id %s duplicated at ordinal %d", displayUserID, ordinal) + } + seen[displayUserID] = struct{}{} + } +} + +func TestAllocateDisplayUserIDUsesSequencePermutationAndSkipsOccupiedCandidate(t *testing.T) { + // 迁移前可能已经有随机号或人工种子号;分配器必须锁住内部序号并跳过已占用的置换结果。 + ctx := context.Background() + schema := mysqlschema.New(t) + repository := New(schema.DB) + firstCandidate, err := displayUserIDFromOrdinal("lalu", 0) + if err != nil { + t.Fatalf("first candidate failed: %v", err) + } + secondCandidate, err := displayUserIDFromOrdinal("lalu", 1) + if err != nil { + t.Fatalf("second candidate failed: %v", err) + } + if _, err := schema.DB.ExecContext(ctx, ` + INSERT INTO user_display_user_ids ( + app_code, + display_user_id, + user_id, + display_user_id_kind, + status, + assigned_at_ms, + activated_at_ms, + created_at_ms, + updated_at_ms + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "lalu", firstCandidate, int64(1), "default", "active", int64(1000), int64(1000), int64(1000), int64(1000)); err != nil { + t.Fatalf("seed occupied display_user_id failed: %v", err) + } + + got, err := repository.AllocateDisplayUserID(ctx, "lalu") + if err != nil { + t.Fatalf("AllocateDisplayUserID failed: %v", err) + } + if got != secondCandidate { + t.Fatalf("allocated display_user_id = %s, want %s", got, secondCandidate) + } + + var nextValue int64 + if err := schema.DB.QueryRowContext(ctx, ` + SELECT next_value + FROM display_user_id_sequences + WHERE app_code = ? + `, "lalu").Scan(&nextValue); err != nil { + t.Fatalf("query sequence failed: %v", err) + } + if nextValue != 2 { + t.Fatalf("sequence next_value = %d, want 2", nextValue) + } +}