feat: 新增关系类型及按话题查询通话记录接口

- 新增 RelationshipSelf 关系类型
- 新增 serveIMCallsByTopic 接口处理按话题查询通话记录
- 实现群成员资料接口返回本人时的 self 关系处理
- 更新接口文档说明本人查询时返回 relationship: self
This commit is contained in:
Austin
2026-09-29 10:46:32 +08:00
parent 0718da784f
commit 7c550aaf9d
5 changed files with 42 additions and 6 deletions
+1 -1
View File
@@ -2204,7 +2204,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` | | `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` | | `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` |
| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` | | `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | | `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | | `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | | `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | | `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
@@ -2142,7 +2142,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | | `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | | `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | | `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | | `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | | `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | | `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
+38 -4
View File
@@ -3133,6 +3133,25 @@ func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor}) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
} }
// serveIMCallsByTopic serves GET /v1/im/calls/{topic} — 对接文档契约路径,按会话查通话记录。
func serveIMCallsByTopic(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsByTopicPath(req.URL.Path))
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) { func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -5794,10 +5813,16 @@ func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID)) // 查询本人时 GetRelationshipContext 不接受 userID==peerID(会报 ErrMalformed),
if err != nil { // 直接构造 self 上下文,群内本人资料返回 200。
writeIMRelationError(wrt, req, err) ctx := &types.RelationshipContext{Relationship: types.RelationshipSelf, CanSend: true}
return if memberID != principal.Uid {
var err error
ctx, err = store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
} }
writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx)) writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx))
} }
@@ -11006,6 +11031,15 @@ func topicFromCallsPath(path string) string {
return topicFromPathWithSuffix(path, prefix, suffix) return topicFromPathWithSuffix(path, prefix, suffix)
} }
func topicFromCallsByTopicPath(path string) string {
const prefix = "/v1/im/calls/"
idx := strings.Index(path, prefix)
if idx < 0 {
return ""
}
return strings.Trim(strings.TrimSpace(path[idx+len(prefix):]), "/")
}
func topicFromDraftPath(path string) string { func topicFromDraftPath(path string) string {
const prefix = "/v1/im/topics/" const prefix = "/v1/im/topics/"
const suffix = "/draft" const suffix = "/draft"
+1
View File
@@ -934,6 +934,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites) mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource) mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource)
mux.HandleFunc(config.ApiPath+"v1/im/calls/rtc-join", serveIMCallRTCJoin) mux.HandleFunc(config.ApiPath+"v1/im/calls/rtc-join", serveIMCallRTCJoin)
mux.HandleFunc(config.ApiPath+"v1/im/calls/", serveIMCallsByTopic)
mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls) mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
+1
View File
@@ -9,6 +9,7 @@ const (
FriendRequestExpired = "expired" FriendRequestExpired = "expired"
RelationshipNone = "none" RelationshipNone = "none"
RelationshipSelf = "self"
RelationshipFriend = "friend" RelationshipFriend = "friend"
RelationshipPendingInbound = "pending_inbound" RelationshipPendingInbound = "pending_inbound"
RelationshipPendingOutbound = "pending_outbound" RelationshipPendingOutbound = "pending_outbound"