From 367bed29401170788b8e97ae1683ff6abea72824 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 11 Sep 2026 17:25:24 +0800 Subject: [PATCH] fix im workbench and profile regressions --- docs/IM-前后端对接文档.md | 16 +- server/db/adapter.go | 1 + server/db/mysql/tenant_groups.go | 13 + server/im_http.go | 286 +++++++++++++++++--- server/im_http_test.go | 445 +++++++++++++++++++++++++++++-- server/store/store.go | 12 + 6 files changed, 702 insertions(+), 71 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index a9152e9..a81ce55 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -1359,7 +1359,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":12}],"target_topics":["grp..."],"comment":"..."}`。`seq` 兼容数字和字符串;P2P 源会话也可以传对方 `usr...` 用户 ID,服务端会归一化为 P2P topic。 - 返回每个目标和源消息的 `client_mid/seq/status`;`status` 取值为 `sent`、`pending`、`failed`。`pending` 表示服务端已分配 `seq/client_mid`,但 Kafka 投递结果暂不确定,客户端应按“发送中”展示,保留同一个 `Idempotency-Key` 重试或等待后续消息同步,不要立即生成第二条本地消息。服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 -- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 +- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送,返回项可带 `comment_client_mid/comment_seq/comment_status/comment_reason`;转发消息本身仍保持原 `head/content`,不得把留言包装进图片、文件等原消息 `content`。 ### 4.20 转发—合并转发—多选状态.png @@ -1852,8 +1852,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:需改造。** -- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 -- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 +- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。 +- 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 ### 6.29 接口示例 @@ -2084,10 +2084,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` | | `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` | | `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` | -| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` | +| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20,"comment_seq":19,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` | | `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` | -| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` | -| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` | +| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent","comment_seq":29,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` | +| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1","comment_seq":30,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` | | `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` | | `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | @@ -2233,7 +2233,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,sort_order,unread_count,enabled,permission}]`、布局版本和企业配置;服务端按 `sort_order ASC` 返回,默认顺序为打卡、月报、日报、部门框架、举报信箱、会议通知、会议笔记。 +- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,sort_order,unread_count,enabled,permission}]`、布局版本和企业配置;服务端按 `sort_order ASC` 返回,默认顺序为打卡、月报、日报、部门框架、举报信箱、会议通知、会议笔记。`meetings` 模块的 `unread_count` 由服务端按未读系统通知实时补齐。 - `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 @@ -2367,7 +2367,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** - 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 -- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 +- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。`subject` 最多 200 字符,`content` 最多 5000 字符;超限返回 `VALIDATION_FAILED`,不返回 500。 - 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 ### 9.13 总经理信箱-文案提示.png diff --git a/server/db/adapter.go b/server/db/adapter.go index 81b9479..b5317fb 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -385,6 +385,7 @@ type TenantBusinessAdapter interface { TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error) TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error) TenantGroupAnnouncementLatest(t.TenantID, string) (*t.GroupAnnouncement, error) + TenantGroupAnnouncementGet(t.TenantID, string, string) (*t.GroupAnnouncement, error) TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error) TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error) diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 659b175..30e4a90 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -698,6 +698,19 @@ func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent) } +func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementID string) (*t.GroupAnnouncement, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return nil, err + } else if tenantAdp != a { + return tenantAdp.TenantGroupAnnouncementGet(id, topic, announcementID) + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND announcement_id=? LIMIT 1", topic, announcementID) +} + func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) { if tenantAdp, err := a.routeGroupTenant(id); err != nil { return nil, err diff --git a/server/im_http.go b/server/im_http.go index 66b9fca..7bd7de8 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -54,6 +54,7 @@ const ( imReasonFileUnavailable = "FILE_UNAVAILABLE" imReasonRateLimited = "RATE_LIMITED" imReasonResourceNotFound = "RESOURCE_NOT_FOUND" + imReasonValidationFailed = "VALIDATION_FAILED" ) const ( @@ -1787,6 +1788,11 @@ func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + modulePayloads := workbenchModulePayloads(modules) + if err := populateWorkbenchModuleUnreadCounts(principal.TenantID, principal.Uid, modulePayloads); err != nil { + writeIMRelationError(wrt, req, err) + return + } tenant, err := store.Tenants.GetByID(principal.TenantID) if err != nil || tenant == nil || strings.TrimSpace(tenant.Code) == "" { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -1794,7 +1800,7 @@ func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) { } writeIMSuccess(wrt, req, map[string]any{ "layout_version": layoutVersion, - "modules": workbenchModulePayloads(modules), + "modules": modulePayloads, "enterprise": map[string]any{ "tenant_code": tenant.Code, "feature_flags": map[string]any{ @@ -2193,8 +2199,8 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { return } } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID, - meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) + publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } @@ -2466,8 +2472,8 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID return } } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID, - meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) + publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } @@ -2495,8 +2501,8 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID return } } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID, - meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) + publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } @@ -2530,8 +2536,8 @@ func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meeting return } } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID, - meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)}) + publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID, + map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)}) writeIMSuccess(wrt, req, map[string]any{ "meeting_id": meetingID, "reminded": meetingParticipantPayloadsWithSummaries(participants, @@ -2566,8 +2572,8 @@ func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetin return } } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID, - meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) + publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } @@ -3031,6 +3037,16 @@ func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) { if !decodeIMJSONBody(wrt, req, &body) { return } + subject := strings.TrimSpace(body.Subject) + content := strings.TrimSpace(body.Content) + if len([]rune(subject)) > 200 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": 200}) + return + } + if len([]rune(content)) > 5000 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": 5000}) + return + } token, err := randomIMToken(12) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -3039,8 +3055,8 @@ func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) { submission, err := store.Mailbox.Submit(principal.TenantID, principal.Uid, &types.MailboxSubmission{ SubmissionID: "mb_" + token, SenderID: principal.Uid, - Subject: strings.TrimSpace(body.Subject), - Content: strings.TrimSpace(body.Content), + Subject: subject, + Content: content, Anonymous: body.Anonymous, AttachmentFileIDs: body.AttachmentFileIDs, NoticeVersion: strings.TrimSpace(body.NoticeVersion), @@ -3050,6 +3066,10 @@ func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, "MAILBOX_UNAVAILABLE", "mailbox unavailable", nil) return } + if errors.Is(err, types.ErrPolicy) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", nil) + return + } writeIMRelationError(wrt, req, err) return } @@ -3721,6 +3741,16 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) { results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)}) continue } + var commentResult map[string]any + commentSaved := false + ensureComment := func() map[string]any { + if commentSaved || comment == "" { + return commentResult + } + commentSaved = true + commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite-comment", target)) + return commentResult + } for _, favoriteID := range body.FavoriteIDs { favoriteID = strings.TrimSpace(favoriteID) item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID) @@ -3728,35 +3758,39 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) { results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } + head, content := favoriteForwardMessageHeadContent(item) msg := &types.Message{ TenantID: principal.TenantID, Topic: target, From: principal.Uid.String(), - Head: types.KVMap{ - "type": item.ItemType, - "x-im-type": item.ItemType, - "x-im-forwarded": true, - }, - Content: favoriteForwardContent(item, comment), + Head: head, + Content: content, } if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil { results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } + commentResult = ensureComment() saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true) if err != nil { if saved != nil && saved.Pending { - results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}) + result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"} + mergeForwardCommentResult(result, commentResult) + results = append(results, result) continue } results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) continue } if saved != nil && saved.Pending { - results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}) + result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"} + mergeForwardCommentResult(result, commentResult) + results = append(results, result) continue } - results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId}) + result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId} + mergeForwardCommentResult(result, commentResult) + results = append(results, result) } } writeIMSuccess(wrt, req, map[string]any{"items": results}) @@ -3816,8 +3850,19 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { } continue } + comment := strings.TrimSpace(body.Comment) + var commentResult map[string]any + commentSaved := false + ensureComment := func() map[string]any { + if commentSaved || comment == "" { + return commentResult + } + commentSaved = true + commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "message-comment", target)) + return commentResult + } if mode == "bundle" { - bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, body.Comment) + bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, "") if len(sourceFailures) > 0 { failed = append(failed, sourceFailures...) continue @@ -3834,11 +3879,12 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err))) continue } + commentResult = ensureComment() saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true) if err != nil { if saved != nil && saved.Pending { forwarded.SeqId = saved.SeqId - items = append(items, map[string]any{ + item := map[string]any{ "sources": bundle["source_refs"], "target_topic": target, "client_mid": clientMid, @@ -3847,14 +3893,16 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { "reason": "MESSAGE_PERSIST_PENDING", "mode": "bundle", "bundle_id": bundle["bundle_id"], - }) + } + mergeForwardCommentResult(item, commentResult) + items = append(items, item) continue } failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err))) continue } if saved != nil && saved.Pending { - items = append(items, map[string]any{ + item := map[string]any{ "sources": bundle["source_refs"], "target_topic": target, "client_mid": clientMid, @@ -3863,12 +3911,14 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { "reason": "MESSAGE_PERSIST_PENDING", "mode": "bundle", "bundle_id": bundle["bundle_id"], - }) + } + mergeForwardCommentResult(item, commentResult) + items = append(items, item) continue } forwarded.SeqId = saved.SeqId broadcastHTTPForwardMessage(principal.TenantID, forwarded) - items = append(items, map[string]any{ + item := map[string]any{ "sources": bundle["source_refs"], "target_topic": target, "client_mid": clientMid, @@ -3876,7 +3926,9 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { "status": "sent", "mode": "bundle", "bundle_id": bundle["bundle_id"], - }) + } + mergeForwardCommentResult(item, commentResult) + items = append(items, item) continue } for _, source := range body.Sources { @@ -3913,49 +3965,56 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) { Topic: target, From: principal.Uid.String(), Head: forwardedMessageHead(msg, clientMid), - Content: forwardedMessageContent(msg, body.Comment), + Content: forwardedMessageContent(msg, ""), } if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil { failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err))) continue } + commentResult = ensureComment() saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true) if err != nil { if saved != nil && saved.Pending { forwarded.SeqId = saved.SeqId - items = append(items, map[string]any{ + item := map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "pending", "reason": "MESSAGE_PERSIST_PENDING", - }) + } + mergeForwardCommentResult(item, commentResult) + items = append(items, item) continue } failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err))) continue } if saved != nil && saved.Pending { - items = append(items, map[string]any{ + item := map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "pending", "reason": "MESSAGE_PERSIST_PENDING", - }) + } + mergeForwardCommentResult(item, commentResult) + items = append(items, item) continue } forwarded.SeqId = saved.SeqId broadcastHTTPForwardMessage(principal.TenantID, forwarded) - items = append(items, map[string]any{ + item := map[string]any{ "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "target_topic": target, "client_mid": clientMid, "seq": saved.SeqId, "status": "sent", - }) + } + mergeForwardCommentResult(item, commentResult) + items = append(items, item) } } writeIMSuccess(wrt, req, map[string]any{"items": items, "failed": failed}) @@ -5376,16 +5435,55 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } + fileName := strings.TrimSpace(body.FileName) + mimeType := strings.TrimSpace(body.MIMEType) + fileType := normalizeIMFileType(body.FileType) + if fileName == "" || len([]rune(fileName)) > 255 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_name", "max_length": 255}) + return + } + if len([]rune(mimeType)) > 128 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "mime_type", "max_length": 128}) + return + } + if body.FileSize < 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"}) + return + } + uploaded, err := store.Files.Get(principal.TenantID, fileID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if uploaded == nil || uploaded.Status != types.UploadCompleted { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonFileUnavailable, "file unavailable", map[string]any{"field": "file_id"}) + return + } + if body.FileSize > 0 && uploaded.Size > 0 && body.FileSize != uploaded.Size { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"}) + return + } + if globals.maxFileUploadSize > 0 && uploaded.Size > globals.maxFileUploadSize { + writeIMError(wrt, req, http.StatusBadRequest, 30046, "FILE_TOO_LARGE", "file too large", map[string]any{"field": "file_size", "max_size": globals.maxFileUploadSize}) + return + } + if mimeType == "" { + mimeType = uploaded.MimeType + } + fileSize := body.FileSize + if fileSize == 0 { + fileSize = uploaded.Size + } file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{ Topic: topic, FileID: fileID, SourceTopic: strings.TrimSpace(body.SourceTopic), SourceSeq: body.SourceSeq, UploaderID: principal.Uid, - FileName: strings.TrimSpace(body.FileName), - MIMEType: strings.TrimSpace(body.MIMEType), - FileType: normalizeIMFileType(body.FileType), - FileSize: body.FileSize, + FileName: fileName, + MIMEType: mimeType, + FileType: fileType, + FileSize: fileSize, Status: "active", IdempotencyKey: idempotencyKey, }) @@ -5667,10 +5765,15 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic } writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)}) case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet: - ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic) - if err != nil && !errors.Is(err, types.ErrNotFound) { - logs.Warn.Println("im group announcement get: returning empty after store error", principal.TenantID, topic, err) - ann = nil + announcementID := strings.TrimSpace(strings.TrimPrefix(tail, "announcements/")) + if announcementID == "" || strings.Contains(announcementID, "/") { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "announcement_id"}) + return + } + ann, err := store.GroupBusiness.GetAnnouncement(principal.TenantID, topic, announcementID) + if err != nil { + writeIMRelationError(wrt, req, err) + return } writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub)) default: @@ -8811,6 +8914,10 @@ func publishIMWorkbenchAndAudit(ctx context.Context, tenantID types.TenantID, ac if err := enqueueKafkaWorkbenchEvent(ctx, tenantID, actor, module, action, resourceID, targets, title, summary, data); err != nil { logs.Warn.Printf("im http: failed to enqueue workbench event action=%s resource=%s: %v", action, resourceID, err) } + publishIMAuditOnly(ctx, tenantID, actor, module, action, resourceID, data) +} + +func publishIMAuditOnly(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, data map[string]any) { if err := enqueueKafkaAuditEvent(ctx, tenantID, "user", actor, action, module, resourceID, "succeeded", "", data); err != nil { logs.Warn.Printf("im http: failed to enqueue audit event action=%s resource=%s: %v", action, resourceID, err) } @@ -9743,6 +9850,35 @@ func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string return payload } +func favoriteForwardMessageHeadContent(item *types.FavoriteItem) (types.KVMap, any) { + itemType := "text" + if item != nil && strings.TrimSpace(item.ItemType) != "" { + itemType = strings.TrimSpace(item.ItemType) + } + head := types.KVMap{"type": itemType, "x-im-type": itemType, "x-im-forwarded": true} + if item != nil { + if snapshot, ok := item.Snapshot.(map[string]any); ok { + if rawHead, ok := snapshot["head"].(map[string]any); ok && len(rawHead) > 0 { + head = types.KVMap{} + for key, value := range rawHead { + head[key] = value + } + } + head["x-im-forwarded"] = true + if _, ok := head["x-im-type"]; !ok { + head["x-im-type"] = itemType + } + if _, ok := head["type"]; !ok { + head["type"] = itemType + } + if content, ok := snapshot["content"]; ok { + return head, content + } + } + } + return forwardBundleMessageHead(""), favoriteForwardContent(item, "") +} + func forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any { return map[string]any{ "source": map[string]any{"topic": strings.TrimSpace(topic), "seq": seq}, @@ -9752,6 +9888,68 @@ func forwardFailurePayload(topic string, seq int64, target, reason string) map[s } } +func saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, target, comment, clientMid string) map[string]any { + comment = strings.TrimSpace(comment) + if principal == nil || comment == "" { + return nil + } + msg := &types.Message{ + TenantID: principal.TenantID, + Topic: target, + From: principal.Uid.String(), + Head: types.KVMap{ + "type": "text", + "x-im-type": "text", + "x-im-forwarded": true, + "x-im-client-mid": clientMid, + }, + Content: map[string]any{"text": comment}, + } + result := map[string]any{"comment_client_mid": clientMid} + if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil { + result["comment_status"] = "failed" + result["comment_reason"] = relationErrorReason(err) + return result + } + saved, err := saveMessageMaybeKafka(ctx, principal.TenantID, msg, nil, true) + if err != nil { + if saved != nil && saved.Pending { + result["comment_status"] = "pending" + result["comment_seq"] = saved.SeqId + result["comment_reason"] = "MESSAGE_PERSIST_PENDING" + return result + } + result["comment_status"] = "failed" + result["comment_reason"] = relationErrorReason(err) + return result + } + if saved != nil && saved.Pending { + result["comment_status"] = "pending" + result["comment_seq"] = saved.SeqId + result["comment_reason"] = "MESSAGE_PERSIST_PENDING" + return result + } + if saved != nil { + msg.SeqId = saved.SeqId + broadcastHTTPForwardMessage(principal.TenantID, msg) + result["comment_status"] = "sent" + result["comment_seq"] = saved.SeqId + return result + } + result["comment_status"] = "failed" + result["comment_reason"] = imReasonInternal + return result +} + +func mergeForwardCommentResult(item, comment map[string]any) { + if item == nil || comment == nil { + return + } + for key, value := range comment { + item[key] = value + } +} + func forwardClientMid(requestKey string, parts ...any) string { requestKey = strings.TrimSpace(requestKey) if requestKey == "" { diff --git a/server/im_http_test.go b/server/im_http_test.go index d3da936..f201e46 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -307,6 +307,11 @@ type testMailboxStore struct { config *types.MailboxConfig submission *types.MailboxSubmission idempotencyKey string + submitErr error +} + +type testUserSecurityStore struct { + unreadByPrefix map[string]int } type testProfilesStore struct { @@ -342,6 +347,7 @@ type testGroupBusinessStore struct { member *types.GroupMember joinRequests []types.GroupJoinRequest announcement *types.GroupAnnouncement + announcements map[string]*types.GroupAnnouncement mutes []types.GroupMute files []types.GroupFile invitations []types.GroupInvitation @@ -472,6 +478,18 @@ func (s *testGroupBusinessStore) LatestAnnouncement(_ types.TenantID, topic stri return s.announcement, nil } +func (s *testGroupBusinessStore) GetAnnouncement(_ types.TenantID, _ string, announcementID string) (*types.GroupAnnouncement, error) { + if s.announcements != nil { + if ann := s.announcements[announcementID]; ann != nil { + return ann, nil + } + } + if s.announcement != nil && s.announcement.AnnouncementID == announcementID { + return s.announcement, nil + } + return nil, types.ErrNotFound +} + func (s *testGroupBusinessStore) PutAnnouncement(_ types.TenantID, ann *types.GroupAnnouncement, _ int) (*types.GroupAnnouncement, error) { ann.Version = 2 if ann.PublishedAt.IsZero() { @@ -906,6 +924,9 @@ func (s *testMailboxStore) Config(_ types.TenantID, _ types.Uid) (*types.Mailbox } func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) { + if s.submitErr != nil { + return nil, s.submitErr + } s.idempotencyKey = idempotencyKey now := time.Now().UTC().Round(time.Millisecond) submission.SenderID = uid @@ -917,6 +938,81 @@ func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *t return submission, nil } +func (s *testUserSecurityStore) GetAuthVersion(_ types.TenantID, _ types.Uid) (int, error) { + return 0, nil +} + +func (s *testUserSecurityStore) BumpAuthVersion(_ types.TenantID, _ types.Uid, _ time.Time) (int, error) { + return 1, nil +} + +func (s *testUserSecurityStore) GetRealName(_ types.TenantID, _ types.Uid) (*types.RealNameVerification, error) { + return nil, types.ErrNotFound +} + +func (s *testUserSecurityStore) CreateRealNameSubmission(_ types.TenantID, _ *types.RealNameVerification) error { + return nil +} + +func (s *testUserSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) { + return appeal, nil +} + +func (s *testUserSecurityStore) CreateActionToken(_ types.TenantID, _ *types.ActionToken) error { + return nil +} + +func (s *testUserSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _, _, _ string, _ time.Time) (*types.ActionToken, error) { + return nil, types.ErrNotFound +} + +func (s *testUserSecurityStore) GetIndependentPassword(_ types.TenantID, _ types.Uid, _ string) (*types.IndependentPassword, error) { + return nil, types.ErrNotFound +} + +func (s *testUserSecurityStore) UpsertIndependentPassword(_ types.TenantID, _ *types.IndependentPassword) error { + return nil +} + +func (s *testUserSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, _ int, _ *time.Time, _ time.Time) error { + return nil +} + +func (s *testUserSecurityStore) GetYouthMode(_ types.TenantID, _ types.Uid) (*types.YouthModeSettings, error) { + return nil, types.ErrNotFound +} + +func (s *testUserSecurityStore) SetYouthMode(_ types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) { + return &types.YouthModeSettings{UserID: uid, Enabled: enabled, PolicyVersion: policyVersion, UpdatedAt: now}, nil +} + +func (s *testUserSecurityStore) CreateSystemNotification(_ types.TenantID, _ *types.SystemNotification) error { + return nil +} + +func (s *testUserSecurityStore) ListSystemNotifications(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.SystemNotification, string, error) { + return nil, "", nil +} + +func (s *testUserSecurityStore) CountUnreadSystemNotifications(_ types.TenantID, _ types.Uid) (int, error) { + total := 0 + for _, count := range s.unreadByPrefix { + total += count + } + return total, nil +} + +func (s *testUserSecurityStore) CountUnreadSystemNotificationsByEventPrefix(_ types.TenantID, _ types.Uid, prefix string) (int, error) { + if s.unreadByPrefix == nil { + return 0, nil + } + return s.unreadByPrefix[prefix], nil +} + +func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, _ string, _ time.Time) error { + return nil +} + func (s *testProfilesStore) Get(_ types.TenantID, uid types.Uid) (*types.UserProfile, error) { if s.profile == nil { now := time.Now().UTC().Round(time.Millisecond) @@ -2577,14 +2673,18 @@ func TestServeIMWorkbench(t *testing.T) { expectIMBearer(t, ctrl, "token-1", 7, 42) oldWorkbench := store.Workbench oldTenants := store.Tenants + oldSecurity := store.Security store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{ {Code: "organization", Name: "部门框架", Route: "/organization", SortOrder: 40, Enabled: true, Permission: "enabled"}, + {Code: "meetings", Name: "会议", Route: "/meetings", SortOrder: 41, Enabled: true, Permission: "enabled"}, {Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"}, }} store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} + store.Security = &testUserSecurityStore{unreadByPrefix: map[string]int{"meeting.": 4}} defer func() { store.Workbench = oldWorkbench store.Tenants = oldTenants + store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil) @@ -2601,13 +2701,17 @@ func TestServeIMWorkbench(t *testing.T) { t.Fatalf("unexpected layout version: %#v", data) } items := data["modules"].([]any) - if len(items) != 1 { + if len(items) != 2 { t.Fatalf("expected external route to be filtered, got %#v", items) } item := items[0].(map[string]any) if item["code"] != "organization" || item["route"] != "/organization" || item["sort_order"].(float64) != 40 { t.Fatalf("unexpected module: %#v", item) } + meetingItem := items[1].(map[string]any) + if meetingItem["code"] != "meetings" || meetingItem["unread_count"].(float64) != 4 { + t.Fatalf("workbench unread count not populated: %#v", meetingItem) + } enterprise := data["enterprise"].(map[string]any) if enterprise["tenant_code"] != "acme" { t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"]) @@ -2811,10 +2915,31 @@ func TestServeIMMeetingsCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) + oldKafkaBus := kafkaBus + oldPublisher := publishKafkaAuditEventEnvelope + oldSecurity := store.Security + oldInfrastructure := store.HTTPInfrastructure oldMeetings := store.Meetings meetings := &testMeetingsStore{} + security := &testSecurityStore{} + infrastructure := &testHTTPInfrastructureStore{} + var auditPublished bool + kafkaBus = &kafkaRuntime{} + publishKafkaAuditEventEnvelope = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error { + auditPublished = topicName == "audit_event" && key == envelope.BizID && + envelope.EventType == "audit.meeting.created" && envelope.BizType == "audit" + return nil + } + store.Security = security + store.HTTPInfrastructure = infrastructure store.Meetings = meetings - defer func() { store.Meetings = oldMeetings }() + defer func() { + kafkaBus = oldKafkaBus + publishKafkaAuditEventEnvelope = oldPublisher + store.Security = oldSecurity + store.HTTPInfrastructure = oldInfrastructure + store.Meetings = oldMeetings + }() body := `{"title":"周会","start_at":"2026-08-14T09:00:00Z","end_at":"2026-08-14T10:00:00Z","location":"A101","attendee_user_ids":["` + types.Uid(43).UserId() + `"],"reminder_minutes":[10]}` req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings", strings.NewReader(body)) @@ -2833,6 +2958,12 @@ func TestServeIMMeetingsCreate(t *testing.T) { if len(meetings.attendeeIDs) != 1 || data["state"] != types.MeetingStateScheduled || data["my_response"] != types.MeetingResponseAccepted { t.Fatalf("unexpected meeting response: %#v attendees=%#v", data, meetings.attendeeIDs) } + if !auditPublished { + t.Fatalf("meeting create should still publish audit event") + } + if len(security.notifications) != 0 || len(infrastructure.events) != 0 { + t.Fatalf("meeting create HTTP layer must not duplicate workbench notifications/events: notifications=%#v events=%#v", security.notifications, infrastructure.events) + } } func TestServeIMMeetingsList(t *testing.T) { @@ -3485,6 +3616,53 @@ func TestServeIMMailboxSubmission(t *testing.T) { } } +func TestServeIMMailboxSubmissionRejectsTooLongContent(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMailbox := store.Mailbox + store.Mailbox = &testMailboxStore{} + defer func() { store.Mailbox = oldMailbox }() + + body := `{"subject":"建议","content":"` + strings.Repeat("a", 5001) + `","notice_version":"v1"}` + req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "mailbox-long") + rec := httptest.NewRecorder() + serveIMMailboxSubmissions(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeIMHTTPResponse(t, rec) + if resp.Reason != imReasonValidationFailed { + t.Fatalf("reason=%q body=%s", resp.Reason, rec.Body.String()) + } +} + +func TestServeIMMailboxSubmissionMapsPolicyToValidationFailed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMailbox := store.Mailbox + store.Mailbox = &testMailboxStore{submitErr: types.ErrPolicy} + defer func() { store.Mailbox = oldMailbox }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(`{"subject":"建议","content":"希望优化流程"}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "mailbox-policy") + rec := httptest.NewRecorder() + serveIMMailboxSubmissions(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeIMHTTPResponse(t, rec) + if resp.Reason != imReasonValidationFailed { + t.Fatalf("reason=%q body=%s", resp.Reason, rec.Body.String()) + } +} + func TestServeIMMeProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -4580,21 +4758,33 @@ func TestServeIMGroupFiles(t *testing.T) { expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness + oldFiles := store.Files subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + files := mock_store.NewMockFilePersistenceInterface(ctrl) groupStore := &testGroupBusinessStore{} store.Subs = subs store.GroupBusiness = groupStore + store.Files = files defer func() { store.Subs = oldSubs store.GroupBusiness = oldGroupBusiness + store.Files = oldFiles }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil).Times(3) fileID := types.Uid(101).String() + files.EXPECT().Get(types.TenantID(7), fileID).Return(&types.FileDef{ + ObjHeader: types.ObjHeader{Id: fileID}, + TenantID: 7, + User: types.Uid(42).String(), + Status: types.UploadCompleted, + MimeType: "application/pdf", + Size: 123, + }, nil) createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files", - strings.NewReader(fmt.Sprintf(`{"file_id":"%s.pdf","file_name":"方案.pdf","file_type":"document","file_size":123}`, fileID))) + strings.NewReader(fmt.Sprintf(`{"file_id":"%s.pdf","file_name":"方案.pdf","mime_type":"application/pdf","file_type":"document","file_size":123}`, fileID))) createReq.Header.Set("Authorization", "Bearer token-1") createReq.Header.Set("Idempotency-Key", "gfile-1") createRec := httptest.NewRecorder() @@ -4632,6 +4822,75 @@ func TestServeIMGroupFiles(t *testing.T) { } } +func TestServeIMGroupFileCreateValidatesInput(t *testing.T) { + tests := []struct { + name string + body string + setupFiles func(*mock_store.MockFilePersistenceInterface, string) + wantReason string + }{ + { + name: "file name too long", + body: `{"file_id":"%s.pdf","file_name":"` + strings.Repeat("a", 256) + `"}`, + wantReason: imReasonValidationFailed, + }, + { + name: "negative size", + body: `{"file_id":"%s.pdf","file_name":"方案.pdf","file_size":-1}`, + wantReason: imReasonValidationFailed, + }, + { + name: "missing upload", + body: `{"file_id":"%s.pdf","file_name":"方案.pdf"}`, + setupFiles: func(files *mock_store.MockFilePersistenceInterface, fileID string) { + files.EXPECT().Get(types.TenantID(7), fileID).Return(nil, nil) + }, + wantReason: imReasonFileUnavailable, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldGroupBusiness := store.GroupBusiness + oldFiles := store.Files + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + files := mock_store.NewMockFilePersistenceInterface(ctrl) + store.Subs = subs + store.GroupBusiness = &testGroupBusinessStore{} + store.Files = files + defer func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + store.Files = oldFiles + }() + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, + }, nil) + fileID := types.Uid(101).String() + if tc.setupFiles != nil { + tc.setupFiles(files, fileID) + } + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files", + strings.NewReader(fmt.Sprintf(tc.body, fileID))) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "gfile-invalid") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeIMHTTPResponse(t, rec) + if resp.Reason != tc.wantReason { + t.Fatalf("reason: got %q, want %q; body=%s", resp.Reason, tc.wantReason, rec.Body.String()) + } + }) + } +} + func TestServeIMGroupQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -4823,6 +5082,70 @@ func TestServeIMGroupAnnouncementLatestIncludesEditPermission(t *testing.T) { } } +func TestServeIMGroupAnnouncementGetUsesPathID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + oldGroupBusiness := store.GroupBusiness + oldSubs := store.Subs + mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) + store.Store = mockStore + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Subs = subs + store.GroupBusiness = &testGroupBusinessStore{announcements: map[string]*types.GroupAnnouncement{ + "ann_old": {AnnouncementID: "ann_old", Topic: "grpAlpha", AuthorUserID: types.Uid(42), Content: "旧公告", Status: types.GroupAnnouncementStatusSuperseded, Version: 1, PublishedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}, + "ann_new": {AnnouncementID: "ann_new", Topic: "grpAlpha", AuthorUserID: types.Uid(42), Content: "新公告", Status: types.GroupAnnouncementStatusCurrent, Version: 2, PublishedAt: time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)}, + }} + defer func() { + store.Store = oldStore + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + }() + selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/ann_old", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("announcement get status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["announcement_id"] != "ann_old" || data["content"] != "旧公告" { + t.Fatalf("announcement get ignored path id: %#v", data) + } +} + +func TestServeIMGroupAnnouncementGetMissingReturnsNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldGroupBusiness := store.GroupBusiness + oldSubs := store.Subs + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Subs = subs + store.GroupBusiness = &testGroupBusinessStore{} + defer func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + }() + selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/ann_missing", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("announcement missing status: got %d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeIMHTTPResponse(t, rec) + if resp.Reason != imReasonResourceNotFound { + t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonResourceNotFound) + } +} + func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -5319,6 +5642,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + mockStore.EXPECT().GetUidString().Return("comment-mid") store.Store = mockStore oldFavorites := store.Favorites oldSubs := store.Subs @@ -5331,7 +5655,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, - Snapshot: map[string]any{"text": "hello"}, + Snapshot: map[string]any{"head": map[string]any{"type": "text"}, "content": map[string]any{"text": "hello"}, "text": "hello"}, Availability: "available", CollectedAt: now, }}} @@ -5352,20 +5676,29 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) - messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( - func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { - if msg.Topic != "grpBeta" || msg.From != types.Uid(42).String() { - t.Fatalf("unexpected forwarded message: %#v", msg) - } - if msg.Head["x-im-forwarded"] != true { - t.Fatalf("forward marker not set: %#v", msg.Head) - } - content := msg.Content.(map[string]any) - if content["favorite_id"] != "fav_1" || content["comment"] != "FYI" { - t.Fatalf("unexpected forward content: %#v", content) - } - return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, nil - }) + gomock.InOrder( + messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( + func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { + if msg.Topic != "grpBeta" || msg.Head["x-im-type"] != "text" || msg.Content.(map[string]any)["text"] != "FYI" { + t.Fatalf("unexpected forward comment message: %#v", msg) + } + return &types.MessageSaveResult{SeqId: 10, MarkedReadBySender: true}, nil + }), + messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( + func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { + if msg.Topic != "grpBeta" || msg.From != types.Uid(42).String() { + t.Fatalf("unexpected forwarded message: %#v", msg) + } + if msg.Head["x-im-forwarded"] != true { + t.Fatalf("forward marker not set: %#v", msg.Head) + } + content := msg.Content.(map[string]any) + if content["text"] != "hello" || content["comment"] != nil || content["snapshot"] != nil { + t.Fatalf("unexpected forward content: %#v", content) + } + return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, nil + }), + ) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpBeta"],"comment":"FYI"}`)) req.Header.Set("Authorization", "Bearer token-1") @@ -5375,7 +5708,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any) - if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 { + if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 || got["comment_seq"].(float64) != 10 { t.Fatalf("unexpected forward response: %#v", got) } } @@ -5502,6 +5835,80 @@ func TestServeIMMessageForwardSeparateSavesMessages(t *testing.T) { } } +func TestServeIMMessageForwardSeparateSendsCommentAsTextMessage(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) + mockStore.EXPECT().GetUidString().Return("comment-mid") + mockStore.EXPECT().GetUidString().Return("mid-1") + + oldSubs := store.Subs + oldMessages := store.Messages + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages + t.Cleanup(func() { + store.Subs = oldSubs + store.Messages = oldMessages + }) + + subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "grpTarget", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, + }, nil) + messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(3)).Return(&types.Message{ + TenantID: 7, + Topic: "grpSource", + SeqId: 3, + From: types.Uid(99).String(), + Head: types.KVMap{"x-im-type": "image"}, + Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, + ObjHeader: types.ObjHeader{ + CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), + }, + }, nil) + gomock.InOrder( + messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( + func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { + if msg.Head["x-im-type"] != "text" || msg.Content.(map[string]any)["text"] != "FYI" { + t.Fatalf("unexpected comment message: %#v", msg) + } + return &types.MessageSaveResult{SeqId: 20, MarkedReadBySender: true}, nil + }), + messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( + func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { + if msg.Head["x-im-type"] != "image" { + t.Fatalf("forwarded message type changed: %#v", msg.Head) + } + content := msg.Content.(map[string]any) + if content["file_id"] != "file-1" || content["snapshot"] != nil || content["comment"] != nil { + t.Fatalf("forwarded message content should stay original: %#v", content) + } + return &types.MessageSaveResult{SeqId: 21, MarkedReadBySender: true}, nil + }), + ) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":"3"}],"target_topics":["grpTarget"],"comment":"FYI"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMessageForward(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("unexpected forward response: %#v", data) + } + got := items[0].(map[string]any) + if got["status"] != "sent" || got["seq"].(float64) != 21 || got["comment_seq"].(float64) != 20 { + t.Fatalf("unexpected forward item: %#v", got) + } +} + func TestServeIMMessageForwardAcceptsNumericSeqAndUserTarget(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/store/store.go b/server/store/store.go index 4d2d6e4..f3ca110 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1629,6 +1629,7 @@ type GroupBusinessPersistenceInterface interface { ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error) ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error) LatestAnnouncement(types.TenantID, string) (*types.GroupAnnouncement, error) + GetAnnouncement(types.TenantID, string, string) (*types.GroupAnnouncement, error) PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error) ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error) @@ -1902,6 +1903,17 @@ func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic str return tenantAdp.TenantGroupAnnouncementLatest(tenantID, strings.TrimSpace(topic)) } +func (groupBusinessMapper) GetAnnouncement(tenantID types.TenantID, topic, announcementID string) (*types.GroupAnnouncement, error) { + if strings.TrimSpace(topic) == "" || strings.TrimSpace(announcementID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantGroupAnnouncementGet(tenantID, strings.TrimSpace(topic), strings.TrimSpace(announcementID)) +} + func (groupBusinessMapper) PutAnnouncement(tenantID types.TenantID, ann *types.GroupAnnouncement, expectedVersion int) (*types.GroupAnnouncement, error) { if ann == nil || ann.Topic == "" || ann.AuthorUserID.IsZero() || ann.Content == "" { return nil, types.ErrMalformed