diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index f11fd04..6899154 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -154,6 +154,12 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 +资源不存在类错误统一语义(2026-09-29 修订): + +- 好友申请 `accept/reject` 在申请不存在时统一返回 `404/20004 RESOURCE_NOT_FOUND`,两个入口语义一致。 +- 群类接口(`DELETE /v1/im/groups/{topic}`、`POST /v1/im/groups/{topic}/leave`、`POST /v1/im/groups/{topic}/qrcodes`、`PUT /v1/im/groups/{topic}/members/me/profile`、`GET/PATCH /v1/im/groups/{topic}/settings` 等经群成员校验的路径)在群不存在时返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散时返回 `410/30004 GROUP_DISSOLVED`;群存在但当前用户非成员仍是 `403/30005 PERMISSION_DENIED`。 +- 服务端对不存在的资源不再返回 5xx 或网关错误。 + ### 0.5 WebSocket 建连、登录和恢复顺序 #### 0.5.1 现有服务端可直接执行的冷启动流程 @@ -1708,6 +1714,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 +- 申请不存在时 `accept/reject` 统一返回 `404/20004 RESOURCE_NOT_FOUND`;通用语义见 0.4。 - 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 ### 5.3 添加好友.png @@ -1962,6 +1969,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 - 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 - 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 ### 6.11 群资料.png @@ -1991,6 +1999,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 - 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。 @@ -2059,6 +2068,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** - `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群主/管理员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 @@ -2071,6 +2081,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 - 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 - 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;已解散重复解散返回 `410/30004 GROUP_DISSOLVED`;非群主返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 ### 6.18 设置群管理员.png diff --git a/docs/对接文档最新/IM-前后端对接文档.md b/docs/对接文档最新/IM-前后端对接文档.md index 4321f23..1ea6383 100644 --- a/docs/对接文档最新/IM-前后端对接文档.md +++ b/docs/对接文档最新/IM-前后端对接文档.md @@ -128,6 +128,12 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 +资源不存在类错误统一语义(2026-09-29 修订): + +- 好友申请 `accept/reject` 在申请不存在时统一返回 `404/20004 RESOURCE_NOT_FOUND`,两个入口语义一致。 +- 群类接口(`DELETE /v1/im/groups/{topic}`、`POST /v1/im/groups/{topic}/leave`、`POST /v1/im/groups/{topic}/qrcodes`、`PUT /v1/im/groups/{topic}/members/me/profile`、`GET/PATCH /v1/im/groups/{topic}/settings` 等经群成员校验的路径)在群不存在时返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散时返回 `410/30004 GROUP_DISSOLVED`;群存在但当前用户非成员仍是 `403/30005 PERMISSION_DENIED`。 +- 服务端对不存在的资源不再返回 5xx 或网关错误。 + ### 0.5 WebSocket 建连、登录和恢复顺序 #### 0.5.1 现有服务端可直接执行的冷启动流程 @@ -1649,6 +1655,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 +- 申请不存在时 `accept/reject` 统一返回 `404/20004 RESOURCE_NOT_FOUND`;通用语义见 0.4。 - 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 ### 5.3 添加好友.png @@ -1903,6 +1910,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 - 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 - 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 ### 6.11 群资料.png @@ -1932,6 +1940,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 - 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 ### 6.13.1 群成员列表拉取与缓存策略 @@ -1999,6 +2008,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** - `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群主/管理员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 @@ -2011,6 +2021,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 - 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 - 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 +- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;已解散重复解散返回 `410/30004 GROUP_DISSOLVED`;非群主返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 ### 6.18 设置群管理员.png diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 7a80374..2430826 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -6899,8 +6899,12 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac defer cancel() } req, err := a.friendRequestByRequestID(ctx, id, requestID) - if err != nil || req == nil { - return req, err + if err != nil { + return nil, err + } + if req == nil { + // 好友申请不存在必须返回 ErrNotFound,否则上层拿到 (nil,nil) 会解引用 panic(网关 502)。 + return nil, t.ErrNotFound } if req.TargetID != actor { return nil, t.ErrPermissionDenied diff --git a/server/im_http.go b/server/im_http.go index 2b46d09..0975048 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -4427,6 +4427,10 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string return } if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() { + // 群不存在(404)或已解散(410)要与"存在但无权限"(403)区分开。 + if selfSub == nil && writeIMGroupMissingIfGone(wrt, req, principal.TenantID, topic) { + return + } writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) return } @@ -9244,12 +9248,33 @@ func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic stri access = sub.ModeGiven & sub.ModeWant } if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) { + // 群不存在(404)或已解散(410)要与"存在但无权限"(403)区分开。 + if sub == nil && writeIMGroupMissingIfGone(wrt, req, principal.TenantID, topic) { + return nil, nil, false + } writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) return nil, nil, false } return principal, sub, true } +// writeIMGroupMissingIfGone 在群不存在或已删除时写出 404/410 并返回 true;查询失败时返回 false 保持原错误路径。 +func writeIMGroupMissingIfGone(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) bool { + group, err := store.Topics.Get(tenantID, topic) + if err != nil { + return false + } + if group == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "group not found", nil) + return true + } + if group.State == types.StateDeleted { + writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) + return true + } + return false +} + func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok {