Compare commits

..
25 Commits
Author SHA1 Message Date
richjohnrambo b92154bece ci: stop overriding Kafka deploy environment 2026-09-07 13:53:50 +08:00
richjohnrambo c68c5ac038 fix: 补齐IM治理功能启动配置 2026-09-07 12:06:46 +08:00
richjohnrambo 6b20a633ce feat: 接入后台消息治理与登录审计 2026-09-07 11:53:24 +08:00
richjohnrambo 90185b6c88 Support meeting note upload registration 2026-09-07 11:41:01 +08:00
richjohnrambo 160d33c759 Reduce Kafka publish latency for messages 2026-09-07 11:33:50 +08:00
richjohnrambo a56efd4454 Add sender summaries to forward bundles 2026-09-07 10:39:42 +08:00
richjohnrambo 6330c592cf Fix media URL extension for m4a uploads 2026-09-07 09:57:16 +08:00
richjohnrambo 7a1647bf17 Fix bootstrap meeting unread counts 2026-09-05 18:31:13 +08:00
richjohnrambo baf15848d3 feat: add meeting notes backend 2026-09-05 17:55:27 +08:00
richjohnrambo 9e290b76b3 feat: add jpush push handler 2026-09-05 17:06:59 +08:00
richjohnrambo efbfb7556a Fix P2P message duplicate seq retry 2026-09-04 20:15:25 +08:00
richjohnrambo b4d3fbe359 Clear stale unanswered calls before new invite 2026-09-04 18:16:26 +08:00
richjohnrambo 4ad6ca00b4 Prevent Redis message seq regression 2026-09-04 16:39:32 +08:00
richjohnrambo e6f2cbf36e Fix user lookup on unscoped tenant database 2026-09-04 16:34:11 +08:00
richjohnrambo 50ccdf5f89 Fix profile display backfill startup 2026-09-04 16:20:28 +08:00
richjohnrambo 7fb1ea70bc Sync profile source and message topic seq 2026-09-04 16:06:57 +08:00
richjohnrambo 47649d33e2 Fix friends display name fallback 2026-09-04 13:45:18 +08:00
richjohnrambo 0f1c1f9255 fix: enable local kafka docker integration 2026-09-04 11:57:38 +08:00
richjohnrambo a191ca0c93 Add group announcement edit permissions 2026-09-04 11:09:29 +08:00
richjohnrambo 62af772260 Read user profile display from profile table 2026-09-04 10:49:33 +08:00
richjohnrambo 3173313ab9 Refresh presence on websocket pong 2026-09-04 10:49:33 +08:00
Your NameandClaude Sonnet 5 9da072e6f9 ci: 部署时透传 admin_internal_api / hotgo secret
commit 7193ba2 把 config.template 里的 admin_internal_api.base_url/secret
和 internal_api hotgo-admin secret 从硬编码改成了环境变量占位符,但
deploy workflow 没有跟着传这几个 secret,导致 Fatal 退出、容器反复重启
(表现为 im-server-green 起不来)。这几个 secret 已经在 Gitea 里配好。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:36:44 +08:00
richjohnrambo c1bfa503cf feat: add client call feature switches 2026-09-03 21:05:33 +08:00
richjohnrambo d6de2a0349 Notify P2P participants after HTTP forwarding 2026-09-03 20:04:18 +08:00
richjohnrambo e36decab28 Fix topic read actions for user topics 2026-09-03 19:55:44 +08:00
58 changed files with 5700 additions and 234 deletions
+18
View File
@@ -23,6 +23,16 @@ jobs:
- name: 构建镜像(从本仓库源码编译,非下载官方release)
run: docker build -t im-server:${{ steps.vars.outputs.tag }} -f docker/tinode/Dockerfile.build .
- name: 校验后台治理启动配置
env:
ADMIN_INTERNAL_API_BASE_URL: ${{ secrets.ADMIN_INTERNAL_API_BASE_URL }}
ADMIN_INTERNAL_API_SECRET: ${{ secrets.ADMIN_INTERNAL_API_SECRET }}
INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }}
run: |
test -n "$ADMIN_INTERNAL_API_BASE_URL" || { echo "missing secret: ADMIN_INTERNAL_API_BASE_URL"; exit 1; }
test -n "$ADMIN_INTERNAL_API_SECRET" || { echo "missing secret: ADMIN_INTERNAL_API_SECRET"; exit 1; }
test -n "$INTERNAL_API_HOTGO_SECRET" || { echo "missing secret: INTERNAL_API_HOTGO_SECRET"; exit 1; }
- name: 蓝绿部署
env:
MYSQL_DSN: ${{ secrets.MYSQL_DSN }}
@@ -31,5 +41,13 @@ jobs:
API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
UID_ENCRYPTION_KEY: ${{ secrets.UID_ENCRYPTION_KEY }}
INTERNAL_API_ENABLED: "true"
INTERNAL_API_TIME_WINDOW_SECONDS: "300"
INTERNAL_API_HOTGO_SERVICE_NAME: hotgo-admin
ADMIN_INTERNAL_API_BASE_URL: ${{ secrets.ADMIN_INTERNAL_API_BASE_URL }}
ADMIN_INTERNAL_API_ENABLED: "true"
ADMIN_INTERNAL_API_SERVICE_NAME: im-server
ADMIN_INTERNAL_API_SECRET: ${{ secrets.ADMIN_INTERNAL_API_SECRET }}
INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }}
run: |
IMAGE=im-server:${{ steps.vars.outputs.tag }} /opt/apps/im-server/deploy.sh
+3 -3
View File
@@ -75,6 +75,7 @@ ENV KAFKA_CONSUMER_GROUP=im-server
ENV KAFKA_DIAL_TIMEOUT_MS=3000
ENV KAFKA_WRITE_TIMEOUT_MS=3000
ENV KAFKA_READ_TIMEOUT_MS=3000
ENV KAFKA_BATCH_TIMEOUT_MS=10
ENV KAFKA_REQUIRED_ACKS=1
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
ENV KAFKA_TLS_ENABLED=false
@@ -82,7 +83,7 @@ ENV KAFKA_SASL_ENABLED=false
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=5
ENV KAFKA_MAX_RETRY_COUNT=3
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
@@ -90,10 +91,9 @@ ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
ENV KAFKA_TOPIC_CONFIG_EVENT=im.config.event
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
ENV KAFKA_TOPIC_GROUP_MESSAGE_PERSIST=im.group.message.persist
ENV KAFKA_TOPIC_MESSAGE_PUSH=im.message.push
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_FILE_LIFECYCLE=im.file.lifecycle
ENV KAFKA_TOPIC_WORKBENCH_EVENT=im.workbench.event
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
+3 -3
View File
@@ -55,6 +55,7 @@ ENV KAFKA_CONSUMER_GROUP=im-server
ENV KAFKA_DIAL_TIMEOUT_MS=3000
ENV KAFKA_WRITE_TIMEOUT_MS=3000
ENV KAFKA_READ_TIMEOUT_MS=3000
ENV KAFKA_BATCH_TIMEOUT_MS=10
ENV KAFKA_REQUIRED_ACKS=1
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
ENV KAFKA_TLS_ENABLED=false
@@ -62,7 +63,7 @@ ENV KAFKA_SASL_ENABLED=false
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=5
ENV KAFKA_MAX_RETRY_COUNT=3
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
@@ -70,10 +71,9 @@ ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
ENV KAFKA_TOPIC_CONFIG_EVENT=im.config.event
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
ENV KAFKA_TOPIC_GROUP_MESSAGE_PERSIST=im.group.message.persist
ENV KAFKA_TOPIC_MESSAGE_PUSH=im.message.push
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_FILE_LIFECYCLE=im.file.lifecycle
ENV KAFKA_TOPIC_WORKBENCH_EVENT=im.workbench.event
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
+13 -13
View File
@@ -118,20 +118,21 @@
},
"kafka": {
"enabled": true,
"brokers": ["64.118.137.182:9094"],
"enabled": $KAFKA_ENABLED,
"brokers": $KAFKA_BROKERS_JSON,
"client_id": "$KAFKA_CLIENT_ID",
"consumer_group": "$KAFKA_CONSUMER_GROUP",
"dial_timeout_ms": $KAFKA_DIAL_TIMEOUT_MS,
"write_timeout_ms": $KAFKA_WRITE_TIMEOUT_MS,
"read_timeout_ms": $KAFKA_READ_TIMEOUT_MS,
"batch_timeout_ms": $KAFKA_BATCH_TIMEOUT_MS,
"required_acks": $KAFKA_REQUIRED_ACKS,
"allow_auto_topic_creation": $KAFKA_ALLOW_AUTO_TOPIC_CREATION,
"tls_enabled": false,
"sasl_enabled": true,
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"tls_enabled": $KAFKA_TLS_ENABLED,
"sasl_enabled": $KAFKA_SASL_ENABLED,
"sasl_mechanism": "$KAFKA_SASL_MECHANISM",
"sasl_username": "$KAFKA_SASL_USERNAME",
"sasl_password": "$KAFKA_SASL_PASSWORD",
"max_retry_count": $KAFKA_MAX_RETRY_COUNT,
"retry_backoff_ms": $KAFKA_RETRY_BACKOFF_MS,
"dead_letter_retention_seconds": $KAFKA_DEAD_LETTER_RETENTION_SECONDS,
@@ -141,27 +142,26 @@
"config_event": "$KAFKA_TOPIC_CONFIG_EVENT",
"business_event": "$KAFKA_TOPIC_BUSINESS_EVENT",
"message_persist": "$KAFKA_TOPIC_MESSAGE_PERSIST",
"group_message_persist": "$KAFKA_TOPIC_GROUP_MESSAGE_PERSIST",
"message_push": "$KAFKA_TOPIC_MESSAGE_PUSH",
"file_security": "$KAFKA_TOPIC_FILE_SECURITY",
"file_lifecycle": "$KAFKA_TOPIC_FILE_LIFECYCLE",
"workbench_event": "$KAFKA_TOPIC_WORKBENCH_EVENT",
"cache_invalidate": "$KAFKA_TOPIC_CACHE_INVALIDATE",
"audit_event": "$KAFKA_TOPIC_AUDIT_EVENT",
"dead_letter": "$KAFKA_TOPIC_DEAD_LETTER"
}
},
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"enabled": $INTERNAL_API_ENABLED,
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
"services": {
"hotgo-admin": {
"$INTERNAL_API_HOTGO_SERVICE_NAME": {
"secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"]
}
}
},
"admin_internal_api": {
"enabled": true,
"enabled": $ADMIN_INTERNAL_API_ENABLED,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET",
+150 -2
View File
@@ -446,6 +446,138 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 |
| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 |
##### 0.5.4.9 极光离线 Push 与重连补偿
离线 Push 只承担提醒和唤醒 App,不作为消息正文或业务状态真源。客户端收到极光 Push 后,必须重新登录 IM,通过 `topic + seq`、`call_id` 或系统通知 ID 拉取服务端状态。普通聊天、群 @、系统通知、好友/群审批、音视频来电都复用同一套 extras 分发协议。
客户端侧要求:
- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。
- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。
- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。
服务端侧要求:
- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。
- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。
- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。
- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。
极光 Push extras 统一格式:
```json
{
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128",
"sender_id": "usr_sender",
"message_type": "text"
}
```
| `extras.type` | 必填字段 | 客户端动作 |
| --- | --- | --- |
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 |
| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
| `group_request` | `tenant_code,topic,request_id` | 打开群审批入口并刷新列表。 |
普通离线消息极光 payload 示例:
```json
{
"platform": "all",
"audience": {
"alias": ["usr_target"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
"body": "你收到一条新消息"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
}
},
"options": {
"time_to_live": 86400,
"apns_production": false,
"classification": 1
}
}
```
音视频来电极光 payload 示例:
```json
{
"platform": "all",
"audience": {
"alias": ["usr_callee"]
},
"notification": {
"alert": "收到一通语音通话",
"android": {
"title": "语音通话",
"alert": "张三邀请你语音通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
},
"ios": {
"alert": {
"title": "语音通话",
"body": "张三邀请你语音通话"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
}
},
"options": {
"time_to_live": 60,
"apns_production": false,
"classification": 1
}
}
```
#### 0.5.5 Long Polling 与文件基础示例
@@ -906,7 +1038,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
@@ -1222,6 +1354,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。
- `items[]` 和 `preview[]` 保留 `from` 原发送人 ID,并新增 `from_user` 用户摘要:`{"user_id":"usr...","display_name":"张三","avatar_file_id":"file_avatar"}`。头像为空时可缺省;若资料查询失败,服务端至少返回 `user_id/display_name` 兜底。
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
@@ -1500,7 +1633,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。
- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。
- 返回 `announcement_id,topic,content,author,version,published_at,permissions.can_edit`;即使暂无公告也返回空公告和编辑权限。仅有权限角色可写,使用乐观锁防覆盖。
- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
### 6.8 群公告状态/邀请进群申请同时存在.png
@@ -1971,6 +2104,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。**
- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。
- 发起前查询 `capabilities.can_call`,再发布持久邀请消息:
```json
@@ -2132,6 +2266,16 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.4.1 会议笔记
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20` 返回可查看会议笔记的会议列表、`note_status/file_count/permissions`。
- 详情:`GET /v1/im/meetings/{meeting_id}/notes` 返回会议信息和 `files[]`。
- 上传并登记:调用通用文件上传 `POST /v0/file/u/`,multipart 包含 `file=@...`、`purpose=meeting_note`、`meeting_id=<meeting_id>`、`id=<本次上传幂等ID>`,请求头按文件上传鉴权携带 `X-MeetingIM-APIKey`、`X-MeetingIM-Auth`。服务端上传成功后会立即登记会议笔记文件,响应 `ctrl.params` 包含 `url,file_id,purpose,meeting_id,note_file_id,note_file_status,version`。
- 兼容绑定:若客户端先完成普通文件上传,也可以调用 `POST /v1/im/meetings/{meeting_id}/notes/files`,Body `{"file_id":"..."}`,并携带 `Idempotency-Key` 完成登记。
- 替换/删除:`PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}`、`DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}?version=n`。只有记录人/发起人按权限可上传、替换和删除。
### 9.5 日报.png
<img src="images/日报.png" alt="日报" width="360">
@@ -2252,6 +2396,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=` | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","note_status":"available","file_count":1,"permissions":{"can_upload":true,"can_download":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` | Path meeting ID | `{"meeting_id":"mtg_1","note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","file_name":"会议纪要.pdf","mime_type":"application/pdf","permissions":{"can_download":true}}]}` |
| `POST /v0/file/u/` 会议笔记上传并登记 | Multipart `purpose=meeting_note&meeting_id=mtg_1&id=upload_1&file=@note.pdf` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file_1.pdf","file_id":"file_1","purpose":"meeting_note","meeting_id":"mtg_1","note_file_id":"mnote_1","note_file_status":"active","version":1}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","status":"active","version":1}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
+51
View File
@@ -460,6 +460,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_meetings (
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
recorder_user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
@@ -475,6 +476,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_meetings (
UNIQUE INDEX uk_im_meetings_meeting (meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer (organizer_id, start_at),
INDEX idx_im_meetings_recorder (recorder_user_id, end_at),
INDEX idx_im_meetings_state_time (state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -493,6 +495,54 @@ CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants (
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY (meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_meetings' AND COLUMN_NAME = 'recorder_user_id') = 0,
'ALTER TABLE tinode.im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
UPDATE tinode.im_meetings SET recorder_user_id = organizer_id WHERE recorder_user_id = 0;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_meetings' AND INDEX_NAME = 'idx_im_meetings_recorder') = 0,
'CREATE INDEX idx_im_meetings_recorder ON tinode.im_meetings(recorder_user_id, end_at)',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
CREATE TABLE IF NOT EXISTS tinode.im_meeting_note_files (
id BIGINT NOT NULL AUTO_INCREMENT,
note_file_id VARCHAR(64) NOT NULL,
meeting_id VARCHAR(64) NOT NULL,
file_id VARCHAR(64) NOT NULL,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
file_etag VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
deleted_by BIGINT,
deleted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_meeting_note_files_id (note_file_id),
INDEX idx_im_meeting_note_files_file (meeting_id, file_id, status),
UNIQUE INDEX uk_im_meeting_note_files_idempotency (meeting_id, uploader_id, idempotency_key),
INDEX idx_im_meeting_note_files_meeting (meeting_id, status, id DESC),
INDEX idx_im_meeting_note_files_uploader (uploader_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_daily_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
@@ -946,6 +996,7 @@ VALUES
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'),
('meeting_notes', '会议笔记', '/meeting-notes', 35, '', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'),
('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 1, 'enabled')
@@ -0,0 +1,88 @@
-- MySQL 8.0. Admin message governance and authentication audit tables.
-- Execute this script in each tenant business database.
CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '',
account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '',
login_type VARCHAR(24) NOT NULL,
event_type VARCHAR(24) NOT NULL DEFAULT 'login',
session_id VARCHAR(96) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
request_id VARCHAR(64) NOT NULL DEFAULT '',
login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3) NULL,
metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time (device_id(64), login_at DESC),
INDEX idx_im_user_login_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_login_failure_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NULL,
account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '',
failure_type VARCHAR(32) NOT NULL,
reason VARCHAR(96) NOT NULL DEFAULT '',
detail VARCHAR(500) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium',
risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
request_id VARCHAR(64) NOT NULL DEFAULT '',
attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_login_failure_account_time (account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time (user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time (ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time (failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT,
action_id VARCHAR(96) NOT NULL,
command_id VARCHAR(96) NOT NULL,
conversation_type VARCHAR(16) NOT NULL,
topic VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL,
sender_user_id BIGINT NULL,
action VARCHAR(16) NOT NULL,
action_status VARCHAR(16) NOT NULL DEFAULT 'pending',
reason VARCHAR(500) NOT NULL DEFAULT '',
actor_admin_id BIGINT NOT NULL DEFAULT 0,
request_id VARCHAR(64) NOT NULL DEFAULT '',
before_snapshot JSON NULL,
after_snapshot JSON NULL,
failure_reason VARCHAR(96) NOT NULL DEFAULT '',
failure_message VARCHAR(1000) NOT NULL DEFAULT '',
processed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_message_admin_action_id (action_id),
UNIQUE INDEX uk_im_message_admin_command (command_id),
INDEX idx_im_message_admin_target (conversation_type, topic, seqid, id DESC),
INDEX idx_im_message_admin_status (action_status, created_at, id),
INDEX idx_im_message_admin_actor (actor_admin_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,317 @@
# IM 极光推送前后端对接文档
更新时间:2026-09-04
## 1. 对接边界
极光 JPush 接入分三段:
| 边界 | 责任 |
| --- | --- |
| 客户端和极光 SDK | 初始化 SDK、申请通知权限、获取 `registration_id`、绑定 alias、接收和点击 Push。 |
| 客户端和 IM Server | 登录后上报设备 Push Token,收到 Push 后按 `topic/seq/call_id` 回 IM 拉取补偿。 |
| IM Server 和极光 REST API | 保存 `master_secret`,生成 Push 任务,调用 `POST /v3/push`,记录 `msg_id` 和失败原因。 |
客户端不需要知道 `master_secret`,也不直接调用极光 REST API。
## 2. 客户端接入
### 2.1 SDK 初始化
客户端按平台集成 JPush SDK:
- Android:初始化 JPush SDK,创建通知 Channel,按厂商通道要求配置包名、签名、Channel ID。
- iOS:配置 APNs 证书或 Token Authentication,申请通知权限。
- HarmonyOS:按鸿蒙 SDK 文档初始化并配置通知分类。
客户端启动后取得 `registration_id`,登录 IM 后把 alias 绑定为当前 `user_id`。同一用户多设备允许绑定同一个 alias。
### 2.2 上报 Push Token
```http
PUT /v1/im/devices/{device_id}/push-token
Authorization: Bearer <IM token>
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Tenant-Code: <tenant_code>
X-Request-ID: <request_id>
Idempotency-Key: <idempotency_key>
Content-Type: application/json
```
请求:
```json
{
"provider": "jpush",
"token": "registration_id",
"platform": "ios",
"app_version": "1.0.0",
"push_env": "test",
"sdk_version": "5.0.0",
"notification_permission": "granted"
}
```
响应 `data`:
```json
{
"device_id": "ios_device_1",
"provider": "jpush",
"platform": "ios",
"app_version": "1.0.0",
"updated_at": "2026-09-04T10:00:00Z"
}
```
字段说明:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `provider` | 是 | 固定传 `jpush`。后续保留 `fcm`、`apns` 等兼容值。 |
| `token` | 是 | 极光 SDK 返回的 `registration_id`。 |
| `platform` | 是 | `android`、`ios`、`hmos`。 |
| `app_version` | 否 | 客户端版本,用于排查。 |
| `push_env` | 否 | `test` 或 `production`,用于 iOS APNs 环境核对。 |
| `sdk_version` | 否 | JPush SDK 版本。 |
| `notification_permission` | 否 | `granted`、`denied`、`unknown`。 |
### 2.3 点击 Push 后处理
客户端统一读取 `extras`:
```json
{
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128",
"sender_id": "usr_sender",
"message_type": "text"
}
```
处理规则:
1. 如果未登录,先进入登录或静默登录。
2. 如果租户不一致,先切换到 `tenant_code`。
3. 按 `type` 跳转目标页面。
4. 按 `topic` 和本地游标拉取消息补偿。
5. 按 `seq` 定位消息;若消息已撤回,展示撤回占位。
6. 对通话类 Push,必须向 IM Server 查询当前通话状态,不能只凭 Push 打开通话中页面。
## 3. 后端接入
### 3.1 配置读取
IM Server 从后台配置读取 `im_jpush` 分组:
```json
{
"jpush_enabled": "true",
"jpush_app_key": "app_key",
"jpush_master_secret": "******",
"jpush_api_domain": "https://api.jpush.cn",
"jpush_apns_production": "false",
"jpush_default_ttl_seconds": "86400",
"jpush_call_ttl_seconds": "60",
"jpush_use_alias": "true",
"jpush_test_model": "false",
"jpush_classification": "1"
}
```
### 3.2 设备记录
设备表建议至少支持:
| 字段 | 说明 |
| --- | --- |
| `user_id` | IM 用户 ID。 |
| `device_id` | 客户端设备 ID。 |
| `provider` | `jpush`。 |
| `push_token` | 极光 `registration_id`。 |
| `platform` | `android/ios/hmos`。 |
| `app_version` | App 版本。 |
| `push_env` | `test/production`。 |
| `enabled` | 是否有效。 |
| `last_seen_at` | 最近上报时间。 |
| `disabled_reason` | 禁用原因。 |
同一 `user_id + device_id + provider` 覆盖更新;同一 token 换绑账号时,以最后一次登录上报为准并禁用旧绑定。
### 3.3 Push 任务
服务端生成任务时推荐数据结构:
```json
{
"task_id": "push_01K...",
"tenant_code": "default",
"target_user_id": "usr_target",
"push_type": "im_message",
"topic": "p2p...",
"seq": 128,
"call_id": "",
"dedupe_key": "default:usr_target:p2p...:128:im_message",
"status": "pending",
"retry_count": 0,
"next_retry_at": "2026-09-04T10:00:00Z"
}
```
任务落库或投 Kafka 后由 worker 发送。极光成功返回后记录 `msg_id`;失败按错误类型重试、跳过或禁用设备。
## 4. IM Server 调极光
### 4.1 请求
```http
POST https://api.jpush.cn/v3/push
Authorization: Basic base64(app_key + ":" + master_secret)
Content-Type: application/json
```
普通消息:
```json
{
"platform": "all",
"audience": {
"alias": ["usr_target"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
"body": "你收到一条新消息"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
}
},
"options": {
"time_to_live": 86400,
"apns_production": false,
"classification": 1
}
}
```
来电提醒:
```json
{
"platform": "all",
"audience": {
"alias": ["usr_callee"]
},
"notification": {
"alert": "收到一通视频通话",
"android": {
"title": "视频通话",
"alert": "张三邀请你视频通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "video",
"caller_id": "usr_caller"
}
},
"ios": {
"alert": {
"title": "视频通话",
"body": "张三邀请你视频通话"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "video",
"caller_id": "usr_caller"
}
}
},
"options": {
"time_to_live": 60,
"apns_production": false,
"classification": 1
}
}
```
### 4.2 响应处理
成功响应示例:
```json
{
"sendno": "0",
"msg_id": "18100287008546343"
}
```
处理规则:
- HTTP 200 且返回 `msg_id`:任务置为 `sent`。
- 网络超时、5xx、限流:任务置为 `retry_wait`,按退避重试。
- 鉴权失败或配置错误:任务置为 `failed`,告警并停止继续重试同配置。
- registration_id 无效:禁用对应设备 token。
- 用户关闭通知权限:记录设备能力,后续可跳过普通提醒。
## 5. Push extras 规范
所有 extras 值建议使用字符串,避免不同平台 SDK 对数字类型解析不一致。
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `type` | 是 | `im_message`、`group_mention`、`call_invite`、`call_missed`、`system_notification`、`friend_request`、`group_request`。 |
| `tenant_code` | 是 | 租户编码。 |
| `topic` | 条件 | 会话或群 Topic。消息、群、通话类必填。 |
| `seq` | 条件 | Topic 消息序号。消息、群、通话类建议必填。 |
| `call_id` | 条件 | 通话类必填。 |
| `call_type` | 条件 | `audio` 或 `video`。 |
| `sender_id` | 否 | 消息发送人。 |
| `caller_id` | 条件 | 来电类必填。 |
| `notification_id` | 条件 | 系统通知类必填。 |
| `route` | 否 | 客户端白名单路由,不允许外部 URL。 |
## 6. 前后端联调清单
| 步骤 | 操作 | 验证 |
| --- | --- | --- |
| 1 | 客户端初始化 JPush SDK | 能拿到 `registration_id`。 |
| 2 | 登录 IM 并绑定 alias=`user_id` | 极光控制台能查到别名或设备。 |
| 3 | 调用设备上报接口 | 服务端设备记录 provider=`jpush`。 |
| 4 | 目标用户退出或断网 | IM 判断为离线或不可 WS 投递。 |
| 5 | 发送 P2P 消息 | 离线用户收到 Push。 |
| 6 | 点击 Push | App 打开对应会话并补齐 `seq`。 |
| 7 | 发起语音/视频通话 | 离线被叫收到短 TTL 来电 Push。 |
| 8 | 通话取消后点击旧 Push | 客户端查询后显示已取消或未接,不进入通话中。 |
| 9 | 关闭会话免打扰 | 普通消息不发 Push,@提醒按策略执行。 |
| 10 | 极光返回无效 token | 后端禁用设备 token,重新上报后恢复。 |
@@ -0,0 +1,368 @@
# IM 极光推送完整调用流程
更新时间:2026-09-04
## 1. 流程目标
本文用于说明 IM 系统接入极光 JPush 后,离线消息、离线来电、系统通知和业务红点提醒从客户端注册设备、后端保存设备、服务端生成 Push 任务、调用极光 REST API 到客户端点击补偿拉取的完整链路。
官方参考:
- 极光快速开始:https://docs.jiguang.cn/jpush/quickstart/
- 极光 REST API 概述:https://docs.jiguang.cn/jpush/server/push/server_overview
- 极光创建推送 API:https://docs.jiguang.cn/jpush/server/push/rest_api_v3_push
## 2. 整体链路
```text
后台配置极光参数
-> 客户端集成 JPush SDK
-> 客户端获得 registration_id
-> 客户端登录 IM 后上报设备 Push Token
-> IM Server 保存 user_id/device_id/provider/registration_id/platform
-> 用户离线或目标设备不在线
-> IM Server 生成离线 Push 任务
-> Push worker 调用极光 POST /v3/push
-> 极光下发通知到 Android/iOS/HarmonyOS
-> 用户点击通知打开 App
-> 客户端按 extras 中的 topic/seq/call_id 跳转并补偿拉取
```
## 3. 后台配置流程
配置建议存放在 HotGo 后台 `hg_sys_config`,分组建议使用 `im_jpush`。IM Server 通过 Admin 内部配置接口读取并缓存,配置变更后通过 Kafka 配置事件或进程内缓存过期生效。
| 配置项 | 示例 | 说明 |
| --- | --- | --- |
| `jpush_enabled` | `true` | 是否启用极光推送。 |
| `jpush_app_key` | `xxxxxxxx` | 极光应用 AppKey。客户端 SDK 初始化也会用到,允许给客户端配置使用。 |
| `jpush_master_secret` | `******` | 极光 REST API 密钥,只能后端保存和使用,不允许返回客户端。 |
| `jpush_api_domain` | `https://api.jpush.cn` | 极光 REST API 域名。 |
| `jpush_push_path` | `/v3/push` | 创建推送 API 路径。 |
| `jpush_apns_production` | `false/true` | iOS APNs 环境,测试包用 false,生产包用 true。 |
| `jpush_default_ttl_seconds` | `86400` | 离线 Push 默认保留时长。 |
| `jpush_call_ttl_seconds` | `60` | 来电 Push 保留时长,建议短一些。 |
| `jpush_use_alias` | `true` | 是否优先使用 alias=user_id 投递。 |
| `jpush_test_model` | `false` | 极光测试模式,仅联调测试设备时开启。 |
| `jpush_classification` | `1` | 重要业务通知建议按系统消息分类处理,需结合厂商通道规则确认。 |
| `jpush_log_payload` | `false` | 生产关闭 payload 明文日志,避免敏感信息进入日志。 |
## 4. 客户端初始化流程
### 4.1 获取极光配置
客户端安装包通常内置对应环境的 `AppKey`,也可以从 App 配置接口获取非敏感配置。`MasterSecret` 不允许下发。
客户端需要完成:
1. 集成 JPush SDK。
2. 初始化 SDK 使用 `jpush_app_key`。
3. 申请系统通知权限。
4. 取得 `registration_id`。
5. 登录 IM 后绑定 alias,建议 alias 使用当前 IM `user_id`,例如 `usr...`。
6. 调用 IM 后端设备接口上报 Push Token。
### 4.2 设备上报
```http
PUT /v1/im/devices/{device_id}/push-token
Authorization: Bearer <IM token>
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Tenant-Code: <tenant_code>
X-Request-ID: <request_id>
Idempotency-Key: <idempotency_key>
Content-Type: application/json
```
请求 Body:
```json
{
"provider": "jpush",
"token": "registration_id_from_jpush",
"platform": "android",
"app_version": "1.0.0",
"push_env": "test",
"sdk_version": "5.0.0",
"notification_permission": "granted"
}
```
成功响应:
```json
{
"code": 0,
"data": {
"device_id": "device_1",
"provider": "jpush",
"platform": "android",
"app_version": "1.0.0",
"updated_at": "2026-09-04T10:00:00Z"
},
"request_id": "req_01K...",
"server_time": "2026-09-04T10:00:00Z"
}
```
### 4.3 设备解绑
退出登录、删除设备、切换账号或 SDK 返回 registration_id 失效时,客户端应调用设备解绑接口或覆盖上报为空状态。后端要把该设备 token 标记为 disabled,避免继续推送到错误账号。
## 5. 服务端 Push 任务生成
### 5.1 触发场景
| 场景 | 触发条件 | Push 目标 |
| --- | --- | --- |
| P2P 离线消息 | 接收方当前无在线可投递设备,或需要离线提醒 | 接收方 user_id。 |
| 群离线消息 | 成员离线且未免打扰,按 Topic seq 补偿 | 被提醒成员 user_id,不对万人群逐条广播无差别 Push。 |
| @提醒 | 被 @ 用户离线或后台 | 被 @ 用户 user_id,高优先级。 |
| 好友申请/审批 | 目标用户离线 | 目标用户 user_id。 |
| 群申请/邀请/审批 | 审批人或目标成员离线 | 对应用户 user_id。 |
| 系统通知 | 已写入系统通知表且用户离线 | 通知目标 user_id。 |
| 语音/视频来电 | 被叫方当前无在线可响铃设备或 App 后台 | 被叫 user_id,短 TTL。 |
| 未接来电 | 通话超时、取消或未接 | 被叫 user_id。 |
### 5.2 在线与离线判断
在线实时消息仍走 WebSocket。Push 不是消息正文真源,只负责提醒。客户端重连后必须按 Topic `seq` 或业务 ID 从 IM Server 拉取补偿,不能只依赖 Push payload 展示最终状态。
推荐判断:
- 当前用户存在可投递在线会话:优先 WS,下发 Push 可按用户设置降级或不发。
- 用户全离线:生成 Push 任务。
- 用户在线但 App 后台状态不可判定:可生成低干扰 Push,客户端本地去重。
- 群万人场景:实时 fanout 走 Redis/Kafka/Topic 路由,离线只对需要提醒的成员生成 Push。
### 5.3 任务状态机
```text
pending -> sending -> sent
pending -> sending -> retry_wait -> sending
pending -> sending -> failed
pending -> skipped
```
| 状态 | 说明 |
| --- | --- |
| `pending` | 已生成,等待 worker 发送。 |
| `sending` | worker 已领取,正在请求极光。 |
| `sent` | 极光 API 返回成功并记录 `msg_id`。 |
| `retry_wait` | 临时错误,等待退避重试。 |
| `failed` | 永久失败或超过最大重试次数。 |
| `skipped` | 用户免打扰、无有效设备、消息已过期或策略不允许发送。 |
任务需保存 `dedupe_key`,例如 `tenant_code:user_id:topic:seq:push_type`,避免重复生成离线提醒。
## 6. 服务端调用极光 REST API
极光创建推送 API:
```http
POST https://api.jpush.cn/v3/push
Authorization: Basic base64(app_key + ":" + master_secret)
Content-Type: application/json
```
官方说明中,创建推送 API 使用 HTTPS、HTTP Basic Authentication 和 JSON 请求体;推送目标支持 `alias`、`registration_id`、`tag` 等方式,`alias` 单次最多 1000 个,`registration_id` 单次最多 1000 个;返回成功时包含 `sendno` 和 `msg_id`。
### 6.1 普通离线消息 Push
```json
{
"platform": ["android", "ios", "hmos"],
"audience": {
"alias": ["usr_target"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128",
"sender_id": "usr_sender",
"message_type": "text"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
"body": "你收到一条新消息"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128",
"sender_id": "usr_sender",
"message_type": "text"
}
},
"hmos": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"category": "IM",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128",
"sender_id": "usr_sender",
"message_type": "text"
}
}
},
"options": {
"time_to_live": 86400,
"apns_production": false,
"classification": 1
}
}
```
### 6.2 群消息 Push
群消息 Push 只提醒,不携带完整消息正文。万人群不建议按群全员同步投递,只对离线且需要提醒的人生成任务,例如被 @、特别关注、管理员公告、未免打扰小群等。
```json
{
"platform": "all",
"audience": {
"alias": ["usr_mentioned"]
},
"notification": {
"alert": "群里有人提到了你",
"android": {
"title": "项目群",
"alert": "群里有人提到了你",
"extras": {
"type": "group_mention",
"tenant_code": "default",
"topic": "grp...",
"seq": "56001",
"sender_id": "usr_sender",
"mention": "1"
}
},
"ios": {
"alert": {
"title": "项目群",
"body": "群里有人提到了你"
},
"badge": "+1",
"sound": "default",
"extras": {
"type": "group_mention",
"tenant_code": "default",
"topic": "grp...",
"seq": "56001",
"sender_id": "usr_sender",
"mention": "1"
}
}
},
"options": {
"time_to_live": 86400,
"apns_production": false,
"classification": 1
}
}
```
### 6.3 来电 Push
语音/视频来电需要短 TTL。普通离线 Push 只做提醒;如后续要实现系统级强提醒、VoIP 样式或厂商 VoIP,需要额外开通并按平台单独配置。
```json
{
"platform": "all",
"audience": {
"alias": ["usr_callee"]
},
"notification": {
"alert": "收到一通语音通话",
"android": {
"title": "语音通话",
"alert": "张三邀请你语音通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
},
"ios": {
"alert": {
"title": "语音通话",
"body": "张三邀请你语音通话"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
}
},
"options": {
"time_to_live": 60,
"apns_production": false,
"classification": 1
}
}
```
## 7. 客户端点击与补偿拉取
客户端收到或点击 Push 后按 `extras.type` 分发:
| `extras.type` | 客户端动作 |
| --- | --- |
| `im_message` | 打开 `topic` 对应会话,按本地最后 `seq + 1` 拉取消息。 |
| `group_mention` | 打开群会话并定位到 `seq`,若本地无该消息先拉取。 |
| `call_invite` | 如果通话仍有效,打开来电页;同时通过 IM 登录和会话补偿确认通话状态。 |
| `call_missed` | 打开通话记录或对应会话。 |
| `system_notification` | 打开系统消息列表或 `action` 指定页面,再调用 HTTP 拉取详情。 |
| `friend_request` | 打开新朋友列表并刷新未处理数。 |
| `group_request` | 打开群审批入口并刷新列表。 |
Push 中的 `topic`、`seq`、`call_id` 只用于定位和补偿,不作为业务真源。客户端必须重新登录 IM 并拉取服务端数据确认状态,例如通话是否已取消、消息是否已撤回、系统通知是否已读。
## 8. 隐私与安全
1. `jpush_master_secret` 只在后端配置和请求极光时使用,不返回客户端。
2. Push 通知栏默认不展示敏感正文,可按用户隐私设置决定是否展示摘要。
3. `extras` 不放身份证号、手机号、文件直链、完整消息正文、审核原因明细等敏感数据。
4. 日志只记录 `task_id/user_id/provider/status/msg_id/error_code`,不记录完整 Push payload。
5. 推送失败后按错误类型禁用无效 registration_id,避免持续打失败请求。
6. 多租户场景下 `tenant_code` 必须进入任务、payload、dedupe_key 和日志,禁止跨租户复用设备 token。
## 9. 联调验收
| 验收项 | 期望结果 |
| --- | --- |
| 客户端初始化 | SDK 返回 registration_id,alias 绑定为当前 `usr...`。 |
| 设备上报 | `PUT /v1/im/devices/{device_id}/push-token` 返回成功,服务端记录 provider=`jpush`。 |
| 普通离线消息 | 用户离线后收到 Push,点击后进入会话并补齐 Topic seq。 |
| 群 @ 提醒 | 被 @ 用户离线时只收到一次提醒,重连后定位到对应消息。 |
| 语音来电 | 被叫离线时收到短 TTL 来电 Push,通话取消后点击不会进入过期通话。 |
| 免打扰 | 免打扰会话不产生普通 Push,@ 或强提醒按策略例外。 |
| 幂等重试 | 同一 `dedupe_key` 不重复发,极光临时失败可重试。 |
| 无效设备 | 极光返回无效 token 后设备记录被禁用或待清理。 |
| iOS 环境 | 测试包 `apns_production=false`,生产包为 true。 |
@@ -220,6 +220,16 @@ INSERT INTO hg_sys_config
SELECT 'im_shengwang', '声网启用状态', 'bool', 'agora_enabled', '0', '0', 100, '是否启用声网 RTC 音视频通话', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_client_feature', '语音通话入口开关', 'bool', 'audio_call_enabled', '1', '1', 10, '控制 /v1/im/bootstrap features.audio_call 与 permissions.audio_call', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_client_feature' AND `key` = 'audio_call_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_client_feature', '视频通话入口开关', 'bool', 'video_call_enabled', '1', '1', 20, '控制 /v1/im/bootstrap features.video_call 与 permissions.video_call', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_client_feature' AND `key` = 'video_call_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_shengwang', 'RTC 服务商', 'string', 'agora_provider', 'agora', 'agora', 101, '取值来自字典 im_rtc_provider,当前支持 agora', 1, 1, NOW(), NOW()
+4
View File
@@ -75,6 +75,10 @@ func (s testSecurityStore) CountUnreadSystemNotifications(types.TenantID, types.
return 0, s.err
}
func (s testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(types.TenantID, types.Uid, string) (int, error) {
return 0, s.err
}
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
return s.err
}
+207
View File
@@ -0,0 +1,207 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"strings"
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func recordSessionLoginSuccess(s *Session, rec *auth.Rec, scheme, suppliedAccount, requestID string, at time.Time) {
if s == nil || rec == nil || rec.Uid.IsZero() || !s.tenantID.IsValid() {
return
}
username := ""
if user, err := store.Users.Get(s.tenantID, rec.Uid); err == nil && user != nil {
username = user.Username
}
accountType, account := loginAuditAccount(scheme, suppliedAccount, rec.Credential)
if account == "" {
accountType, account = classifyLoginAccount(username)
}
event := &types.LoginAuditEvent{
UserID: rec.Uid, Username: username, AccountType: accountType,
AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
LoginType: loginAuditType(scheme), EventType: "login", SessionID: truncateAuditValue(s.sid, 96),
DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf),
AppVersion: truncateAuditValue(versionToString(s.ver), 32), IP: sessionAuditIP(s.remoteAddr),
UserAgent: truncateAuditValue(s.userAgent, 512), RiskLevel: "low",
RequestID: truncateAuditValue(requestID, 64), LoginAt: at.UTC().Round(time.Millisecond),
Metadata: map[string]any{"auth_level": rec.AuthLevel.String(), "tenant_code": s.tenantCode},
}
if err := store.SecurityAudit.RecordLogin(s.tenantID, event); err != nil {
logs.Warn.Printf("login audit write failed tenant:%d uid:%s sid:%s err:%v", s.tenantID, rec.Uid, s.sid, err)
}
}
func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *auth.Rec, requestID string, cause error, at time.Time) {
if s == nil || !s.tenantID.IsValid() {
return
}
suppliedAccount := ""
if strings.EqualFold(strings.TrimSpace(scheme), "basic") {
suppliedAccount = strings.SplitN(string(secret), ":", 2)[0]
}
credential := ""
uid := types.ZeroUid
if rec != nil {
credential = rec.Credential
uid = rec.Uid
}
accountType, account := loginAuditAccount(scheme, suppliedAccount, credential)
failureType, reason, riskLevel, riskAction := classifyLoginFailure(scheme, cause)
event := &types.LoginFailureEvent{
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
FailureType: failureType, Reason: reason, Detail: truncateAuditValue(loginFailureDetail(cause), 500),
IP: sessionAuditIP(s.remoteAddr), DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf),
AppVersion: truncateAuditValue(versionToString(s.ver), 32), UserAgent: truncateAuditValue(s.userAgent, 512),
RiskLevel: riskLevel, RiskAction: riskAction, RequestID: truncateAuditValue(requestID, 64),
AttemptAt: at.UTC().Round(time.Millisecond), Metadata: map[string]any{"scheme": strings.TrimSpace(scheme), "tenant_code": s.tenantCode},
}
if err := store.SecurityAudit.RecordLoginFailure(s.tenantID, event); err != nil {
logs.Warn.Printf("login failure audit write failed tenant:%d sid:%s err:%v", s.tenantID, s.sid, err)
}
}
func loginAuditAccount(scheme, supplied, credential string) (string, string) {
if parts := strings.SplitN(strings.TrimSpace(credential), ":", 2); len(parts) == 2 && parts[1] != "" {
kind := strings.ToLower(strings.TrimSpace(parts[0]))
if kind == "tel" {
kind = "phone"
}
return kind, strings.TrimSpace(parts[1])
}
if strings.EqualFold(strings.TrimSpace(scheme), "basic") {
return classifyLoginAccount(supplied)
}
return "", ""
}
func classifyLoginAccount(account string) (string, string) {
account = strings.TrimSpace(account)
switch {
case account == "":
return "", ""
case strings.Contains(account, "@"):
return "email", account
case isPhoneLike(account):
return "phone", account
default:
return "username", account
}
}
func isPhoneLike(value string) bool {
digits := 0
for i, r := range value {
if r >= '0' && r <= '9' {
digits++
continue
}
if !(i == 0 && r == '+') {
return false
}
}
return digits >= 7
}
func maskLoginAccount(account string) string {
account = strings.TrimSpace(account)
if account == "" {
return ""
}
if at := strings.LastIndex(account, "@"); at > 0 {
return string([]rune(account[:at])[:1]) + "***" + account[at:]
}
runes := []rune(account)
if isPhoneLike(account) && len(runes) > 7 {
return string(runes[:3]) + "****" + string(runes[len(runes)-4:])
}
if len(runes) <= 2 {
return strings.Repeat("*", len(runes))
}
return string(runes[:1]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1:])
}
func hashLoginAccount(account string) string {
account = strings.ToLower(strings.TrimSpace(account))
if account == "" {
return ""
}
sum := sha256.Sum256([]byte(account))
return hex.EncodeToString(sum[:])
}
func loginAuditType(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "basic":
return "password"
case "token":
return "token_refresh"
case "code", "sms", "sms_code":
return "sms_code"
case "sso":
return "sso"
case "qr", "qr_code":
return "qr_code"
default:
return "password"
}
}
func classifyLoginFailure(scheme string, cause error) (failureType, reason, riskLevel, riskAction string) {
failureType, reason, riskLevel, riskAction = "password_error", "AUTH_FAILED", "medium", "audit_only"
detail := strings.ToLower(loginFailureDetail(cause))
switch {
case strings.Contains(detail, "rate") || strings.Contains(detail, "too many"):
return "rate_limited", "RATE_LIMITED", "high", "blocked"
case errors.Is(cause, types.ErrPolicy) || strings.Contains(detail, "risk"):
return "risk_blocked", "RISK_BLOCKED", "high", "blocked"
case errors.Is(cause, types.ErrPermissionDenied):
return "account_disabled", "ACCOUNT_DISABLED", "high", "blocked"
case errors.Is(cause, types.ErrUserNotFound) || errors.Is(cause, types.ErrNotFound):
return "account_not_found", "ACCOUNT_NOT_FOUND", "medium", "audit_only"
case loginAuditType(scheme) == "sms_code":
return "code_error", "CODE_ERROR", "medium", "audit_only"
default:
return
}
}
func loginFailureDetail(cause error) string {
if cause == nil {
return "authentication failed"
}
return cause.Error()
}
func sessionAuditIP(remoteAddr string) string {
remoteAddr = strings.TrimSpace(remoteAddr)
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return truncateAuditValue(host, 64)
}
return truncateAuditValue(strings.Trim(remoteAddr, "[]"), 64)
}
func normalizeAuditPlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
if platform == "" {
return "unknown"
}
return truncateAuditValue(platform, 32)
}
func truncateAuditValue(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"errors"
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestLoginAuditAccountDoesNotTreatTokenAsAccount(t *testing.T) {
kind, account := loginAuditAccount("token", "secret-token", "")
if kind != "" || account != "" {
t.Fatalf("token must not be persisted as an account: kind=%q account=%q", kind, account)
}
kind, account = loginAuditAccount("basic", "13800138000", "")
if kind != "phone" || account != "13800138000" {
t.Fatalf("unexpected basic account classification: kind=%q account=%q", kind, account)
}
}
func TestMaskAndHashLoginAccount(t *testing.T) {
tests := map[string]string{
"13800138000": "138****8000",
"alice@example.com": "a***@example.com",
"alice": "a***e",
"ab": "**",
}
for input, want := range tests {
if got := maskLoginAccount(input); got != want {
t.Errorf("maskLoginAccount(%q)=%q want %q", input, got, want)
}
}
hash := hashLoginAccount(" Alice ")
if len(hash) != 64 || hash != hashLoginAccount("alice") || strings.Contains(hash, "alice") {
t.Fatalf("unexpected account hash %q", hash)
}
}
func TestClassifyLoginFailure(t *testing.T) {
tests := []struct {
scheme string
err error
want string
}{
{"basic", types.ErrFailed, "password_error"},
{"sms_code", types.ErrFailed, "code_error"},
{"basic", types.ErrUserNotFound, "account_not_found"},
{"basic", types.ErrPermissionDenied, "account_disabled"},
{"basic", types.ErrPolicy, "risk_blocked"},
{"basic", errors.New("too many attempts"), "rate_limited"},
}
for _, tc := range tests {
got, _, _, _ := classifyLoginFailure(tc.scheme, tc.err)
if got != tc.want {
t.Errorf("classifyLoginFailure(%q,%v)=%q want %q", tc.scheme, tc.err, got, tc.want)
}
}
}
func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
privateSpec := privateMessagesInternalQuerySpec()
groupSpec := groupMessagesInternalQuerySpec()
for label, spec := range map[string]*internalQuerySpec{"private": privateSpec, "group": groupSpec} {
joined := strings.ToLower(spec.Select + " " + spec.From)
for _, field := range []string{"from_user_id", "content_type", "content_excerpt", "status", "im_message_admin_actions"} {
if !strings.Contains(joined, field) {
t.Errorf("%s query missing %s", label, field)
}
}
}
if strings.Contains(strings.ToLower(privateSpec.Select), "m.content as content") || strings.Contains(strings.ToLower(groupSpec.Select), "m.content as content") {
t.Fatal("message list query must not expose full message content")
}
}
func TestRedactClientMessageForLog(t *testing.T) {
raw := []byte(`{"login":{"id":"1","scheme":"basic","secret":"YWxpY2U6YWxpY2UxMjM="},"acc":{"secret":"account-secret","tmpsecret":"temp-secret"}}`)
redacted := string(redactClientMessageForLog(raw))
for _, secret := range []string{"YWxpY2U6YWxpY2UxMjM=", "account-secret", "temp-secret"} {
if strings.Contains(redacted, secret) {
t.Fatalf("redacted log still contains secret %q: %s", secret, redacted)
}
}
if strings.Count(redacted, "[REDACTED]") != 3 {
t.Fatalf("unexpected redacted payload: %s", redacted)
}
}
+11 -1
View File
@@ -95,6 +95,8 @@ type videoCall struct {
callID string
// Product-level call type: audio or video.
callType string
// Time when the invite was accepted by the server.
startedAt time.Time
// Time when the call was accepted.
acceptedAt time.Time
}
@@ -168,6 +170,13 @@ func (call *videoCall) matchesPayload(payload any) bool {
return payloadCallID == "" || payloadCallID == call.callID
}
func (call *videoCall) isStaleUnanswered(now time.Time, timeoutSeconds int) bool {
if call == nil || len(call.parties) != 1 || call.startedAt.IsZero() || timeoutSeconds <= 0 {
return false
}
return now.Sub(call.startedAt) > time.Duration(timeoutSeconds)*time.Second
}
func (t *Topic) auditCallCreate(call *videoCall, caller types.Uid) {
if store.Calls == nil || call == nil || call.callID == "" || caller.IsZero() {
return
@@ -350,6 +359,7 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
contentMime: msg.Pub.Head["mime"],
callID: callContentString(msg.Pub.Content, "callId"),
callType: callContentString(msg.Pub.Content, "callType"),
startedAt: types.TimeNow(),
}
t.currentCall.parties[msg.sess.sid] = callPartyData{
uid: asUid,
@@ -358,7 +368,7 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
}
t.auditCallCreate(t.currentCall, asUid)
// Wait for constCallEstablishmentTimeout for the other side to accept the call.
t.callEstablishmentTimer.Reset(time.Duration(globals.callEstablishmentTimeout) * time.Second)
t.callEstablishmentTimer.Reset(time.Duration(imCallTimeoutSeconds()) * time.Second)
}
// Handles events on existing video call (acceptance, termination, metadata exchange).
+12
View File
@@ -1634,6 +1634,18 @@ func ErrCallBusyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {
return ErrCallBusyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)
}
func ErrCallBusyWithCallReply(msg *ClientComMessage, ts time.Time, call *videoCall) *ServerComMessage {
reply := ErrCallBusyReply(msg, ts)
if call != nil {
reply.Ctrl.Params = map[string]any{
"seq": call.seq,
"call_id": call.callID,
"call_type": call.callType,
}
}
return reply
}
// ErrUnknown database or other server error (500).
func ErrUnknown(id, topic string, ts time.Time) *ServerComMessage {
return ErrUnknownExplicitTs(id, topic, ts, ts)
+13
View File
@@ -240,6 +240,13 @@ type TenantInternalAdminQueryAdapter interface {
TenantInternalAdminQueryOne(tenantID t.TenantID, query string, args []any) (map[string]any, error)
}
type TenantSecurityAuditAdapter interface {
TenantLoginAuditCreate(t.TenantID, *t.LoginAuditEvent) error
TenantLoginFailureCreate(t.TenantID, *t.LoginFailureEvent) error
TenantLoginAuditClose(t.TenantID, string, time.Time) error
TenantMessageAdminApply(t.TenantID, *t.MessageAdminAction) error
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
@@ -412,6 +419,11 @@ type TenantBusinessAdapter interface {
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error)
TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, error)
TenantMeetingNoteList(t.TenantID, t.Uid, string, string, int) ([]t.Meeting, string, error)
TenantMeetingNoteFiles(t.TenantID, t.Uid, string) ([]t.MeetingNoteFile, error)
TenantMeetingNoteFileCreate(t.TenantID, t.Uid, string, string, string, string) (*t.MeetingNoteFile, error)
TenantMeetingNoteFileReplace(t.TenantID, t.Uid, string, string, string, string, int) (*t.MeetingNoteFile, error)
TenantMeetingNoteFileDelete(t.TenantID, t.Uid, string, string, string, int) (*t.MeetingNoteFile, error)
TenantDailyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.DailyReport, string, error)
TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error)
TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error)
@@ -485,6 +497,7 @@ type TenantBusinessAdapter interface {
TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, error)
TenantSystemNotificationUnreadCountByEventPrefix(t.TenantID, t.Uid, string) (int, error)
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error)
+221 -21
View File
@@ -272,6 +272,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
}
if err = ensureIMAdminGovernanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure admin governance schema: " + err.Error())
}
if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
}
@@ -281,6 +284,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMAttendanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error())
}
if err = ensureIMMeetingNotesTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure meeting notes schema: " + err.Error())
}
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
}
@@ -509,6 +515,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMAdminGovernanceTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMFileReviewTables(db); err != nil {
db.Close()
return nil, nil, err
@@ -521,6 +531,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMMeetingNotesTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close()
return nil, nil, err
@@ -943,6 +957,9 @@ func (a *adapter) CreateDb(reset bool) error {
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
return err
}
if err = ensureIMAdminGovernanceTables(tx); err != nil {
return err
}
if err = ensureIMFileReviewTables(tx); err != nil {
return err
}
@@ -1055,6 +1072,9 @@ func (a *adapter) UpgradeDb() error {
if err := a.ensureMessageTopicSeqIndex(); err != nil {
return err
}
if err := a.ensureTopicSeqMatchesMessages(); err != nil {
return err
}
if a.version == adpVersion {
return nil
@@ -1080,6 +1100,61 @@ func (a *adapter) ensureMessageTopicSeqIndex() error {
return err
}
func (a *adapter) ensureTopicSeqMatchesMessages() error {
ctx := context.Background()
conn, err := a.db.Connx(ctx)
if err != nil {
return err
}
defer conn.Close()
topicsScoped, err := identityColumnExists(ctx, conn, "im_topics", "tenant_id")
if err != nil {
return err
}
messagesScoped, err := identityColumnExists(ctx, conn, "im_messages", "tenant_id")
if err != nil {
return err
}
if topicsScoped && messagesScoped {
if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT tenant_id,topic,MAX(seqid) AS max_seq FROM im_messages GROUP BY tenant_id,topic) AS m
ON m.tenant_id=t.tenant_id AND m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`); err != nil {
return err
}
} else {
if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT topic,MAX(seqid) AS max_seq FROM im_messages GROUP BY topic) AS m ON m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`); err != nil {
return err
}
}
groupMessagesExist, err := identityTableExists(ctx, conn, "im_group_messages")
if err != nil || !groupMessagesExist {
return err
}
groupMessagesScoped, err := identityColumnExists(ctx, conn, "im_group_messages", "tenant_id")
if err != nil {
return err
}
if topicsScoped && groupMessagesScoped {
_, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT tenant_id,topic,MAX(seqid) AS max_seq FROM im_group_messages GROUP BY tenant_id,topic) AS m
ON m.tenant_id=t.tenant_id AND m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`)
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT topic,MAX(seqid) AS max_seq FROM im_group_messages GROUP BY topic) AS m ON m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`)
return err
}
func createIMNotificationTables(tx *sql.Tx) error {
_, err := tx.Exec(
`CREATE TABLE im_system_notifications(
@@ -1154,6 +1229,7 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
('organization','组织架构','/organization',10,'organization',1,'enabled'),
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'),
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled')
@@ -1395,6 +1471,7 @@ func createIMMeetingTables(tx *sql.Tx) error {
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
recorder_user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
@@ -1410,6 +1487,7 @@ func createIMMeetingTables(tx *sql.Tx) error {
UNIQUE INDEX uk_im_meetings_meeting(meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer(organizer_id, start_at),
INDEX idx_im_meetings_recorder(recorder_user_id, end_at),
INDEX idx_im_meetings_state_time(state, start_at)
)`,
`CREATE TABLE im_meeting_participants(
@@ -1426,6 +1504,33 @@ func createIMMeetingTables(tx *sql.Tx) error {
INDEX idx_im_meeting_participants_response(meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES im_meetings(meeting_id) ON DELETE CASCADE
)`,
`CREATE TABLE im_meeting_note_files(
id BIGINT NOT NULL AUTO_INCREMENT,
note_file_id VARCHAR(64) NOT NULL,
meeting_id VARCHAR(64) NOT NULL,
file_id VARCHAR(64) NOT NULL,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
file_etag VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
deleted_by BIGINT,
deleted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
)`,
}
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
@@ -1435,6 +1540,63 @@ func createIMMeetingTables(tx *sql.Tx) error {
return nil
}
func ensureIMMeetingNotesTables(db *sqlx.DB) error {
if db == nil {
return nil
}
var recorderExists bool
if err := db.QueryRow(`SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_meetings' AND column_name='recorder_user_id'
)`).Scan(&recorderExists); err != nil {
return err
}
if !recorderExists {
if _, err := db.Exec(`ALTER TABLE im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id`); err != nil {
return err
}
if _, err := db.Exec(`UPDATE im_meetings SET recorder_user_id=organizer_id WHERE recorder_user_id=0`); err != nil {
return err
}
if _, err := db.Exec(`CREATE INDEX idx_im_meetings_recorder ON im_meetings(recorder_user_id,end_at)`); err != nil && !isDupe(err) {
return err
}
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS im_meeting_note_files(
id BIGINT NOT NULL AUTO_INCREMENT,
note_file_id VARCHAR(64) NOT NULL,
meeting_id VARCHAR(64) NOT NULL,
file_id VARCHAR(64) NOT NULL,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
file_etag VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
deleted_by BIGINT,
deleted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
)`); err != nil {
return err
}
_, err := db.Exec(`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled')
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`)
return err
}
func createIMReportTables(tx *sql.Tx) error {
if _, err := tx.Exec(
`CREATE TABLE im_daily_reports(
@@ -2019,6 +2181,10 @@ func (a *adapter) UserCreate(user *t.User) error {
if err != nil {
return err
}
profileID, err := nextSnowflakeID()
if err != nil {
return err
}
var phone any
if strings.TrimSpace(user.Phone) != "" {
canonicalPhone, err := normalizeUserPhone(user.Phone)
@@ -2048,6 +2214,11 @@ func (a *adapter) UserCreate(user *t.User) error {
securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
nickname, avatarFileID := profileSeedFromPublic(user.Public)
if _, err = tx.Exec(`INSERT IGNORE INTO im_user_profiles(id,user_id,avatar_file_id,nickname,public_account,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, profileID, decoded_uid, avatarFileID, nickname, user.Uid().UserId(), user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
user.Tags = nil
return tx.Commit()
@@ -2316,13 +2487,15 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
if cancel != nil {
defer cancel()
}
var user t.User
err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
var row profileUserRow
err := a.db.GetContext(ctx, &row, `SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil {
user := profileUserFromRow(row)
user.SetUid(uid)
user.Public = common.FromJSON(user.Public)
user.Trusted = common.FromJSON(user.Trusted)
return &user, nil
}
@@ -2348,8 +2521,11 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
if cancel != nil {
defer cancel()
}
q, uids, _ := sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted)
q, uids, _ := sqlx.In(`SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id IN (?) AND u.state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
if err != nil {
return nil, err
@@ -2357,14 +2533,12 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
defer rows.Close()
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
var row profileUserRow
if err = rows.StructScan(&row); err != nil {
users = nil
break
}
user.SetUid(common.EncodeUidString(user.Id))
user.Public = common.FromJSON(user.Public)
user.Trusted = common.FromJSON(user.Trusted)
user := profileUserFromRow(row)
users = append(users, user)
}
@@ -3084,12 +3258,13 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
// Fetch p2p users and join to p2p subscriptions.
if len(usrq) > 0 {
q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " +
"FROM im_users WHERE id IN (?)"
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?)`
q, args, _ = sqlx.In(q, usrq)
if !keepDeleted {
// Optionally skip deleted users.
q += " AND state!=?"
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
}
@@ -3105,19 +3280,19 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
}
for rows.Next() {
var usr2 t.User
var usr2 profileUserRow
if err = rows.StructScan(&usr2); err != nil {
break
}
joinOn := uid.P2PName(common.EncodeUidString(usr2.Id))
joinOn := uid.P2PName(common.EncodeUidString(usr2.ID))
if sub, ok := join[joinOn]; ok {
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.effectiveUpdatedAt())
sub.SetState(usr2.State)
sub.SetPublic(common.FromJSON(usr2.Public))
sub.SetPublic(usr2.profilePublic())
sub.SetTrusted(common.FromJSON(usr2.Trusted))
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent)
join[joinOn] = sub
}
}
@@ -4865,6 +5040,14 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er
if err = rows.Scan(&id, &loc); err != nil {
break
}
var noteRefs int
fileID := store.EncodeUid(int64(id)).String()
if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(&noteRefs); err != nil {
break
}
if noteRefs > 0 {
continue
}
if loc != "" {
locations = append(locations, loc)
}
@@ -5004,8 +5187,25 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
)
)
OR EXISTS (
SELECT 1 FROM im_meeting_note_files AS mnf
INNER JOIN im_meetings AS mtg
ON mtg.meeting_id=mnf.meeting_id
WHERE mnf.file_id=? AND mnf.status='active'
AND mtg.state<>?
AND mtg.end_at<=CURRENT_TIMESTAMP(3)
AND (
mtg.organizer_id=?
OR mtg.recorder_user_id=?
OR EXISTS (
SELECT 1 FROM im_meeting_participants AS mp
WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=?
)
)
)
) LIMIT 1`,
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid),
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
if err != nil {
return false, err
}
@@ -0,0 +1,64 @@
//go:build mysql
// +build mysql
package mysql
func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '', account_mask VARCHAR(128) NOT NULL DEFAULT '',
login_type VARCHAR(24) NOT NULL, event_type VARCHAR(24) NOT NULL DEFAULT 'login',
session_id VARCHAR(96) NOT NULL DEFAULT '', device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown', app_version VARCHAR(32) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '', risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
INDEX idx_im_user_login_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_login_failure_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT,
account_type VARCHAR(24) NOT NULL DEFAULT '', account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '', failure_type VARCHAR(32) NOT NULL,
reason VARCHAR(96) NOT NULL DEFAULT '', detail VARCHAR(500) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '', user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium', risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
request_id VARCHAR(64) NOT NULL DEFAULT '', attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_login_failure_account_time(account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time(user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time(ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
topic VARCHAR(64) NOT NULL, seqid BIGINT NOT NULL, sender_user_id BIGINT,
action VARCHAR(16) NOT NULL, action_status VARCHAR(16) NOT NULL DEFAULT 'pending',
reason VARCHAR(500) NOT NULL DEFAULT '', actor_admin_id BIGINT NOT NULL DEFAULT 0,
request_id VARCHAR(64) NOT NULL DEFAULT '', before_snapshot JSON, after_snapshot JSON,
failure_reason VARCHAR(96) NOT NULL DEFAULT '', failure_message VARCHAR(1000) NOT NULL DEFAULT '',
processed_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_message_admin_action_id(action_id),
UNIQUE INDEX uk_im_message_admin_command(command_id),
INDEX idx_im_message_admin_target(conversation_type, topic, seqid, id DESC),
INDEX idx_im_message_admin_status(action_status, created_at, id),
INDEX idx_im_message_admin_actor(actor_admin_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,28 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 3 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_message_admin_actions"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("admin governance schema must stay inside the selected tenant database: %s", joined)
}
}
+641 -37
View File
@@ -47,6 +47,127 @@ func stampTenantUser(id t.TenantID, user *t.User) *t.User {
return user
}
type profileUserRow struct {
TenantID t.TenantID `db:"tenant_id"`
ID string `db:"id"`
Username string
CreatedAt time.Time `db:"createdat"`
UpdatedAt time.Time `db:"updatedat"`
State t.ObjState `db:"state"`
StateAt *time.Time `db:"stateat"`
Access t.DefaultAccess
Phone string `db:"phone"`
LastSeen sql.NullTime `db:"lastseen"`
UserAgent string `db:"useragent"`
Public any `db:"public"`
Trusted any `db:"trusted"`
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}
func (row profileUserRow) effectiveUpdatedAt() time.Time {
if row.ProfileUpdatedAt.Valid {
return common.SelectLatestTime(row.UpdatedAt, row.ProfileUpdatedAt.Time)
}
return row.UpdatedAt
}
func (row profileUserRow) lastSeen() *time.Time {
if row.LastSeen.Valid {
return &row.LastSeen.Time
}
return nil
}
func (row profileUserRow) profilePublic() any {
return profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID)
}
func profileUserFromRow(row profileUserRow) t.User {
user := t.User{
ObjHeader: t.ObjHeader{
Id: row.ID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.effectiveUpdatedAt(),
},
TenantID: row.TenantID,
State: row.State,
StateAt: row.StateAt,
Username: row.Username,
Access: row.Access,
LastSeen: row.lastSeen(),
UserAgent: row.UserAgent,
Phone: row.Phone,
Public: row.profilePublic(),
Trusted: common.FromJSON(row.Trusted),
}
user.SetUid(common.EncodeUidString(row.ID))
return user
}
func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) any {
public := publicMap(base)
clearProfilePublicFields(public)
if nickname.Valid {
value := strings.TrimSpace(nickname.String)
if value != "" {
for _, key := range []string{"display_name", "nickname", "fn", "name"} {
public[key] = value
}
}
}
if avatarFileID.Valid {
value := strings.TrimSpace(avatarFileID.String)
if value != "" {
public["avatar_file_id"] = value
}
}
return public
}
func clearProfilePublicFields(public map[string]any) {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar"} {
delete(public, key)
}
}
func profileSeedFromPublic(public any) (nickname, avatarFileID string) {
values := publicMap(public)
for _, key := range []string{"display_name", "nickname", "fn", "name"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
nickname = value
break
}
}
for _, key := range []string{"avatar_file_id"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
avatarFileID = value
break
}
}
return nickname, avatarFileID
}
func publicMap(value any) map[string]any {
switch public := value.(type) {
case map[string]any:
out := make(map[string]any, len(public))
for key, val := range public {
out[key] = val
}
return out
case map[string]string:
out := make(map[string]any, len(public))
for key, val := range public {
out[key] = val
}
return out
default:
return map[string]any{}
}
}
func stampTenantUsers(id t.TenantID, users []t.User) []t.User {
for i := range users {
users[i].TenantID = id
@@ -211,9 +332,12 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
if cancel != nil {
defer cancel()
}
var user t.User
err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`,
var row profileUserRow
err = a.db.GetContext(ctx, &row, `SELECT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`,
id, decodeUID(uid), t.StateDeleted)
if err == sql.ErrNoRows {
return nil, nil
@@ -221,7 +345,7 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
if err != nil {
return nil, err
}
scanTenantUser(&user)
user := profileUserFromRow(row)
return &user, nil
}
@@ -870,10 +994,12 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
}
}
if len(usrq) > 0 {
q = "SELECT tenant_id,id,updatedat,state,access,lastseen,useragent,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?)"
q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)`
q, args, _ = sqlx.In(q, id, usrq)
if !keepDeleted {
q += " AND state!=?"
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
}
rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
@@ -881,19 +1007,19 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
return nil, err
}
for rows.Next() {
var user t.User
var user profileUserRow
if err = rows.StructScan(&user); err != nil {
rows.Close()
return nil, err
}
joinOn := uid.P2PName(common.EncodeUidString(user.Id))
joinOn := uid.P2PName(common.EncodeUidString(user.ID))
if sub, ok := join[joinOn]; ok {
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, user.UpdatedAt)
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, user.effectiveUpdatedAt())
sub.SetState(user.State)
sub.SetPublic(common.FromJSON(user.Public))
sub.SetPublic(user.profilePublic())
sub.SetTrusted(common.FromJSON(user.Trusted))
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
sub.SetLastSeenAndUA(user.LastSeen, user.UserAgent)
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
join[joinOn] = sub
}
}
@@ -3220,8 +3346,12 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
if meeting.IdempotencyKey != "" {
idempotencyKey = meeting.IdempotencyKey
}
_, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version)
VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location),
recorderID := meeting.RecorderID
if recorderID.IsZero() {
recorderID = uid
}
_, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version)
VALUES(?,?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), decodeUID(recorderID), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location),
t.MeetingStateScheduled, string(reminderJSON), idempotencyKey)
if err != nil {
if isDupe(err) && meeting.IdempotencyKey != "" {
@@ -3231,7 +3361,7 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
}
return nil, err
}
participants := uniqueMeetingParticipants(uid, attendeeIDs)
participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID))
now := time.Now().UTC().Round(time.Millisecond)
for _, participant := range participants {
response := t.MeetingResponsePending
@@ -3274,10 +3404,10 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
if limit <= 0 || limit > 100 {
limit = 20
}
where := []string{`(m.organizer_id=? OR EXISTS (
where := []string{`(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
))`}
args := []any{decodeUID(uid), decodeUID(uid)}
args := []any{decodeUID(uid), decodeUID(uid), decodeUID(uid)}
if strings.TrimSpace(cursor) != "" {
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
if err != nil || parsed <= 0 {
@@ -3305,6 +3435,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
ID int64
MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string
StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_at"`
@@ -3324,6 +3455,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
ID: row.ID,
MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title,
StartAt: row.StartAt,
EndAt: row.EndAt,
@@ -3337,6 +3469,9 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
}
@@ -3445,6 +3580,13 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string
sets = append(sets, "location=?")
args = append(args, strings.TrimSpace(*patch.Location))
}
if patch.RecorderID != nil {
if patch.RecorderID.IsZero() {
return nil, t.ErrMalformed
}
sets = append(sets, "recorder_user_id=?")
args = append(args, decodeUID(*patch.RecorderID))
}
if patch.ReminderMinutes != nil {
encoded, err := json.Marshal(patch.ReminderMinutes)
if err != nil {
@@ -3474,6 +3616,12 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string
if affected == 0 {
return nil, t.ErrInvalidResponse
}
if patch.RecorderID != nil {
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO im_meeting_participants(meeting_id,user_id,response,response_at)
VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(*patch.RecorderID), t.MeetingResponsePending, nil); err != nil {
return nil, err
}
}
participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
if err != nil {
return nil, err
@@ -3755,6 +3903,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
ID int64
MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string
StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_at"`
@@ -3768,9 +3917,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m
WHERE m.meeting_id=? AND (m.organizer_id=? OR EXISTS (
WHERE m.meeting_id=? AND (m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
))`, meetingID, decodeUID(viewer), decodeUID(viewer)); err != nil {
))`, meetingID, decodeUID(viewer), decodeUID(viewer), decodeUID(viewer)); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
@@ -3780,6 +3929,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
ID: row.ID,
MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title,
StartAt: row.StartAt,
EndAt: row.EndAt,
@@ -3793,6 +3943,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
}
@@ -3843,6 +3996,402 @@ func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string,
return participants, nil
}
func (a *adapter) TenantMeetingNoteList(id t.TenantID, uid t.Uid, query, cursor string, limit int) ([]t.Meeting, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteList(id, uid, query, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
where := []string{`m.state<>?`, `m.end_at<=?`, `(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
))`}
args := []any{t.MeetingStateCanceled, time.Now().UTC(), decodeUID(uid), decodeUID(uid), decodeUID(uid)}
if q := strings.TrimSpace(query); q != "" {
where = append(where, "(m.title LIKE ? OR m.location LIKE ?)")
like := "%" + q + "%"
args = append(args, like, like)
}
if strings.TrimSpace(cursor) != "" {
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.id<?")
args = append(args, parsed)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, `SELECT m.* FROM im_meetings AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.end_at DESC,m.id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
meetings := make([]t.Meeting, 0, limit)
nextCursor := ""
for rows.Next() {
meeting, rowID, err := scanMeetingRow(rows)
if err != nil {
return nil, "", err
}
if len(meetings) == limit {
nextCursor = strconv.FormatInt(rowID, 10)
continue
}
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
if err != nil {
return nil, "", err
}
meeting.Participants = participants
meeting.State = t.MeetingStateEnded
meetings = append(meetings, *meeting)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return meetings, nextCursor, nil
}
func (a *adapter) TenantMeetingNoteFiles(id t.TenantID, uid t.Uid, meetingID string) ([]t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFiles(id, uid, meetingID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if _, err := a.meetingNoteVisible(ctx, uid, strings.TrimSpace(meetingID)); err != nil {
return nil, err
}
return a.meetingNoteFiles(ctx, strings.TrimSpace(meetingID))
}
func (a *adapter) TenantMeetingNoteFileCreate(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileCreate(id, uid, meetingID, noteFileID, fileID, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
if err != nil {
return nil, err
}
if file == nil || file.Status != t.UploadCompleted {
return nil, t.ErrNotFound
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,idempotency_key,version)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1)`, strings.TrimSpace(noteFileID), meeting.MeetingID, strings.TrimSpace(fileID), decodeUID(uid), strings.TrimSpace(fileID),
file.MimeType, file.Size, file.ETag, t.MeetingNoteFileStatusActive, "ready", "native", strings.TrimSpace(idempotencyKey))
if err != nil {
if isDupe(err) {
return a.meetingNoteFileByIdempotency(ctx, meeting.MeetingID, uid, strings.TrimSpace(idempotencyKey))
}
return nil, err
}
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
}
func (a *adapter) TenantMeetingNoteFileReplace(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileReplace(id, uid, meetingID, noteFileID, fileID, idempotencyKey, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
if err != nil {
return nil, err
}
if file == nil || file.Status != t.UploadCompleted {
return nil, t.ErrNotFound
}
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,idempotency_key=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
strings.TrimSpace(fileID), strings.TrimSpace(fileID), file.MimeType, file.Size, file.ETag, strings.TrimSpace(idempotencyKey),
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
}
func (a *adapter) TenantMeetingNoteFileDelete(id t.TenantID, uid t.Uid, meetingID, noteFileID, _ string, version int) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileDelete(id, uid, meetingID, noteFileID, "", version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
item, err := a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
if err != nil {
return nil, err
}
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
SET status=?,deleted_by=?,deleted_at=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
t.MeetingNoteFileStatusDeleted, decodeUID(uid), time.Now().UTC().Round(time.Millisecond),
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
deletedAt := time.Now().UTC().Round(time.Millisecond)
item.Status = t.MeetingNoteFileStatusDeleted
item.DeletedBy = uid
item.DeletedAt = &deletedAt
item.Version++
return item, nil
}
func scanMeetingRow(scanner interface{ StructScan(any) error }) (*t.Meeting, int64, error) {
row := struct {
ID int64
MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string
StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_at"`
Location string
State string
ReminderMinutes []byte `db:"reminder_minutes"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
Version int
CanceledAt *time.Time `db:"canceled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := scanner.StructScan(&row); err != nil {
return nil, 0, err
}
meeting := &t.Meeting{
ID: row.ID,
MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title,
StartAt: row.StartAt,
EndAt: row.EndAt,
Location: row.Location,
State: row.State,
Version: row.Version,
CanceledAt: row.CanceledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
}
return meeting, row.ID, nil
}
func (a *adapter) meetingNoteVisible(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) {
meeting, err := a.meetingByID(ctx, meetingID, uid)
if err != nil {
return nil, err
}
if meeting.State == t.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) {
return nil, t.ErrPermissionDenied
}
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
if err != nil {
return nil, err
}
meeting.Participants = participants
meeting.State = t.MeetingStateEnded
return meeting, nil
}
func (a *adapter) meetingNoteWritable(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) {
meeting, err := a.meetingNoteVisible(ctx, uid, meetingID)
if err != nil {
return nil, err
}
if meeting.RecorderID != uid {
return nil, t.ErrPermissionDenied
}
return meeting, nil
}
func (a *adapter) meetingNoteFiles(ctx context.Context, meetingID string) ([]t.MeetingNoteFile, error) {
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_meeting_note_files
WHERE meeting_id=? AND status=? ORDER BY id DESC`, meetingID, t.MeetingNoteFileStatusActive)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]t.MeetingNoteFile, 0, 4)
for rows.Next() {
item, err := scanMeetingNoteFileRow(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (a *adapter) meetingNoteFileByID(ctx context.Context, meetingID, noteFileID string) (*t.MeetingNoteFile, error) {
row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND note_file_id=? LIMIT 1`, meetingID, noteFileID)
item, err := scanMeetingNoteFileRow(row)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &item, nil
}
func (a *adapter) meetingNoteFileByIdempotency(ctx context.Context, meetingID string, uid t.Uid, idempotencyKey string) (*t.MeetingNoteFile, error) {
row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, meetingID, decodeUID(uid), idempotencyKey)
item, err := scanMeetingNoteFileRow(row)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &item, nil
}
func scanMeetingNoteFileRow(scanner interface{ StructScan(any) error }) (t.MeetingNoteFile, error) {
row := struct {
ID int64
NoteFileID string `db:"note_file_id"`
MeetingID string `db:"meeting_id"`
FileID string `db:"file_id"`
UploaderID int64 `db:"uploader_id"`
FileName string `db:"file_name"`
MIMEType string `db:"mime_type"`
FileSize int64 `db:"file_size"`
FileETag string `db:"file_etag"`
Status string
PreviewStatus string `db:"preview_status"`
PreviewMode string `db:"preview_mode"`
PreviewFileID string `db:"preview_file_id"`
IdempotencyKey string `db:"idempotency_key"`
Version int
DeletedBy sql.NullInt64 `db:"deleted_by"`
DeletedAt *time.Time `db:"deleted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := scanner.StructScan(&row); err != nil {
return t.MeetingNoteFile{}, err
}
item := t.MeetingNoteFile{
ID: row.ID,
NoteFileID: row.NoteFileID,
MeetingID: row.MeetingID,
FileID: row.FileID,
UploaderID: store.EncodeUid(row.UploaderID),
FileName: row.FileName,
MIMEType: row.MIMEType,
FileSize: row.FileSize,
FileETag: row.FileETag,
Status: row.Status,
PreviewStatus: row.PreviewStatus,
PreviewMode: row.PreviewMode,
PreviewFileID: row.PreviewFileID,
IdempotencyKey: row.IdempotencyKey,
Version: row.Version,
DeletedAt: row.DeletedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.DeletedBy.Valid {
item.DeletedBy = store.EncodeUid(row.DeletedBy.Int64)
}
return item, nil
}
func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid {
seen := map[t.Uid]bool{organizer: true}
participants := []t.Uid{organizer}
@@ -5727,15 +6276,36 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush
}
func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...)
}
func (a *adapter) TenantDevicePushTokenGetAllByProvider(id t.TenantID, provider string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
return a.tenantDevicePushTokenGetAllByProviders(id, []string{provider}, uids...)
}
func (a *adapter) tenantDevicePushTokenGetAllByProviders(id t.TenantID, providers []string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, 0, err
}
if tenantAdp != a {
return tenantAdp.TenantDevicePushTokenGetAll(id, uids...)
return tenantAdp.tenantDevicePushTokenGetAllByProviders(id, providers, uids...)
}
}
normalizedProviders := make([]any, 0, len(providers))
for _, provider := range providers {
if provider = strings.ToLower(strings.TrimSpace(provider)); provider != "" {
normalizedProviders = append(normalizedProviders, provider)
}
}
if len(normalizedProviders) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
decoded := make([]any, 0, len(uids))
for _, uid := range uids {
if !uid.IsZero() {
@@ -5746,7 +6316,7 @@ func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
query, args, err := sqlx.In(`SELECT user_id,token,platform,updated_at FROM im_device_push_tokens
WHERE provider IN ('fcm','firebase') AND user_id IN (?)`, decoded)
WHERE provider IN (?) AND user_id IN (?)`, normalizedProviders, decoded)
if err != nil {
return nil, 0, err
}
@@ -8223,6 +8793,33 @@ func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid)
return count, nil
}
func (a *adapter) TenantSystemNotificationUnreadCountByEventPrefix(id t.TenantID, uid t.Uid, eventPrefix string) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationUnreadCountByEventPrefix(id, uid, eventPrefix)
}
}
eventPrefix = strings.TrimSpace(eventPrefix)
if err := validTenant(id); err != nil || uid.IsZero() || eventPrefix == "" {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_system_notifications WHERE user_id=? AND read_at IS NULL AND event_type LIKE ?`,
decodeUID(uid), eventPrefix+"%"); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -8791,7 +9388,8 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted
rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
FROM im_friendships AS fs
JOIN im_users AS u ON u.id=fs.peer_id
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
@@ -8805,16 +9403,19 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
UserID int64 `db:"user_id"`
PeerID int64 `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
ID int64
UserID int64 `db:"user_id"`
PeerID int64 `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
@@ -8838,7 +9439,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: common.FromJSON(row.Public),
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID),
Trusted: common.FromJSON(row.Trusted),
},
})
@@ -9182,13 +9783,16 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
WHERE privacy_friend.user_id=? AND privacy_friend.peer_id=u.id AND privacy_friend.status=?
) OR COALESCE(JSON_UNQUOTE(JSON_EXTRACT(privacy.add_me_methods, ?)), 'true')='true')`)
args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod)
args = append([]any{id}, args...)
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted FROM im_users AS u `+strings.Join(joins, " ")+`
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.updated_at AS profile_updated_at
FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -9197,11 +9801,11 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
users := make([]t.User, 0, limit)
nextCursor := ""
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
var row profileUserRow
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
scanTenantUser(&user)
user := profileUserFromRow(row)
if len(users) == limit {
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
continue
+1 -1
View File
@@ -380,7 +380,7 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
inv.IdempotencyKey = inv.InvitationID
}
if inv.Status == "" {
inv.Status = t.GroupInvitationStatusJoined
inv.Status = t.GroupInvitationStatusPending
}
if inv.ExpiresAt.IsZero() {
inv.ExpiresAt = now.Add(7 * 24 * time.Hour)
+166
View File
@@ -0,0 +1,166 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil || event.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginAuditCreate(id, event)
}
metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
_, err = a.db.Exec(`INSERT INTO im_user_login_logs
(user_id,username,account_type,account_hash,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
decodeUID(event.UserID), event.Username, event.AccountType, event.AccountHash, event.AccountMask,
event.LoginType, event.EventType, event.SessionID, event.DeviceID, event.Platform, event.AppVersion,
event.IP, event.Region, event.UserAgent, event.RiskLevel, event.RequestID, event.LoginAt, event.LogoutAt, nullableJSON(metadata))
return err
}
func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginFailureCreate(id, event)
}
metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
var userID any
if !event.UserID.IsZero() {
userID = decodeUID(event.UserID)
}
_, err = a.db.Exec(`INSERT INTO im_login_failure_logs
(user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail,
event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent,
event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata))
return err
}
func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || strings.TrimSpace(sessionID) == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginAuditClose(id, sessionID, at)
}
_, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE session_id=? AND logout_at IS NULL", at, sessionID)
return err
}
func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminAction) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || action == nil || action.Topic == "" || action.SeqID <= 0 || (action.Action != "hide" && action.Action != "delete") {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageAdminApply(id, action)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var existing int64
if err = tx.GetContext(ctx, &existing, "SELECT COUNT(*) FROM im_message_admin_actions WHERE command_id=?", action.CommandID); err != nil {
return err
}
if existing > 0 {
return tx.Commit()
}
sender, beforeStatus, err := lockAdminMessage(ctx, tx, action.ConversationType, action.Topic, action.SeqID)
if err != nil {
return err
}
before, _ := json.Marshal(map[string]any{"status": beforeStatus, "sender_user_id": sender})
afterStatus := "hidden"
if action.Action == "delete" {
afterStatus = "deleted"
}
if action.ConversationType == "group" {
_, err = tx.ExecContext(ctx, "UPDATE im_group_messages SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
} else {
_, err = tx.ExecContext(ctx, "UPDATE im_messages SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
}
if err != nil {
return err
}
after, _ := json.Marshal(map[string]any{"status": afterStatus, "sender_user_id": sender})
_, err = tx.ExecContext(ctx, `INSERT INTO im_message_admin_actions
(action_id,command_id,conversation_type,topic,seqid,sender_user_id,action,action_status,reason,actor_admin_id,request_id,before_snapshot,after_snapshot,processed_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)`,
action.ActionID, action.CommandID, action.ConversationType, action.Topic, action.SeqID, sender,
action.Action, "success", action.Reason, action.ActorAdminID, action.RequestID, string(before), string(after), action.ProcessedAt)
if err != nil {
return err
}
return tx.Commit()
}
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic string, seqID int64) (int64, string, error) {
if conversationType == "group" {
var row struct {
Sender int64 `db:"from_user"`
Status string `db:"status"`
}
err := tx.GetContext(ctx, &row, "SELECT from_user,status FROM im_group_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
}
return row.Sender, row.Status, err
}
var row struct {
Sender int64 `db:"from"`
DeletedAt sql.NullTime `db:"deletedat"`
RecalledAt sql.NullTime `db:"recalled_at"`
}
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM im_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
}
status := "normal"
if row.RecalledAt.Valid {
status = "recalled"
}
if row.DeletedAt.Valid {
status = "deleted"
}
return row.Sender, status, err
}
func nullableJSON(raw []byte) any {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return string(raw)
}
+48
View File
@@ -108,6 +108,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
return err
}
if err = backfillProfileDisplay(ctx, conn, usersScoped); err != nil {
return err
}
}
indexName := "users_phone"
@@ -416,6 +419,51 @@ func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped boo
return nil
}
func backfillProfileDisplay(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error {
profilesScoped, err := identityColumnExists(ctx, conn, "im_user_profiles", "tenant_id")
if err != nil {
return err
}
if profilesScoped != usersScoped {
return errors.New("mysql: im_users and im_user_profiles tenant scope mismatch")
}
nicknameExpr := `COALESCE(
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.display_name')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.nickname')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.fn')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.name')), '')
)`
avatarExpr := `COALESCE(
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')), '')
)`
if usersScoped {
if _, err = conn.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,tenant_id,user_id,nickname,avatar_file_id,public_account)
SELECT u.id,u.tenant_id,u.id,COALESCE(`+nicknameExpr+`,''),COALESCE(`+avatarExpr+`,''),''
FROM im_users AS u
WHERE `+nicknameExpr+` IS NOT NULL OR `+avatarExpr+` IS NOT NULL`); err != nil {
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_user_profiles AS p
JOIN im_users AS u ON u.tenant_id=p.tenant_id AND u.id=p.user_id
SET p.nickname=IF(p.nickname='' AND `+nicknameExpr+` IS NOT NULL, `+nicknameExpr+`, p.nickname),
p.avatar_file_id=IF(p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL, `+avatarExpr+`, p.avatar_file_id)
WHERE (p.nickname='' AND `+nicknameExpr+` IS NOT NULL) OR (p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL)`)
return err
}
if _, err = conn.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,nickname,avatar_file_id,public_account)
SELECT u.id,u.id,COALESCE(`+nicknameExpr+`,''),COALESCE(`+avatarExpr+`,''),''
FROM im_users AS u
WHERE `+nicknameExpr+` IS NOT NULL OR `+avatarExpr+` IS NOT NULL`); err != nil {
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_user_profiles AS p
JOIN im_users AS u ON u.id=p.user_id
SET p.nickname=IF(p.nickname='' AND `+nicknameExpr+` IS NOT NULL, `+nicknameExpr+`, p.nickname),
p.avatar_file_id=IF(p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL, `+avatarExpr+`, p.avatar_file_id)
WHERE (p.nickname='' AND `+nicknameExpr+` IS NOT NULL) OR (p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL)`)
return err
}
func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
var out []identityValue
appendQuery := func(query string, args ...any) error {
+53 -1
View File
@@ -3,7 +3,10 @@
package mysql
import "testing"
import (
"database/sql"
"testing"
)
func TestNormalizeUserPhone(t *testing.T) {
for input, want := range map[string]string{
@@ -23,3 +26,52 @@ func TestNormalizeUserPhone(t *testing.T) {
t.Fatal("invalid phone unexpectedly accepted")
}
}
func TestProfilePublicFromFields(t *testing.T) {
public := profilePublicFromFields(map[string]any{
"fn": "旧昵称",
"name": "旧昵称",
"nickname": "旧昵称",
"display_name": "旧昵称",
"avatar_file_id": "old_avatar",
"photo": "old_photo",
"public_account": "usrAccount",
}, sql.NullString{String: "新昵称", Valid: true}, sql.NullString{String: "file_avatar_1", Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name"} {
if public[key] != "新昵称" {
t.Fatalf("nickname field %s not overlaid: %#v", key, public)
}
}
if public["avatar_file_id"] != "file_avatar_1" || public["public_account"] != "usrAccount" {
t.Fatalf("profile public not overlaid: %#v", public)
}
public = profilePublicFromFields(map[string]any{
"fn": "旧昵称",
"avatar_file_id": "old_avatar",
"public_account": "usrAccount",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar"} {
if _, ok := public[key]; ok {
t.Fatalf("legacy profile field %s should not be read from im_users.public: %#v", key, public)
}
}
if public["public_account"] != "usrAccount" {
t.Fatalf("unrelated public field changed: %#v", public)
}
}
func TestProfileSeedFromPublic(t *testing.T) {
nickname, avatar := profileSeedFromPublic(map[string]any{
"fn": "旧昵称",
"avatar_file_id": "file_avatar_1",
})
if nickname != "旧昵称" || avatar != "file_avatar_1" {
t.Fatalf("unexpected profile seed nickname=%q avatar=%q", nickname, avatar)
}
_, avatar = profileSeedFromPublic(map[string]any{"photo": "https://example.com/avatar.png"})
if avatar != "" {
t.Fatalf("photo URL must not be treated as avatar_file_id: %q", avatar)
}
}
+52 -1
View File
@@ -37,6 +37,8 @@ import (
// See https://www.iana.org/assignments/media-types/media-types.xhtml
var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"}
const fileUploadPurposeMeetingNote = "meeting_note"
// RequestPrincipal is a tenant-scoped identity authenticated for HTTP/gRPC file access.
type RequestPrincipal struct {
TenantID types.TenantID
@@ -249,6 +251,11 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
return
}
meetingID, meetingNoteIdempotencyKey, err := meetingNoteUploadParams(req, principal)
if err != nil {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
}
// Check if uploads are handled elsewhere.
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
@@ -354,16 +361,60 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
logs.Warn.Printf("media upload: failed to enqueue file lifecycle event file=%s: %v", fdef.Id, err)
}
params := map[string]string{"url": url}
params := map[string]any{"url": url, "file_id": fdef.Uid().String()}
if globals.mediaGcPeriod > 0 {
// How long this file is guaranteed to exist without being attached to a message or a topic.
params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339)
}
if meetingID != "" {
noteFile, err := createMeetingNoteFromUpload(principal, meetingID, meetingNoteIdempotencyKey, fdef)
if err != nil {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
}
params["purpose"] = fileUploadPurposeMeetingNote
params["meeting_id"] = noteFile.MeetingID
params["note_file_id"] = noteFile.NoteFileID
params["note_file_status"] = noteFile.Status
params["version"] = noteFile.Version
}
writeHttpResponse(NoErrParams(msgID, "", now, params), nil)
logs.Info.Println("media upload: ok", fdef.Id, fdef.Location)
}
func meetingNoteUploadParams(req *http.Request, principal RequestPrincipal) (string, string, error) {
if strings.TrimSpace(req.FormValue("purpose")) != fileUploadPurposeMeetingNote {
return "", "", nil
}
if principal.UID.IsZero() {
return "", "", types.ErrPermissionDenied
}
if store.MeetingNotes == nil {
return "", "", types.ErrUnsupported
}
meetingID := strings.TrimSpace(req.FormValue("meeting_id"))
if meetingID == "" {
return "", "", types.ErrMalformed
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
idempotencyKey = strings.TrimSpace(req.FormValue("id"))
}
if idempotencyKey == "" {
return "", "", types.ErrMalformed
}
return meetingID, idempotencyKey, nil
}
func createMeetingNoteFromUpload(principal RequestPrincipal, meetingID, idempotencyKey string, fdef *types.FileDef) (*types.MeetingNoteFile, error) {
if fdef == nil || fdef.Uid().IsZero() {
return nil, types.ErrMalformed
}
noteFileID := "mnote_" + store.Store.GetUidString()
return store.MeetingNotes.CreateFile(principal.TenantID, principal.UID, meetingID, noteFileID, fdef.Uid().String(), idempotencyKey)
}
// LargeFileServe is the gRPC equivalent of largeFileServeHTTP.
func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
now := types.TimeNow()
+119
View File
@@ -1,19 +1,51 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
type testLargeFileMediaHandler struct {
def *types.FileDef
data string
}
func (h *testLargeFileMediaHandler) Init(string) error { return nil }
func (h *testLargeFileMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) {
return nil, 0, nil
}
func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) {
data, _ := io.ReadAll(file)
h.def = def
h.data = string(data)
def.Location = "/v0/file/s/" + def.Uid().String() + ".txt"
def.ETag = "etag"
return def.Location, int64(len(data)), nil
}
func (h *testLargeFileMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) {
return nil, nil, types.ErrUnsupported
}
func (h *testLargeFileMediaHandler) Delete([]string) error { return nil }
func (h *testLargeFileMediaHandler) GetIdFromUrl(string) types.Uid {
return types.ZeroUid
}
func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
oldSessionStore := globals.sessionStore
globals.sessionStore = NewSessionStore(time.Minute)
@@ -137,3 +169,90 @@ func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
}
func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldFiles := store.Files
oldMeetingNotes := store.MeetingNotes
oldKafkaBus := kafkaBus
defer func() {
store.Store = oldStore
store.Files = oldFiles
store.MeetingNotes = oldMeetingNotes
kafkaBus = oldKafkaBus
}()
handler := &testLargeFileMediaHandler{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
notes := &testMeetingNotesStore{}
store.Store = mockStore
store.Files = files
store.MeetingNotes = notes
kafkaBus = nil
fileID := types.Uid(101)
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
mockStore.EXPECT().GetUidString().Return(fileID.String())
mockStore.EXPECT().GetUidString().Return("note-1")
tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "192.0.2.1:12345"}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
files.EXPECT().FinishUpload(types.TenantID(7), gomock.Any(), true, int64(11)).DoAndReturn(
func(_ types.TenantID, fd *types.FileDef, _ bool, size int64) (*types.FileDef, error) {
fd.Status = types.UploadCompleted
fd.Size = size
return fd, nil
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("id", "upload-meeting-note-1"); err != nil {
t.Fatal(err)
}
if err := writer.WriteField("purpose", fileUploadPurposeMeetingNote); err != nil {
t.Fatal(err)
}
if err := writer.WriteField("meeting_id", "mtg_test"); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", "notes.txt")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("hello notes")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v0/file/u/", &body)
req.RemoteAddr = "192.0.2.1:12345"
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte("token-1")))
rec := httptest.NewRecorder()
largeFileReceiveHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp ServerComMessage
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v body=%s", err, rec.Body.String())
}
params := resp.Ctrl.Params.(map[string]any)
if params["file_id"] != fileID.String() || params["purpose"] != fileUploadPurposeMeetingNote ||
params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" {
t.Fatalf("unexpected upload params: %#v", params)
}
if handler.data != "hello notes" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" {
t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes)
}
}
+3
View File
@@ -12,6 +12,7 @@ package main
import (
"encoding/json"
"net/http"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
@@ -45,6 +46,8 @@ func (sess *Session) readLoop() {
sess.ws.SetReadLimit(globals.maxMessageSize)
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
sess.ws.SetPongHandler(func(string) error {
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
sess.refreshPresence("ws.pong")
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
+42
View File
@@ -20,6 +20,8 @@ const (
imReasonCallNotFound = "CALL_NOT_FOUND"
imReasonCallNotAllowed = "CALL_NOT_ALLOWED"
imReasonCallStateInvalid = "CALL_STATE_INVALID"
imClientFeatureConfigGroup = "im_client_feature"
)
type imCallRTCJoinRequest struct {
@@ -189,6 +191,46 @@ func loadIMAgoraConfig() (imAgoraConfig, error) {
return config, nil
}
func imCallFeatureFlags() (bool, bool) {
runtimeEnabled := imCallRuntimeEnabled()
if !runtimeEnabled || store.ThirdPartyConfig == nil {
return false, false
}
values, err := store.ThirdPartyConfig.Get(imClientFeatureConfigGroup, "audio_call_enabled", "video_call_enabled")
if err != nil {
return false, false
}
return imTruthy(values["audio_call_enabled"]), imTruthy(values["video_call_enabled"])
}
func imCallRuntimeEnabled() bool {
webrtcEnabled := len(globals.iceServers) > 0
agoraEnabled := false
if config, err := loadIMAgoraConfig(); err == nil {
agoraEnabled = config.Enabled
}
return webrtcEnabled || agoraEnabled
}
func imCallFeatureEnabled() bool {
audioCall, videoCall := imCallFeatureFlags()
return audioCall || videoCall
}
func imCallTimeoutSeconds() int {
if globals.callEstablishmentTimeout > 0 {
return globals.callEstablishmentTimeout
}
if imCallFeatureEnabled() {
return defaultCallEstablishmentTimeout
}
return 0
}
func imCanCall(allowed bool) bool {
return allowed && imCallFeatureEnabled()
}
func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) {
if store.Calls == nil {
return nil, types.ErrUnsupported
+654 -50
View File
File diff suppressed because it is too large Load Diff
+654 -13
View File
@@ -269,6 +269,17 @@ type testMeetingsStore struct {
responseVersion int
}
type testMeetingNotesStore struct {
meetings []types.Meeting
files []types.MeetingNoteFile
query string
cursor string
limit int
createdMeetingID string
createdFileID string
createdIdempotencyKey string
}
type testDailyReportsStore struct {
report *types.DailyReport
saved bool
@@ -658,6 +669,46 @@ func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, r
return s.meeting, nil
}
func (s *testMeetingNotesStore) List(_ types.TenantID, _ types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error) {
s.query = query
s.cursor = cursor
s.limit = limit
return s.meetings, "note-next", nil
}
func (s *testMeetingNotesStore) ListFiles(_ types.TenantID, _ types.Uid, _ string) ([]types.MeetingNoteFile, error) {
return s.files, nil
}
func (s *testMeetingNotesStore) CreateFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) {
now := time.Now().UTC().Round(time.Millisecond)
s.createdMeetingID = meetingID
s.createdFileID = fileID
s.createdIdempotencyKey = idempotencyKey
return &types.MeetingNoteFile{
NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid,
FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native",
IdempotencyKey: idempotencyKey, Version: 1, CreatedAt: now, UpdatedAt: now,
}, nil
}
func (s *testMeetingNotesStore) ReplaceFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) {
now := time.Now().UTC().Round(time.Millisecond)
return &types.MeetingNoteFile{
NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid,
FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native",
IdempotencyKey: idempotencyKey, Version: version + 1, CreatedAt: now, UpdatedAt: now,
}, nil
}
func (s *testMeetingNotesStore) DeleteFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, _ string, version int) (*types.MeetingNoteFile, error) {
now := time.Now().UTC().Round(time.Millisecond)
return &types.MeetingNoteFile{
NoteFileID: noteFileID, MeetingID: meetingID, UploaderID: uid,
Status: types.MeetingNoteFileStatusDeleted, Version: version + 1, DeletedBy: uid, DeletedAt: &now, CreatedAt: now, UpdatedAt: now,
}, nil
}
func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) {
if s.report == nil {
return nil, "", nil
@@ -1772,6 +1823,51 @@ func TestServeIMPublicUploadsAllowsGroupAvatar(t *testing.T) {
}
}
func TestServeIMPublicUploadsAllowsUserAvatar(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldTenantEntry := store.TenantEntry
entry := &testTenantEntryStore{}
handler := &testPublicMediaHandler{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
store.Store = mockStore
store.TenantEntry = entry
defer func() {
store.Store = oldStore
store.TenantEntry = oldTenantEntry
}()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("purpose", types.PublicUploadPurposeUserAvatar); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", "avatar.png")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
serveIMPublicUploads(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
}
if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeUserAvatar {
t.Fatalf("unexpected upload: %#v", entry.upload)
}
}
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
@@ -2315,23 +2411,40 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
oldSecurity := store.Security
oldWorkbench := store.Workbench
oldRelations := store.Relations
oldThirdParty := store.ThirdPartyConfig
readAt := time.Now().UTC()
security := &testSecurityStore{
realName: &types.RealNameVerification{Status: "verified"},
unreadNotifications: 4,
realName: &types.RealNameVerification{Status: "verified"},
notifications: []types.SystemNotification{
{EventID: "evt-meeting-created", EventType: "meeting.created"},
{EventID: "evt-meeting-updated", EventType: "meeting.updated"},
{EventID: "evt-friend-request", EventType: "friend.request.created"},
{EventID: "evt-daily-report", EventType: "daily_report.reminded"},
{EventID: "evt-meeting-read", EventType: "meeting.created", ReadAt: &readAt},
},
}
relations := &testRelationsStore{pendingCount: 2}
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"},
{Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 5, Enabled: false, Permission: "enabled"},
{Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 0, Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"},
}}
store.Security = security
store.Relations = relations
store.Workbench = workbench
store.ThirdPartyConfig = testThirdPartyConfigStore{
"agora_enabled": "1",
"agora_auth_mode": "app_id",
"agora_app_id": "app123",
"agora_token_ttl_seconds": "3600",
"audio_call_enabled": "1",
"video_call_enabled": "1",
}
defer func() {
store.Security = oldSecurity
store.Relations = oldRelations
store.Workbench = oldWorkbench
store.ThirdPartyConfig = oldThirdParty
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
@@ -2347,6 +2460,10 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil {
t.Fatalf("bootstrap missing home capabilities: %#v", data)
}
features := data["features"].(map[string]any)
if features["audio_call"] != true || features["video_call"] != true {
t.Fatalf("bootstrap call features disabled unexpectedly: %#v", features)
}
if security.realNameTenant != 7 || security.realNameUser != 42 {
t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser)
}
@@ -2369,11 +2486,14 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
if modules[0].(map[string]any)["unread_count"].(float64) != 3 {
t.Fatalf("unexpected bootstrap module unread: %#v", modules[0])
}
if modules[1].(map[string]any)["unread_count"].(float64) != 2 {
t.Fatalf("unexpected bootstrap meetings unread: %#v", modules[1])
}
badges := data["entry_badges"].(map[string]any)
if badges["new_friends_pending_count"].(float64) != 2 ||
badges["contacts_total_unread"].(float64) != 2 ||
badges["system_unread_count"].(float64) != 4 ||
badges["workbench_total_unread"].(float64) != 3 {
badges["workbench_total_unread"].(float64) != 5 {
t.Fatalf("unexpected bootstrap badges: %#v", badges)
}
entries := data["entries"].(map[string]any)
@@ -2719,6 +2839,61 @@ func TestServeIMMeetingsList(t *testing.T) {
}
}
func TestServeIMMeetingGetResolvesDisplayNames(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
viewer := types.Uid(42)
attendee := types.Uid(43)
expectIMBearer(t, ctrl, "token-1", 7, viewer)
oldMeetings := store.Meetings
oldUsers := store.Users
now := time.Now().UTC().Round(time.Millisecond)
meetings := &testMeetingsStore{meeting: &types.Meeting{
MeetingID: "mtg_test",
OrganizerID: viewer,
Title: "周会",
StartAt: now.Add(time.Hour),
EndAt: now.Add(2 * time.Hour),
State: types.MeetingStateScheduled,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
Participants: []types.MeetingParticipant{
{MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted},
{MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending},
},
}}
store.Meetings = meetings
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
organizer := types.User{Public: map[string]any{"fn": "张三"}}
organizer.SetUid(viewer)
participant := types.User{Public: map[string]any{"fn": "李四"}}
participant.SetUid(attendee)
users.EXPECT().GetAll(types.TenantID(7), viewer, attendee).Return([]types.User{organizer, participant}, nil)
store.Users = users
defer func() {
store.Meetings = oldMeetings
store.Users = oldUsers
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMeetingResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["organizer_display_name"] != "张三" {
t.Fatalf("unexpected organizer display name: %#v", data)
}
participants := data["participants"].([]any)
if participants[0].(map[string]any)["display_name"] != "张三" || participants[1].(map[string]any)["display_name"] != "李四" {
t.Fatalf("unexpected participant names: %#v", participants)
}
}
func TestServeIMMeetingMyResponse(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2758,6 +2933,100 @@ func TestServeIMMeetingMyResponse(t *testing.T) {
}
}
func TestServeIMMeetingNotesListAndDetail(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
viewer := types.Uid(42)
expectIMBearer(t, ctrl, "token-1", 7, viewer)
expectIMBearer(t, ctrl, "token-1", 7, viewer)
oldMeetings := store.Meetings
oldMeetingNotes := store.MeetingNotes
oldUsers := store.Users
now := time.Now().UTC().Round(time.Millisecond)
meeting := types.Meeting{
MeetingID: "mtg_test",
OrganizerID: viewer,
RecorderID: viewer,
Title: "复盘会",
StartAt: now.Add(-2 * time.Hour),
EndAt: now.Add(-time.Hour),
State: types.MeetingStateEnded,
Version: 2,
CreatedAt: now.Add(-3 * time.Hour),
UpdatedAt: now,
Participants: []types.MeetingParticipant{
{MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted},
},
}
file := types.MeetingNoteFile{
NoteFileID: "mnote_test", MeetingID: "mtg_test", FileID: "file_test", UploaderID: viewer,
FileName: "file_test", MIMEType: "application/pdf", FileSize: 123, Status: types.MeetingNoteFileStatusActive,
PreviewStatus: "ready", PreviewMode: "native", Version: 1, CreatedAt: now, UpdatedAt: now,
}
store.Meetings = &testMeetingsStore{meeting: &meeting}
notes := &testMeetingNotesStore{meetings: []types.Meeting{meeting}, files: []types.MeetingNoteFile{file}}
store.MeetingNotes = notes
store.Users = nil
defer func() {
store.Meetings = oldMeetings
store.MeetingNotes = oldMeetingNotes
store.Users = oldUsers
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/meeting-notes?query=%E5%A4%8D%E7%9B%98&limit=20", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMeetingNotes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
item := items[0].(map[string]any)
if notes.query != "复盘" || item["note_status"] != types.MeetingNoteStatusAvailable || item["file_count"].(float64) != 1 || data["has_more"] != true {
t.Fatalf("unexpected meeting notes list: data=%#v store=%#v", data, notes)
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test/notes", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMMeetingResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
files := data["files"].([]any)
if data["recorder_user_id"] != viewer.UserId() || len(files) != 1 || files[0].(map[string]any)["file_id"] != "file_test" {
t.Fatalf("unexpected meeting notes detail: %#v", data)
}
}
func TestServeIMMeetingNoteFilesCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
viewer := types.Uid(42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, viewer)
mockStore.EXPECT().GetUidString().Return("note-1")
oldMeetingNotes := store.MeetingNotes
store.MeetingNotes = &testMeetingNotesStore{}
defer func() {
store.MeetingNotes = oldMeetingNotes
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings/mtg_test/notes/files", strings.NewReader(`{"file_id":"file_test"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "note-file-1")
rec := httptest.NewRecorder()
serveIMMeetingResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["note_file_id"] != "mnote_note-1" || data["file_id"] != "file_test" || data["version"].(float64) != 1 {
t.Fatalf("unexpected note file payload: %#v", data)
}
}
func TestServeIMDailyReportsCreateAndList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -3683,9 +3952,23 @@ func TestServeIMGroupList(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users
oldGroupBusiness := store.GroupBusiness
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
groupBusiness := &testGroupBusinessStore{settings: &types.GroupSettings{
Topic: "grpAlpha",
OwnerUserID: types.Uid(42),
Name: "Alpha",
AvatarFileID: "file-1",
MemberCount: 3,
Version: 1,
UpdatedAt: time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC),
}}
store.Users = users
defer func() { store.Users = oldUsers }()
store.GroupBusiness = groupBusiness
defer func() {
store.Users = oldUsers
store.GroupBusiness = oldGroupBusiness
}()
sub := types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
@@ -3694,8 +3977,8 @@ func TestServeIMGroupList(t *testing.T) {
ModeGiven: types.ModeCFull,
ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)},
}
sub.SetPublic(map[string]any{"name": "Alpha", "avatar_file_id": "file-1"})
sub.SetSubCnt(3)
sub.SetPublic(map[string]any{"name": "grpAlpha"})
sub.SetSubCnt(0)
users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups?category=owned&query=alp", nil)
@@ -3709,7 +3992,7 @@ func TestServeIMGroupList(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["my_role"] != "owner" {
if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 3 || items[0].(map[string]any)["my_role"] != "owner" {
t.Fatalf("unexpected group list response: %#v", data)
}
}
@@ -4072,7 +4355,8 @@ func TestServeIMGroupLeave(t *testing.T) {
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("test")
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
@@ -4088,6 +4372,7 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
@@ -4101,10 +4386,10 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" {
if len(items) != 1 || items[0].(map[string]any)["status"] != types.GroupInvitationStatusPending {
t.Fatalf("unexpected invitation response: %#v", data)
}
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target {
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target || groupStore.invitations[0].InvitationID != "ginv_test" {
t.Fatalf("invitation was not persisted: %#v", groupStore.invitations)
}
}
@@ -4336,6 +4621,39 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
}
}
func TestServeIMGroupAnnouncementLatestIncludesEditPermission(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{}
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.ModeCFull, ModeGiven: types.ModeCFull}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/latest", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("latest announcement status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
permissions := data["permissions"].(map[string]any)
if data["topic"] != "grpAlpha" || data["announcement_id"] != "" || permissions["can_edit"] != true {
t.Fatalf("unexpected latest announcement response: %#v", data)
}
}
func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -4414,6 +4732,8 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3)
store.Store = mockStore
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
favorites := &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7,
@@ -4427,11 +4747,36 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
CollectedAt: now,
}}}
store.Favorites = favorites
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Store = oldStore
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
}()
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.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil).Times(2)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7,
Topic: "grpAlpha",
SeqId: 3,
From: types.Uid(42).String(),
Head: types.KVMap{"type": "text"},
Content: map[string]any{"text": "hello"},
ObjHeader: types.ObjHeader{
CreatedAt: now,
},
}, nil).Times(2)
req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -4469,6 +4814,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
mockStore.EXPECT().GetUidString().Return("new-fav")
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
@@ -4482,13 +4828,23 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
Availability: "available",
CollectedAt: now,
}}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
})
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.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7,
Topic: "grpAlpha",
@@ -4514,6 +4870,104 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
}
}
func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
store.Favorites = &testFavoritesStore{}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
})
subs.EXPECT().Get(types.TenantID(7), "grpSecret", types.Uid(42), false).Return(nil, types.ErrNotFound)
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpSecret","seq":"1"}]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavorites(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 0 {
t.Fatalf("unexpected favorite counts: %#v", data)
}
failed := data["failed"].([]any)
if len(failed) != 1 || failed[0].(map[string]any)["reason"] != "NOT_FOUND" {
t.Fatalf("unexpected failed response: %#v", data)
}
}
func TestServeIMFavoriteBatchCreateNormalizesP2PUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("new-fav")
actor := types.Uid(42)
peer := types.Uid(43)
normalizedTopic := actor.P2PName(peer)
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
favorites := &testFavoritesStore{}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Favorites = favorites
store.Subs = subs
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
})
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{
TenantID: 7,
User: actor.String(),
Topic: normalizedTopic,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(3)).Return(&types.Message{
TenantID: 7,
Topic: normalizedTopic,
SeqId: 3,
From: peer.String(),
Head: types.KVMap{"type": "text"},
Content: map[string]any{"text": "hello"},
ObjHeader: types.ObjHeader{
CreatedAt: now,
},
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"`+peer.UserId()+`","seq":"3"}]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavorites(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 {
t.Fatalf("unexpected favorite counts: %#v", data)
}
if favorites.created == nil || favorites.created.SourceTopic != normalizedTopic {
t.Fatalf("favorite source topic was not normalized: %#v", favorites.created)
}
}
func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -4793,7 +5247,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
kafkaBus = &kafkaRuntime{}
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 3)}
store.Subs = subs
store.Topics = topics
store.Messages = messages
@@ -4883,6 +5337,24 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
default:
t.Fatal("expected forwarded message to be broadcast")
}
presByUser := make(map[string]*MsgServerPres)
for range 2 {
select {
case delivered := <-globals.hub.routeSrv:
if delivered.Pres == nil {
t.Fatalf("unexpected non-presence message: %#v", delivered)
}
presByUser[delivered.RcptTo] = delivered.Pres
default:
t.Fatal("expected forwarded message presence notifications")
}
}
if pres := presByUser[types.Uid(42).UserId()]; pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != targetUser.UserId() || pres.SeqId != 35 || pres.AcsActor != "" || pres.SkipTopic != targetTopic {
t.Fatalf("unexpected sender presence: %#v", pres)
}
if pres := presByUser[targetUser.UserId()]; pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != types.Uid(42).UserId() || pres.SeqId != 35 || pres.AcsActor != types.Uid(42).UserId() || pres.SkipTopic != targetTopic {
t.Fatalf("unexpected receiver presence: %#v", pres)
}
}
func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
@@ -4896,13 +5368,17 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
oldSubs := store.Subs
oldMessages := store.Messages
oldUsers := store.Users
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
store.Users = users
t.Cleanup(func() {
store.Subs = oldSubs
store.Messages = oldMessages
store.Users = oldUsers
})
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
@@ -4930,6 +5406,11 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
},
}, nil)
users.EXPECT().GetAll(types.TenantID(7), types.Uid(99)).Return([]types.User{{
ObjHeader: types.ObjHeader{Id: types.Uid(99).String()},
TenantID: 7,
Public: types.KVMap{"display_name": "后端-potter", "avatar_file_id": "avatar-99"},
}}, 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 != "grpTarget" || msg.Head["x-im-type"] != "forward_bundle" {
@@ -4939,6 +5420,16 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
if content["bundle_id"] != "fbundle_bundle-1" || content["item_count"] != 1 {
t.Fatalf("unexpected bundle content: %#v", content)
}
bundleItems := content["items"].([]map[string]any)
itemUser := bundleItems[0]["from_user"].(map[string]any)
if itemUser["user_id"] != types.Uid(99).UserId() || itemUser["display_name"] != "后端-potter" || itemUser["avatar_file_id"] != "avatar-99" {
t.Fatalf("unexpected item sender summary: %#v", itemUser)
}
previewItems := content["preview"].([]map[string]any)
previewUser := previewItems[0]["from_user"].(map[string]any)
if previewUser["user_id"] != types.Uid(99).UserId() || previewUser["display_name"] != "后端-potter" || previewUser["avatar_file_id"] != "avatar-99" {
t.Fatalf("unexpected preview sender summary: %#v", previewUser)
}
return &types.MessageSaveResult{SeqId: 23, MarkedReadBySender: true}, nil
})
@@ -5084,6 +5575,14 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
t.Fatalf("abuse reason scope mismatch: %#v", safety)
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=group_member", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMAbuseReasons(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unsupported reasons status: got %d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse",
bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`))
req.Header.Set("Authorization", "Bearer token-1")
@@ -5186,6 +5685,91 @@ func TestServeIMMessageSearchContextAndMedia(t *testing.T) {
}
}
func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
actor := types.Uid(42)
peer := types.Uid(43)
normalizedTopic := actor.P2PName(peer)
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: actor, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
store.Store = mockStore
oldSubs := store.Subs
oldMessages := store.Messages
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Store = oldStore
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{
TenantID: 7,
User: actor.String(),
Topic: normalizedTopic,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil).Times(2)
textMsg := types.Message{TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "好的"}}
imageMsg := types.Message{TenantID: 7, SeqId: 10, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}}
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "好的", "text", "", 50).Return([]types.Message{textMsg}, "", nil)
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "", "image", "", 50).Return([]types.Message{imageMsg}, "", nil)
searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/search?q=%E5%A5%BD%E7%9A%84&type=text", nil)
searchReq.Header.Set("Authorization", "Bearer token-1")
searchRec := httptest.NewRecorder()
serveIMTopicMessageAction(searchRec, searchReq)
if searchRec.Code != http.StatusOK {
t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String())
}
mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/media?type=image", nil)
mediaReq.Header.Set("Authorization", "Bearer token-1")
mediaRec := httptest.NewRecorder()
serveIMTopicMessageAction(mediaRec, mediaReq)
if mediaRec.Code != http.StatusOK {
t.Fatalf("media status: got %d body=%s", mediaRec.Code, mediaRec.Body.String())
}
}
func TestMessageSearchPayloadMatchesUseSnippetRuneOffsets(t *testing.T) {
msg := types.Message{
TenantID: 7,
Topic: "grpAlpha",
SeqId: 1,
Version: 2,
From: types.Uid(42).String(),
Head: types.KVMap{"type": "text"},
Content: map[string]any{"txt": "你们已成为好友,现在可以开始聊天了。", "fmt": []any{}},
ObjHeader: types.ObjHeader{
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
},
}
payload := messageSearchPayload(msg, "好友")
if payload["snippet"] != "你们已成为好友,现在可以开始聊天了。" {
t.Fatalf("unexpected snippet: %#v", payload["snippet"])
}
matches := payload["matches"].([]map[string]any)
if len(matches) != 1 || matches[0]["start"] != 5 || matches[0]["end"] != 7 {
t.Fatalf("unexpected matches: %#v", matches)
}
if payload["version"] != 2 {
t.Fatalf("unexpected version: %#v", payload["version"])
}
snapshot := payload["snapshot"].(map[string]any)
if snapshot["text"] != "你们已成为好友,现在可以开始聊天了。" || snapshot["version"] != 2 {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
}
func TestServeIMTopicCalls(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -6154,11 +6738,68 @@ func TestServeIMFriendsList(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "小明" {
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "小明" || items[0].(map[string]any)["display_name_source"] != "remark" {
t.Fatalf("unexpected friends list: %#v", data)
}
}
func TestServeIMFriendsListUsesUserIDSourceOnlyForFallback(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
now := time.Now().UTC().Round(time.Millisecond)
namedPeer := types.Uid(2)
unnamedPeer := types.Uid(3)
store.Relations = &testRelationsStore{friends: []types.FriendListItem{{
Friendship: types.Friendship{
TenantID: 7,
UserID: 1,
PeerID: namedPeer,
Status: types.RelationshipFriend,
Topic: types.Uid(1).P2PName(namedPeer),
CreatedAt: now,
UpdatedAt: now,
},
Peer: &types.User{ObjHeader: types.ObjHeader{Id: namedPeer.String()}, TenantID: 7, Public: map[string]any{"fn": "yang"}},
}, {
Friendship: types.Friendship{
TenantID: 7,
UserID: 1,
PeerID: unnamedPeer,
Status: types.RelationshipFriend,
Topic: types.Uid(1).P2PName(unnamedPeer),
CreatedAt: now,
UpdatedAt: now,
},
Peer: &types.User{ObjHeader: types.ObjHeader{Id: unnamedPeer.String()}, TenantID: 7, Public: map[string]any{}},
}}}
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/friends", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFriends(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 2 {
t.Fatalf("unexpected friends list length: %#v", data)
}
named := items[0].(map[string]any)
if named["display_name"] != "yang" || named["display_name_source"] != "profile" {
t.Fatalf("named friend should use profile display: %#v", named)
}
unnamed := items[1].(map[string]any)
if unnamed["display_name"] != unnamed["user_id"] || unnamed["display_name_source"] != "user_id" {
t.Fatalf("unnamed friend should use user_id fallback source: %#v", unnamed)
}
}
func TestServeIMFriendRemark(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+92
View File
@@ -60,6 +60,18 @@ var internalGroupResourceSpecs = map[string]*internalQuerySpec{
}
var internalTenantResourceSpecs = map[string]*internalQuerySpec{
"private-messages": privateMessagesInternalQuerySpec(),
"group-messages": groupMessagesInternalQuerySpec(),
"user-login-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
},
"login-failure-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
"audit-logs": {
Select: "SELECT id,request_id,actor_user_id,action,target_type,target_id,ip,user_agent,detail,created_at",
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
@@ -278,6 +290,86 @@ func groupsInternalQuerySpec() *internalQuerySpec {
}
}
func privateMessagesInternalQuerySpec() *internalQuerySpec {
rawContentType := `COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$."x-im-type"')),''),NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type')),''),CASE WHEN JSON_EXTRACT(m.content,'$.callId') IS NOT NULL THEN 'call' ELSE 'text' END)`
contentType := `CASE ` + rawContentType + ` WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE ` + rawContentType + ` END`
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.recalled_at IS NOT NULL THEN 'recalled' WHEN m.deletedat IS NOT NULL THEN 'deleted' ELSE 'normal' END`
return &internalQuerySpec{
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.` + "`from`" + ` AS CHAR) AS from_user_id,
COALESCE(su.username,'') AS sender_username,COALESCE(sp.nickname,'') AS sender_nickname,
CAST(rs.userid AS CHAR) AS to_user_id,COALESCE(ru.username,'') AS receiver_username,COALESCE(rp.nickname,'') AS receiver_nickname,
` + contentType + ` AS content_type,` + contentExcerpt + ` AS content_excerpt,
` + status + ` AS status,m.createdat AS created_at,m.updatedat AS updated_at`,
From: `FROM (SELECT * FROM im_messages WHERE topic LIKE 'p2p%') AS m
LEFT JOIN im_users AS su ON su.id=m.` + "`from`" + `
LEFT JOIN im_user_profiles AS sp ON sp.user_id=m.` + "`from`" + `
LEFT JOIN im_subscriptions AS rs ON rs.id=(SELECT MIN(s2.id) FROM im_subscriptions AS s2 WHERE s2.topic=m.topic AND s2.userid<>m.` + "`from`" + `)
LEFT JOIN im_users AS ru ON ru.id=rs.userid
LEFT JOIN im_user_profiles AS rp ON rp.user_id=rs.userid
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='private' AND a2.topic=m.topic AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"from_user_id", "m.`from`", "=", "uid"}, {"to_user_id", "rs.userid", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.createdat", ">=", "time"}, {"created_at_end", "m.createdat", "<=", "time"}},
}
}
func groupMessagesInternalQuerySpec() *internalQuerySpec {
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
return &internalQuerySpec{
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.from_user AS CHAR) AS from_user_id,
COALESCE(u.username,'') AS sender_username,COALESCE(p.nickname,'') AS sender_nickname,
COALESCE(gs.name,'') AS group_name,m.topic AS group_no,` + contentType + ` AS content_type,
` + contentExcerpt + ` AS content_excerpt,
` + status + ` AS status,COALESCE(m.reply_seqid,0) AS reply_to_seq,m.mentioned_user_ids AS at_users,
m.created_at,m.updated_at`,
From: `FROM im_group_messages AS m
LEFT JOIN im_users AS u ON u.id=m.from_user
LEFT JOIN im_user_profiles AS p ON p.user_id=m.from_user
LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"sender_user_id", "m.from_user", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.created_at", ">=", "time"}, {"created_at_end", "m.created_at", "<=", "time"}},
}
}
func serveInternalDashboardOverview(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
row, err := store.InternalAdminQueries.QueryOne(tenantID, `SELECT
(SELECT COUNT(*) FROM im_users WHERE state<>20) AS total_users,
(SELECT COUNT(*) FROM im_users WHERE state=0 AND lastseen>=UTC_DATE()) AS active_users,
(SELECT COUNT(*) FROM im_group_settings WHERE group_status='active') AS total_groups,
(SELECT COUNT(*) FROM im_topics WHERE state<>20 AND (name LIKE 'p2p%' OR name LIKE 'grp%')) AS total_conversations,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%')+(SELECT COUNT(*) FROM im_group_messages)) AS total_messages,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%' AND createdat>=UTC_DATE())+(SELECT COUNT(*) FROM im_group_messages WHERE created_at>=UTC_DATE())) AS today_messages`, nil)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalDashboardTrends(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
days := parseIMPositiveInt(req.URL.Query().Get("days"), 7)
if days > 31 {
days = 31
}
query := fmt.Sprintf(`WITH RECURSIVE dates AS (
SELECT UTC_DATE()-INTERVAL %d DAY AS day
UNION ALL SELECT day+INTERVAL 1 DAY FROM dates WHERE day<UTC_DATE()
) SELECT DATE_FORMAT(d.day,'%%Y-%%m-%%d') AS date,
(SELECT COUNT(*) FROM im_users AS u WHERE u.createdat>=d.day AND u.createdat<d.day+INTERVAL 1 DAY) AS user_count,
((SELECT COUNT(*) FROM im_messages AS pm WHERE pm.topic LIKE 'p2p%%' AND pm.createdat>=d.day AND pm.createdat<d.day+INTERVAL 1 DAY)+
(SELECT COUNT(*) FROM im_group_messages AS gm WHERE gm.created_at>=d.day AND gm.created_at<d.day+INTERVAL 1 DAY)) AS message_count
FROM dates AS d ORDER BY d.day ASC`, days-1)
result, err := store.InternalAdminQueries.Query(tenantID, query, fmt.Sprintf("SELECT %d", days), nil, 1, days)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage})
}
func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string) (string, string, []any, int, int, error) {
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200)
+19 -15
View File
@@ -99,6 +99,14 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
}
switch {
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "overview":
serveInternalDashboardOverview(wrt, req, tenant.ID)
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "trends":
serveInternalDashboardTrends(wrt, req, tenant.ID)
case len(parts) == 3 && parts[2] == "private-messages":
serveInternalAdminResourceList(wrt, req, tenant.ID, "private-messages", "")
case len(parts) == 3 && parts[2] == "group-messages":
serveInternalAdminResourceList(wrt, req, tenant.ID, "group-messages", "")
case len(parts) == 3 && parts[2] == "users":
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users":
@@ -377,25 +385,21 @@ func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenan
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
resource := "private-messages"
spec := privateMessagesInternalQuerySpec()
where := "m.topic=? AND m.seqid=?"
if strings.HasPrefix(topic, "grp") {
resource = "group-messages"
spec = groupMessagesInternalQuerySpec()
}
spec.Select += ",m.head,JSON_UNQUOTE(m.content) AS content"
row, err := store.InternalAdminQueries.QueryOne(tenantID, spec.Select+" "+spec.From+" WHERE "+where, []any{topic, seq})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": msg.Topic,
"seq_id": msg.SeqId,
"from": internalAdminUIDStringRaw(msg.From),
"client_msg_id": msg.ClientMsgID,
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"deleted_at": msg.DeletedAt,
"recalled_at": msg.RecalledAt,
"recalled_by": internalAdminUIDStringRaw(msg.RecalledBy),
"created_at": msg.CreatedAt,
"updated_at": msg.UpdatedAt,
})
row["conversation_type"] = strings.TrimSuffix(resource, "-messages")
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) {
+68 -19
View File
@@ -33,6 +33,8 @@ const (
adminCommandUserCancel = "user.account.cancel"
adminCommandUserDelete = "user.account.delete"
adminCommandUserPasswordReset = "user.password.reset"
adminCommandMessageHide = "message.hide"
adminCommandMessageDelete = "message.delete"
adminCommandKafkaDeadReplay = "kafka.dead_letter.replay"
adminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore"
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
@@ -41,22 +43,24 @@ const (
var publishKafkaDeadLetter = publishKafkaEnvelope
type adminCommandPayload struct {
CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"`
Topic string `json:"topic"`
UserID string `json:"user_id"`
UserIDs []string `json:"user_ids"`
TargetID string `json:"target_user_id"`
DeviceID string `json:"device_id"`
PasswordHash string `json:"password_hash"`
Version int `json:"version"`
Reason string `json:"reason"`
DeadID string `json:"dead_id"`
Note string `json:"note"`
Hard bool `json:"hard"`
ExpiresAt *time.Time `json:"expires_at"`
Profile groupProfilePatch `json:"profile"`
Governance groupGovernancePatch `json:"governance"`
CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"`
Topic string `json:"topic"`
ConversationType string `json:"conversation_type"`
SeqID int64 `json:"seq_id"`
UserID string `json:"user_id"`
UserIDs []string `json:"user_ids"`
TargetID string `json:"target_user_id"`
DeviceID string `json:"device_id"`
PasswordHash string `json:"password_hash"`
Version int `json:"version"`
Reason string `json:"reason"`
DeadID string `json:"dead_id"`
Note string `json:"note"`
Hard bool `json:"hard"`
ExpiresAt *time.Time `json:"expires_at"`
Profile groupProfilePatch `json:"profile"`
Governance groupGovernancePatch `json:"governance"`
}
type groupProfilePatch struct {
@@ -166,6 +170,15 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
if eventType == adminCommandUserPasswordReset {
return executeKafkaUserPasswordResetCommand(tenantID, payload)
}
if eventType == adminCommandMessageHide || eventType == adminCommandMessageDelete {
return executeKafkaMessageAdminCommand(tenantID, envelope, payload)
}
if eventType == adminCommandUserBan || eventType == adminCommandUserUnban ||
eventType == adminCommandUserFreeze || eventType == adminCommandUserUnfreeze ||
eventType == adminCommandUserDeviceKick || eventType == adminCommandUserLogoutAll ||
eventType == adminCommandUserCancel || eventType == adminCommandUserDelete {
return executeKafkaUserGovernanceCommand(tenantID, envelope, payload, types.ZeroUid)
}
actor := parseKafkaCommandUser(payload.ActorUserID)
if actor.IsZero() {
return nil, types.ErrMalformed
@@ -187,9 +200,6 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
return nil, err
}
return map[string]any{"dead_id": payload.DeadID, "status": types.KafkaDeadStatusResolved}, nil
case adminCommandUserBan, adminCommandUserUnban, adminCommandUserFreeze, adminCommandUserUnfreeze,
adminCommandUserDeviceKick, adminCommandUserLogoutAll, adminCommandUserCancel, adminCommandUserDelete:
return executeKafkaUserGovernanceCommand(tenantID, envelope, payload, actor)
}
if store.GroupBusiness == nil {
return nil, errors.New("group business store is not initialized")
@@ -320,6 +330,45 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
return nil, errors.New("unsupported admin command: " + envelope.EventType)
}
func executeKafkaMessageAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload) (map[string]any, error) {
conversationType := strings.TrimSpace(payload.ConversationType)
topic := strings.TrimSpace(payload.Topic)
if (conversationType != "private" && conversationType != "group") || topic == "" || payload.SeqID <= 0 {
return nil, types.ErrMalformed
}
action := strings.TrimPrefix(strings.TrimSpace(envelope.EventType), "message.")
actorAdminID := int64(0)
if envelope.Actor != nil {
actorAdminID, _ = strconv.ParseInt(strings.TrimSpace(envelope.Actor.ID), 10, 64)
}
now := time.Now().UTC().Round(time.Millisecond)
commandID := firstNonEmpty(payload.CommandID, envelope.EventID)
actionID := "action_" + envelope.EventID
if len(actionID) > 96 {
actionID = actionID[:96]
}
requestID := strings.TrimSpace(envelope.TraceID)
if len(requestID) > 64 {
requestID = requestID[:64]
}
if err := store.SecurityAudit.ApplyMessageAction(tenantID, &types.MessageAdminAction{
ActionID: actionID, CommandID: commandID, ConversationType: conversationType,
Topic: topic, SeqID: payload.SeqID, Action: action, Reason: strings.TrimSpace(payload.Reason),
ActorAdminID: actorAdminID, RequestID: requestID, ProcessedAt: now,
}); err != nil {
return nil, err
}
if conversationType == "group" {
if err := store.InvalidateHotMessages(tenantID, topic, []types.Range{{Low: payload.SeqID}}); err != nil {
logs.Warn.Printf("message admin cache invalidation failed tenant:%d topic:%s seq:%d err:%v", tenantID, topic, payload.SeqID, err)
}
}
return map[string]any{
"conversation_type": conversationType, "topic": topic, "seq_id": payload.SeqID,
"action": action, "status": "success", "processed_at": now,
}, nil
}
func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload, actor types.Uid) (map[string]any, error) {
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
if target.IsZero() {
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type messageAdminAuditRecorder struct {
action *types.MessageAdminAction
}
func (r *messageAdminAuditRecorder) RecordLogin(types.TenantID, *types.LoginAuditEvent) error {
return nil
}
func (r *messageAdminAuditRecorder) RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error {
return nil
}
func (r *messageAdminAuditRecorder) CloseLogin(types.TenantID, string, time.Time) error {
return nil
}
func (r *messageAdminAuditRecorder) ApplyMessageAction(_ types.TenantID, action *types.MessageAdminAction) error {
r.action = action
return nil
}
func TestExecuteKafkaMessageAdminCommand(t *testing.T) {
previous := store.SecurityAudit
recorder := &messageAdminAuditRecorder{}
store.SecurityAudit = recorder
t.Cleanup(func() { store.SecurityAudit = previous })
result, err := executeKafkaMessageAdminCommand(1, kafkaEnvelope{
EventID: "event-1", TraceID: "trace-1", EventType: adminCommandMessageHide,
Actor: &kafkaActor{Type: "admin", ID: "42", DisplayName: "operator"},
}, adminCommandPayload{
CommandID: "command-1", ConversationType: "private", Topic: "p2ptest", SeqID: 7, Reason: "sensitive content",
})
if err != nil {
t.Fatal(err)
}
if recorder.action == nil {
t.Fatal("message action was not persisted")
}
if recorder.action.ActionID != "action_event-1" || recorder.action.CommandID != "command-1" ||
recorder.action.ActorAdminID != 42 || recorder.action.Action != "hide" || recorder.action.Topic != "p2ptest" || recorder.action.SeqID != 7 {
t.Fatalf("unexpected persisted action: %+v", recorder.action)
}
if result["status"] != "success" || result["action"] != "hide" {
t.Fatalf("unexpected command result: %+v", result)
}
}
+11 -2
View File
@@ -39,6 +39,7 @@ type kafkaConfig struct {
DialTimeoutMS int `json:"dial_timeout_ms"`
WriteTimeoutMS int `json:"write_timeout_ms"`
ReadTimeoutMS int `json:"read_timeout_ms"`
BatchTimeoutMS int `json:"batch_timeout_ms"`
RequiredAcks int `json:"required_acks"`
AllowAutoTopic bool `json:"allow_auto_topic_creation"`
TLSEnabled bool `json:"tls_enabled"`
@@ -131,6 +132,7 @@ func initKafkaRuntime(raw json.RawMessage) error {
Transport: transport,
RequiredAcks: kafka.RequiredAcks(cfg.RequiredAcks),
AllowAutoTopicCreation: cfg.AllowAutoTopic,
BatchTimeout: time.Duration(cfg.BatchTimeoutMS) * time.Millisecond,
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
MaxAttempts: cfg.MaxRetryCount,
@@ -162,6 +164,9 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
if cfg.ReadTimeoutMS <= 0 {
cfg.ReadTimeoutMS = 3000
}
if cfg.BatchTimeoutMS <= 0 {
cfg.BatchTimeoutMS = 10
}
if cfg.RequiredAcks == 0 {
cfg.RequiredAcks = int(kafka.RequireOne)
}
@@ -274,9 +279,13 @@ func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
if topic == "" {
topic = topicName
}
groupID := kafkaBus.config.ConsumerGroup
if topicName != "" {
groupID += "." + topicName
}
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: kafkaBus.config.Brokers,
GroupID: kafkaBus.config.ConsumerGroup,
GroupID: groupID,
Topic: topic,
StartOffset: kafka.FirstOffset,
MinBytes: 1,
@@ -312,7 +321,7 @@ func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
}
}
}()
logs.Info.Printf("Kafka consumer started: topic=%s group=%s", topic, kafkaBus.config.ConsumerGroup)
logs.Info.Printf("Kafka consumer started: topic=%s group=%s", topic, groupID)
}
func (rt *kafkaRuntime) dialer() *kafka.Dialer {
+11
View File
@@ -30,6 +30,17 @@ func TestKafkaTopicsKeepOnlyMessageInternalAsyncTopics(t *testing.T) {
}
}
func TestNormalizeKafkaConfigUsesLowLatencyBatchTimeout(t *testing.T) {
cfg := kafkaConfig{}
normalizeKafkaConfig(&cfg)
if cfg.BatchTimeoutMS != 10 {
t.Fatalf("unexpected kafka batch timeout: %d", cfg.BatchTimeoutMS)
}
if cfg.MaxRetryCount != 3 {
t.Fatalf("unexpected kafka max retry count: %d", cfg.MaxRetryCount)
}
}
func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) {
oldKafkaBus := kafkaBus
oldPublisher := publishKafkaAuditEventEnvelope
+2
View File
@@ -46,6 +46,7 @@ import (
// Push notifications
"github.com/tinode/chat/server/push"
_ "github.com/tinode/chat/server/push/fcm"
_ "github.com/tinode/chat/server/push/jpush"
_ "github.com/tinode/chat/server/push/stdout"
_ "github.com/tinode/chat/server/push/tnpg"
@@ -827,6 +828,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/attendance/records", serveIMAttendanceRecords)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals)
mux.HandleFunc(config.ApiPath+"v1/im/meeting-notes", serveIMMeetingNotes)
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
+1 -5
View File
@@ -9,7 +9,6 @@ import (
"errors"
"hash/fnv"
"io"
"mime"
"net/http"
"net/url"
"os"
@@ -141,10 +140,7 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
}
fname := fdef.Id
ext, _ := mime.ExtensionsByType(fdef.MimeType)
if len(ext) > 0 {
fname += ext[0]
}
fname += media.ExtensionByMimeType(fdef.MimeType)
fdef.Location = location
// Use file path to create ETag. File paths are unique so will be the ETag.
+32
View File
@@ -4,6 +4,7 @@ package media
import (
"errors"
"io"
"mime"
"net/http"
"net/url"
"path"
@@ -68,6 +69,37 @@ type AllowedOrigin struct {
var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`)
// ExtensionByMimeType returns a stable URL extension for uploaded media.
func ExtensionByMimeType(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
case "audio/x-m4a", "audio/m4a", "audio/mp4", "audio/mp4a-latm":
return ".m4a"
case "audio/aac", "audio/aacp":
return ".aac"
case "audio/mpeg", "audio/mp3":
return ".mp3"
case "audio/ogg":
return ".ogg"
case "audio/wav", "audio/x-wav", "audio/wave":
return ".wav"
case "audio/webm":
return ".webm"
case "image/pjpeg", "image/jpg":
return ".jpg"
case "image/jpeg":
return ".jpg"
case "image/jfif":
return ".jfif"
case "video/mp4":
return ".mp4"
}
exts, _ := mime.ExtensionsByType(mimeType)
if len(exts) > 0 {
return exts[0]
}
return ""
}
// GetIdFromUrl is a helper method for extracting file ID from a URL.
func GetIdFromUrl(url, serveUrl string) types.Uid {
dir, fname := path.Split(path.Clean(url))
+18
View File
@@ -219,3 +219,21 @@ func TestMatchCORSOrigin(t *testing.T) {
func containsSubstring(str, substr string) bool {
return strings.Contains(strings.ToLower(str), strings.ToLower(substr))
}
func TestExtensionByMimeType(t *testing.T) {
cases := map[string]string{
"audio/x-m4a": ".m4a",
"audio/x-m4a; charset=utf": ".m4a",
"audio/mp4": ".m4a",
"audio/aac": ".aac",
"audio/mpeg": ".mp3",
"image/jpeg": ".jpg",
"image/jfif": ".jfif",
"video/mp4": ".mp4",
}
for mimeType, want := range cases {
if got := ExtensionByMimeType(mimeType); got != want {
t.Fatalf("ExtensionByMimeType(%q)=%q, want %q", mimeType, got, want)
}
}
}
+1 -5
View File
@@ -7,7 +7,6 @@ import (
"errors"
"hash/fnv"
"io"
"mime"
"net/http"
"net/url"
"path"
@@ -247,10 +246,7 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
}
fname := fdef.Id
ext, _ := mime.ExtensionsByType(fdef.MimeType)
if len(ext) > 0 {
fname += ext[0]
}
fname += media.ExtensionByMimeType(fdef.MimeType)
fdef.Location = key
fdef.ETag = etagFromLocation(key)
+7 -7
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/url"
"path"
@@ -280,22 +279,23 @@ func (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
}
rc := readerCounter{reader: file}
result, err := tmClient.UploadObject(context.Background(), &transfermanager.UploadObjectInput{
input := &transfermanager.UploadObjectInput{
CacheControl: aws.String(ah.conf.CacheControl),
Bucket: aws.String(ah.conf.BucketName),
Key: aws.String(key),
Body: &rc,
})
}
if fdef.MimeType != "" {
input.ContentType = aws.String(fdef.MimeType)
}
result, err := tmClient.UploadObject(context.Background(), input)
if err != nil {
return "", 0, err
}
fname := fdef.Id
ext, _ := mime.ExtensionsByType(fdef.MimeType)
if len(ext) > 0 {
fname += ext[0]
}
fname += media.ExtensionByMimeType(fdef.MimeType)
fdef.Location = key
if result.ETag != nil {
+14
View File
@@ -114,6 +114,7 @@
"dial_timeout_ms": 3000,
"write_timeout_ms": 3000,
"read_timeout_ms": 3000,
"batch_timeout_ms": 10,
"required_acks": 1,
"allow_auto_topic_creation": false,
"tls_enabled": false,
@@ -207,6 +208,19 @@
"config": {
"enabled": false
}
},
{
"name": "jpush",
"config": {
"enabled": false,
"api_domain": "https://api.jpush.cn",
"push_path": "/v3/push",
"default_ttl_seconds": 86400,
"call_ttl_seconds": 60,
"apns_production": false,
"use_alias": true,
"classification": 1
}
}
],
+617
View File
@@ -0,0 +1,617 @@
// Package jpush implements push notification plugin for JPush.
package jpush
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/push"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
const (
defaultBufferSize = 1024
defaultAPIDomain = "https://api.jpush.cn"
defaultPushPath = "/v3/push"
defaultTTL = 86400
defaultCallTTL = 60
)
var handler Handler
type Handler struct {
input chan *push.Receipt
channel chan *push.ChannelReq
stop chan bool
client *http.Client
config configType
}
type configType struct {
Enabled bool `json:"enabled"`
DryRun bool `json:"dry_run"`
AppKey string `json:"app_key"`
MasterSecret string `json:"master_secret"`
APIDomain string `json:"api_domain"`
PushPath string `json:"push_path"`
Buffer int `json:"buffer"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
CallTTLSeconds int `json:"call_ttl_seconds"`
APNsProduction bool `json:"apns_production"`
UseAlias *bool `json:"use_alias"`
Classification int `json:"classification"`
Title string `json:"title"`
}
type jpushPayload struct {
Platform any `json:"platform"`
Audience map[string][]string `json:"audience,omitempty"`
Notification *jpushNotification `json:"notification,omitempty"`
Message *jpushMessage `json:"message,omitempty"`
Options jpushOptions `json:"options"`
CID string `json:"cid,omitempty"`
}
type jpushNotification struct {
Alert string `json:"alert,omitempty"`
Android *androidPayload `json:"android,omitempty"`
IOS *iosPayload `json:"ios,omitempty"`
HMOS *hmosPayload `json:"hmos,omitempty"`
}
type androidPayload struct {
Alert string `json:"alert"`
Title string `json:"title,omitempty"`
Extras map[string]string `json:"extras,omitempty"`
Priority int `json:"priority,omitempty"`
}
type iosPayload struct {
Alert iosAlert `json:"alert"`
Sound string `json:"sound,omitempty"`
Badge string `json:"badge,omitempty"`
Extras map[string]string `json:"extras,omitempty"`
}
type iosAlert struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
type hmosPayload struct {
Alert string `json:"alert"`
Title string `json:"title,omitempty"`
Category string `json:"category,omitempty"`
Extras map[string]string `json:"extras,omitempty"`
}
type jpushMessage struct {
MsgContent string `json:"msg_content"`
Title string `json:"title,omitempty"`
Extras map[string]string `json:"extras,omitempty"`
}
type jpushOptions struct {
TimeToLive int `json:"time_to_live"`
APNsProduction bool `json:"apns_production"`
Classification int `json:"classification,omitempty"`
}
type jpushResponse struct {
SendNo any `json:"sendno"`
MsgID any `json:"msg_id"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
if handler.input != nil {
return false, errors.New("already initialized")
}
var config configType
if err := json.Unmarshal(jsonconf, &config); err != nil {
return false, errors.New("failed to parse config: " + err.Error())
}
loadThirdPartyConfig(&config)
if v := strings.TrimSpace(os.Getenv("IM_JPUSH_APP_KEY")); v != "" {
config.AppKey = v
config.Enabled = true
}
if v := strings.TrimSpace(os.Getenv("IM_JPUSH_MASTER_SECRET")); v != "" {
config.MasterSecret = v
config.Enabled = true
}
if v := strings.TrimSpace(os.Getenv("IM_JPUSH_API_DOMAIN")); v != "" {
config.APIDomain = v
}
if !config.Enabled {
return false, nil
}
normalized, err := normalizeConfig(config)
if err != nil {
return false, err
}
handler.input = make(chan *push.Receipt, normalized.Buffer)
handler.channel = make(chan *push.ChannelReq, normalized.Buffer)
handler.stop = make(chan bool, 1)
handler.client = &http.Client{Timeout: 10 * time.Second}
handler.config = normalized
go func() {
for {
select {
case rcpt := <-handler.input:
go sendReceipt(rcpt, &handler.config, handler.client)
case req := <-handler.channel:
logs.Info.Println("jpush: channel subscription ignored", req.Uid.UserId(), req.Channel)
case <-handler.stop:
return
}
}
}()
return true, nil
}
func normalizeConfig(config configType) (configType, error) {
config.AppKey = strings.TrimSpace(config.AppKey)
config.MasterSecret = strings.TrimSpace(config.MasterSecret)
config.APIDomain = strings.TrimRight(strings.TrimSpace(config.APIDomain), "/")
config.PushPath = strings.TrimSpace(config.PushPath)
config.Title = strings.TrimSpace(config.Title)
if config.APIDomain == "" {
config.APIDomain = defaultAPIDomain
}
if config.PushPath == "" {
config.PushPath = defaultPushPath
}
if !strings.HasPrefix(config.PushPath, "/") {
config.PushPath = "/" + config.PushPath
}
if config.Buffer <= 0 {
config.Buffer = defaultBufferSize
}
if config.DefaultTTLSeconds <= 0 {
config.DefaultTTLSeconds = defaultTTL
}
if config.CallTTLSeconds <= 0 {
config.CallTTLSeconds = defaultCallTTL
}
if config.Title == "" {
config.Title = "MeetingIM"
}
if config.AppKey == "" || config.MasterSecret == "" {
return config, errors.New("jpush: missing app_key or master_secret")
}
return config, nil
}
func sendReceipt(rcpt *push.Receipt, config *configType, client *http.Client) {
payloads, uids := preparePayloads(rcpt, config)
for i := range payloads {
if config.DryRun {
logs.Info.Println("jpush dry run:", uids[i].UserId(), payloads[i].CID)
continue
}
if err := sendPayload(payloads[i], config, client); err != nil {
logs.Warn.Println("jpush push failed:", uids[i].UserId(), err)
return
}
}
}
func sendPayload(payload *jpushPayload, config *configType, client *http.Client) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, config.APIDomain+config.PushPath, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(config.AppKey+":"+config.MasterSecret)))
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var parsed jpushResponse
if len(respBody) > 0 {
if err = json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
if parsed.Error != nil {
return fmt.Errorf("jpush error %d: %s", parsed.Error.Code, parsed.Error.Message)
}
if parsed.MsgID != nil && logs.Info != nil {
logs.Info.Println("jpush sent:", parsed.MsgID)
}
return nil
}
func loadThirdPartyConfig(config *configType) {
if config == nil || store.ThirdPartyConfig == nil {
return
}
values, err := store.ThirdPartyConfig.Get("im_jpush",
"jpush_enabled",
"jpush_app_key",
"jpush_master_secret",
"jpush_api_domain",
"jpush_push_path",
"jpush_default_ttl_seconds",
"jpush_call_ttl_seconds",
"jpush_apns_production",
"jpush_use_alias",
"jpush_classification",
"jpush_title",
)
if err != nil || len(values) == 0 {
return
}
if v, ok := parseConfigBool(values["jpush_enabled"]); ok {
config.Enabled = v
}
if v := strings.TrimSpace(values["jpush_app_key"]); v != "" {
config.AppKey = v
}
if v := strings.TrimSpace(values["jpush_master_secret"]); v != "" {
config.MasterSecret = v
}
if v := strings.TrimSpace(values["jpush_api_domain"]); v != "" {
config.APIDomain = v
}
if v := strings.TrimSpace(values["jpush_push_path"]); v != "" {
config.PushPath = v
}
if v, ok := parseConfigInt(values["jpush_default_ttl_seconds"]); ok {
config.DefaultTTLSeconds = v
}
if v, ok := parseConfigInt(values["jpush_call_ttl_seconds"]); ok {
config.CallTTLSeconds = v
}
if v, ok := parseConfigBool(values["jpush_apns_production"]); ok {
config.APNsProduction = v
}
if v, ok := parseConfigBool(values["jpush_use_alias"]); ok {
config.UseAlias = &v
}
if v, ok := parseConfigInt(values["jpush_classification"]); ok {
config.Classification = v
}
if v := strings.TrimSpace(values["jpush_title"]); v != "" {
config.Title = v
}
}
func parseConfigBool(value string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on", "enabled":
return true, true
case "0", "false", "no", "off", "disabled":
return false, true
default:
return false, false
}
}
func parseConfigInt(value string) (int, bool) {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
return parsed, err == nil
}
func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, []t.Uid) {
if rcpt == nil || config == nil {
return nil, nil
}
data, err := pushData(rcpt.Payload)
if err != nil {
logs.Warn.Println("jpush: could not build payload:", err)
return nil, nil
}
useAlias := true
if config.UseAlias != nil {
useAlias = *config.UseAlias
}
uids := sortedRecipients(rcpt.To)
if len(uids) == 0 {
return nil, nil
}
var devices map[t.Uid][]t.DeviceDef
if !useAlias {
devices, _, err = providerDevicesForUsers(rcpt.TenantID, "jpush", uids...)
if err != nil {
logs.Warn.Println("jpush: db error", err)
return nil, nil
}
}
var payloads []*jpushPayload
var targets []t.Uid
for _, uid := range uids {
recipient := rcpt.To[uid]
if recipient.Delivered > 0 {
continue
}
userData := cloneData(data)
topic := rcpt.Payload.Topic
if t.GetTopicCat(topic) == t.TopicCatP2P {
if alias, err := t.P2PNameForUser(uid, topic); err == nil {
topic = alias
userData["topic"] = alias
}
}
var audience map[string][]string
if useAlias {
audience = map[string][]string{"alias": {uid.UserId()}}
} else {
var registrationIDs []string
for _, device := range devices[uid] {
if device.DeviceId != "" && !containsString(recipient.Devices, device.DeviceId) {
registrationIDs = append(registrationIDs, device.DeviceId)
}
}
if len(registrationIDs) == 0 {
continue
}
audience = map[string][]string{"registration_id": registrationIDs}
}
payloads = append(payloads, buildPayload(audience, topic, userData, recipient.Unread, config))
targets = append(targets, uid)
}
return payloads, targets
}
func pushData(pl push.Payload) (map[string]string, error) {
data := map[string]string{
"what": pl.What,
"type": "im_message",
"topic": pl.Topic,
"ts": pl.Timestamp.Format(time.RFC3339Nano),
}
if pl.From != "" {
data["sender_id"] = pl.From
data["xfrom"] = pl.From
}
if pl.Silent {
data["silent"] = "true"
}
switch pl.What {
case push.ActMsg:
data["seq"] = strconv.FormatInt(pl.SeqId, 10)
if pl.ContentType != "" {
data["mime"] = pl.ContentType
data["message_type"] = messageTypeFromMime(pl.ContentType)
}
if text := payloadContentString(pl.Content, "text"); text != "" {
data["content"] = truncateRunes(text, push.MaxPayloadLength)
}
if pl.Webrtc != "" {
data["webrtc"] = pl.Webrtc
if callID := payloadContentString(pl.Content, "callId"); callID != "" {
data["call_id"] = callID
}
if callType := payloadContentString(pl.Content, "callType"); callType == "audio" || callType == "video" {
data["call_type"] = callType
}
data["type"] = "call_invite"
if pl.Webrtc != "started" {
data["type"] = "call_missed"
}
if pl.AudioOnly || data["call_type"] == "audio" {
data["call_type"] = "audio"
data["aonly"] = "true"
} else if data["call_type"] == "" {
data["call_type"] = "video"
}
if pl.From != "" {
data["caller_id"] = pl.From
}
}
if pl.Replace != "" {
data["replace"] = pl.Replace
data["silent"] = "true"
}
case push.ActSub:
data["type"] = "system_notification"
data["modeWant"] = pl.ModeWant.String()
data["modeGiven"] = pl.ModeGiven.String()
case push.ActRead:
data["type"] = "read"
data["seq"] = strconv.FormatInt(pl.SeqId, 10)
data["silent"] = "true"
default:
return nil, errors.New("unknown push type")
}
return data, nil
}
func buildPayload(audience map[string][]string, topic string, data map[string]string, unread int, config *configType) *jpushPayload {
alert, title := alertText(data, config)
ttl := config.DefaultTTLSeconds
if data["type"] == "call_invite" {
ttl = config.CallTTLSeconds
}
payload := &jpushPayload{
Platform: "all",
Audience: audience,
Options: jpushOptions{
TimeToLive: ttl,
APNsProduction: config.APNsProduction,
Classification: config.Classification,
},
CID: cidFor(topic, data),
}
if data["silent"] == "true" || data["type"] == "read" {
payload.Message = &jpushMessage{MsgContent: alert, Title: title, Extras: data}
return payload
}
badge := "+1"
if unread > 0 {
badge = strconv.Itoa(unread)
}
payload.Notification = &jpushNotification{
Alert: alert,
Android: &androidPayload{
Title: title,
Alert: alert,
Extras: data,
Priority: 1,
},
IOS: &iosPayload{
Alert: iosAlert{Title: title, Body: alert},
Sound: "default",
Badge: badge,
Extras: data,
},
HMOS: &hmosPayload{
Title: title,
Alert: alert,
Category: "IM",
Extras: data,
},
}
return payload
}
func alertText(data map[string]string, config *configType) (string, string) {
title := config.Title
switch data["type"] {
case "call_invite":
if data["call_type"] == "audio" {
return "收到一通语音通话", "语音通话"
}
return "收到一通视频通话", "视频通话"
case "call_missed":
return "你有一通未接来电", "未接来电"
case "system_notification":
return "你收到一条系统通知", title
default:
return "你收到一条新消息", title
}
}
func cidFor(topic string, data map[string]string) string {
parts := []string{data["type"], topic, data["seq"], data["replace"]}
return strings.Trim(strings.Join(parts, "_"), "_")
}
func messageTypeFromMime(mime string) string {
switch {
case strings.Contains(mime, "image"):
return "image"
case strings.Contains(mime, "audio"):
return "voice"
case strings.Contains(mime, "video"):
return "video"
default:
return "text"
}
}
func payloadContentString(content any, key string) string {
if content == nil {
return ""
}
if values, ok := content.(map[string]any); ok {
if value, ok := values[key].(string); ok {
return strings.TrimSpace(value)
}
}
if values, ok := content.(map[string]string); ok {
return strings.TrimSpace(values[key])
}
return ""
}
func truncateRunes(value string, limit int) string {
if limit <= 0 || len(value) <= limit {
return value
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit]) + "…"
}
func sortedRecipients(recipients map[t.Uid]push.Recipient) []t.Uid {
uids := make([]t.Uid, 0, len(recipients))
for uid := range recipients {
if !uid.IsZero() {
uids = append(uids, uid)
}
}
for i := 1; i < len(uids); i++ {
for j := i; j > 0 && uids[j].String() < uids[j-1].String(); j-- {
uids[j], uids[j-1] = uids[j-1], uids[j]
}
}
return uids
}
func cloneData(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func providerDevicesForUsers(tenantID t.TenantID, provider string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenProviderQueryInterface); ok {
return tokens.GetAllByProvider(tenantID, provider, uids...)
}
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
func (Handler) IsReady() bool {
return handler.input != nil
}
func (Handler) Push() chan<- *push.Receipt {
return handler.input
}
func (Handler) Channel() chan<- *push.ChannelReq {
return handler.channel
}
func (Handler) Stop() {
if handler.stop != nil {
handler.stop <- true
}
handler.input = nil
handler.channel = nil
handler.stop = nil
}
func init() {
push.Register("jpush", &handler)
}
+209
View File
@@ -0,0 +1,209 @@
package jpush
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/tinode/chat/server/push"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testThirdPartyConfigStore map[string]string
func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]string, error) {
result := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s[key]; ok {
result[key] = value
}
}
return result, nil
}
type testDevicePushTokenStore struct {
devices map[types.Uid][]types.DeviceDef
}
func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil
}
func (s *testDevicePushTokenStore) GetAllByProvider(types.TenantID, string, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
count := 0
for _, devices := range s.devices {
count += len(devices)
}
return s.devices, count, nil
}
func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) {
oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = testThirdPartyConfigStore{
"jpush_enabled": "1",
"jpush_app_key": "app-key",
"jpush_master_secret": "master",
"jpush_api_domain": "https://push.example.com/",
"jpush_push_path": "v3/push",
"jpush_default_ttl_seconds": "123",
"jpush_call_ttl_seconds": "45",
"jpush_use_alias": "false",
"jpush_classification": "1",
}
defer func() { store.ThirdPartyConfig = oldConfig }()
var config configType
loadThirdPartyConfig(&config)
config, err := normalizeConfig(config)
if err != nil {
t.Fatalf("normalize config: %v", err)
}
if !config.Enabled || config.AppKey != "app-key" || config.MasterSecret != "master" {
t.Fatalf("unexpected credentials: %#v", config)
}
if config.APIDomain != "https://push.example.com" || config.PushPath != "/v3/push" {
t.Fatalf("unexpected endpoint: %#v", config)
}
if config.DefaultTTLSeconds != 123 || config.CallTTLSeconds != 45 || config.UseAlias == nil || *config.UseAlias {
t.Fatalf("unexpected ttl/use_alias: %#v", config)
}
}
func TestPreparePayloadsUsesAliasAndNormalizesP2PTopic(t *testing.T) {
caller := types.Uid(42)
callee := types.Uid(43)
topic := caller.P2PName(callee)
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
payloads, uids := preparePayloads(&push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{
callee: {Unread: 3},
},
Payload: push.Payload{
What: push.ActMsg,
Topic: topic,
From: caller.UserId(),
SeqId: 12,
ContentType: "text/plain",
Content: map[string]any{"text": "hello"},
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
},
}, config)
if len(payloads) != 1 || len(uids) != 1 || uids[0] != callee {
t.Fatalf("unexpected payload count: %d %#v", len(payloads), uids)
}
payload := payloads[0]
if got := payload.Audience["alias"][0]; got != callee.UserId() {
t.Fatalf("unexpected alias: %s", got)
}
userTopic, _ := types.P2PNameForUser(callee, topic)
if got := payload.Notification.Android.Extras["topic"]; got != userTopic {
t.Fatalf("topic was not normalized for callee: got %s want %s", got, userTopic)
}
if payload.Notification.IOS.Badge != "3" {
t.Fatalf("unexpected badge: %s", payload.Notification.IOS.Badge)
}
}
func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
oldTokens := store.DevicePushTokens
uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
uid: {
{DeviceId: "online-token", Platform: "ios"},
{DeviceId: "offline-token", Platform: "android"},
},
}}
defer func() { store.DevicePushTokens = oldTokens }()
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias}
payloads, _ := preparePayloads(&push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{
uid: {Devices: []string{"online-token"}},
},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}, config)
if len(payloads) != 1 {
t.Fatalf("unexpected payload count: %d", len(payloads))
}
got := payloads[0].Audience["registration_id"]
if len(got) != 1 || got[0] != "offline-token" {
t.Fatalf("unexpected registration ids: %#v", got)
}
}
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
"type": "call_invite",
"topic": "p2pTopic",
"seq": "30",
"call_type": "audio",
}, 0, config)
if payload.Options.TimeToLive != 60 {
t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive)
}
if payload.Notification.Android.Title != "语音通话" {
t.Fatalf("unexpected title: %s", payload.Notification.Android.Title)
}
}
func TestPushDataExtractsCallFields(t *testing.T) {
data, err := pushData(push.Payload{
What: push.ActMsg,
Topic: "p2pTopic",
From: "usrCaller",
SeqId: 30,
Webrtc: "started",
Content: map[string]any{
"callId": "call_1",
"callType": "audio",
},
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("push data: %v", err)
}
if data["type"] != "call_invite" || data["call_id"] != "call_1" || data["call_type"] != "audio" || data["caller_id"] != "usrCaller" {
t.Fatalf("unexpected call data: %#v", data)
}
}
func TestSendPayloadUsesBasicAuth(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if r.URL.Path != "/v3/push" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var payload jpushPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.Audience["alias"][0] != "usr1" {
t.Fatalf("unexpected audience: %#v", payload.Audience)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"sendno":"0","msg_id":"1"}`))
}))
defer srv.Close()
config := &configType{AppKey: "key", MasterSecret: "secret", APIDomain: srv.URL, PushPath: "/v3/push"}
err := sendPayload(&jpushPayload{
Platform: "all",
Audience: map[string][]string{"alias": {"usr1"}},
Message: &jpushMessage{MsgContent: "hello"},
Options: jpushOptions{TimeToLive: 60},
}, config, srv.Client())
if err != nil {
t.Fatalf("send payload: %v", err)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("key:secret"))
if gotAuth != wantAuth {
t.Fatalf("unexpected auth: got %s want %s", gotAuth, wantAuth)
}
}
+60 -22
View File
@@ -461,6 +461,9 @@ func (s *Session) purgeChannels() {
func (s *Session) cleanUp(expired bool) {
atomic.StoreInt32(&s.terminating, 1)
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.SecurityAudit.CloseLogin(s.tenantID, s.sid, time.Now().UTC().Round(time.Millisecond)); err != nil {
logs.Warn.Println("s.cleanUp: failed to close login audit", s.uid, err, s.sid)
}
if err := store.MarkUserOffline(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.cleanUp: failed to mark user offline", s.uid, err, s.sid)
}
@@ -498,37 +501,50 @@ func (s *Session) dispatchRaw(raw []byte) {
return
}
toLog := raw
truncated := ""
if len(raw) > 512 {
toLog = raw[:512]
truncated = "<...>"
}
logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
if err := json.Unmarshal(raw, &msg); err != nil {
// Malformed message
logs.Warn.Println("s.dispatch", err, s.sid)
s.queueOut(ErrMalformed("", "", now))
return
}
toLog := redactClientMessageForLog(raw)
truncated := ""
if len(toLog) > 512 {
toLog = toLog[:512]
truncated = "<...>"
}
logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
s.dispatch(&msg)
}
func redactClientMessageForLog(raw []byte) []byte {
var payload map[string]any
if json.Unmarshal(raw, &payload) != nil {
return []byte("[malformed payload]")
}
for _, section := range []string{"login", "acc"} {
body, ok := payload[section].(map[string]any)
if !ok {
continue
}
for _, field := range []string{"secret", "tmpsecret"} {
if _, exists := body[field]; exists {
body[field] = "[REDACTED]"
}
}
}
redacted, err := json.Marshal(payload)
if err != nil {
return []byte("[unprintable payload]")
}
return redacted
}
func (s *Session) dispatch(msg *ClientComMessage) {
now := types.TimeNow()
atomic.StoreInt64(&s.lastAction, now.UnixNano())
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.RefreshUserPresence(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh user presence", s.uid, err, s.sid)
}
for _, topic := range s.groupSubs() {
if err := store.MarkTopicMemberOnline(s.tenantID, topic, s.uid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh group member presence", topic, s.uid, err, s.sid)
}
}
}
s.refreshPresence("s.dispatch")
// This should be the first block here, before any other checks.
var resp *ServerComMessage
@@ -701,6 +717,20 @@ func (s *Session) dispatch(msg *ClientComMessage) {
}
}
func (s *Session) refreshPresence(context string) {
if s.uid.IsZero() || !s.tenantID.IsValid() {
return
}
if err := store.RefreshUserPresence(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println(context+": failed to refresh user presence", s.uid, err, s.sid)
}
for _, topic := range s.groupSubs() {
if err := store.MarkTopicMemberOnline(s.tenantID, topic, s.uid, currentNodeID()); err != nil {
logs.Warn.Println(context+": failed to refresh group member presence", topic, s.uid, err, s.sid)
}
}
}
// Request to subscribe to a topic.
func (s *Session) subscribe(msg *ClientComMessage) {
if strings.HasPrefix(msg.Original, "new") || strings.HasPrefix(msg.Original, "nch") {
@@ -898,8 +928,8 @@ func (s *Session) hello(msg *ClientComMessage) {
if len(globals.iceServers) > 0 {
params["iceServers"] = globals.iceServers
}
if globals.callEstablishmentTimeout > 0 {
params["callTimeout"] = globals.callEstablishmentTimeout
if timeout := imCallTimeoutSeconds(); timeout > 0 {
params["callTimeout"] = timeout
}
if s.proto == GRPC {
@@ -1084,6 +1114,7 @@ func (s *Session) login(msg *ClientComMessage) {
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
if handler == nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp)
logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid)
s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
return
@@ -1092,6 +1123,7 @@ func (s *Session) login(msg *ClientComMessage) {
rec, challenge, err := handler.Authenticate(
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
if resp.Ctrl.Code >= 500 {
// Log internal errors
@@ -1102,6 +1134,7 @@ func (s *Session) login(msg *ClientComMessage) {
}
if rec == nil || rec.TenantID != s.tenantID {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, types.ErrFailed, msg.Timestamp)
logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid)
s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
return
@@ -1116,6 +1149,7 @@ func (s *Session) login(msg *ClientComMessage) {
}
if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
return
@@ -1137,10 +1171,11 @@ func (s *Session) login(msg *ClientComMessage) {
}
}
if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
} else {
s.queueOut(s.onLogin(msg.Id, msg.Timestamp, rec, missing))
s.queueOut(s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Login.Scheme, strings.SplitN(string(msg.Login.Secret), ":", 2)[0]))
}
}
@@ -1204,7 +1239,7 @@ func (s *Session) authSecretReset(params []byte) error {
}
// onLogin performs steps after successful authentication.
func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, missing []string) *ServerComMessage {
func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, missing []string, scheme, account string) *ServerComMessage {
var reply *ServerComMessage
var params map[string]any
@@ -1261,6 +1296,9 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
logs.Warn.Println("failed to update device record", err)
}
}
if !s.uid.IsZero() {
recordSessionLoginSuccess(s, rec, scheme, account, msgID, timestamp)
}
}
// GenSecret fails only if tokenLifetime is < 0. It can't be < 0 here,
+4 -1
View File
@@ -410,7 +410,10 @@ func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string
}
seqKey := r.seqKey(tenantID, topic)
seq, err := r.client.Eval(context.Background(), `
if redis.call("EXISTS", KEYS[1]) == 0 then
local current = redis.call("GET", KEYS[1])
if not current then
redis.call("SET", KEYS[1], ARGV[1])
elseif tonumber(current) < tonumber(ARGV[1]) then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCR", KEYS[1])
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type SecurityAuditPersistenceInterface interface {
RecordLogin(types.TenantID, *types.LoginAuditEvent) error
RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error
CloseLogin(types.TenantID, string, time.Time) error
ApplyMessageAction(types.TenantID, *types.MessageAdminAction) error
}
type securityAuditMapper struct{}
var SecurityAudit SecurityAuditPersistenceInterface = securityAuditMapper{}
func tenantSecurityAuditAdapter(tenantID types.TenantID) (adapter.TenantSecurityAuditAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
audit, ok := business.(adapter.TenantSecurityAuditAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return audit, nil
}
func (securityAuditMapper) RecordLogin(tenantID types.TenantID, event *types.LoginAuditEvent) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantLoginAuditCreate(tenantID, event)
}
func (securityAuditMapper) RecordLoginFailure(tenantID types.TenantID, event *types.LoginFailureEvent) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantLoginFailureCreate(tenantID, event)
}
func (securityAuditMapper) CloseLogin(tenantID types.TenantID, sessionID string, at time.Time) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantLoginAuditClose(tenantID, sessionID, at)
}
func (securityAuditMapper) ApplyMessageAction(tenantID types.TenantID, action *types.MessageAdminAction) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantMessageAdminApply(tenantID, action)
}
+139 -4
View File
@@ -2231,9 +2231,19 @@ type MeetingsPersistenceInterface interface {
Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error)
}
type MeetingNotesPersistenceInterface interface {
List(tenantID types.TenantID, uid types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error)
ListFiles(tenantID types.TenantID, uid types.Uid, meetingID string) ([]types.MeetingNoteFile, error)
CreateFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error)
ReplaceFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error)
DeleteFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error)
}
type meetingsMapper struct{}
type meetingNotesMapper struct{}
var Meetings MeetingsPersistenceInterface
var MeetingNotes MeetingNotesPersistenceInterface
func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) {
if uid.IsZero() || meeting == nil || !validMeetingForCreate(meeting) {
@@ -2315,6 +2325,64 @@ func (meetingsMapper) Respond(tenantID types.TenantID, uid types.Uid, meetingID,
return tenantAdp.TenantMeetingRespond(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(response), version)
}
func (meetingNotesMapper) List(tenantID types.TenantID, uid types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantMeetingNoteList(tenantID, uid, strings.TrimSpace(query), strings.TrimSpace(cursor), limit)
}
func (meetingNotesMapper) ListFiles(tenantID types.TenantID, uid types.Uid, meetingID string) ([]types.MeetingNoteFile, error) {
if uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantMeetingNoteFiles(tenantID, uid, strings.TrimSpace(meetingID))
}
func (meetingNotesMapper) CreateFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) {
if uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantMeetingNoteFileCreate(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(noteFileID), strings.TrimSpace(fileID), strings.TrimSpace(idempotencyKey))
}
func (meetingNotesMapper) ReplaceFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) {
if uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || version <= 0 {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantMeetingNoteFileReplace(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(noteFileID), strings.TrimSpace(fileID), strings.TrimSpace(idempotencyKey), version)
}
func (meetingNotesMapper) DeleteFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) {
if uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || version <= 0 {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantMeetingNoteFileDelete(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(noteFileID), strings.TrimSpace(idempotencyKey), version)
}
func validMeetingForCreate(meeting *types.Meeting) bool {
return strings.TrimSpace(meeting.MeetingID) != "" &&
strings.TrimSpace(meeting.Title) != "" &&
@@ -2324,7 +2392,7 @@ func validMeetingForCreate(meeting *types.Meeting) bool {
}
func validMeetingPatch(patch types.MeetingPatch) bool {
if patch.Title == nil && patch.StartAt == nil && patch.EndAt == nil && patch.Location == nil && patch.ReminderMinutes == nil {
if patch.Title == nil && patch.RecorderID == nil && patch.StartAt == nil && patch.EndAt == nil && patch.Location == nil && patch.ReminderMinutes == nil {
return false
}
if patch.Title != nil && strings.TrimSpace(*patch.Title) == "" {
@@ -2606,6 +2674,10 @@ type DevicePushTokenQueryInterface interface {
GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
}
type DevicePushTokenProviderQueryInterface interface {
GetAllByProvider(tenantID types.TenantID, provider string, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
}
type devicePushTokenMapper struct{}
var DevicePushTokens DevicePushTokenPersistenceInterface
@@ -2636,6 +2708,24 @@ func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid)
return reader.TenantDevicePushTokenGetAll(tenantID, uids...)
}
func (devicePushTokenMapper) GetAllByProvider(tenantID types.TenantID, provider string, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return map[types.Uid][]types.DeviceDef{}, 0, nil
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, 0, err
}
reader, ok := tenantAdp.(interface {
TenantDevicePushTokenGetAllByProvider(types.TenantID, string, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
})
if !ok {
return map[types.Uid][]types.DeviceDef{}, 0, nil
}
return reader.TenantDevicePushTokenGetAllByProvider(tenantID, provider, uids...)
}
type SafetyPersistenceInterface interface {
CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error)
ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error)
@@ -3351,9 +3441,6 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
err = tenantAdp.TenantGroupMessageSave(tenantID, msg)
} else {
err = tenantAdp.TenantMessageSave(tenantID, msg)
if err == types.ErrDuplicate && clientMsgID == "" {
err = retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg)
}
}
if err != nil {
if isGroupMessage && err == types.ErrDuplicate && clientMsgID != "" {
@@ -3369,9 +3456,30 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
if !isGroupMessage && err == types.ErrDuplicate {
if clientMsgID != "" {
duplicate, dupErr := tenantAdp.TenantMessageGetByClientID(tenantID, fromUid, clientMsgID)
if dupErr != nil {
return nil, dupErr
}
if duplicate != nil {
copyPersistedMessage(msg, duplicate)
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg); retryErr != nil {
return nil, retryErr
}
err = nil
}
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
if err != nil {
return nil, err
}
}
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return nil, err
}
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
@@ -3435,6 +3543,19 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: markedReadBySender}, nil
}
func copyPersistedMessage(dst, src *types.Message) {
dst.ObjHeader = src.ObjHeader
dst.DeletedAt = src.DeletedAt
dst.DelId = src.DelId
dst.DeletedFor = src.DeletedFor
dst.SeqId = src.SeqId
dst.Topic = src.Topic
dst.From = src.From
dst.ClientMsgID = src.ClientMsgID
dst.Head = src.Head
dst.Content = src.Content
}
func PrepareMessagePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
return Messages.PreparePersist(tenantID, msg, attachmentURLs, readBySender)
}
@@ -4137,6 +4258,7 @@ type UserSecurityPersistenceInterface interface {
CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error)
CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error)
CountUnreadSystemNotificationsByEventPrefix(tenantID types.TenantID, uid types.Uid, eventPrefix string) (int, error)
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error
}
@@ -4300,6 +4422,17 @@ func (userSecurityMapper) CountUnreadSystemNotifications(tenantID types.TenantID
return tenantAdp.TenantSystemNotificationUnreadCount(tenantID, uid)
}
func (userSecurityMapper) CountUnreadSystemNotificationsByEventPrefix(tenantID types.TenantID, uid types.Uid, eventPrefix string) (int, error) {
if uid.IsZero() || strings.TrimSpace(eventPrefix) == "" {
return 0, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantSystemNotificationUnreadCountByEventPrefix(tenantID, uid, eventPrefix)
}
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
@@ -4581,6 +4714,7 @@ func init() {
RegistrationConfig = registrationConfigMapper{}
TenantEntry = tenantEntryMapper{}
InternalAdminQueries = internalAdminQueryMapper{}
SecurityAudit = securityAuditMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
@@ -4592,6 +4726,7 @@ func init() {
Organization = organizationMapper{}
Attendance = attendanceMapper{}
Meetings = meetingsMapper{}
MeetingNotes = meetingNotesMapper{}
DailyReports = dailyReportsMapper{}
MonthlyReports = monthlyReportsMapper{}
Mailbox = mailboxMapper{}
+59
View File
@@ -0,0 +1,59 @@
package types
import "time"
type LoginAuditEvent struct {
UserID Uid
Username string
AccountType string
AccountHash string
AccountMask string
LoginType string
EventType string
SessionID string
DeviceID string
Platform string
AppVersion string
IP string
Region string
UserAgent string
RiskLevel string
RequestID string
LoginAt time.Time
LogoutAt *time.Time
Metadata map[string]any
}
type LoginFailureEvent struct {
UserID Uid
AccountType string
AccountHash string
AccountMask string
FailureType string
Reason string
Detail string
IP string
Region string
DeviceID string
Platform string
AppVersion string
UserAgent string
RiskLevel string
RiskAction string
RequestID string
AttemptAt time.Time
Metadata map[string]any
}
type MessageAdminAction struct {
ActionID string
CommandID string
ConversationType string
Topic string
SeqID int64
Action string
Reason string
ActorAdminID int64
RequestID string
ProcessedAt time.Time
}
+2 -1
View File
@@ -12,7 +12,8 @@ const (
GroupMemberStatusActive = "active"
GroupMemberStatusLeft = "left"
GroupInvitationStatusJoined = "joined"
GroupInvitationStatusPending = "pending"
GroupInvitationStatusJoined = "joined"
GroupJoinRequestStatusPending = "pending"
GroupJoinRequestStatusAccepted = "accepted"
+33
View File
@@ -4,6 +4,7 @@ import "time"
const (
MeetingStateScheduled = "scheduled"
MeetingStateEnded = "ended"
MeetingStateCanceled = "canceled"
MeetingResponsePending = "pending"
@@ -16,6 +17,7 @@ type Meeting struct {
ID int64
MeetingID string
OrganizerID Uid
RecorderID Uid
Title string
StartAt time.Time
EndAt time.Time
@@ -42,9 +44,40 @@ type MeetingParticipant struct {
type MeetingPatch struct {
Title *string
RecorderID *Uid
StartAt *time.Time
EndAt *time.Time
Location *string
ReminderMinutes []int
Version int
}
const (
MeetingNoteStatusEmpty = "empty"
MeetingNoteStatusAvailable = "available"
MeetingNoteFileStatusActive = "active"
MeetingNoteFileStatusDeleted = "deleted"
)
type MeetingNoteFile struct {
ID int64
NoteFileID string
MeetingID string
FileID string
UploaderID Uid
FileName string
MIMEType string
FileSize int64
FileETag string
Status string
PreviewStatus string
PreviewMode string
PreviewFileID string
IdempotencyKey string
Version int
DeletedBy Uid
DeletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
+1
View File
@@ -5,6 +5,7 @@ import "time"
const (
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
PublicUploadPurposeUserAvatar = "user_avatar"
PublicUploadPurposeGroupAvatar = "group_avatar"
TenantApplicationStatusReceived = "received"
+14
View File
@@ -114,6 +114,7 @@
"dial_timeout_ms": 3000,
"write_timeout_ms": 3000,
"read_timeout_ms": 3000,
"batch_timeout_ms": 10,
"required_acks": 1,
"allow_auto_topic_creation": false,
"tls_enabled": false,
@@ -207,6 +208,19 @@
"config": {
"enabled": false
}
},
{
"name": "jpush",
"config": {
"enabled": false,
"api_domain": "https://api.jpush.cn",
"push_path": "/v3/push",
"default_ttl_seconds": 86400,
"call_ttl_seconds": 60,
"apns_production": false,
"use_alias": true,
"classification": 1
}
}
],
+10 -3
View File
@@ -1218,7 +1218,7 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil
if isCall {
if len(globals.iceServers) == 0 {
if !imCallFeatureEnabled() {
msg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow()))
return
}
@@ -1227,8 +1227,15 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
return
}
if t.currentCall != nil {
msg.sess.queueOut(ErrCallBusyReply(msg, types.TimeNow()))
return
now := types.TimeNow()
if t.currentCall.isStaleUnanswered(now, imCallTimeoutSeconds()) {
logs.Warn.Printf("topic[%s]: clearing stale unanswered call seq=%d call_id=%s before new invite", t.name, t.currentCall.seq, t.currentCall.callID)
t.terminateCallInProgress(true)
}
if t.currentCall != nil {
msg.sess.queueOut(ErrCallBusyWithCallReply(msg, now, t.currentCall))
return
}
}
head, content, err := normalizeCallInvite(msg.Pub.Head, msg.Pub.Content)
if err != nil {
+38 -3
View File
@@ -375,9 +375,18 @@ func TestHandleBroadcastCall(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
oldThirdParty := store.ThirdPartyConfig
globals.iceServers = []iceServer{{Username: "dummy"}}
store.ThirdPartyConfig = testThirdPartyConfigStore{
"audio_call_enabled": "1",
"video_call_enabled": "1",
}
helper.topic.lastID = 5
defer helper.tearDown()
defer func() {
globals.iceServers = nil
store.ThirdPartyConfig = oldThirdParty
helper.tearDown()
}()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
@@ -401,8 +410,6 @@ func TestHandleBroadcastCall(t *testing.T) {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
globals.iceServers = nil
// Message uid1 -> uid2.
for i, m := range helper.results {
if i == 0 {
@@ -490,9 +497,15 @@ func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
oldThirdParty := store.ThirdPartyConfig
globals.iceServers = []iceServer{{Username: "dummy"}}
store.ThirdPartyConfig = testThirdPartyConfigStore{
"audio_call_enabled": "1",
"video_call_enabled": "1",
}
defer func() {
globals.iceServers = nil
store.ThirdPartyConfig = oldThirdParty
helper.tearDown()
}()
@@ -517,6 +530,28 @@ func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
}
}
func TestVideoCallStaleUnanswered(t *testing.T) {
now := time.Date(2026, time.September, 4, 10, 5, 0, 0, time.UTC)
call := &videoCall{
parties: map[string]callPartyData{"origin": {isOriginator: true}},
startedAt: now.Add(-31 * time.Second),
}
if !call.isStaleUnanswered(now, 30) {
t.Fatal("unanswered call older than establishment timeout should be stale")
}
call.startedAt = now.Add(-29 * time.Second)
if call.isStaleUnanswered(now, 30) {
t.Fatal("unanswered call within establishment timeout should not be stale")
}
call.parties["callee"] = callPartyData{}
call.startedAt = now.Add(-31 * time.Second)
if call.isStaleUnanswered(now, 30) {
t.Fatal("accepted call must not be treated as stale by establishment timeout")
}
}
func TestHandleBroadcastDataGroup(t *testing.T) {
topicName := "grp-test"
numUsers := 4
+1 -1
View File
@@ -307,7 +307,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
if msg.Acc.Login {
// Process user's login request.
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing)
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Acc.Scheme, account)
} else {
// Not using the new account for logging in.
reply = NoErrCreated(msg.Id, "", msg.Timestamp)
+14
View File
@@ -241,6 +241,7 @@ type testSecurityStore struct {
nextCursor string
unreadTenant types.TenantID
unreadUser types.Uid
unreadEventPrefix string
readTenant types.TenantID
readUser types.Uid
}
@@ -402,6 +403,19 @@ func (s *testSecurityStore) CountUnreadSystemNotifications(tenantID types.Tenant
return count, s.err
}
func (s *testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(tenantID types.TenantID, uid types.Uid, eventPrefix string) (int, error) {
s.unreadTenant = tenantID
s.unreadUser = uid
s.unreadEventPrefix = eventPrefix
count := 0
for _, notification := range s.notifications {
if notification.ReadAt == nil && strings.HasPrefix(notification.EventType, eventPrefix) {
count++
}
}
return count, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid