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)
|
- name: 构建镜像(从本仓库源码编译,非下载官方release)
|
||||||
run: docker build -t im-server:${{ steps.vars.outputs.tag }} -f docker/tinode/Dockerfile.build .
|
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: 蓝绿部署
|
- name: 蓝绿部署
|
||||||
env:
|
env:
|
||||||
MYSQL_DSN: ${{ secrets.MYSQL_DSN }}
|
MYSQL_DSN: ${{ secrets.MYSQL_DSN }}
|
||||||
@@ -31,5 +41,13 @@ jobs:
|
|||||||
API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
|
API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
|
||||||
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
|
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
|
||||||
UID_ENCRYPTION_KEY: ${{ secrets.UID_ENCRYPTION_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: |
|
run: |
|
||||||
IMAGE=im-server:${{ steps.vars.outputs.tag }} /opt/apps/im-server/deploy.sh
|
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_DIAL_TIMEOUT_MS=3000
|
||||||
ENV KAFKA_WRITE_TIMEOUT_MS=3000
|
ENV KAFKA_WRITE_TIMEOUT_MS=3000
|
||||||
ENV KAFKA_READ_TIMEOUT_MS=3000
|
ENV KAFKA_READ_TIMEOUT_MS=3000
|
||||||
|
ENV KAFKA_BATCH_TIMEOUT_MS=10
|
||||||
ENV KAFKA_REQUIRED_ACKS=1
|
ENV KAFKA_REQUIRED_ACKS=1
|
||||||
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
|
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
|
||||||
ENV KAFKA_TLS_ENABLED=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_MECHANISM=SCRAM-SHA-512
|
||||||
ENV KAFKA_SASL_USERNAME=
|
ENV KAFKA_SASL_USERNAME=
|
||||||
ENV KAFKA_SASL_PASSWORD=
|
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_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
|
||||||
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
|
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
|
||||||
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
|
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_CONFIG_EVENT=im.config.event
|
||||||
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
|
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
|
||||||
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
|
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_MESSAGE_PUSH=im.message.push
|
||||||
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
|
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_CACHE_INVALIDATE=im.cache.invalidate
|
||||||
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
|
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
|
||||||
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
|
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_DIAL_TIMEOUT_MS=3000
|
||||||
ENV KAFKA_WRITE_TIMEOUT_MS=3000
|
ENV KAFKA_WRITE_TIMEOUT_MS=3000
|
||||||
ENV KAFKA_READ_TIMEOUT_MS=3000
|
ENV KAFKA_READ_TIMEOUT_MS=3000
|
||||||
|
ENV KAFKA_BATCH_TIMEOUT_MS=10
|
||||||
ENV KAFKA_REQUIRED_ACKS=1
|
ENV KAFKA_REQUIRED_ACKS=1
|
||||||
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
|
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
|
||||||
ENV KAFKA_TLS_ENABLED=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_MECHANISM=SCRAM-SHA-512
|
||||||
ENV KAFKA_SASL_USERNAME=
|
ENV KAFKA_SASL_USERNAME=
|
||||||
ENV KAFKA_SASL_PASSWORD=
|
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_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
|
||||||
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
|
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
|
||||||
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
|
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_CONFIG_EVENT=im.config.event
|
||||||
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
|
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
|
||||||
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
|
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_MESSAGE_PUSH=im.message.push
|
||||||
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
|
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_CACHE_INVALIDATE=im.cache.invalidate
|
||||||
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
|
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
|
||||||
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
|
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
|
||||||
|
|||||||
@@ -118,20 +118,21 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"kafka": {
|
"kafka": {
|
||||||
"enabled": true,
|
"enabled": $KAFKA_ENABLED,
|
||||||
"brokers": ["64.118.137.182:9094"],
|
"brokers": $KAFKA_BROKERS_JSON,
|
||||||
"client_id": "$KAFKA_CLIENT_ID",
|
"client_id": "$KAFKA_CLIENT_ID",
|
||||||
"consumer_group": "$KAFKA_CONSUMER_GROUP",
|
"consumer_group": "$KAFKA_CONSUMER_GROUP",
|
||||||
"dial_timeout_ms": $KAFKA_DIAL_TIMEOUT_MS,
|
"dial_timeout_ms": $KAFKA_DIAL_TIMEOUT_MS,
|
||||||
"write_timeout_ms": $KAFKA_WRITE_TIMEOUT_MS,
|
"write_timeout_ms": $KAFKA_WRITE_TIMEOUT_MS,
|
||||||
"read_timeout_ms": $KAFKA_READ_TIMEOUT_MS,
|
"read_timeout_ms": $KAFKA_READ_TIMEOUT_MS,
|
||||||
|
"batch_timeout_ms": $KAFKA_BATCH_TIMEOUT_MS,
|
||||||
"required_acks": $KAFKA_REQUIRED_ACKS,
|
"required_acks": $KAFKA_REQUIRED_ACKS,
|
||||||
"allow_auto_topic_creation": $KAFKA_ALLOW_AUTO_TOPIC_CREATION,
|
"allow_auto_topic_creation": $KAFKA_ALLOW_AUTO_TOPIC_CREATION,
|
||||||
"tls_enabled": false,
|
"tls_enabled": $KAFKA_TLS_ENABLED,
|
||||||
"sasl_enabled": true,
|
"sasl_enabled": $KAFKA_SASL_ENABLED,
|
||||||
"sasl_mechanism": "SCRAM-SHA-512",
|
"sasl_mechanism": "$KAFKA_SASL_MECHANISM",
|
||||||
"sasl_username": "kafka-external",
|
"sasl_username": "$KAFKA_SASL_USERNAME",
|
||||||
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
|
"sasl_password": "$KAFKA_SASL_PASSWORD",
|
||||||
"max_retry_count": $KAFKA_MAX_RETRY_COUNT,
|
"max_retry_count": $KAFKA_MAX_RETRY_COUNT,
|
||||||
"retry_backoff_ms": $KAFKA_RETRY_BACKOFF_MS,
|
"retry_backoff_ms": $KAFKA_RETRY_BACKOFF_MS,
|
||||||
"dead_letter_retention_seconds": $KAFKA_DEAD_LETTER_RETENTION_SECONDS,
|
"dead_letter_retention_seconds": $KAFKA_DEAD_LETTER_RETENTION_SECONDS,
|
||||||
@@ -141,27 +142,26 @@
|
|||||||
"config_event": "$KAFKA_TOPIC_CONFIG_EVENT",
|
"config_event": "$KAFKA_TOPIC_CONFIG_EVENT",
|
||||||
"business_event": "$KAFKA_TOPIC_BUSINESS_EVENT",
|
"business_event": "$KAFKA_TOPIC_BUSINESS_EVENT",
|
||||||
"message_persist": "$KAFKA_TOPIC_MESSAGE_PERSIST",
|
"message_persist": "$KAFKA_TOPIC_MESSAGE_PERSIST",
|
||||||
|
"group_message_persist": "$KAFKA_TOPIC_GROUP_MESSAGE_PERSIST",
|
||||||
"message_push": "$KAFKA_TOPIC_MESSAGE_PUSH",
|
"message_push": "$KAFKA_TOPIC_MESSAGE_PUSH",
|
||||||
"file_security": "$KAFKA_TOPIC_FILE_SECURITY",
|
"file_security": "$KAFKA_TOPIC_FILE_SECURITY",
|
||||||
"file_lifecycle": "$KAFKA_TOPIC_FILE_LIFECYCLE",
|
|
||||||
"workbench_event": "$KAFKA_TOPIC_WORKBENCH_EVENT",
|
|
||||||
"cache_invalidate": "$KAFKA_TOPIC_CACHE_INVALIDATE",
|
"cache_invalidate": "$KAFKA_TOPIC_CACHE_INVALIDATE",
|
||||||
"audit_event": "$KAFKA_TOPIC_AUDIT_EVENT",
|
"audit_event": "$KAFKA_TOPIC_AUDIT_EVENT",
|
||||||
"dead_letter": "$KAFKA_TOPIC_DEAD_LETTER"
|
"dead_letter": "$KAFKA_TOPIC_DEAD_LETTER"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"internal_api": {
|
"internal_api": {
|
||||||
"enabled": true,
|
"enabled": $INTERNAL_API_ENABLED,
|
||||||
"time_window_seconds": 300,
|
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
|
||||||
"services": {
|
"services": {
|
||||||
"hotgo-admin": {
|
"$INTERNAL_API_HOTGO_SERVICE_NAME": {
|
||||||
"secret": "$INTERNAL_API_HOTGO_SECRET",
|
"secret": "$INTERNAL_API_HOTGO_SECRET",
|
||||||
"allow": ["admin_query"]
|
"allow": ["admin_query"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin_internal_api": {
|
"admin_internal_api": {
|
||||||
"enabled": true,
|
"enabled": $ADMIN_INTERNAL_API_ENABLED,
|
||||||
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
|
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
|
||||||
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
|
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
|
||||||
"secret": "$ADMIN_INTERNAL_API_SECRET",
|
"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}}}` | 禁止重试,提示压缩或拆分 |
|
| 消息过大 | `{"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` 增量拉取 |
|
| 断线 | 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 与文件基础示例
|
#### 0.5.5 Long Polling 与文件基础示例
|
||||||
|
|
||||||
@@ -906,7 +1038,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
| 接口 | 输入示例 | 成功 `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/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":""}` |
|
| `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"}` |
|
| `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=` 分页详情。
|
- 调用 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 获取原会话额外权限。
|
||||||
- 多目标共享不可变 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}`。
|
- 查询最新公告:`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` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
|
- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
|
||||||
|
|
||||||
### 6.8 群公告状态/邀请进群申请同时存在.png
|
### 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`,再发布持久邀请消息:
|
- 发起前查询 `capabilities.can_call`,再发布持久邀请消息:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -2132,6 +2266,16 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
|
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
|
||||||
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
|
- 会议过期返回 `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
|
### 9.5 日报.png
|
||||||
|
|
||||||
<img src="images/日报.png" alt="日报" width="360">
|
<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}/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"}]}` |
|
| `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"}` |
|
| `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=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}}` |
|
| `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}}` |
|
| `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,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
meeting_id VARCHAR(64) NOT NULL,
|
meeting_id VARCHAR(64) NOT NULL,
|
||||||
organizer_id BIGINT NOT NULL,
|
organizer_id BIGINT NOT NULL,
|
||||||
|
recorder_user_id BIGINT NOT NULL,
|
||||||
title VARCHAR(200) NOT NULL,
|
title VARCHAR(200) NOT NULL,
|
||||||
start_at DATETIME(3) NOT NULL,
|
start_at DATETIME(3) NOT NULL,
|
||||||
end_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_meeting (meeting_id),
|
||||||
UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key),
|
UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key),
|
||||||
INDEX idx_im_meetings_organizer (organizer_id, start_at),
|
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)
|
INDEX idx_im_meetings_state_time (state, start_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
) 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
|
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;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS tinode.im_daily_reports (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
report_id VARCHAR(64) NOT NULL,
|
report_id VARCHAR(64) NOT NULL,
|
||||||
@@ -946,6 +996,7 @@ VALUES
|
|||||||
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
|
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
|
||||||
('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'),
|
('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'),
|
||||||
('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'),
|
('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'),
|
||||||
|
('meeting_notes', '会议笔记', '/meeting-notes', 35, '', 1, 'enabled'),
|
||||||
('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'),
|
('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'),
|
||||||
('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'),
|
('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'),
|
||||||
('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 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()
|
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');
|
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
|
INSERT INTO hg_sys_config
|
||||||
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
|
(`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()
|
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
|
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 {
|
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
|
||||||
return s.err
|
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
|
callID string
|
||||||
// Product-level call type: audio or video.
|
// Product-level call type: audio or video.
|
||||||
callType string
|
callType string
|
||||||
|
// Time when the invite was accepted by the server.
|
||||||
|
startedAt time.Time
|
||||||
// Time when the call was accepted.
|
// Time when the call was accepted.
|
||||||
acceptedAt time.Time
|
acceptedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -168,6 +170,13 @@ func (call *videoCall) matchesPayload(payload any) bool {
|
|||||||
return payloadCallID == "" || payloadCallID == call.callID
|
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) {
|
func (t *Topic) auditCallCreate(call *videoCall, caller types.Uid) {
|
||||||
if store.Calls == nil || call == nil || call.callID == "" || caller.IsZero() {
|
if store.Calls == nil || call == nil || call.callID == "" || caller.IsZero() {
|
||||||
return
|
return
|
||||||
@@ -350,6 +359,7 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
|
|||||||
contentMime: msg.Pub.Head["mime"],
|
contentMime: msg.Pub.Head["mime"],
|
||||||
callID: callContentString(msg.Pub.Content, "callId"),
|
callID: callContentString(msg.Pub.Content, "callId"),
|
||||||
callType: callContentString(msg.Pub.Content, "callType"),
|
callType: callContentString(msg.Pub.Content, "callType"),
|
||||||
|
startedAt: types.TimeNow(),
|
||||||
}
|
}
|
||||||
t.currentCall.parties[msg.sess.sid] = callPartyData{
|
t.currentCall.parties[msg.sess.sid] = callPartyData{
|
||||||
uid: asUid,
|
uid: asUid,
|
||||||
@@ -358,7 +368,7 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
|
|||||||
}
|
}
|
||||||
t.auditCallCreate(t.currentCall, asUid)
|
t.auditCallCreate(t.currentCall, asUid)
|
||||||
// Wait for constCallEstablishmentTimeout for the other side to accept the call.
|
// 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).
|
// 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)
|
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).
|
// ErrUnknown database or other server error (500).
|
||||||
func ErrUnknown(id, topic string, ts time.Time) *ServerComMessage {
|
func ErrUnknown(id, topic string, ts time.Time) *ServerComMessage {
|
||||||
return ErrUnknownExplicitTs(id, topic, ts, ts)
|
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)
|
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
|
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
|
||||||
// "one tenant, one database" deployment model.
|
// "one tenant, one database" deployment model.
|
||||||
type TenantRoutedAdapter interface {
|
type TenantRoutedAdapter interface {
|
||||||
@@ -412,6 +419,11 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
|
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
|
||||||
TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error)
|
TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error)
|
||||||
TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, 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)
|
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)
|
TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error)
|
||||||
TenantDailyReportGet(t.TenantID, t.Uid, string) (*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
|
TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error
|
||||||
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
|
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
|
||||||
TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, 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
|
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
|
||||||
|
|
||||||
TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, 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 {
|
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
|
||||||
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
|
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 {
|
if err = ensureIMFileReviewTables(a.db); err != nil {
|
||||||
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
|
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 {
|
if err = ensureIMAttendanceTables(a.db); err != nil {
|
||||||
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error())
|
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 {
|
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
|
||||||
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
|
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()
|
db.Close()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
if err = ensureIMAdminGovernanceTables(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
if err = ensureIMFileReviewTables(db); err != nil {
|
if err = ensureIMFileReviewTables(db); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -521,6 +531,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
if err = ensureIMMeetingNotesTables(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
if err = ensureSnowflakePrimaryKeys(db); err != nil {
|
if err = ensureSnowflakePrimaryKeys(db); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -943,6 +957,9 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
|
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = ensureIMAdminGovernanceTables(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err = ensureIMFileReviewTables(tx); err != nil {
|
if err = ensureIMFileReviewTables(tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1072,9 @@ func (a *adapter) UpgradeDb() error {
|
|||||||
if err := a.ensureMessageTopicSeqIndex(); err != nil {
|
if err := a.ensureMessageTopicSeqIndex(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.ensureTopicSeqMatchesMessages(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if a.version == adpVersion {
|
if a.version == adpVersion {
|
||||||
return nil
|
return nil
|
||||||
@@ -1080,6 +1100,61 @@ func (a *adapter) ensureMessageTopicSeqIndex() error {
|
|||||||
return err
|
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 {
|
func createIMNotificationTables(tx *sql.Tx) error {
|
||||||
_, err := tx.Exec(
|
_, err := tx.Exec(
|
||||||
`CREATE TABLE im_system_notifications(
|
`CREATE TABLE im_system_notifications(
|
||||||
@@ -1154,6 +1229,7 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
|
|||||||
('organization','组织架构','/organization',10,'organization',1,'enabled'),
|
('organization','组织架构','/organization',10,'organization',1,'enabled'),
|
||||||
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
|
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
|
||||||
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
|
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
|
||||||
|
('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'),
|
||||||
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
|
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
|
||||||
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
|
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
|
||||||
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',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,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
meeting_id VARCHAR(64) NOT NULL,
|
meeting_id VARCHAR(64) NOT NULL,
|
||||||
organizer_id BIGINT NOT NULL,
|
organizer_id BIGINT NOT NULL,
|
||||||
|
recorder_user_id BIGINT NOT NULL,
|
||||||
title VARCHAR(200) NOT NULL,
|
title VARCHAR(200) NOT NULL,
|
||||||
start_at DATETIME(3) NOT NULL,
|
start_at DATETIME(3) NOT NULL,
|
||||||
end_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_meeting(meeting_id),
|
||||||
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
|
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
|
||||||
INDEX idx_im_meetings_organizer(organizer_id, start_at),
|
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)
|
INDEX idx_im_meetings_state_time(state, start_at)
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE im_meeting_participants(
|
`CREATE TABLE im_meeting_participants(
|
||||||
@@ -1426,6 +1504,33 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
|||||||
INDEX idx_im_meeting_participants_response(meeting_id, response),
|
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
|
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 {
|
for _, stmt := range statements {
|
||||||
if _, err := tx.Exec(stmt); err != nil {
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
@@ -1435,6 +1540,63 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
|||||||
return nil
|
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 {
|
func createIMReportTables(tx *sql.Tx) error {
|
||||||
if _, err := tx.Exec(
|
if _, err := tx.Exec(
|
||||||
`CREATE TABLE im_daily_reports(
|
`CREATE TABLE im_daily_reports(
|
||||||
@@ -2019,6 +2181,10 @@ func (a *adapter) UserCreate(user *t.User) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
profileID, err := nextSnowflakeID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var phone any
|
var phone any
|
||||||
if strings.TrimSpace(user.Phone) != "" {
|
if strings.TrimSpace(user.Phone) != "" {
|
||||||
canonicalPhone, err := normalizeUserPhone(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 {
|
securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
|
||||||
return err
|
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
|
user.Tags = nil
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
@@ -2316,13 +2487,15 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
|
|||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
var user t.User
|
var row profileUserRow
|
||||||
err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
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(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
|
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 {
|
if err == nil {
|
||||||
|
user := profileUserFromRow(row)
|
||||||
user.SetUid(uid)
|
user.SetUid(uid)
|
||||||
user.Public = common.FromJSON(user.Public)
|
|
||||||
user.Trusted = common.FromJSON(user.Trusted)
|
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2348,8 +2521,11 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
|
|||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
q, uids, _ := sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
q, uids, _ := sqlx.In(`SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
|
||||||
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted)
|
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...)
|
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2357,14 +2533,12 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user t.User
|
var row profileUserRow
|
||||||
if err = rows.StructScan(&user); err != nil {
|
if err = rows.StructScan(&row); err != nil {
|
||||||
users = nil
|
users = nil
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
user.SetUid(common.EncodeUidString(user.Id))
|
user := profileUserFromRow(row)
|
||||||
user.Public = common.FromJSON(user.Public)
|
|
||||||
user.Trusted = common.FromJSON(user.Trusted)
|
|
||||||
|
|
||||||
users = append(users, user)
|
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.
|
// Fetch p2p users and join to p2p subscriptions.
|
||||||
if len(usrq) > 0 {
|
if len(usrq) > 0 {
|
||||||
q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " +
|
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
||||||
"FROM im_users WHERE id IN (?)"
|
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)
|
q, args, _ = sqlx.In(q, usrq)
|
||||||
if !keepDeleted {
|
if !keepDeleted {
|
||||||
// Optionally skip deleted users.
|
// Optionally skip deleted users.
|
||||||
q += " AND state!=?"
|
q += " AND u.state!=?"
|
||||||
args = append(args, t.StateDeleted)
|
args = append(args, t.StateDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3105,19 +3280,19 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var usr2 t.User
|
var usr2 profileUserRow
|
||||||
if err = rows.StructScan(&usr2); err != nil {
|
if err = rows.StructScan(&usr2); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
joinOn := uid.P2PName(common.EncodeUidString(usr2.Id))
|
joinOn := uid.P2PName(common.EncodeUidString(usr2.ID))
|
||||||
if sub, ok := join[joinOn]; ok {
|
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.SetState(usr2.State)
|
||||||
sub.SetPublic(common.FromJSON(usr2.Public))
|
sub.SetPublic(usr2.profilePublic())
|
||||||
sub.SetTrusted(common.FromJSON(usr2.Trusted))
|
sub.SetTrusted(common.FromJSON(usr2.Trusted))
|
||||||
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
|
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
|
||||||
sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
|
sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent)
|
||||||
join[joinOn] = sub
|
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 {
|
if err = rows.Scan(&id, &loc); err != nil {
|
||||||
break
|
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 != "" {
|
if loc != "" {
|
||||||
locations = append(locations, 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)
|
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`,
|
) 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 {
|
if err != nil {
|
||||||
return false, err
|
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
|
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 {
|
func stampTenantUsers(id t.TenantID, users []t.User) []t.User {
|
||||||
for i := range users {
|
for i := range users {
|
||||||
users[i].TenantID = id
|
users[i].TenantID = id
|
||||||
@@ -211,9 +332,12 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
|
|||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
var user t.User
|
var row profileUserRow
|
||||||
err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
|
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(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`,
|
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)
|
id, decodeUID(uid), t.StateDeleted)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -221,7 +345,7 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
scanTenantUser(&user)
|
user := profileUserFromRow(row)
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,10 +994,12 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(usrq) > 0 {
|
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)
|
q, args, _ = sqlx.In(q, id, usrq)
|
||||||
if !keepDeleted {
|
if !keepDeleted {
|
||||||
q += " AND state!=?"
|
q += " AND u.state!=?"
|
||||||
args = append(args, t.StateDeleted)
|
args = append(args, t.StateDeleted)
|
||||||
}
|
}
|
||||||
rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user t.User
|
var user profileUserRow
|
||||||
if err = rows.StructScan(&user); err != nil {
|
if err = rows.StructScan(&user); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
joinOn := uid.P2PName(common.EncodeUidString(user.Id))
|
joinOn := uid.P2PName(common.EncodeUidString(user.ID))
|
||||||
if sub, ok := join[joinOn]; ok {
|
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.SetState(user.State)
|
||||||
sub.SetPublic(common.FromJSON(user.Public))
|
sub.SetPublic(user.profilePublic())
|
||||||
sub.SetTrusted(common.FromJSON(user.Trusted))
|
sub.SetTrusted(common.FromJSON(user.Trusted))
|
||||||
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
|
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
|
||||||
sub.SetLastSeenAndUA(user.LastSeen, user.UserAgent)
|
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
|
||||||
join[joinOn] = sub
|
join[joinOn] = sub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3220,8 +3346,12 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
|
|||||||
if meeting.IdempotencyKey != "" {
|
if meeting.IdempotencyKey != "" {
|
||||||
idempotencyKey = 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)
|
recorderID := meeting.RecorderID
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location),
|
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)
|
t.MeetingStateScheduled, string(reminderJSON), idempotencyKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDupe(err) && meeting.IdempotencyKey != "" {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
participants := uniqueMeetingParticipants(uid, attendeeIDs)
|
participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID))
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
for _, participant := range participants {
|
for _, participant := range participants {
|
||||||
response := t.MeetingResponsePending
|
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 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 20
|
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=?
|
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) != "" {
|
if strings.TrimSpace(cursor) != "" {
|
||||||
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
|
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
|
||||||
if err != nil || parsed <= 0 {
|
if err != nil || parsed <= 0 {
|
||||||
@@ -3305,6 +3435,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
|
|||||||
ID int64
|
ID int64
|
||||||
MeetingID string `db:"meeting_id"`
|
MeetingID string `db:"meeting_id"`
|
||||||
OrganizerID int64 `db:"organizer_id"`
|
OrganizerID int64 `db:"organizer_id"`
|
||||||
|
RecorderID int64 `db:"recorder_user_id"`
|
||||||
Title string
|
Title string
|
||||||
StartAt time.Time `db:"start_at"`
|
StartAt time.Time `db:"start_at"`
|
||||||
EndAt time.Time `db:"end_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,
|
ID: row.ID,
|
||||||
MeetingID: row.MeetingID,
|
MeetingID: row.MeetingID,
|
||||||
OrganizerID: store.EncodeUid(row.OrganizerID),
|
OrganizerID: store.EncodeUid(row.OrganizerID),
|
||||||
|
RecorderID: store.EncodeUid(row.RecorderID),
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
StartAt: row.StartAt,
|
StartAt: row.StartAt,
|
||||||
EndAt: row.EndAt,
|
EndAt: row.EndAt,
|
||||||
@@ -3337,6 +3469,9 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
|
|||||||
if row.IdempotencyKey.Valid {
|
if row.IdempotencyKey.Valid {
|
||||||
meeting.IdempotencyKey = row.IdempotencyKey.String
|
meeting.IdempotencyKey = row.IdempotencyKey.String
|
||||||
}
|
}
|
||||||
|
if meeting.RecorderID.IsZero() {
|
||||||
|
meeting.RecorderID = meeting.OrganizerID
|
||||||
|
}
|
||||||
if len(row.ReminderMinutes) > 0 {
|
if len(row.ReminderMinutes) > 0 {
|
||||||
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
_ = 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=?")
|
sets = append(sets, "location=?")
|
||||||
args = append(args, strings.TrimSpace(*patch.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 {
|
if patch.ReminderMinutes != nil {
|
||||||
encoded, err := json.Marshal(patch.ReminderMinutes)
|
encoded, err := json.Marshal(patch.ReminderMinutes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3474,6 +3616,12 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string
|
|||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return nil, t.ErrInvalidResponse
|
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, "")
|
participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -3755,6 +3903,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
|||||||
ID int64
|
ID int64
|
||||||
MeetingID string `db:"meeting_id"`
|
MeetingID string `db:"meeting_id"`
|
||||||
OrganizerID int64 `db:"organizer_id"`
|
OrganizerID int64 `db:"organizer_id"`
|
||||||
|
RecorderID int64 `db:"recorder_user_id"`
|
||||||
Title string
|
Title string
|
||||||
StartAt time.Time `db:"start_at"`
|
StartAt time.Time `db:"start_at"`
|
||||||
EndAt time.Time `db:"end_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"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}{}
|
}{}
|
||||||
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m
|
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=?
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, t.ErrNotFound
|
return nil, t.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -3780,6 +3929,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
|||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
MeetingID: row.MeetingID,
|
MeetingID: row.MeetingID,
|
||||||
OrganizerID: store.EncodeUid(row.OrganizerID),
|
OrganizerID: store.EncodeUid(row.OrganizerID),
|
||||||
|
RecorderID: store.EncodeUid(row.RecorderID),
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
StartAt: row.StartAt,
|
StartAt: row.StartAt,
|
||||||
EndAt: row.EndAt,
|
EndAt: row.EndAt,
|
||||||
@@ -3793,6 +3943,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
|||||||
if row.IdempotencyKey.Valid {
|
if row.IdempotencyKey.Valid {
|
||||||
meeting.IdempotencyKey = row.IdempotencyKey.String
|
meeting.IdempotencyKey = row.IdempotencyKey.String
|
||||||
}
|
}
|
||||||
|
if meeting.RecorderID.IsZero() {
|
||||||
|
meeting.RecorderID = meeting.OrganizerID
|
||||||
|
}
|
||||||
if len(row.ReminderMinutes) > 0 {
|
if len(row.ReminderMinutes) > 0 {
|
||||||
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
||||||
}
|
}
|
||||||
@@ -3843,6 +3996,402 @@ func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string,
|
|||||||
return participants, nil
|
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 {
|
func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid {
|
||||||
seen := map[t.Uid]bool{organizer: true}
|
seen := map[t.Uid]bool{organizer: true}
|
||||||
participants := []t.Uid{organizer}
|
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) {
|
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 {
|
if !a.tenantScopedDB {
|
||||||
tenantAdp, err := a.routedTenantAdapter(id)
|
tenantAdp, err := a.routedTenantAdapter(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if tenantAdp != a {
|
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))
|
decoded := make([]any, 0, len(uids))
|
||||||
for _, uid := range uids {
|
for _, uid := range uids {
|
||||||
if !uid.IsZero() {
|
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
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -8223,6 +8793,33 @@ func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid)
|
|||||||
return count, nil
|
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 {
|
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error {
|
||||||
if !a.tenantScopedDB {
|
if !a.tenantScopedDB {
|
||||||
tenantAdp, err := a.routedTenantAdapter(id)
|
tenantAdp, err := a.routedTenantAdapter(id)
|
||||||
@@ -8791,7 +9388,8 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
|
|||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
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
|
FROM im_friendships AS fs
|
||||||
JOIN im_users AS u ON u.id=fs.peer_id
|
JOIN im_users AS u ON u.id=fs.peer_id
|
||||||
LEFT JOIN im_user_profiles AS p ON p.user_id=u.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 := ""
|
nextCursor := ""
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
row := struct {
|
row := struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int64 `db:"user_id"`
|
UserID int64 `db:"user_id"`
|
||||||
PeerID int64 `db:"peer_id"`
|
PeerID int64 `db:"peer_id"`
|
||||||
Status string
|
Status string
|
||||||
Remark string
|
Remark string
|
||||||
Topic string
|
Topic string
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
Public any
|
Public any
|
||||||
Trusted 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 {
|
if err = rows.StructScan(&row); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
@@ -8838,7 +9439,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
|
|||||||
Peer: &t.User{
|
Peer: &t.User{
|
||||||
ObjHeader: t.ObjHeader{Id: peerID.String()},
|
ObjHeader: t.ObjHeader{Id: peerID.String()},
|
||||||
TenantID: id,
|
TenantID: id,
|
||||||
Public: common.FromJSON(row.Public),
|
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID),
|
||||||
Trusted: common.FromJSON(row.Trusted),
|
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=?
|
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')`)
|
) OR COALESCE(JSON_UNQUOTE(JSON_EXTRACT(privacy.add_me_methods, ?)), 'true')='true')`)
|
||||||
args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod)
|
args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod)
|
||||||
|
args = append([]any{id}, args...)
|
||||||
args = append(args, limit+1)
|
args = append(args, limit+1)
|
||||||
ctx, cancel := a.getContext()
|
ctx, cancel := a.getContext()
|
||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
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,
|
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 FROM im_users AS u `+strings.Join(joins, " ")+`
|
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...)
|
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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)
|
users := make([]t.User, 0, limit)
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user t.User
|
var row profileUserRow
|
||||||
if err = rows.StructScan(&user); err != nil {
|
if err = rows.StructScan(&row); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
scanTenantUser(&user)
|
user := profileUserFromRow(row)
|
||||||
if len(users) == limit {
|
if len(users) == limit {
|
||||||
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
|
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
|
|||||||
inv.IdempotencyKey = inv.InvitationID
|
inv.IdempotencyKey = inv.InvitationID
|
||||||
}
|
}
|
||||||
if inv.Status == "" {
|
if inv.Status == "" {
|
||||||
inv.Status = t.GroupInvitationStatusJoined
|
inv.Status = t.GroupInvitationStatusPending
|
||||||
}
|
}
|
||||||
if inv.ExpiresAt.IsZero() {
|
if inv.ExpiresAt.IsZero() {
|
||||||
inv.ExpiresAt = now.Add(7 * 24 * time.Hour)
|
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 {
|
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = backfillProfileDisplay(ctx, conn, usersScoped); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
indexName := "users_phone"
|
indexName := "users_phone"
|
||||||
@@ -416,6 +419,51 @@ func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped boo
|
|||||||
return nil
|
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) {
|
func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
|
||||||
var out []identityValue
|
var out []identityValue
|
||||||
appendQuery := func(query string, args ...any) error {
|
appendQuery := func(query string, args ...any) error {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
|
|
||||||
package mysql
|
package mysql
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNormalizeUserPhone(t *testing.T) {
|
func TestNormalizeUserPhone(t *testing.T) {
|
||||||
for input, want := range map[string]string{
|
for input, want := range map[string]string{
|
||||||
@@ -23,3 +26,52 @@ func TestNormalizeUserPhone(t *testing.T) {
|
|||||||
t.Fatal("invalid phone unexpectedly accepted")
|
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
|
// See https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||||
var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"}
|
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.
|
// RequestPrincipal is a tenant-scoped identity authenticated for HTTP/gRPC file access.
|
||||||
type RequestPrincipal struct {
|
type RequestPrincipal struct {
|
||||||
TenantID types.TenantID
|
TenantID types.TenantID
|
||||||
@@ -249,6 +251,11 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
|
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
meetingID, meetingNoteIdempotencyKey, err := meetingNoteUploadParams(req, principal)
|
||||||
|
if err != nil {
|
||||||
|
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if uploads are handled elsewhere.
|
// Check if uploads are handled elsewhere.
|
||||||
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
|
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)
|
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 {
|
if globals.mediaGcPeriod > 0 {
|
||||||
// How long this file is guaranteed to exist without being attached to a message or a topic.
|
// 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)
|
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)
|
writeHttpResponse(NoErrParams(msgID, "", now, params), nil)
|
||||||
logs.Info.Println("media upload: ok", fdef.Id, fdef.Location)
|
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.
|
// LargeFileServe is the gRPC equivalent of largeFileServeHTTP.
|
||||||
func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
|
func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
|
||||||
now := types.TimeNow()
|
now := types.TimeNow()
|
||||||
|
|||||||
@@ -1,19 +1,51 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"github.com/golang/mock/gomock"
|
||||||
"github.com/tinode/chat/server/auth"
|
"github.com/tinode/chat/server/auth"
|
||||||
"github.com/tinode/chat/server/auth/mock_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"
|
||||||
"github.com/tinode/chat/server/store/mock_store"
|
"github.com/tinode/chat/server/store/mock_store"
|
||||||
"github.com/tinode/chat/server/store/types"
|
"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) {
|
func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
|
||||||
oldSessionStore := globals.sessionStore
|
oldSessionStore := globals.sessionStore
|
||||||
globals.sessionStore = NewSessionStore(time.Minute)
|
globals.sessionStore = NewSessionStore(time.Minute)
|
||||||
@@ -137,3 +169,90 @@ func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
|
|||||||
|
|
||||||
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -45,6 +46,8 @@ func (sess *Session) readLoop() {
|
|||||||
sess.ws.SetReadLimit(globals.maxMessageSize)
|
sess.ws.SetReadLimit(globals.maxMessageSize)
|
||||||
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
sess.ws.SetPongHandler(func(string) error {
|
sess.ws.SetPongHandler(func(string) error {
|
||||||
|
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
|
||||||
|
sess.refreshPresence("ws.pong")
|
||||||
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const (
|
|||||||
imReasonCallNotFound = "CALL_NOT_FOUND"
|
imReasonCallNotFound = "CALL_NOT_FOUND"
|
||||||
imReasonCallNotAllowed = "CALL_NOT_ALLOWED"
|
imReasonCallNotAllowed = "CALL_NOT_ALLOWED"
|
||||||
imReasonCallStateInvalid = "CALL_STATE_INVALID"
|
imReasonCallStateInvalid = "CALL_STATE_INVALID"
|
||||||
|
|
||||||
|
imClientFeatureConfigGroup = "im_client_feature"
|
||||||
)
|
)
|
||||||
|
|
||||||
type imCallRTCJoinRequest struct {
|
type imCallRTCJoinRequest struct {
|
||||||
@@ -189,6 +191,46 @@ func loadIMAgoraConfig() (imAgoraConfig, error) {
|
|||||||
return config, nil
|
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) {
|
func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) {
|
||||||
if store.Calls == nil {
|
if store.Calls == nil {
|
||||||
return nil, types.ErrUnsupported
|
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
|
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 {
|
type testDailyReportsStore struct {
|
||||||
report *types.DailyReport
|
report *types.DailyReport
|
||||||
saved bool
|
saved bool
|
||||||
@@ -658,6 +669,46 @@ func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, r
|
|||||||
return s.meeting, nil
|
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) {
|
func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) {
|
||||||
if s.report == nil {
|
if s.report == nil {
|
||||||
return nil, "", 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) {
|
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||||
oldTenants := store.Tenants
|
oldTenants := store.Tenants
|
||||||
oldVerifications := store.Verifications
|
oldVerifications := store.Verifications
|
||||||
@@ -2315,23 +2411,40 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
|
|||||||
oldSecurity := store.Security
|
oldSecurity := store.Security
|
||||||
oldWorkbench := store.Workbench
|
oldWorkbench := store.Workbench
|
||||||
oldRelations := store.Relations
|
oldRelations := store.Relations
|
||||||
|
oldThirdParty := store.ThirdPartyConfig
|
||||||
|
readAt := time.Now().UTC()
|
||||||
security := &testSecurityStore{
|
security := &testSecurityStore{
|
||||||
realName: &types.RealNameVerification{Status: "verified"},
|
realName: &types.RealNameVerification{Status: "verified"},
|
||||||
unreadNotifications: 4,
|
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}
|
relations := &testRelationsStore{pendingCount: 2}
|
||||||
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
|
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
|
||||||
{Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"},
|
{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"},
|
{Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"},
|
||||||
}}
|
}}
|
||||||
store.Security = security
|
store.Security = security
|
||||||
store.Relations = relations
|
store.Relations = relations
|
||||||
store.Workbench = workbench
|
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() {
|
defer func() {
|
||||||
store.Security = oldSecurity
|
store.Security = oldSecurity
|
||||||
store.Relations = oldRelations
|
store.Relations = oldRelations
|
||||||
store.Workbench = oldWorkbench
|
store.Workbench = oldWorkbench
|
||||||
|
store.ThirdPartyConfig = oldThirdParty
|
||||||
}()
|
}()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
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 {
|
if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil {
|
||||||
t.Fatalf("bootstrap missing home capabilities: %#v", data)
|
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 {
|
if security.realNameTenant != 7 || security.realNameUser != 42 {
|
||||||
t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser)
|
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 {
|
if modules[0].(map[string]any)["unread_count"].(float64) != 3 {
|
||||||
t.Fatalf("unexpected bootstrap module unread: %#v", modules[0])
|
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)
|
badges := data["entry_badges"].(map[string]any)
|
||||||
if badges["new_friends_pending_count"].(float64) != 2 ||
|
if badges["new_friends_pending_count"].(float64) != 2 ||
|
||||||
badges["contacts_total_unread"].(float64) != 2 ||
|
badges["contacts_total_unread"].(float64) != 2 ||
|
||||||
badges["system_unread_count"].(float64) != 4 ||
|
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)
|
t.Fatalf("unexpected bootstrap badges: %#v", badges)
|
||||||
}
|
}
|
||||||
entries := data["entries"].(map[string]any)
|
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) {
|
func TestServeIMMeetingMyResponse(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
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) {
|
func TestServeIMDailyReportsCreateAndList(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
@@ -3683,9 +3952,23 @@ func TestServeIMGroupList(t *testing.T) {
|
|||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||||
oldUsers := store.Users
|
oldUsers := store.Users
|
||||||
|
oldGroupBusiness := store.GroupBusiness
|
||||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
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
|
store.Users = users
|
||||||
defer func() { store.Users = oldUsers }()
|
store.GroupBusiness = groupBusiness
|
||||||
|
defer func() {
|
||||||
|
store.Users = oldUsers
|
||||||
|
store.GroupBusiness = oldGroupBusiness
|
||||||
|
}()
|
||||||
sub := types.Subscription{
|
sub := types.Subscription{
|
||||||
TenantID: 7,
|
TenantID: 7,
|
||||||
User: types.Uid(42).String(),
|
User: types.Uid(42).String(),
|
||||||
@@ -3694,8 +3977,8 @@ func TestServeIMGroupList(t *testing.T) {
|
|||||||
ModeGiven: types.ModeCFull,
|
ModeGiven: types.ModeCFull,
|
||||||
ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)},
|
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.SetPublic(map[string]any{"name": "grpAlpha"})
|
||||||
sub.SetSubCnt(3)
|
sub.SetSubCnt(0)
|
||||||
users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil)
|
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)
|
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)
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||||
items := data["items"].([]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)
|
t.Fatalf("unexpected group list response: %#v", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4072,7 +4355,8 @@ func TestServeIMGroupLeave(t *testing.T) {
|
|||||||
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
|
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
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
|
oldSubs := store.Subs
|
||||||
oldGroupBusiness := store.GroupBusiness
|
oldGroupBusiness := store.GroupBusiness
|
||||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
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{
|
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,
|
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
||||||
}, nil)
|
}, nil)
|
||||||
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
|
||||||
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
|
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)
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||||
items := data["items"].([]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)
|
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)
|
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) {
|
func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
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)
|
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3)
|
||||||
store.Store = mockStore
|
store.Store = mockStore
|
||||||
oldFavorites := store.Favorites
|
oldFavorites := store.Favorites
|
||||||
|
oldSubs := store.Subs
|
||||||
|
oldMessages := store.Messages
|
||||||
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
||||||
favorites := &testFavoritesStore{items: []types.FavoriteItem{{
|
favorites := &testFavoritesStore{items: []types.FavoriteItem{{
|
||||||
TenantID: 7,
|
TenantID: 7,
|
||||||
@@ -4427,11 +4747,36 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
|
|||||||
CollectedAt: now,
|
CollectedAt: now,
|
||||||
}}}
|
}}}
|
||||||
store.Favorites = favorites
|
store.Favorites = favorites
|
||||||
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||||
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||||
|
store.Subs = subs
|
||||||
|
store.Messages = messages
|
||||||
defer func() {
|
defer func() {
|
||||||
store.Store = oldStore
|
store.Store = oldStore
|
||||||
store.Favorites = oldFavorites
|
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 := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil)
|
||||||
req.Header.Set("Authorization", "Bearer token-1")
|
req.Header.Set("Authorization", "Bearer token-1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -4469,6 +4814,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
|
|||||||
mockStore.EXPECT().GetUidString().Return("new-fav")
|
mockStore.EXPECT().GetUidString().Return("new-fav")
|
||||||
|
|
||||||
oldFavorites := store.Favorites
|
oldFavorites := store.Favorites
|
||||||
|
oldSubs := store.Subs
|
||||||
oldMessages := store.Messages
|
oldMessages := store.Messages
|
||||||
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
||||||
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
|
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
|
||||||
@@ -4482,13 +4828,23 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
|
|||||||
Availability: "available",
|
Availability: "available",
|
||||||
CollectedAt: now,
|
CollectedAt: now,
|
||||||
}}}
|
}}}
|
||||||
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||||
|
store.Subs = subs
|
||||||
store.Messages = messages
|
store.Messages = messages
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
store.Favorites = oldFavorites
|
store.Favorites = oldFavorites
|
||||||
|
store.Subs = oldSubs
|
||||||
store.Messages = oldMessages
|
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{
|
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
|
||||||
TenantID: 7,
|
TenantID: 7,
|
||||||
Topic: "grpAlpha",
|
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) {
|
func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
@@ -4793,7 +5247,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
|
|||||||
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
||||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||||
kafkaBus = &kafkaRuntime{}
|
kafkaBus = &kafkaRuntime{}
|
||||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 3)}
|
||||||
store.Subs = subs
|
store.Subs = subs
|
||||||
store.Topics = topics
|
store.Topics = topics
|
||||||
store.Messages = messages
|
store.Messages = messages
|
||||||
@@ -4883,6 +5337,24 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatal("expected forwarded message to be broadcast")
|
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) {
|
func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
|
||||||
@@ -4896,13 +5368,17 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
|
|||||||
|
|
||||||
oldSubs := store.Subs
|
oldSubs := store.Subs
|
||||||
oldMessages := store.Messages
|
oldMessages := store.Messages
|
||||||
|
oldUsers := store.Users
|
||||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||||
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||||
store.Subs = subs
|
store.Subs = subs
|
||||||
store.Messages = messages
|
store.Messages = messages
|
||||||
|
store.Users = users
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
store.Subs = oldSubs
|
store.Subs = oldSubs
|
||||||
store.Messages = oldMessages
|
store.Messages = oldMessages
|
||||||
|
store.Users = oldUsers
|
||||||
})
|
})
|
||||||
|
|
||||||
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
|
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),
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
}, nil)
|
}, 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(
|
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn(
|
||||||
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
|
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
|
||||||
if msg.Topic != "grpTarget" || msg.Head["x-im-type"] != "forward_bundle" {
|
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 {
|
if content["bundle_id"] != "fbundle_bundle-1" || content["item_count"] != 1 {
|
||||||
t.Fatalf("unexpected bundle content: %#v", content)
|
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
|
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)
|
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",
|
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"]}`))
|
bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`))
|
||||||
req.Header.Set("Authorization", "Bearer token-1")
|
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) {
|
func TestServeIMTopicCalls(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
@@ -6154,11 +6738,68 @@ func TestServeIMFriendsList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||||
items := data["items"].([]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)
|
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) {
|
func TestServeIMFriendRemark(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ var internalGroupResourceSpecs = map[string]*internalQuerySpec{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var internalTenantResourceSpecs = 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": {
|
"audit-logs": {
|
||||||
Select: "SELECT id,request_id,actor_user_id,action,target_type,target_id,ip,user_agent,detail,created_at",
|
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",
|
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) {
|
func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string) (string, string, []any, int, int, error) {
|
||||||
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
|
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
|
||||||
perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200)
|
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 {
|
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":
|
case len(parts) == 3 && parts[2] == "users":
|
||||||
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
|
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
|
||||||
case len(parts) == 4 && parts[2] == "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)
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeInternalQueryError(wrt, req, err)
|
writeInternalQueryError(wrt, req, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeIMSuccess(wrt, req, map[string]any{
|
row["conversation_type"] = strings.TrimSuffix(resource, "-messages")
|
||||||
"topic": msg.Topic,
|
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
|
||||||
"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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) {
|
func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const (
|
|||||||
adminCommandUserCancel = "user.account.cancel"
|
adminCommandUserCancel = "user.account.cancel"
|
||||||
adminCommandUserDelete = "user.account.delete"
|
adminCommandUserDelete = "user.account.delete"
|
||||||
adminCommandUserPasswordReset = "user.password.reset"
|
adminCommandUserPasswordReset = "user.password.reset"
|
||||||
|
adminCommandMessageHide = "message.hide"
|
||||||
|
adminCommandMessageDelete = "message.delete"
|
||||||
adminCommandKafkaDeadReplay = "kafka.dead_letter.replay"
|
adminCommandKafkaDeadReplay = "kafka.dead_letter.replay"
|
||||||
adminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore"
|
adminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore"
|
||||||
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
|
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
|
||||||
@@ -41,22 +43,24 @@ const (
|
|||||||
var publishKafkaDeadLetter = publishKafkaEnvelope
|
var publishKafkaDeadLetter = publishKafkaEnvelope
|
||||||
|
|
||||||
type adminCommandPayload struct {
|
type adminCommandPayload struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
ActorUserID string `json:"actor_user_id"`
|
ActorUserID string `json:"actor_user_id"`
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
UserID string `json:"user_id"`
|
ConversationType string `json:"conversation_type"`
|
||||||
UserIDs []string `json:"user_ids"`
|
SeqID int64 `json:"seq_id"`
|
||||||
TargetID string `json:"target_user_id"`
|
UserID string `json:"user_id"`
|
||||||
DeviceID string `json:"device_id"`
|
UserIDs []string `json:"user_ids"`
|
||||||
PasswordHash string `json:"password_hash"`
|
TargetID string `json:"target_user_id"`
|
||||||
Version int `json:"version"`
|
DeviceID string `json:"device_id"`
|
||||||
Reason string `json:"reason"`
|
PasswordHash string `json:"password_hash"`
|
||||||
DeadID string `json:"dead_id"`
|
Version int `json:"version"`
|
||||||
Note string `json:"note"`
|
Reason string `json:"reason"`
|
||||||
Hard bool `json:"hard"`
|
DeadID string `json:"dead_id"`
|
||||||
ExpiresAt *time.Time `json:"expires_at"`
|
Note string `json:"note"`
|
||||||
Profile groupProfilePatch `json:"profile"`
|
Hard bool `json:"hard"`
|
||||||
Governance groupGovernancePatch `json:"governance"`
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
Profile groupProfilePatch `json:"profile"`
|
||||||
|
Governance groupGovernancePatch `json:"governance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type groupProfilePatch struct {
|
type groupProfilePatch struct {
|
||||||
@@ -166,6 +170,15 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
if eventType == adminCommandUserPasswordReset {
|
if eventType == adminCommandUserPasswordReset {
|
||||||
return executeKafkaUserPasswordResetCommand(tenantID, payload)
|
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)
|
actor := parseKafkaCommandUser(payload.ActorUserID)
|
||||||
if actor.IsZero() {
|
if actor.IsZero() {
|
||||||
return nil, types.ErrMalformed
|
return nil, types.ErrMalformed
|
||||||
@@ -187,9 +200,6 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"dead_id": payload.DeadID, "status": types.KafkaDeadStatusResolved}, nil
|
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 {
|
if store.GroupBusiness == nil {
|
||||||
return nil, errors.New("group business store is not initialized")
|
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)
|
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) {
|
func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload, actor types.Uid) (map[string]any, error) {
|
||||||
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
|
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
|
||||||
if target.IsZero() {
|
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"`
|
DialTimeoutMS int `json:"dial_timeout_ms"`
|
||||||
WriteTimeoutMS int `json:"write_timeout_ms"`
|
WriteTimeoutMS int `json:"write_timeout_ms"`
|
||||||
ReadTimeoutMS int `json:"read_timeout_ms"`
|
ReadTimeoutMS int `json:"read_timeout_ms"`
|
||||||
|
BatchTimeoutMS int `json:"batch_timeout_ms"`
|
||||||
RequiredAcks int `json:"required_acks"`
|
RequiredAcks int `json:"required_acks"`
|
||||||
AllowAutoTopic bool `json:"allow_auto_topic_creation"`
|
AllowAutoTopic bool `json:"allow_auto_topic_creation"`
|
||||||
TLSEnabled bool `json:"tls_enabled"`
|
TLSEnabled bool `json:"tls_enabled"`
|
||||||
@@ -131,6 +132,7 @@ func initKafkaRuntime(raw json.RawMessage) error {
|
|||||||
Transport: transport,
|
Transport: transport,
|
||||||
RequiredAcks: kafka.RequiredAcks(cfg.RequiredAcks),
|
RequiredAcks: kafka.RequiredAcks(cfg.RequiredAcks),
|
||||||
AllowAutoTopicCreation: cfg.AllowAutoTopic,
|
AllowAutoTopicCreation: cfg.AllowAutoTopic,
|
||||||
|
BatchTimeout: time.Duration(cfg.BatchTimeoutMS) * time.Millisecond,
|
||||||
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
|
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
|
||||||
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
|
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
|
||||||
MaxAttempts: cfg.MaxRetryCount,
|
MaxAttempts: cfg.MaxRetryCount,
|
||||||
@@ -162,6 +164,9 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
|
|||||||
if cfg.ReadTimeoutMS <= 0 {
|
if cfg.ReadTimeoutMS <= 0 {
|
||||||
cfg.ReadTimeoutMS = 3000
|
cfg.ReadTimeoutMS = 3000
|
||||||
}
|
}
|
||||||
|
if cfg.BatchTimeoutMS <= 0 {
|
||||||
|
cfg.BatchTimeoutMS = 10
|
||||||
|
}
|
||||||
if cfg.RequiredAcks == 0 {
|
if cfg.RequiredAcks == 0 {
|
||||||
cfg.RequiredAcks = int(kafka.RequireOne)
|
cfg.RequiredAcks = int(kafka.RequireOne)
|
||||||
}
|
}
|
||||||
@@ -274,9 +279,13 @@ func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
|
|||||||
if topic == "" {
|
if topic == "" {
|
||||||
topic = topicName
|
topic = topicName
|
||||||
}
|
}
|
||||||
|
groupID := kafkaBus.config.ConsumerGroup
|
||||||
|
if topicName != "" {
|
||||||
|
groupID += "." + topicName
|
||||||
|
}
|
||||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||||
Brokers: kafkaBus.config.Brokers,
|
Brokers: kafkaBus.config.Brokers,
|
||||||
GroupID: kafkaBus.config.ConsumerGroup,
|
GroupID: groupID,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
StartOffset: kafka.FirstOffset,
|
StartOffset: kafka.FirstOffset,
|
||||||
MinBytes: 1,
|
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 {
|
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) {
|
func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) {
|
||||||
oldKafkaBus := kafkaBus
|
oldKafkaBus := kafkaBus
|
||||||
oldPublisher := publishKafkaAuditEventEnvelope
|
oldPublisher := publishKafkaAuditEventEnvelope
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import (
|
|||||||
// Push notifications
|
// Push notifications
|
||||||
"github.com/tinode/chat/server/push"
|
"github.com/tinode/chat/server/push"
|
||||||
_ "github.com/tinode/chat/server/push/fcm"
|
_ "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/stdout"
|
||||||
_ "github.com/tinode/chat/server/push/tnpg"
|
_ "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/records", serveIMAttendanceRecords)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins)
|
mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals)
|
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", serveIMMeetings)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
|
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
|
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -141,10 +140,7 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
fname := fdef.Id
|
fname := fdef.Id
|
||||||
ext, _ := mime.ExtensionsByType(fdef.MimeType)
|
fname += media.ExtensionByMimeType(fdef.MimeType)
|
||||||
if len(ext) > 0 {
|
|
||||||
fname += ext[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
fdef.Location = location
|
fdef.Location = location
|
||||||
// Use file path to create ETag. File paths are unique so will be the ETag.
|
// Use file path to create ETag. File paths are unique so will be the ETag.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package media
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
@@ -68,6 +69,37 @@ type AllowedOrigin struct {
|
|||||||
|
|
||||||
var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`)
|
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.
|
// GetIdFromUrl is a helper method for extracting file ID from a URL.
|
||||||
func GetIdFromUrl(url, serveUrl string) types.Uid {
|
func GetIdFromUrl(url, serveUrl string) types.Uid {
|
||||||
dir, fname := path.Split(path.Clean(url))
|
dir, fname := path.Split(path.Clean(url))
|
||||||
|
|||||||
@@ -219,3 +219,21 @@ func TestMatchCORSOrigin(t *testing.T) {
|
|||||||
func containsSubstring(str, substr string) bool {
|
func containsSubstring(str, substr string) bool {
|
||||||
return strings.Contains(strings.ToLower(str), strings.ToLower(substr))
|
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"
|
"errors"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
@@ -247,10 +246,7 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
fname := fdef.Id
|
fname := fdef.Id
|
||||||
ext, _ := mime.ExtensionsByType(fdef.MimeType)
|
fname += media.ExtensionByMimeType(fdef.MimeType)
|
||||||
if len(ext) > 0 {
|
|
||||||
fname += ext[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
fdef.Location = key
|
fdef.Location = key
|
||||||
fdef.ETag = etagFromLocation(key)
|
fdef.ETag = etagFromLocation(key)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
@@ -280,22 +279,23 @@ func (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
rc := readerCounter{reader: file}
|
rc := readerCounter{reader: file}
|
||||||
result, err := tmClient.UploadObject(context.Background(), &transfermanager.UploadObjectInput{
|
input := &transfermanager.UploadObjectInput{
|
||||||
CacheControl: aws.String(ah.conf.CacheControl),
|
CacheControl: aws.String(ah.conf.CacheControl),
|
||||||
Bucket: aws.String(ah.conf.BucketName),
|
Bucket: aws.String(ah.conf.BucketName),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
Body: &rc,
|
Body: &rc,
|
||||||
})
|
}
|
||||||
|
if fdef.MimeType != "" {
|
||||||
|
input.ContentType = aws.String(fdef.MimeType)
|
||||||
|
}
|
||||||
|
result, err := tmClient.UploadObject(context.Background(), input)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fname := fdef.Id
|
fname := fdef.Id
|
||||||
ext, _ := mime.ExtensionsByType(fdef.MimeType)
|
fname += media.ExtensionByMimeType(fdef.MimeType)
|
||||||
if len(ext) > 0 {
|
|
||||||
fname += ext[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
fdef.Location = key
|
fdef.Location = key
|
||||||
if result.ETag != nil {
|
if result.ETag != nil {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"dial_timeout_ms": 3000,
|
"dial_timeout_ms": 3000,
|
||||||
"write_timeout_ms": 3000,
|
"write_timeout_ms": 3000,
|
||||||
"read_timeout_ms": 3000,
|
"read_timeout_ms": 3000,
|
||||||
|
"batch_timeout_ms": 10,
|
||||||
"required_acks": 1,
|
"required_acks": 1,
|
||||||
"allow_auto_topic_creation": false,
|
"allow_auto_topic_creation": false,
|
||||||
"tls_enabled": false,
|
"tls_enabled": false,
|
||||||
@@ -207,6 +208,19 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"enabled": false
|
"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) {
|
func (s *Session) cleanUp(expired bool) {
|
||||||
atomic.StoreInt32(&s.terminating, 1)
|
atomic.StoreInt32(&s.terminating, 1)
|
||||||
if !s.uid.IsZero() && s.tenantID.IsValid() {
|
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 {
|
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)
|
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
|
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 {
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
// Malformed message
|
// Malformed message
|
||||||
logs.Warn.Println("s.dispatch", err, s.sid)
|
logs.Warn.Println("s.dispatch", err, s.sid)
|
||||||
s.queueOut(ErrMalformed("", "", now))
|
s.queueOut(ErrMalformed("", "", now))
|
||||||
return
|
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)
|
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) {
|
func (s *Session) dispatch(msg *ClientComMessage) {
|
||||||
now := types.TimeNow()
|
now := types.TimeNow()
|
||||||
atomic.StoreInt64(&s.lastAction, now.UnixNano())
|
atomic.StoreInt64(&s.lastAction, now.UnixNano())
|
||||||
if !s.uid.IsZero() && s.tenantID.IsValid() {
|
s.refreshPresence("s.dispatch")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should be the first block here, before any other checks.
|
// This should be the first block here, before any other checks.
|
||||||
var resp *ServerComMessage
|
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.
|
// Request to subscribe to a topic.
|
||||||
func (s *Session) subscribe(msg *ClientComMessage) {
|
func (s *Session) subscribe(msg *ClientComMessage) {
|
||||||
if strings.HasPrefix(msg.Original, "new") || strings.HasPrefix(msg.Original, "nch") {
|
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 {
|
if len(globals.iceServers) > 0 {
|
||||||
params["iceServers"] = globals.iceServers
|
params["iceServers"] = globals.iceServers
|
||||||
}
|
}
|
||||||
if globals.callEstablishmentTimeout > 0 {
|
if timeout := imCallTimeoutSeconds(); timeout > 0 {
|
||||||
params["callTimeout"] = globals.callEstablishmentTimeout
|
params["callTimeout"] = timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.proto == GRPC {
|
if s.proto == GRPC {
|
||||||
@@ -1084,6 +1114,7 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
|
|
||||||
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
|
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
|
||||||
if handler == nil {
|
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)
|
logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid)
|
||||||
s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
|
s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
|
||||||
return
|
return
|
||||||
@@ -1092,6 +1123,7 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
rec, challenge, err := handler.Authenticate(
|
rec, challenge, err := handler.Authenticate(
|
||||||
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
|
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
|
||||||
if err != nil {
|
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)
|
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
|
||||||
if resp.Ctrl.Code >= 500 {
|
if resp.Ctrl.Code >= 500 {
|
||||||
// Log internal errors
|
// Log internal errors
|
||||||
@@ -1102,6 +1134,7 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rec == nil || rec.TenantID != s.tenantID {
|
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)
|
logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid)
|
||||||
s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
|
s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
|
||||||
return
|
return
|
||||||
@@ -1116,6 +1149,7 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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)
|
logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid)
|
||||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
||||||
return
|
return
|
||||||
@@ -1137,10 +1171,11 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid)
|
||||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
||||||
} else {
|
} 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.
|
// 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 reply *ServerComMessage
|
||||||
var params map[string]any
|
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)
|
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,
|
// 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)
|
seqKey := r.seqKey(tenantID, topic)
|
||||||
seq, err := r.client.Eval(context.Background(), `
|
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])
|
redis.call("SET", KEYS[1], ARGV[1])
|
||||||
end
|
end
|
||||||
return redis.call("INCR", KEYS[1])
|
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)
|
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 meetingsMapper struct{}
|
||||||
|
type meetingNotesMapper struct{}
|
||||||
|
|
||||||
var Meetings MeetingsPersistenceInterface
|
var Meetings MeetingsPersistenceInterface
|
||||||
|
var MeetingNotes MeetingNotesPersistenceInterface
|
||||||
|
|
||||||
func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) {
|
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) {
|
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)
|
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 {
|
func validMeetingForCreate(meeting *types.Meeting) bool {
|
||||||
return strings.TrimSpace(meeting.MeetingID) != "" &&
|
return strings.TrimSpace(meeting.MeetingID) != "" &&
|
||||||
strings.TrimSpace(meeting.Title) != "" &&
|
strings.TrimSpace(meeting.Title) != "" &&
|
||||||
@@ -2324,7 +2392,7 @@ func validMeetingForCreate(meeting *types.Meeting) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validMeetingPatch(patch types.MeetingPatch) 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
|
return false
|
||||||
}
|
}
|
||||||
if patch.Title != nil && strings.TrimSpace(*patch.Title) == "" {
|
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)
|
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{}
|
type devicePushTokenMapper struct{}
|
||||||
|
|
||||||
var DevicePushTokens DevicePushTokenPersistenceInterface
|
var DevicePushTokens DevicePushTokenPersistenceInterface
|
||||||
@@ -2636,6 +2708,24 @@ func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid)
|
|||||||
return reader.TenantDevicePushTokenGetAll(tenantID, uids...)
|
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 {
|
type SafetyPersistenceInterface interface {
|
||||||
CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error)
|
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)
|
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)
|
err = tenantAdp.TenantGroupMessageSave(tenantID, msg)
|
||||||
} else {
|
} else {
|
||||||
err = tenantAdp.TenantMessageSave(tenantID, msg)
|
err = tenantAdp.TenantMessageSave(tenantID, msg)
|
||||||
if err == types.ErrDuplicate && clientMsgID == "" {
|
|
||||||
err = retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isGroupMessage && err == types.ErrDuplicate && clientMsgID != "" {
|
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
|
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 != "" {
|
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
|
||||||
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, 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
|
return nil, err
|
||||||
}
|
}
|
||||||
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
|
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
|
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) {
|
func PrepareMessagePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
|
||||||
return Messages.PreparePersist(tenantID, msg, attachmentURLs, readBySender)
|
return Messages.PreparePersist(tenantID, msg, attachmentURLs, readBySender)
|
||||||
}
|
}
|
||||||
@@ -4137,6 +4258,7 @@ type UserSecurityPersistenceInterface interface {
|
|||||||
CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error
|
CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error
|
||||||
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, 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)
|
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
|
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)
|
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 {
|
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
|
||||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -4581,6 +4714,7 @@ func init() {
|
|||||||
RegistrationConfig = registrationConfigMapper{}
|
RegistrationConfig = registrationConfigMapper{}
|
||||||
TenantEntry = tenantEntryMapper{}
|
TenantEntry = tenantEntryMapper{}
|
||||||
InternalAdminQueries = internalAdminQueryMapper{}
|
InternalAdminQueries = internalAdminQueryMapper{}
|
||||||
|
SecurityAudit = securityAuditMapper{}
|
||||||
Users = usersMapper{}
|
Users = usersMapper{}
|
||||||
Topics = topicsMapper{}
|
Topics = topicsMapper{}
|
||||||
Subs = subsMapper{}
|
Subs = subsMapper{}
|
||||||
@@ -4592,6 +4726,7 @@ func init() {
|
|||||||
Organization = organizationMapper{}
|
Organization = organizationMapper{}
|
||||||
Attendance = attendanceMapper{}
|
Attendance = attendanceMapper{}
|
||||||
Meetings = meetingsMapper{}
|
Meetings = meetingsMapper{}
|
||||||
|
MeetingNotes = meetingNotesMapper{}
|
||||||
DailyReports = dailyReportsMapper{}
|
DailyReports = dailyReportsMapper{}
|
||||||
MonthlyReports = monthlyReportsMapper{}
|
MonthlyReports = monthlyReportsMapper{}
|
||||||
Mailbox = mailboxMapper{}
|
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"
|
GroupMemberStatusActive = "active"
|
||||||
GroupMemberStatusLeft = "left"
|
GroupMemberStatusLeft = "left"
|
||||||
|
|
||||||
GroupInvitationStatusJoined = "joined"
|
GroupInvitationStatusPending = "pending"
|
||||||
|
GroupInvitationStatusJoined = "joined"
|
||||||
|
|
||||||
GroupJoinRequestStatusPending = "pending"
|
GroupJoinRequestStatusPending = "pending"
|
||||||
GroupJoinRequestStatusAccepted = "accepted"
|
GroupJoinRequestStatusAccepted = "accepted"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "time"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
MeetingStateScheduled = "scheduled"
|
MeetingStateScheduled = "scheduled"
|
||||||
|
MeetingStateEnded = "ended"
|
||||||
MeetingStateCanceled = "canceled"
|
MeetingStateCanceled = "canceled"
|
||||||
|
|
||||||
MeetingResponsePending = "pending"
|
MeetingResponsePending = "pending"
|
||||||
@@ -16,6 +17,7 @@ type Meeting struct {
|
|||||||
ID int64
|
ID int64
|
||||||
MeetingID string
|
MeetingID string
|
||||||
OrganizerID Uid
|
OrganizerID Uid
|
||||||
|
RecorderID Uid
|
||||||
Title string
|
Title string
|
||||||
StartAt time.Time
|
StartAt time.Time
|
||||||
EndAt time.Time
|
EndAt time.Time
|
||||||
@@ -42,9 +44,40 @@ type MeetingParticipant struct {
|
|||||||
|
|
||||||
type MeetingPatch struct {
|
type MeetingPatch struct {
|
||||||
Title *string
|
Title *string
|
||||||
|
RecorderID *Uid
|
||||||
StartAt *time.Time
|
StartAt *time.Time
|
||||||
EndAt *time.Time
|
EndAt *time.Time
|
||||||
Location *string
|
Location *string
|
||||||
ReminderMinutes []int
|
ReminderMinutes []int
|
||||||
Version 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 (
|
const (
|
||||||
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
|
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
|
||||||
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
|
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
|
||||||
|
PublicUploadPurposeUserAvatar = "user_avatar"
|
||||||
PublicUploadPurposeGroupAvatar = "group_avatar"
|
PublicUploadPurposeGroupAvatar = "group_avatar"
|
||||||
|
|
||||||
TenantApplicationStatusReceived = "received"
|
TenantApplicationStatusReceived = "received"
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"dial_timeout_ms": 3000,
|
"dial_timeout_ms": 3000,
|
||||||
"write_timeout_ms": 3000,
|
"write_timeout_ms": 3000,
|
||||||
"read_timeout_ms": 3000,
|
"read_timeout_ms": 3000,
|
||||||
|
"batch_timeout_ms": 10,
|
||||||
"required_acks": 1,
|
"required_acks": 1,
|
||||||
"allow_auto_topic_creation": false,
|
"allow_auto_topic_creation": false,
|
||||||
"tls_enabled": false,
|
"tls_enabled": false,
|
||||||
@@ -207,6 +208,19 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"enabled": false
|
"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
|
isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil
|
||||||
if isCall {
|
if isCall {
|
||||||
if len(globals.iceServers) == 0 {
|
if !imCallFeatureEnabled() {
|
||||||
msg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow()))
|
msg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1227,8 +1227,15 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if t.currentCall != nil {
|
if t.currentCall != nil {
|
||||||
msg.sess.queueOut(ErrCallBusyReply(msg, types.TimeNow()))
|
now := types.TimeNow()
|
||||||
return
|
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)
|
head, content, err := normalizeCallInvite(msg.Pub.Head, msg.Pub.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+38
-3
@@ -375,9 +375,18 @@ func TestHandleBroadcastCall(t *testing.T) {
|
|||||||
numUsers := 2
|
numUsers := 2
|
||||||
helper := TopicTestHelper{}
|
helper := TopicTestHelper{}
|
||||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
|
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
|
||||||
|
oldThirdParty := store.ThirdPartyConfig
|
||||||
globals.iceServers = []iceServer{{Username: "dummy"}}
|
globals.iceServers = []iceServer{{Username: "dummy"}}
|
||||||
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
||||||
|
"audio_call_enabled": "1",
|
||||||
|
"video_call_enabled": "1",
|
||||||
|
}
|
||||||
helper.topic.lastID = 5
|
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()).
|
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||||
|
|
||||||
@@ -401,8 +410,6 @@ func TestHandleBroadcastCall(t *testing.T) {
|
|||||||
t.Fatal(errorMsgs[0].Ctrl.Text)
|
t.Fatal(errorMsgs[0].Ctrl.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
globals.iceServers = nil
|
|
||||||
|
|
||||||
// Message uid1 -> uid2.
|
// Message uid1 -> uid2.
|
||||||
for i, m := range helper.results {
|
for i, m := range helper.results {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -490,9 +497,15 @@ func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
|
|||||||
numUsers := 2
|
numUsers := 2
|
||||||
helper := TopicTestHelper{}
|
helper := TopicTestHelper{}
|
||||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
|
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
|
||||||
|
oldThirdParty := store.ThirdPartyConfig
|
||||||
globals.iceServers = []iceServer{{Username: "dummy"}}
|
globals.iceServers = []iceServer{{Username: "dummy"}}
|
||||||
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
||||||
|
"audio_call_enabled": "1",
|
||||||
|
"video_call_enabled": "1",
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
globals.iceServers = nil
|
globals.iceServers = nil
|
||||||
|
store.ThirdPartyConfig = oldThirdParty
|
||||||
helper.tearDown()
|
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) {
|
func TestHandleBroadcastDataGroup(t *testing.T) {
|
||||||
topicName := "grp-test"
|
topicName := "grp-test"
|
||||||
numUsers := 4
|
numUsers := 4
|
||||||
|
|||||||
+1
-1
@@ -307,7 +307,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
|||||||
if msg.Acc.Login {
|
if msg.Acc.Login {
|
||||||
// Process user's login request.
|
// Process user's login request.
|
||||||
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)
|
_, 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 {
|
} else {
|
||||||
// Not using the new account for logging in.
|
// Not using the new account for logging in.
|
||||||
reply = NoErrCreated(msg.Id, "", msg.Timestamp)
|
reply = NoErrCreated(msg.Id, "", msg.Timestamp)
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ type testSecurityStore struct {
|
|||||||
nextCursor string
|
nextCursor string
|
||||||
unreadTenant types.TenantID
|
unreadTenant types.TenantID
|
||||||
unreadUser types.Uid
|
unreadUser types.Uid
|
||||||
|
unreadEventPrefix string
|
||||||
readTenant types.TenantID
|
readTenant types.TenantID
|
||||||
readUser types.Uid
|
readUser types.Uid
|
||||||
}
|
}
|
||||||
@@ -402,6 +403,19 @@ func (s *testSecurityStore) CountUnreadSystemNotifications(tenantID types.Tenant
|
|||||||
return count, s.err
|
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 {
|
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
|
||||||
s.readTenant = tenantID
|
s.readTenant = tenantID
|
||||||
s.readUser = uid
|
s.readUser = uid
|
||||||
|
|||||||
Reference in New Issue
Block a user