修复邀请以及用户id
This commit is contained in:
parent
6a783ec487
commit
3f465e4490
@ -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 的基础配置/种子数据,不新建一套服务,也不复制业务代码。
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{},
|
||||
|
||||
@ -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"}}
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user