fix im workbench and profile regressions
This commit is contained in:
+8
-8
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+242
-44
@@ -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 == "" {
|
||||
|
||||
+414
-7
@@ -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,6 +5676,14 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
|
||||
ModeWant: types.ModeCPublic,
|
||||
ModeGiven: types.ModeCPublic,
|
||||
}, 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() {
|
||||
@@ -5361,11 +5693,12 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
|
||||
t.Fatalf("forward marker not set: %#v", msg.Head)
|
||||
}
|
||||
content := msg.Content.(map[string]any)
|
||||
if content["favorite_id"] != "fav_1" || content["comment"] != "FYI" {
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user