修复邀请以及用户id

This commit is contained in:
zhx 2026-06-30 20:50:50 +08:00
parent 6a783ec487
commit 3f465e4490
18 changed files with 735 additions and 56 deletions

View File

@ -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 的基础配置/种子数据,不新建一套服务,也不复制业务代码。

View File

@ -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"`

View File

@ -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 {

View File

@ -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{},

View File

@ -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"}}

View File

@ -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()),

View File

@ -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 gatewaychatapp3 老 token 需要原样转给 chatapp3hyapp 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(),
}
}

View File

@ -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))

View File

@ -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)

View File

@ -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],

View File

@ -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,

View File

@ -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(),

View File

@ -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',

View File

@ -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

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}
}