Compare commits
25
Commits
7193ba2316
...
b92154bece
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92154bece | ||
|
|
c68c5ac038 | ||
|
|
6b20a633ce | ||
|
|
90185b6c88 | ||
|
|
160d33c759 | ||
|
|
a56efd4454 | ||
|
|
6330c592cf | ||
|
|
7a1647bf17 | ||
|
|
baf15848d3 | ||
|
|
9e290b76b3 | ||
|
|
efbfb7556a | ||
|
|
b4d3fbe359 | ||
|
|
4ad6ca00b4 | ||
|
|
e6f2cbf36e | ||
|
|
50ccdf5f89 | ||
|
|
7fb1ea70bc | ||
|
|
47649d33e2 | ||
|
|
0f1c1f9255 | ||
|
|
a191ca0c93 | ||
|
|
62af772260 | ||
|
|
3173313ab9 | ||
|
|
9da072e6f9 | ||
|
|
c1bfa503cf | ||
|
|
d6de2a0349 | ||
|
|
e36decab28 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}}` |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(¬eRefs); 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+654
-13
@@ -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()
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,7 +12,8 @@ const (
|
||||
GroupMemberStatusActive = "active"
|
||||
GroupMemberStatusLeft = "left"
|
||||
|
||||
GroupInvitationStatusJoined = "joined"
|
||||
GroupInvitationStatusPending = "pending"
|
||||
GroupInvitationStatusJoined = "joined"
|
||||
|
||||
GroupJoinRequestStatusPending = "pending"
|
||||
GroupJoinRequestStatusAccepted = "accepted"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "time"
|
||||
const (
|
||||
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
|
||||
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
|
||||
PublicUploadPurposeUserAvatar = "user_avatar"
|
||||
PublicUploadPurposeGroupAvatar = "group_avatar"
|
||||
|
||||
TenantApplicationStatusReceived = "received"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user