From 734e042af687a34eed341231f69a03622796a459 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 17 Sep 2026 13:37:34 +0800 Subject: [PATCH] feat: expose account fields in user profiles --- docs/IM-前后端对接文档.md | 8 +++--- server/im_http.go | 45 +++++++++++++++++++++++++++-- server/im_http_test.go | 59 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index ee4992f..36fccaf 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -1747,7 +1747,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。个人资料页头部和单聊标题统一使用 `display_name`,需要展示对方原始昵称时再使用 `nickname`。 +- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。 - 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 @@ -1844,7 +1844,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | | `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | | `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | -| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | +| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | | `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | | `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | | `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | @@ -2894,7 +2894,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 +- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`;`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 @@ -3100,7 +3100,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | 接口 | 输入示例 | 成功 `data` 示例 | | --- | --- | --- | | `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | -| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | +| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | | `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"group_invite_verification":true}}` | | `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | diff --git a/server/im_http.go b/server/im_http.go index 847e6a8..7174f16 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -3191,7 +3191,12 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, userProfilePayload10(profile)) + payload, err := currentUserProfilePayload(principal.TenantID, principal.Uid, profile) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, payload) case http.MethodPatch: var body struct { AvatarFileID *string `json:"avatar_file_id"` @@ -3240,7 +3245,12 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, userProfilePayload10(profile)) + payload, err := currentUserProfilePayload(principal.TenantID, principal.Uid, profile) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, payload) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } @@ -9799,6 +9809,21 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any { return data } +func currentUserProfilePayload(tenantID types.TenantID, uid types.Uid, profile *types.UserProfile) (map[string]any, error) { + user, err := store.Users.Get(tenantID, uid) + if err != nil { + return nil, err + } + data := userProfilePayload10(profile) + data["phone_mask"] = "" + data["username"] = "" + if user != nil { + data["phone_mask"] = maskIMProfilePhone(user.Phone) + data["username"] = user.Username + } + return data, nil +} + func validIMSecurityChallengeScene(scene string) bool { switch strings.TrimSpace(scene) { case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password", @@ -10280,6 +10305,7 @@ func blockListItemPayload(item types.BlockListItem) map[string]any { func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { data := userSummaryPayload(user, ctx.Remark) + data["username"] = user.Username data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ @@ -10303,6 +10329,7 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { data := userSummaryPayload(user, ctx.Remark) + data["username"] = user.Username data["relationship"] = ctx.Relationship data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ @@ -11365,6 +11392,20 @@ func maskIMPhone(phone string) string { return phone[:4] + "****" + phone[len(phone)-3:] } +func maskIMProfilePhone(phone string) string { + phone = strings.TrimSpace(phone) + if strings.HasPrefix(phone, "+86") { + phone = strings.TrimPrefix(phone, "+86") + } + if phone == "" { + return "" + } + if len(phone) <= 7 { + return phone + } + return phone[:3] + "****" + phone[len(phone)-4:] +} + func randomIMNumericCode(length int) (string, error) { max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) n, err := rand.Int(rand.Reader, max) diff --git a/server/im_http_test.go b/server/im_http_test.go index 1aa5073..1f8d58a 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -3821,6 +3821,11 @@ func TestServeIMMeProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) + oldUsers := store.Users + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Phone: "+8613800138000", Username: "gcharles7"}, nil) + store.Users = users + defer func() { store.Users = oldUsers }() oldProfiles := store.Profiles profiles := &testProfilesStore{} store.Profiles = profiles @@ -3835,11 +3840,57 @@ func TestServeIMMeProfile(t *testing.T) { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 { + if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 || + data["phone_mask"] != "138****8000" || data["username"] != "gcharles7" { t.Fatalf("unexpected profile: %#v", data) } } +func TestServeIMMeProfileGetPhoneMask(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldUsers := store.Users + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Phone: "+8613600003333", Username: "gcharles7"}, nil) + store.Users = users + defer func() { store.Users = oldUsers }() + oldProfiles := store.Profiles + store.Profiles = &testProfilesStore{} + defer func() { store.Profiles = oldProfiles }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/me/profile", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMeProfile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["phone_mask"] != "136****3333" || data["username"] != "gcharles7" { + t.Fatalf("unexpected account fields: %#v body=%s", data, rec.Body.String()) + } +} + +func TestMaskIMProfilePhone(t *testing.T) { + for _, tc := range []struct { + name string + phone string + want string + }{ + {name: "empty", phone: "", want: ""}, + {name: "mainland e164", phone: "+8613600003333", want: "136****3333"}, + {name: "mainland local", phone: "13600003333", want: "136****3333"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := maskIMProfilePhone(tc.phone); got != tc.want { + t.Fatalf("maskIMProfilePhone(%q)=%q, want %q", tc.phone, got, tc.want) + } + }) + } +} + func TestServeIMMePrivacy(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -8907,6 +8958,7 @@ func TestServeIMUserProfile(t *testing.T) { users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, + Username: "alice7", Public: map[string]any{"display_name": "Alice", "nickname": "Alice", "avatar_file_id": "file-1", "signature": "hello"}, }, nil) store.Users = users @@ -8936,7 +8988,7 @@ func TestServeIMUserProfile(t *testing.T) { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" { + if data["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" || data["username"] != "alice7" { t.Fatalf("unexpected profile response: %#v", data) } if data["display_name"] != "老同事" || data["remark"] != "老同事" { @@ -8959,6 +9011,7 @@ func TestServeIMUserProfileBlockedByMeReturnsMinimalProfile(t *testing.T) { users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, + Username: "alice7", Public: map[string]any{ "display_name": "Alice", "nickname": "Alice", @@ -8996,7 +9049,7 @@ func TestServeIMUserProfileBlockedByMeReturnsMinimalProfile(t *testing.T) { } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipBlockedByMe || data["display_name"] != "老同事" || - data["avatar_file_id"] != "file-1" || data["topic"] == "" { + data["avatar_file_id"] != "file-1" || data["topic"] == "" || data["username"] != "alice7" { t.Fatalf("unexpected blocked profile response: %#v", data) } capabilities := data["capabilities"].(map[string]any)