Compare commits

..
19 Commits
Author SHA1 Message Date
richjohnrambo bc101b3278 fix: query tenant-sharded private messages
Deploy im-server production / deploy (push) Successful in 7m7s
2026-10-21 12:40:40 +08:00
richjohnrambo 39b0392545 feat: add trusted customer service friendships
Deploy im-server production / deploy (push) Successful in 11m38s
2026-10-20 22:24:34 +08:00
Your Name 93a6ff0d03 fix(ci): keep im deploy scoped to service
Deploy im-server production / deploy (push) Successful in 6m19s
2026-10-20 17:56:31 +08:00
richjohnrambo 9c10862a6d fix: recreate Caddy after IM CORS update
Deploy im-server production / deploy (push) Successful in 7m58s
2026-10-19 14:34:13 +08:00
richjohnrambo 5197960e58 fix: remove conflicting production IM CORS header
Deploy im-server production / deploy (push) Successful in 7m23s
2026-10-19 14:24:57 +08:00
richjohnrambo fe5f870b6b ci: print production IM Caddy route
Deploy im-server production / deploy (push) Successful in 7m25s
2026-10-19 14:16:11 +08:00
richjohnrambo 5b270860e7 ci: inspect production IM CORS routing
Deploy im-server production / deploy (push) Successful in 8m9s
2026-10-19 14:06:20 +08:00
Your Name 209f105b12 fix: honor admin internal api toggle
Deploy im-server production / deploy (push) Successful in 4m25s
2026-10-18 20:24:39 +08:00
Your Name 5d49008960 ci: deploy pro with docker compose
Deploy im-server production / deploy (push) Failing after 8m46s
2026-10-18 20:11:07 +08:00
richjohnrambo 73c3dc0541 feat(media): add MinIO storage switch and HTTPS OSS endpoint 2026-10-17 22:35:04 +08:00
richjohnrambo 4c2c35b55b fix: enforce group file upload governance 2026-10-17 17:08:52 +08:00
richjohnrambo 178ecff21f fix(ci): align test CPU request with ArgoCD 2026-10-17 13:15:04 +08:00
richjohnrambo 9132207e21 fix: group moderation and profile sync 2026-10-17 10:12:20 +08:00
Austin ea29fef40f docs: 更新IM接口文档,新增avatar_url字段及乐观锁说明 2026-10-15 14:38:17 +08:00
Austin dbc7c6a3e9 fix(avatar): 租户路径文件 ACL 补齐头像放行条
fileMapper.UserHasAccess 恒经 TenantFileUserHasAccess,共享库多租户
模式走 tenant_adapter 的 SQL;此前第 9 条(头像链接行对登录用户放行)
只加在了基础路径 FileUserHasAccess,导致共享库环境下他人下载头像仍
403。同步补齐,与 FileUserHasAccess 保持一致。
2026-10-15 13:38:39 +08:00
Your Name 3b186298a0 fix: configure Kafka brokers at runtime 2026-10-15 12:26:09 +08:00
Austin ab572a6c36 fix(avatar): 修复群成员拉取他人头像 403 及补充 avatar_url 回填规则 2026-10-14 22:30:13 +08:00
richjohnrambo 441009b81c fix privacy friend verification updates 2026-10-14 17:11:41 +08:00
Austin 297f1fa0b7 docs: 补充注册 username 规则说明
新增服务端 username 强校验规则,包括长度、字符限制、自动生成规则及错误返回详情,明确最小长度来自部署配置、上限为 10。
2026-10-14 16:49:19 +08:00
42 changed files with 1964 additions and 314 deletions
+53 -102
View File
@@ -1,118 +1,69 @@
name: Deploy im-server (k3s test cluster)
name: Deploy im-server production
on:
push:
branches:
- pro
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
concurrency:
group: production-im-server
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/im_server
SERVICE: im-server
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs:
deploy:
runs-on: deploy-host
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: 确定镜像tag
id: vars
- name: Log in to Gitea Container Registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 构建并推送镜像(从本仓库源码编译,不是官方预编译包)
- name: Build and push image
env:
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -f docker/tinode/Dockerfile.build \
-t git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }}
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: 部署到k3s测试集群
- name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl set resources statefulset/im-server -n im-server-system -c im-server \
--requests=cpu=1,memory=512Mi --limits=cpu=2,memory=1Gi
kubectl set env statefulset/im-server -n im-server-system -c im-server \
CLUSTER_PROXY_TO_MASTER_BUFFER=4096 \
CLUSTER_PROXY_TO_MASTER_WORKERS=16 \
CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS=300 \
CLUSTER_ROUTE_RETRIES=3 \
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25 \
MYSQL_MAX_OPEN_CONNS=24 \
MYSQL_MAX_IDLE_CONNS=8 \
MYSQL_REGISTRY_MAX_OPEN_CONNS=4 \
MYSQL_REGISTRY_MAX_IDLE_CONNS=2 \
MYSQL_CONN_MAX_LIFETIME_SECONDS=1800
kubectl patch statefulset/im-server -n im-server-system --type=strategic -p \
'{"spec":{"template":{"spec":{"containers":[{"name":"im-server","readinessProbe":{"httpGet":{"path":"/healthz","port":6060},"timeoutSeconds":3,"periodSeconds":10,"failureThreshold":3},"livenessProbe":{"httpGet":{"path":"/livez","port":6060},"timeoutSeconds":3,"periodSeconds":20,"failureThreshold":3}}]}}}}'
if kubectl auth can-i get services -n im-server-system >/dev/null 2>&1 && \
kubectl auth can-i patch services -n im-server-system >/dev/null 2>&1; then
kubectl patch service/im-server -n im-server-system --type=merge -p \
'{"spec":{"sessionAffinity":"None"}}'
else
echo "::warning::ci-deployer cannot get/patch Service; skipped sessionAffinity update"
fi
if kubectl auth can-i get ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
kubectl auth can-i patch ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
kubectl get ingress/im-server -n im-server-system >/dev/null 2>&1; then
kubectl annotate ingress/im-server -n im-server-system --overwrite \
nginx.ingress.kubernetes.io/load-balance=least_conn
else
echo "::warning::ci-deployer cannot manage Ingress/im-server or it does not exist; skipped least_conn update"
fi
kubectl rollout restart statefulset/im-server -n im-server-system
if ! kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s; then
echo "::error::im-server rollout did not become ready within 300 seconds"
kubectl get statefulset/im-server -n im-server-system -o wide || true
kubectl get pods -n im-server-system -l app=im-server -o wide || true
kubectl describe statefulset/im-server -n im-server-system || true
for pod in $(kubectl get pods -n im-server-system -l app=im-server -o name 2>/dev/null); do
echo "===== ${pod} describe ====="
kubectl describe -n im-server-system "${pod}" || true
echo "===== ${pod} current log ====="
kubectl logs -n im-server-system "${pod}" -c im-server --tail=200 || true
echo "===== ${pod} previous log ====="
kubectl logs -n im-server-system "${pod}" -c im-server --previous --tail=200 || true
done
if kubectl auth can-i list events -n im-server-system >/dev/null 2>&1; then
echo "===== recent events ====="
kubectl get events -n im-server-system --sort-by=.lastTimestamp | tail -100 || true
fi
exit 1
fi
echo "Waiting for ArgoCD reconciliation before validating effective settings"
sleep 60
request_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.cpu}')"
request_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.memory}')"
limit_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.cpu}')"
limit_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.memory}')"
liveness="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].livenessProbe.httpGet.path}')"
mysql_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_OPEN_CONNS")].value}')"
mysql_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_IDLE_CONNS")].value}')"
mysql_registry_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_OPEN_CONNS")].value}')"
mysql_registry_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_IDLE_CONNS")].value}')"
mysql_conn_max_lifetime="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_CONN_MAX_LIFETIME_SECONDS")].value}')"
echo "effective requests=${request_cpu}/${request_memory} limits=${limit_cpu}/${limit_memory} liveness=${liveness} mysql=${mysql_max_open}/${mysql_max_idle} registry=${mysql_registry_max_open}/${mysql_registry_max_idle} lifetime=${mysql_conn_max_lifetime}"
if [ "${request_cpu}" != "1" ] || [ "${request_memory}" != "512Mi" ] || \
[ "${limit_cpu}" != "2" ] || [ "${limit_memory}" != "1Gi" ] || \
[ "${liveness}" != "/livez" ] || [ "${mysql_max_open}" != "24" ] || \
[ "${mysql_max_idle}" != "8" ] || [ "${mysql_registry_max_open}" != "4" ] || \
[ "${mysql_registry_max_idle}" != "2" ] || [ "${mysql_conn_max_lifetime}" != "1800" ]; then
echo "::error::ArgoCD reverted StatefulSet resources/probes/MySQL pools. Apply deploy/argocd/im-server-test/statefulset-performance-patch.yaml to the im-server-test source."
exit 1
fi
set -euo pipefail
install -d -m 700 "$HOME/.ssh"
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
cleanup() {
shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"cd /opt/olns && docker compose pull im-server && docker compose up -d --no-deps --wait im-server"
+2 -1
View File
@@ -31,10 +31,11 @@ kubectl auth can-i list pods.metrics.k8s.io \
-n im-server-system
```
Expected values are requests `1 CPU / 512Mi`, limits `2 CPU / 1Gi`, and
Expected values are requests `2 CPU / 512Mi`, limits `2 CPU / 1Gi`, and
liveness path `/livez`. The MySQL pool environment must be
`MYSQL_MAX_OPEN_CONNS=24`, `MYSQL_MAX_IDLE_CONNS=8`,
`MYSQL_REGISTRY_MAX_OPEN_CONNS=4`, and `MYSQL_REGISTRY_MAX_IDLE_CONNS=2`.
The MySQL connection lifetime must be `MYSQL_CONN_MAX_LIFETIME_SECONDS=1800`.
The WebSocket heartbeat environment must be `WS_PING_PERIOD_SECONDS=15` and
`WS_PONG_WAIT_SECONDS=90`.
The Pod DNS options must be `ndots:1`, `timeout:2`, and `attempts:2`. Keeping
@@ -46,9 +46,11 @@ spec:
value: "4"
- name: MYSQL_REGISTRY_MAX_IDLE_CONNS
value: "2"
- name: MYSQL_CONN_MAX_LIFETIME_SECONDS
value: "1800"
resources:
requests:
cpu: "1"
cpu: "2"
memory: 512Mi
limits:
cpu: "2"
+2 -2
View File
@@ -128,7 +128,7 @@
"kafka": {
"enabled": true,
"brokers": ["kafka.118im.com:30835"],
"brokers": $KAFKA_BROKERS_JSON,
"client_id": "im-server",
"consumer_group": "im-server",
"dial_timeout_ms": 3000,
@@ -177,7 +177,7 @@
}
},
"admin_internal_api": {
"enabled": true,
"enabled": $ADMIN_INTERNAL_API_ENABLED,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET",
+16 -12
View File
@@ -335,6 +335,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
**注册 `username` 规则(服务端强校验,2026-10-14 确认)**:3~10 个字符,仅允许小写字母 `a-z` 和数字 `0-9`;不允许大写、下划线、点、汉字及任何符号。不传 `username` 只传 `phone` 时服务端自动生成随机用户名(此时 `secret` 使用 `base64(手机号:密码)`)。违规表现:过短返回 `422 policy violation, params.what=auth`(本次联调实测,用户名 `zz` 即此情形);超长或含非法字符返回 `422, params.what=username, reason=USERNAME_INVALID_FORMAT`。最小长度来自部署配置 `basic` 认证的 `min_login_length`(当前部署模板为 3),服务端代码上限为 10。
##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 |
@@ -642,8 +644,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**文件上传/下载鉴权与回收规则(已实现,客户端必须遵守):**
1. 上传聊天附件(图片/视频/语音/文件)必须携带 `topic=<消息将要发送到的会话 topic>`。服务端校验上传者是该 `p2p`/`grp` 会话的订阅成员(`modewant`/`modegiven` 含读权限),不是成员返回 `403 permission denied`;校验通过则在服务端建立「文件-会话」引用,该会话全体成员凭有效 Token 即可下载。
2. 不带 `topic` 的上传只有上传者本人可下载(可用于头像等场景)。任何文件若一直未被任何会话/消息引用,服务端在创建 1 小时后由后台任务物理回收;上传后必须及时发送消息或携带 `topic`,否则对端将拿到 404。
3. 下载放行范围:上传者本人、文件所属会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用。非成员返回 `403 permission denied`(即 `FILE_FORBIDDEN`),客户端不自动重试。
2. 不带 `topic` 的上传只有上传者本人可下载。任何文件若一直未被任何会话/消息引用,服务端在创建 1 小时后由后台任务物理回收;上传后必须及时发送消息或携带 `topic`,否则对端将拿到 404。用户头像/资料类文件不受此限:客户端设置头像(PATCH `avatar_file_id`)时服务端自动写入文件引用,该文件不会被回收。
3. 下载放行范围(2026-10-14 更新):上传者本人、文件所属会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、**用户头像/资料文件(对任何登录用户放行,2026-10-14 修复:此前仅上传者可读,群成员拉取他人头像 403)**。非授权返回 `403 permission denied`(即 `FILE_FORBIDDEN`),客户端不自动重试。
4. `avatar_url` 统一回填规则(2026-10-14 起,所有返回用户/群摘要的接口一致):`avatar_file_id` 为完整 URL 时 `avatar_url` 原样返回;为裸 file_id 时返回 `"/v0/file/s/{file_id}"`(可直接下载,无需客户端拼接);头像为空时回落默认头像 URL(男/女按性别,`avatar_file_id` 与 `avatar_url` 同值)。涉及接口:`/v1/im/groups/{topic}/members`、`/members/{userId}/profile`、`/v1/im/users/{id}/profile`、`/v1/im/me/profile`、转发目标列表等。
### 0.6 Topic 与产品消息
@@ -1708,7 +1711,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增产品通讯录接口。**
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online,online_visibility`;好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,avatar_url,initial,online,online_visibility`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。
- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。
- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1771,7 +1774,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。**
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,avatar_url,gender,region,public_account,signature,relationship,capabilities`(`avatar_url` 为可直接下载的头像地址,统一回填规则见 0.5.5.1 第 4 条);`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1869,7 +1872,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
@@ -2338,10 +2341,11 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/文件-下载.png" alt="文件-下载" width="360">
**状态:下载授权已实现(按上传者/会话成员/消息引用/群成员/好友/收藏放行)。**
**状态:下载授权已实现(按上传者/会话成员/消息引用/群成员/好友/收藏/头像放行)。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。
- 服务端按文件 ACL 放行:上传者本人、文件所属 `p2p`/`grp` 会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用;非成员返回 `403 permission denied`(`FILE_FORBIDDEN`)。对象存储模式下鉴权通过返回 302 短期签名 URL。
- 服务端按文件 ACL 放行:上传者本人、文件所属 `p2p`/`grp` 会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、用户头像/资料文件(对任何登录用户放行,2026-10-14 更新);非授权返回 `403 permission denied`(`FILE_FORBIDDEN`)。对象存储模式下鉴权通过返回 302 短期签名 URL。
- 头像排障口径:群成员/资料接口返回的 `avatar_url` 可直接下载;若对端头像显示占位图且下载 `404`,说明该头像文件从未被引用或已被回收,让对方重新上传并设置头像即可;`403` 为非登录态(Token 失效),重新登录。
- 前提:发送方上传附件时带了 `topic`(见 0.5.5.1)。若对端下载一直 404,说明该文件从未关联会话/消息且已被 1 小时回收任务删除,应让发送方重新上传并携带 `topic`。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
@@ -2967,8 +2971,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`;`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,avatar_url,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`(乐观锁必填,`version` 取自最近一次 `GET /v1/im/me/profile` 的返回值);缺失或 `<=0` 返回 `400/20000 MALFORMED_REQUEST`,与当前版本不一致返回 `409/20009 VERSION_CONFLICT`(重新 GET 取最新 `version` 后重试即可);服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
@@ -2980,7 +2984,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突(`409/20009`)先拉最新资料再由用户确认。
### 10.4 出生日期.png
@@ -3173,8 +3177,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
+604
View File
@@ -0,0 +1,604 @@
# MeetingIM Web 端全功能测试用例
> 编制日期:2026-10-16;三域名实测方案补充:2026-10-17
> 适用环境:MeetingIM Web 测试环境
> 测试方式:以 Web 页面人工操作为主,浏览器 Network、WebSocket Frames、接口响应和数据库核验为辅
> 依据:当前 `dev` 代码、`IM-前后端对接文档.md`、会议笔记/App 更新/青少年模式/第三方对接文档及现有问题清单
## 1. 测试目标
本文用于对 Web 端进行完整业务验收,覆盖:
1. 登录、注册、企业入口和账号安全。
2. WebSocket 建连、消息首页、单聊、群聊、消息状态和断线恢复。
3. 好友、黑名单、群成员治理、群公告和群文件。
4. 文件上传下载、收藏、转发、聊天记录搜索和媒体预览。
5. 音视频通话、工作台、组织、会议、日报、月报和会议笔记。
6. 个人资料、隐私、版本检查、举报、反馈和跨租户隔离。
7. 页面四态:加载、空数据、正常数据、失败/重试。
本文不把“接口返回 200”视为完成。每个用例至少同时检查页面结果、网络请求、刷新后的持久化结果;涉及双用户的场景还要检查接收方实时表现和离线补偿。
## 2. 当前服务边界
| 能力 | Web 请求入口 | 服务归属 |
| --- | --- | --- |
| 登录、聊天、好友、群、文件、通话、资料、安全 | `/v1/im/*`、`/v0/channels`、`/v0/file/*` | `im_server` |
| 工作台、组织、考勤、会议、会议笔记、日报、月报、信箱 | `/v1/workbench/*` | `im_workbench` |
| 用户条款、隐私政策 | `/legal/*` | `im_server` |
| Web 页面 | `https://web1.118im.com`、`https://web2.118im.com`、`https://web3.118im.com` | 本轮三个独立登录域名 |
| REST API | `https://api.118im.com` | API Gateway |
| WebSocket | `wss://api.118im.com/v0/channels?apikey=...` | API Gateway -> `im_server` |
工作台页面如果仍请求 `/v1/im/workbench`、`/v1/im/meetings` 等旧路径,应直接记录为前端路由缺陷。新路径必须是 `/v1/workbench/*`。
### 2.1 验收判定优先级
代码和文档可能处于不同迭代阶段,验收时按以下顺序判定:
1. 当前测试环境实际行为和当前 `dev` 代码是实现事实。
2. 专项文档(如工作台拆分、App 版本、会议笔记)优先于旧版综合文档。
3. `IM-前后端对接文档.md` 是完整目标契约;其中标记“新增”、“需改造”、“待实现”的内容必须通过实测确认,不能仅根据文档判定通过。
4. 历史问题清单只用于选取高风险回归场景,实测通过后应更新结论,不得将历史结论直接当作当前缺陷。
### 2.2 参考文档
- `docs/IM-前后端对接文档.md`
- `docs/IM-工作台服务拆分迁移稳定方案.md`
- `docs/IM-会议笔记前后端对接文档.md`
- `docs/IM-App版本更新前后端对接文档.md`
- `docs/third/IM-声网音视频通话完整调用流程.md`
- `docs/third/IM-文件安全第三方接入记录.md`
- `docs/problems/IM-后端问题清单 (3).md`
## 3. 测试环境与账号准备
### 3.1 三域名浏览器准备
| 编号 | 准备项 | 要求 |
| --- | --- | --- |
| ENV-01 | 页面 A | `https://web1.118im.com/tenant`,从企业码 `system` 进入,注册并登录用户 A,负责发起操作 |
| ENV-02 | 页面 B | `https://web2.118im.com/tenant`,从企业码 `system` 进入,注册并登录用户 B,负责接收、审批、回执 |
| ENV-03 | 页面 C | `https://web3.118im.com/tenant`,从企业码 `system` 进入,注册并登录用户 C,负责群第三成员、离线恢复和旁观校验 |
| ENV-04 | 开发者工具 | 保留 Network 日志,开启 Preserve log,显示 WS Frames |
| ENV-05 | 网络模拟 | 准备 Offline、Slow 3G、断网 15 秒后恢复三种场景 |
| ENV-06 | 缓存场景 | 对指定域名分别执行普通刷新、强制刷新、退出重登和清站点数据;不要一次清掉三个域名 |
三个域名用于在同一浏览器中并行保留三个登录态;实测时分别核对 Cookie 的 Domain、LocalStorage、Token 和 WebSocket,不能仅凭域名不同就推定不会串号。三个页面都选 `system`,此举只实现**不同账号并行登录同一商户**。跨租户用例需另备真实可用的第二商户,并另行执行。
**当前页面观察(2026-10-17)**:三个 `/tenant` 页均可输入企业码;`web1` 输入 `system` 可进入 `/login`,页面显示 `system merchant`。`/tenant` 的“企业注册”进入 `/enterprise-register`,其字段为统一社会信用代码、营业执照、企业邮箱和提交审核,属于**申请新企业**。当前 `/login` 可见控件中未出现“新用户注册”入口。开始执行第 3.4 节前,先完成 `FLOW-REG-01` 的入口检查;若入口仍缺失,记录 Web 前端阻塞,不要把企业申请当作用户 A/B/C 注册成功,也不要为了绕过页面手工插入账号后宣称注册用例通过。
### 3.2 测试账号矩阵
| 代号 | 租户 | 角色/关系 | 必备条件 |
| --- | --- | --- | --- |
| A | `system` | 新注册用户 | 在 `web1` 注册,作为发起方和群主;建议短信方式绑定手机号 |
| B | `system` | 新注册用户 | 在 `web2` 注册,作为接收方;建议有效邀请码方式验证推荐关系 |
| C | `system` | 新注册用户 | 在 `web3` 注册,作为第三方、离线方;与 A/B 使用不同手机号 |
| O | `system` | 群主 | 优先由 A 创建群 G1 后承担,无需额外账号 |
| M | `system` | 群管理员 | 由 A 将 B 提升为管理员 |
| N | `system` | 普通群成员 | 由 C 承担 |
| X | 另一个真实商户 | 跨租户用户 | 仅执行第 17 章时准备;不得把 `web3` 当成另一个商户 |
| P | `system` | 无手机号用户 | 仅当页面和服务端支持时注册;用于忘记密码负向测试 |
| R | `system` | 管理范围用户 | 如 A/B/C 均无组织管理权限,另备管理账号 |
注册前选定未使用的用户名/手机号,并准备 `system` 商户有效的邀请码;不要复用已有测试用户作为“新注册成功”的证据。注册完成后记录每个账号的 `username`、`public_account`、`usr...` 用户 ID、群 Topic 和初始资料版本。不要在本文或缺陷单中记录密码、完整 Token、验证码票据和 OSS 签名参数。
### 3.3 测试数据
| 数据 | 要求 |
| --- | --- |
| 群 G1 | A/O 为群主,B/M 为管理员,C/N 为普通成员,开启群公告和群文件 |
| 群 G2 | A 不在群内,用于越权测试 |
| 文件 F1 | 小于 1 MB 的 PNG |
| 文件 F2 | 2~5 MB 的 PDF |
| 文件 F3 | 10 MB 左右的视频 |
| 文件 F4 | 大于前端允许上限的文件 |
| 消息数据 | 文本、链接、图片、视频、语音、文件、名片、回复、已撤回消息各至少一条 |
| 工作台数据 | 部门、成员、会议、日报、月报、会议笔记各至少一条 |
### 3.4 从注册开始的三页面执行剧本
按下表顺序执行,记录每一步是否能通过**可见 Web 页面**完成。注册成功才进入好友、单聊和群聊;入口不可达时,后续依赖新账号的用例标记“阻塞”,可另用预置账号单独继续其他功能验收,但不覆盖注册结果。`system` 商户已存在,用户注册应提交 `registration_type=0` 和 `tenant_code=system`;“企业注册”审核表单不是本节注册动作。
| ID | 优先级 | 页面/操作 | 页面、Network/WS 与跨页预期 |
| --- | --- | --- | --- |
| FLOW-REG-01 | P0 | A 从 `web1/tenant` 输入 `system` 进入登录页,寻找“新用户注册”;B/C 同样检查 | 可从页面进入 **system 企业内用户注册**;若只有 `/enterprise-register`,记录实际页面截图和 URL,判“注册入口阻塞” |
| FLOW-REG-02 | P0 | A 在注册页填独立的用户名、手机号、密码,选择短信验证码 | 显示 `system` 企业上下文;注册请求带 `registration_type=0`、`tenant_code=system`,不进入 `default` |
| FLOW-REG-03 | P0 | A 点击获取验证码,输入测试环境当前有效验证码,完成校验并提交注册 | 创建 challenge,按接口倒计时;校验后一次性票据用于注册;成功返回 `usr...`、最终 username、Token,A 进入消息首页;测试码 `123456` 仅在该部署仍明确启用时使用 |
| FLOW-REG-04 | P0 | B 在 `web2` 使用另一组用户名/手机号和有效 5 位邀请码注册 | 统一输入框识别邀请码,保存手机号和推荐关系;邀请码方式不冒充“手机号已短信验证”;B 进入独立登录态 |
| FLOW-REG-05 | P0 | C 在 `web3` 使用第三组用户名/手机号注册 | C 与 A/B 均属于 `system` 且 ID 各异;三个域名同时在线,各自显示自己的资料和 WebSocket |
| FLOW-REG-06 | P0 | 三页分别刷新,再单独退出并重新登录 B | A/C 仍保持各自会话;B 退出后只清 `web2` 登录态,重登到 B,不显示 A/C 的会话或缓存 |
| FLOW-REG-06A | P0 | A/B/C 三个页面同时在线,分别查看个人资料和会话页 | 各页身份、Token 和 WS 登录用户分别对应 A/B/C;如果共享顶级域 Cookie 导致串号,记录为多开阻塞 |
| FLOW-REG-07 | P0 | A 搜索 B 并申请好友,B 在 `web2` 接受,A 给 B 发消息 | B 实时出现申请/消息;A/B 会话、未读、回执、历史一致;C 无关页面不误收单聊 |
| FLOW-REG-08 | P0 | A 创建 G1 并邀请 B/C,B/C 分别加入 | 三页成员数一致;A 发群消息 B/C 实时收到,B/C 回信 A 可见 |
| FLOW-REG-09 | P0 | 将 C 页面断网,A/B 继续在 G1 发消息,C 恢复网络 | C 按群 Topic seq 补齐离线消息,顺序正确、无重复,未读数只计算 C 自己的状态 |
| FLOW-REG-10 | P1 | A 把 B 设为管理员;B 修改公告或审批入群,C 查看 | B 的治理入口和服务端权限同步;C 仅见自己有权看的通知,三页刷新后状态一致 |
| FLOW-REG-11 | P1 | A 给 B 发图片/PDF;B 下载,C 从群内访问群文件 | 上传走 OSS/文件服务;仅授权会话/群成员能下载;文件内容与原文件一致 |
| FLOW-REG-12 | P1 | 三页依次完成资料修改、收藏、搜索、工作台检查 | 每次操作按第 7~16 章对应详细用例记录;A/B/C 的资料、未读和权限不能串号 |
| FLOW-REG-13 | P1 | 同一 `system` 商户内用独立新账号再次输入 B 已用的用户名或手机号 | 分别提示用户名/手机号已占用,不覆盖 B,也不误判为跨租户冲突 |
| FLOW-REG-14 | P1 | 依次输入 2 位用户名、大写或下划线用户名、无效邀请码、错误或过期验证码 | 页面给出可定位的校验错误;绕过前端提交仍由服务端拒绝;不创建用户 |
| FLOW-REG-15 | P1 | A 注册完成后在 `web2` 用 A 登录,再从 `web2` 登出 | A 在 `web1` 的会话按产品多端规则保持或收到明确互踢提示;B/C 账号不能被顶替或清空 |
注册失败分支还需覆盖:用户名重复或格式非法、手机号重复、密码不符合策略、验证码错误/过期/重放、无效/过期/撤销邀请码、频繁取码、`system` 商户停用或用户配额满。负向用例不得消耗 A/B/C 计划使用的有效用户名和手机号。
## 4. 通用验收规则
### 4.1 每条用例必须记录
| 字段 | 内容 |
| --- | --- |
| 测试结果 | 通过 / 失败 / 阻塞 / 不适用 |
| 测试账号 | 账号代号,不写密码 |
| 时间 | 精确到秒,便于查询日志 |
| Request ID | HTTP 响应或请求头中的 `X-Request-ID` |
| Topic/Seq | 消息相关用例记录 Topic 和 Seq,按字符串保存 |
| 截图 | 页面结果和必要的 Network/WS Frames |
| 缺陷说明 | 实际结果、预期结果、复现率、是否刷新后仍存在 |
### 4.2 通用网络检查
所有 `/v1/im/*` 和 `/v1/workbench/*` 请求应满足:
- 使用 HTTPS,不出现 Mixed Content。
- 登录后携带 `Authorization: Bearer ...`,日志中必须打码。
- 携带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`。
- 写请求携带 `Idempotency-Key`;重试同一操作不得重复创建数据。
- 成功响应包含 `code=0`、`request_id`、`server_time`。
- 失败时前端按稳定 `reason` 展示本地化文案,不能直接展示服务端英文 `message`。
- ID、Topic、Seq、cursor 在 JavaScript 中按字符串处理,不能发生精度丢失或科学计数法。
### 4.3 WebSocket 检查
- Upgrade 成功后第一条业务帧必须是 `hi`,且携带当前企业码。
- `hi` 成功后才允许 `login`;登录成功后再订阅 `me`、`fnd` 和会话 Topic。
- 页面不能建立无上限重复连接;刷新、重登、切换企业后旧连接应关闭。
- 断线重连后按 Topic seq 增量补偿,不重复展示、不丢消息。
- 同一消息重发使用相同 `client_mid`,服务端只能生成一条业务消息。
## 5. P0 冒烟用例
以下用例应在每次 Web 发版后优先执行。
| ID | 场景 | Web 操作 | 预期结果 |
| --- | --- | --- | --- |
| SMK-01 | 打开企业入口 | 依次访问 `web1`、`web2`、`web3` 的 `/tenant` | 页面正常加载,无白屏、无限刷新和 404 静态资源 |
| SMK-01A | 新用户注册入口 | 三页输入 `system` 后,从登录页打开新用户注册 | 进入 `system` 内用户注册页;若只有企业申请入口,记录 P0 阻塞 |
| SMK-01B | 三账号注册 | A/B/C 各在指定域名完成注册 | 三个新账号均属于 `system`,各自进入消息首页且互不串号 |
| SMK-02 | 企业登录 | 在 `web1` 输入 `system`、A 账号密码登录 | `hi` 绑定 `system`,登录成功进入消息页 |
| SMK-03 | Token 恢复 | 刷新页面 | 使用 Token 恢复,不再次要求输入密码,旧 WS 被关闭 |
| SMK-04 | 消息首页 | 进入消息页 | 会话列表、未读数、系统消息入口正常 |
| SMK-05 | 单聊文本 | A 给 B 发消息 | A 获得 ACK/seq,B 实时收到且刷新后仍存在 |
| SMK-06 | 单聊图片 | A 上传并发送 PNG | B 可查看原图,刷新后可再次打开 |
| SMK-07 | 已读回执 | B 打开会话 | A 端已读状态更新且游标不回退 |
| SMK-08 | 好友列表 | 打开通讯录 | 好友昵称、备注、头像、在线状态正常 |
| SMK-09 | 群聊文本 | A 在 G1 发消息 | 在线成员实时收到,离线后重连可补偿 |
| SMK-10 | 群成员 | 打开 G1 成员页 | 成员数量和列表一致,分页无重复/缺失 |
| SMK-11 | 文件下载 | 发送并下载 PDF | 下载成功,文件大小和内容正确 |
| SMK-12 | 个人资料 | 修改昵称后刷新 | 当前页、会话、好友/群成员投影最终一致 |
| SMK-13 | 工作台 | 打开工作台 | 请求走 `/v1/workbench/*`,模块正常显示 |
| SMK-14 | 登出 | 点击退出登录 | Token 被撤销,返回登录页,后续请求为 401 |
| SMK-15 | 跨租户 | 用 `system` Token 请求另一个真实商户的数据 | 返回 403/404,不出现跨租户数据;需额外商户数据,不能用 `web3` 代替 |
## 6. 登录、注册与账号安全
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-AUTH-01 | P0 | 企业码 `system` + 正确账号密码登录 | 登录成功;URL、日志、LocalStorage 不出现明文密码 |
| WEB-AUTH-02 | P0 | 输入错误密码 | 页面显示“账号或密码错误”,保留账号、清空密码,不泄露账号是否存在 |
| WEB-AUTH-03 | P0 | 输入不存在账号 | 与错误密码的状态码、页面文案一致 |
| WEB-AUTH-04 | P0 | 输入不存在或停用企业码 | 显示企业不可用,不建立已登录 Session |
| WEB-AUTH-05 | P0 | A 登录后切到另一个真实商户再刷新 | 不得携带 `system` Token 自动进入;应清租户缓存并重新登录 |
| WEB-AUTH-06 | P0 | 登录成功后普通刷新、强刷、关闭再打开 | Token 恢复稳定,只保留一个有效 WebSocket |
| WEB-AUTH-07 | P1 | 连续快速点击登录按钮 | 只提交一次或正确禁用按钮,不产生并发登录风暴 |
| WEB-AUTH-08 | P1 | 模拟登录接口 Slow 3G | 展示加载态,可恢复,不允许重复提交 |
| WEB-AUTH-09 | P1 | 在注册页打开用户条款和隐私政策 | 在页内弹窗加载 `/legal/*` 正文,无需登录;关闭后保留注册表单,不自动勾选协议 |
| WEB-AUTH-10 | P0 | 从登录页进入 `system` 商户的新用户注册页 | 页面入口可见,注册按企业用户 `registration_type=0`、`tenant_code=system` 处理;不得进入 `/enterprise-register` 企业申请流程 |
| WEB-AUTH-11 | P0 | 用户名注册,验证码框填写有效 5 位邀请码 | 注册成功,邀请码可重复使用,建立推荐关系 |
| WEB-AUTH-12 | P0 | 手机号注册,使用测试验证码 | 验证成功后注册;倒计时以接口返回值为准 |
| WEB-AUTH-13 | P1 | 验证码填写错误、过期、重复使用 | 分别显示明确状态,已消费票据不可重放 |
| WEB-AUTH-14 | P1 | 倒计时内重复点击获取验证码 | 按 `retry_after_seconds` 禁用,不生成多个有效挑战 |
| WEB-AUTH-15 | P0 | 有手机号账号执行忘记密码 | 发码、校验、重置成功;旧 Token 和旧密码失效 |
| WEB-AUTH-16 | P1 | 无手机号账号 P 点击忘记密码 | 提示联系管理员,不进入短信流程 |
| WEB-AUTH-17 | P0 | 登录后点击退出 | `/v1/im/auth/logout` 成功,缓存和 Token 清除,后退不能回主页面 |
| WEB-AUTH-18 | P1 | 两个浏览器登录同一账号,一个修改密码 | 旧 Token 失效;另一端退出或下次请求进入登录页 |
| WEB-AUTH-19 | P1 | 直接篡改 LocalStorage 中 Token | 返回认证失败,不出现重试死循环 |
| WEB-AUTH-20 | P1 | 从 `/tenant` 单独进入企业申请页上传营业执照,或进入问题反馈页上传截图 | 上传进度和错误态正常,文件走 OSS/文件服务,不把 base64 正文塞进业务 JSON;企业申请成功不等于 `system` 用户注册成功 |
## 7. 启动、导航、会话列表与系统通知
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-HOME-01 | P0 | 登录后进入首页 | bootstrap、WS 订阅和会话列表顺序正确,无重复初始化 |
| WEB-HOME-02 | P0 | 检查总未读数和每会话未读数 | 数量一致;打开会话后对应未读归零,总数同步减少 |
| WEB-HOME-03 | P1 | 完成三账号主流程后,A 再从 `web2` 登录同一账号,一端读消息 | 另一端未读状态最终同步,不出现负数;测试结束后 `web2` 重新登录 B |
| WEB-HOME-04 | P1 | 置顶一个群聊 | 刷新后仍置顶;取消后恢复更新时间排序 |
| WEB-HOME-05 | P1 | 置顶一个单聊 | 刷新后列表可识别置顶;若页面丢失置顶,记录批量设置读取缺口 |
| WEB-HOME-06 | P1 | 设置会话免打扰 | 刷新后保持,来消息不改变设置 |
| WEB-HOME-07 | P0 | 浏览器切换 Offline 15 秒,B 连续发 3 条消息,再恢复 | A 自动重连并补齐 3 条,顺序正确、无重复 |
| WEB-HOME-08 | P1 | WS 重连期间切换会话 | 页面不白屏,不把消息写入错误会话 |
| WEB-HOME-09 | P1 | 搜索会话名称和消息摘要 | 只显示当前租户可见会话,清空关键字恢复完整列表 |
| WEB-HOME-10 | P1 | 打开系统消息页 | 好友、群、会议、安全等通知标题和摘要可读,不显示机器码 |
| WEB-HOME-11 | P1 | 点击系统通知 | 跳转到正确对象;已删除对象显示合理空态 |
| WEB-HOME-12 | P1 | 同一通知重复标记已读 | 已读时间保持第一次持久化值,不重复增加未读/已读计数 |
| WEB-HOME-13 | P1 | 空账号首次登录 | 会话页显示空态和可用入口,不出现错误占位 |
| WEB-HOME-14 | P0 | 把 API Key 改成无效值后打开页面 | WS Upgrade 403,页面提示环境配置问题并停止无限重连 |
## 8. 单聊与消息状态
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-P2P-01 | P0 | A 从好友页点击“发消息” | 创建/取得唯一 P2P Topic,进入正确会话 |
| WEB-P2P-02 | P0 | A 连续发送 10 条文本 | Seq 严格递增,B 顺序一致,刷新后历史一致 |
| WEB-P2P-03 | P0 | 发送按钮双击或网络重试 | 相同 `client_mid` 只落一条消息 |
| WEB-P2P-04 | P1 | 发送空白、超长文本 | 前端拦截;绕过前端后服务端仍校验 |
| WEB-P2P-05 | P0 | B 在线接收 | B 无需刷新实时收到;会话摘要和未读更新 |
| WEB-P2P-06 | P0 | B 离线,A 发消息,B 再登录 | B 按 seq 拉取补偿;Web 不依赖移动 Push 才能恢复消息 |
| WEB-P2P-07 | P1 | B 收到后不打开/打开会话 | A 端送达、已读状态分别正确变化 |
| WEB-P2P-08 | P1 | A 删除本地会话后重新搜索 B | 历史保留策略符合产品约定,不重复创建 Topic |
| WEB-P2P-09 | P0 | A 拉黑 B,B 尝试发送 | 服务端拒绝;A 不收到;双方页面关系状态一致 |
| WEB-P2P-10 | P0 | A 删除 B 好友,双方尝试发送 | 历史可查看,新增消息被拒绝并展示好友关系提示 |
| WEB-P2P-11 | P1 | A 修改 B 的备注 | 好友列表、资料页、会话标题一致;原昵称仍可查看 |
| WEB-P2P-12 | P1 | 向上翻页历史消息 | 使用 WS `get data before/limit`,无重复、无跳页 |
| WEB-P2P-13 | P1 | 清除聊天记录 | 只影响当前用户可见历史,不误删对方数据;刷新后保持 |
| WEB-P2P-14 | P1 | 直接在地址栏访问无权限 P2P Topic | 返回无权限/不存在,不泄露对方资料 |
## 9. 消息类型与消息操作
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-MSG-01 | P0 | 发送 Unicode 表情和多行文本 | 编码正确,刷新后内容一致 |
| WEB-MSG-02 | P1 | 发送网页链接 | 显示链接卡片或可点击链接;收藏“链接”分类可检索到 |
| WEB-MSG-03 | P0 | 上传并发送图片 | 本地预览、上传中、成功、失败重试状态完整 |
| WEB-MSG-04 | P0 | 上传并发送视频 | 封面、大小、时长字段正常,可播放/下载 |
| WEB-MSG-05 | P1 | 浏览器授权麦克风后发送语音 | 授权拒绝有提示;授权后录制、取消、发送正常 |
| WEB-MSG-06 | P0 | 发送 PDF/普通文件 | 文件名、大小、类型正确,收件人可下载 |
| WEB-MSG-07 | P1 | 发送联系人名片 | 点击名片进入正确用户资料,不串租户 |
| WEB-MSG-08 | P1 | 回复一条消息 | 引用摘要正确;原消息撤回后引用展示降级合理 |
| WEB-MSG-09 | P1 | 群聊 @单个成员 | 被提醒人收到正确提示,其他成员无错误强提醒 |
| WEB-MSG-10 | P1 | 群主/管理员使用 @所有人 | 权限允许;普通成员绕过 UI 调用应被拒绝 |
| WEB-MSG-11 | P0 | 在允许时间内撤回自己的消息 | 双方实时变成“已撤回”,刷新后仍一致 |
| WEB-MSG-12 | P1 | 超时撤回、撤回他人消息 | 服务端拒绝,原消息不变 |
| WEB-MSG-13 | P1 | 编辑文本消息 | 内容和版本更新;另一端实时或通过 mutation 水位同步 |
| WEB-MSG-14 | P1 | 多选删除“仅自己” | 当前用户隐藏,其他用户仍可见 |
| WEB-MSG-15 | P1 | 逐条转发到单聊和群聊 | 每条消息结构、发送人和文件引用正确 |
| WEB-MSG-16 | P1 | 合并转发多条混合消息 | 打开合并卡片能看到稳定快照,不依赖源会话权限 |
| WEB-MSG-17 | P0 | 并发快速转发 12 次 | 页面成功/失败必须与实际落库一致,不得失败后实际已发送 |
| WEB-MSG-18 | P1 | 带附言转发 | 附言和转发内容类型一致,接收端可正常渲染 |
| WEB-MSG-19 | P1 | 批量撤回部分成功、部分失败 | 页面逐条展示结果,成功项撤回,失败项保留 |
| WEB-MSG-20 | P1 | 页面刷新后同步消息变更 | mutation 水位连续,不重复应用撤回/编辑事件 |
## 10. 通讯录、好友与黑名单
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-FRIEND-01 | P0 | 打开通讯录 | 分组、首字母、搜索、头像、备注正确 |
| WEB-FRIEND-02 | P0 | 精确搜索 C 的用户名/公开账号 | 返回 C,ID 保持 `usr...`,可进入资料页 |
| WEB-FRIEND-03 | P0 | 搜索跨租户 X | 不返回结果,不泄露用户存在性 |
| WEB-FRIEND-04 | P1 | C 设置“仅好友可见”,A 查看 C 资料 | 非好友返回受限页/403;不能显示完整隐私资料 |
| WEB-FRIEND-05 | P0 | A 向 C 发好友申请 | C 的新朋友入口实时或刷新后出现,未读数增加 |
| WEB-FRIEND-06 | P1 | 重复发送同一好友申请 | 不创建重复申请,页面显示当前状态 |
| WEB-FRIEND-07 | P0 | C 接受申请 | 双方好友列表出现,P2P Topic 可用,系统通知正确 |
| WEB-FRIEND-08 | P1 | C 拒绝申请 | A 收到可读通知,双方不建立好友关系 |
| WEB-FRIEND-09 | P1 | 接受/拒绝不存在或已处理申请 | 返回资源不存在/状态冲突,不出现 500 |
| WEB-FRIEND-10 | P1 | A 修改好友备注后刷新资料页 | 备注保持,原昵称单独存在,不被备注覆盖丢失 |
| WEB-FRIEND-11 | P0 | A 拉黑 B | 黑名单页出现;好友列表、会话上下文和资料页状态一致 |
| WEB-FRIEND-12 | P0 | B 在被拉黑后发送/呼叫 A | 服务端拒绝,A 不收到消息或呼叫 |
| WEB-FRIEND-13 | P1 | A 解除拉黑 | 黑名单移除,关系按业务规则恢复,不自动制造错误好友关系 |
| WEB-FRIEND-14 | P1 | 批量移出黑名单 | 成功和失败项分别显示,刷新后保持 |
| WEB-FRIEND-15 | P0 | A 删除 B 好友 | 双方关系解除,历史保留,发送权限关闭 |
| WEB-FRIEND-16 | P1 | 生成个人二维码并解析 | 展示资料正确;过期、跨用途 Token 被拒绝 |
## 11. 群聊、成员与治理
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-GRP-01 | P0 | A 创建群并选择 B/N | 创建成功,群主/成员/订阅一致,成员数准确 |
| WEB-GRP-02 | P1 | 重复点击创建或重放幂等键 | 只创建一个群 |
| WEB-GRP-03 | P0 | 打开群列表并按拥有/管理/加入筛选 | 分类和角色正确,分页无重漏 |
| WEB-GRP-04 | P0 | 修改群名、头像、简介 | 所有在线成员实时或刷新后看到新资料 |
| WEB-GRP-05 | P1 | 使用旧 version 并发修改群资料 | 一个成功,一个 `VERSION_CONFLICT`,页面可刷新恢复 |
| WEB-GRP-06 | P0 | 拉取全部群成员 | 首屏和分页完整,成员总数与实际列表一致 |
| WEB-GRP-07 | P1 | 点击单个成员资料 | 只请求单成员详情,不重复拉全量成员页 |
| WEB-GRP-08 | P1 | 搜索成员、按管理员角色筛选 | 写操作后结果及时更新,不出现 90 秒旧缓存 |
| WEB-GRP-09 | P0 | O 将 N 设为管理员 | 角色、权限按钮、成员列表和 WS 管理事件一致 |
| WEB-GRP-10 | P0 | O 取消 M 管理员 | M 立即失去治理权限,绕过 UI 调用也被拒绝 |
| WEB-GRP-11 | P0 | M 移除普通成员 N | N 离群,群成员数和会话状态同步 |
| WEB-GRP-12 | P1 | M 尝试移除 O、自己或同级不允许对象 | 页面禁用;绕过 UI 仍被服务端拒绝 |
| WEB-GRP-13 | P0 | O 禁言 N,N 发送消息 | 发送被拒绝,其他成员不收到 |
| WEB-GRP-14 | P1 | M 尝试禁言群主或用户自我禁言 | 应被拒绝;若成功,记录角色层级缺陷 |
| WEB-GRP-15 | P0 | 开启全员禁言 | 普通成员不可发送,群主/管理员按规则可发送 |
| WEB-GRP-16 | P1 | O 设置允许/禁止成员邀请 | 普通成员邀请入口和服务端权限同步 |
| WEB-GRP-17 | P0 | 直接邀请用户入群 | 无审批时直接加入;有审批时生成待处理申请 |
| WEB-GRP-18 | P0 | 通过二维码申请入群 | Token 有效时进入正确群;过期/跨群 Token 拒绝 |
| WEB-GRP-19 | P0 | 管理员同意/拒绝入群申请 | 申请状态、成员关系、系统消息和成员数一致 |
| WEB-GRP-20 | P1 | 并发同意达到成员上限的两个申请 | 至多一个成功,成员数不超过上限 |
| WEB-GRP-21 | P1 | 发布、编辑、删除群公告 | 成员实时看到;权限和版本控制正确 |
| WEB-GRP-22 | P1 | 普通成员查看公告并标记已读 | 已读状态保持;已读人数/总人数可正确展示 |
| WEB-GRP-23 | P1 | 修改本人群昵称 | 群成员页和新消息发送者名称更新,个人昵称不被改动 |
| WEB-GRP-24 | P0 | O 转让群主给 M | 所有权、角色、权限原子切换;旧群主不能继续执行群主专属操作 |
| WEB-GRP-25 | P1 | 普通成员退出群 | 会话和成员列表更新;群主不能直接退出而不转让/解散 |
| WEB-GRP-26 | P0 | O 解散群 | 所有成员收到状态变化,后续访问返回 `GROUP_DISSOLVED`/410 |
| WEB-GRP-27 | P1 | 非成员访问 G2 资料、成员、设置、文件 | 返回 403/404,不泄露群数据 |
| WEB-GRP-28 | P0 | 群消息断网恢复 | 恢复后按群 Topic seq 补齐,顺序正确、无重复 |
## 12. 文件、收藏与聊天记录搜索
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-FILE-01 | P0 | 上传 PNG/PDF/视频 | 进度、取消、失败重试、成功态完整,文件存入 OSS/文件服务 |
| WEB-FILE-02 | P0 | 聊天附件上传时检查请求 | 必须带正确 Topic 和 purpose,不能只上传后裸引用 |
| WEB-FILE-03 | P1 | 上传超限文件 F4 | 前端预检查并阻止;服务端也返回明确 `FILE_TOO_LARGE` |
| WEB-FILE-04 | P1 | 上传不允许类型或伪造 MIME | 服务端校验,不因扩展名放行 |
| WEB-FILE-05 | P0 | 接收者下载附件 | 鉴权成功;对象存储 302 可跟随;文件完整 |
| WEB-FILE-06 | P0 | 非会话成员使用已知 file_id 下载 | 返回 `FILE_FORBIDDEN`/404,不泄露 OSS 地址 |
| WEB-FILE-07 | P1 | 下载中断后 Range 续传 | 只下载剩余部分,ETag/大小一致 |
| WEB-FILE-08 | P1 | Token 失效后重新登录继续下载 | 使用新 Token 恢复,不重复已完成分片 |
| WEB-FILE-09 | P1 | 下载不存在、审核中、已拒绝文件 | 分别显示不可用/审核中/违规提示,不出现 500 |
| WEB-FAV-01 | P0 | 收藏文本、图片、语音、文件、链接 | 各分类可查询,重复收藏不产生重复项 |
| WEB-FAV-02 | P1 | 取消收藏 | 列表立即更新,刷新后保持 |
| WEB-FAV-03 | P1 | 收藏列表分页超过 20 条 | 无边界记录丢失、重复和死循环 |
| WEB-FAV-04 | P1 | 从收藏转发给好友/群 | 内容可用、权限正确,转发结果与实际落库一致 |
| WEB-SEARCH-01 | P0 | 搜索明确存在的文本关键词 | 返回全部合法命中,点击后定位到上下文消息 |
| WEB-SEARCH-02 | P1 | 按文件、链接、图片、视频筛选 | 类型正确,ID 保持 `usr...`/Topic 原格式 |
| WEB-SEARCH-03 | P1 | 搜索已撤回、仅自己删除、无权限消息 | 按可见性过滤,不泄露内容 |
| WEB-SEARCH-04 | P1 | 特殊字符、中文、大小写、空关键词 | 页面稳定,结果符合约定,不执行危险模糊查询 |
## 13. 实时语音与视频通话
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-CALL-01 | P0 | A 向在线好友 B 发起语音通话 | B 实时收到邀请,双方显示同一 call_id |
| WEB-CALL-02 | P0 | B 接听并挂断 | 状态按邀请、接通、结束变化;通话记录双方一致 |
| WEB-CALL-03 | P1 | B 拒绝 | A 显示已拒绝,记录结束原因正确 |
| WEB-CALL-04 | P1 | B 不接听直到超时 | 双方记录为未接/超时,不永久停留呼叫中 |
| WEB-CALL-05 | P1 | A 主叫时取消 | B 邀请立即消失,状态一致 |
| WEB-CALL-06 | P1 | 拒绝浏览器麦克风/摄像头权限 | 页面提示授权方法,不创建假通话中状态 |
| WEB-CALL-07 | P1 | 通话中开关麦克风、摄像头、扬声器 | 本地状态和对端媒体表现符合预期 |
| WEB-CALL-08 | P1 | 通话中刷新或断网恢复 | 通话按产品策略恢复或结束,不产生多条重复记录 |
| WEB-CALL-09 | P0 | 被拉黑/非好友发起通话 | 服务端拒绝,被叫不收到邀请 |
| WEB-CALL-10 | P1 | 查看通话记录 | `peer`、方向、开始/接通/结束时间、duration、topic 均可正确渲染 |
| WEB-CALL-11 | P1 | 同账号多浏览器收到来电 | 接听一个后其他端及时结束响铃 |
| WEB-CALL-12 | P1 | 检查声网 Token 获取 | 只在合法通话成员请求时返回,过期后不可继续加入 |
## 14. 工作台与办公协同
本章页面请求必须走 `/v1/workbench/*`。如果网关返回 404,先确认路径是否仍误用 `/v1/im/*`。
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-WB-01 | P0 | 打开工作台 | 模块、排序、权限、未读数正常,请求走新服务路径 |
| WEB-WB-02 | P1 | 普通用户与管理用户分别打开 | 模块权限和入口不同,不只依赖前端隐藏 |
| WEB-WB-03 | P1 | 刷新工作台 | Redis Token 鉴权通过,不随机 401,不要求二次登录 |
| WEB-ORG-01 | P0 | 展开组织树 | 根部门、子部门、成员按租户返回,展开状态稳定 |
| WEB-ORG-02 | P1 | 手机注册测试账号查看组织 | 按产品规则显示允许的组织范围 |
| WEB-ORG-03 | P1 | 邀请进入账号查看组织 | 只显示本人或配置范围,不泄露全部组织 |
| WEB-ORG-04 | P1 | 搜索部门/成员 | 只返回可见范围,跨租户同名成员不可见 |
| WEB-ATT-01 | P1 | 打开考勤页并打卡 | 班次、位置、打卡状态和时间正确,重复打卡有明确结果 |
| WEB-MTG-01 | P0 | 创建会议并选择真实参会人 | 会议创建、参与人、通知和未读数一致 |
| WEB-MTG-02 | P1 | 添加不存在/跨租户 user_id | 服务端拒绝,不创建幽灵参会人 |
| WEB-MTG-03 | P1 | 修改会议并使用旧 version 冲突 | 乐观锁生效,页面可刷新到最新版本 |
| WEB-MTG-04 | P1 | 参会人接受/拒绝 | 发起人实时或刷新后看到状态,重复响应幂等 |
| WEB-MTG-05 | P1 | 取消会议并提交错误 version | 应返回版本冲突,不得无条件取消 |
| WEB-MTG-06 | P1 | 发送会议提醒 | 只提醒未响应/目标用户,不重复轰炸 |
| WEB-NOTE-01 | P0 | 记录员上传会议笔记 PDF | 文件上传并登记,列表和详情出现文件 |
| WEB-NOTE-02 | P1 | 普通参会人预览/下载 | 根据权限放行;非参会人拒绝 |
| WEB-NOTE-03 | P1 | 替换、删除会议笔记 | version 校验正确,旧文件引用按策略处理 |
| WEB-DAILY-01 | P0 | 创建日报草稿、编辑并提交 | 内容持久化,状态从 draft 到 submitted |
| WEB-DAILY-02 | P1 | 提交空白日报 | 服务端拒绝,不允许仅靠前端必填校验 |
| WEB-DAILY-03 | P1 | 输入非法日期、未来异常年份 | 返回校验错误,不创建数据 |
| WEB-DAILY-04 | P1 | 管理者查看统计和未提交人员 | 权限、数量和列表一致,提醒可追踪 |
| WEB-MONTH-01 | P0 | 创建月报、保存总结/计划并提交 | 必填项、状态、版本和详情正确 |
| WEB-MONTH-02 | P1 | 月报分页和管理范围 | 无跨部门/跨租户越权,分页稳定 |
| WEB-MAIL-01 | P1 | 查看总经理信箱说明并提交 | 必填、匿名、长度、附件数量按配置校验 |
| WEB-MAIL-02 | P1 | 重复点击提交 | 幂等键防止重复工单 |
## 15. 个人资料、设置与隐私
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-ME-01 | P0 | 打开个人资料 | username、昵称、脱敏手机号、头像、签名字段完整 |
| WEB-ME-02 | P0 | 修改昵称和签名 | version 递增,刷新保持,好友/群投影最终同步 |
| WEB-ME-03 | P1 | 两个浏览器并发编辑同一资料 | 一个成功,另一个版本冲突,不覆盖新值 |
| WEB-ME-04 | P1 | 设置出生日期、性别、地区、邮箱 | 格式与长度校验符合 field_rules |
| WEB-ME-05 | P0 | 上传头像 | 文件用途为用户头像,成功后所有主要页面可访问 |
| WEB-ME-06 | P1 | 生成并展示个人二维码 | 二维码包含服务端 Token,不直接暴露内部用户 ID |
| WEB-PRIV-01 | P0 | profile_visibility 在 everyone/friends/nobody 间切换 | C/A 分别查看时权限立即符合设置 |
| WEB-PRIV-02 | P0 | 关闭按账号/手机号/二维码/群添加 | 对应搜索或添加入口和服务端结果一致 |
| WEB-PRIV-03 | P1 | 关闭已读回执 | 对端不再获得已读状态,不能只在 UI 隐藏 |
| WEB-PRIV-04 | P1 | 开启好友申请验证 | 新申请进入 pending;关闭后按产品规则直接建立/简化流程 |
| WEB-PRIV-05 | P1 | 开启入群验证 | 邀请进入审批态,不能直接入群 |
| WEB-SET-01 | P1 | 点击清空缓存 | 只清浏览器缓存,不删除服务端消息;重新加载可恢复 |
| WEB-SET-02 | P0 | 修改登录密码 | 成功后旧 Token 失效,新密码可登录 |
| WEB-SET-03 | P1 | 输入错误旧密码或弱密码 | 页面按 reason 提示,密码不变 |
| WEB-SET-04 | P1 | 发起账号注销并完成验证 | Token 撤销、账号不可继续登录;重复提交幂等 |
| WEB-VER-01 | P1 | Web 端检查版本 | `platform=web` 返回可用结果;推荐更新可关闭,强更不可绕过 |
青少年模式已从当前 Web 产品入口移除,不作为 Web 发版阻塞项;如后续恢复入口,应单独执行 `IM-青少年模式前后端对接文档.md` 的完整流程。
## 16. 举报、反馈与安全
| ID | 优先级 | 场景与操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-SEC-01 | P1 | 发送疑似风险 URL | 页面按 allow/warn/block 展示,不能直接放行 block |
| WEB-SEC-02 | P1 | 风险警告选择继续 | risk token 单次有效,重复使用被拒绝 |
| WEB-SEC-03 | P1 | 风险警告选择取消 | 不发送原消息,不生成可继续使用的 token |
| WEB-ABUSE-01 | P0 | 举报用户、群和消息 | 目标类型/ID正确,提交后有回执和 report_id |
| WEB-ABUSE-02 | P1 | 选择 requires_description 的原因但留空 | 前端和服务端都拒绝 |
| WEB-ABUSE-03 | P1 | 上传举报证据 | 仅合法 file_id 可绑定,超数量/无权限文件拒绝 |
| WEB-ABUSE-04 | P1 | 重复点击举报提交 | 只生成一个举报工单 |
| WEB-FB-01 | P0 | 打开意见反馈 | 分类、长度、附件策略和诊断说明来自服务端配置 |
| WEB-FB-02 | P0 | 提交文本反馈 | 成功后显示受理结果并清空草稿 |
| WEB-FB-03 | P1 | 带截图提交反馈 | 上传成功、文件审核状态和业务引用正确 |
| WEB-FB-04 | P1 | 内容超长、附件超限、联系方式非法 | 页面定位到对应字段,服务端返回明确校验错误 |
| WEB-FB-05 | P1 | 勾选/取消上传诊断信息 | 只有明确同意时才提交脱敏诊断,不上传 Token/密码 |
## 17. 跨租户、安全与可靠性专项
| ID | 优先级 | 操作 | 预期结果 |
| --- | --- | --- | --- |
| WEB-TENANT-01 | P0 | `system` 与另一个真实商户使用同名 username 分别登录 | 各自进入正确账号,Token 绑定各自租户;另备第二商户执行,三个 Web 域名不是三个商户 |
| WEB-TENANT-02 | P0 | 把 `system` Token 放到第二商户请求头 | `TENANT_MISMATCH`/认证失败,不返回任何业务数据 |
| WEB-TENANT-03 | P0 | 已知跨租户 user/topic/group/file ID 后直接访问 | 统一 403/404,不暴露对象存在性 |
| WEB-TENANT-04 | P0 | 切换租户后查看会话、搜索、收藏、上传任务 | 不出现上一租户缓存数据 |
| WEB-TENANT-05 | P1 | 同名群/系统 Topic 在两个租户同时活动 | 消息、未读、系统通知完全隔离 |
| WEB-REL-01 | P0 | HTTP 写请求重复提交相同幂等键和相同 Body | 返回首次结果,不重复写 |
| WEB-REL-02 | P0 | 相同幂等键改 Body 重放 | 返回 `IDEMPOTENCY_CONFLICT` |
| WEB-REL-03 | P1 | 关键请求超时后刷新页面 | 先查询真实状态,不能直接重复创建 |
| WEB-REL-04 | P0 | 同一 Token 并发发起 20 个只读请求 | 不应随机 401;出现时记录全部 request_id |
| WEB-REL-05 | P1 | 服务端 429 | 页面按 `retry_after_seconds` 倒计时,不立即重试 |
| WEB-REL-06 | P1 | 服务端 500/502/503 | 展示可重试错误,不清有效登录态,不无限重试 |
| WEB-REL-07 | P1 | 浏览器后退/前进恢复页面 | 不重复写操作,不展示其他账号缓存 |
| WEB-REL-08 | P1 | 登录页和业务页检查 Console | 无 Token、密码、完整手机号、身份证号和 OSS 签名泄漏 |
## 18. 当前高风险专项回归
以下项目来自历史问题清单。无论清单是否已过期,都应在 Web 验收中重新确认,以实际结果更新状态。
| ID | 对应风险 | Web 验证方法 | 通过标准 |
| --- | --- | --- | --- |
| RISK-01 | 并发转发真假失败不一致 | 同一目标并发转发 12 次,对照页面、WS 和历史 | 成功数、响应数、落库数一致 |
| RISK-02 | Bearer Token 并发随机 401 | 同 Token 并发加载首页各模块 | 无随机 401;失败有可追踪日志 |
| RISK-03 | 文件下载 SQL/死锁错误 | 连续打开图片、头像、会议文件 50 次 | 无 500、死锁直返和错误列异常 |
| RISK-04 | 好友拉黑状态不一致 | 拉黑后对照好友列表、资料页、会话上下文 | relationship/capabilities 一致 |
| RISK-05 | 群成员计数双真源 | 对照群详情、设置、二维码、成员分页 | member_count 全部一致 |
| RISK-06 | 群角色筛选缓存滞后 | 提升/撤销管理员后立即筛选 | 立即返回新角色,不读旧缓存 |
| RISK-07 | 搜索召回不稳定 | 发送唯一关键词后循环搜索 10 次 | 每次命中,定位上下文一致 |
| RISK-08 | 用户 ID/Topic 格式不一致 | 检查搜索、收藏、通话记录 | 均返回可寻址 ID,不裁剪 `usr` 或返回内部 hash |
| RISK-09 | 群二维码字段不一致 | 创建二维码并立即解析 | 创建/解析的 group 字段契约一致 |
| RISK-10 | 通话记录字段偏离文档 | 完成一轮呼叫后查看列表 | peer、incoming、时间和 duration 可直接渲染 |
| RISK-11 | 群公告已读人数缺失 | 3 人查看公告后检查详情 | 已读/总人数准确,状态可刷新恢复 |
| RISK-12 | 文件审核任务不消费 | 上传需审核图片并观察状态 | 在合理时间内进入 approved/rejected,不永久 pending |
## 19. 浏览器兼容与体验
| ID | 场景 | 预期结果 |
| --- | --- | --- |
| WEB-UX-01 | Chrome、Edge、Firefox 最新版登录和收发消息 | 核心流程一致 |
| WEB-UX-02 | 1366x768、1920x1080、窄屏窗口 | 无按钮遮挡、文本溢出和不可滚动区域 |
| WEB-UX-03 | 浏览器缩放 80%/100%/125%/150% | 布局可用,弹窗和菜单不超出视口 |
| WEB-UX-04 | 中文、英文、长昵称、长群名 | 截断和换行合理,不覆盖相邻控件 |
| WEB-UX-05 | 空列表、加载中、请求失败、权限拒绝 | 四态完整且文案准确 |
| WEB-UX-06 | 键盘 Tab 导航、Enter 发送、Esc 关闭弹窗 | 行为一致,不误提交危险操作 |
| WEB-UX-07 | 刷新时存在未发送草稿 | 草稿按产品策略恢复,不串会话 |
| WEB-UX-08 | 页面运行 2 小时并持续收消息 | 内存、WS 数量和事件监听不持续增长 |
## 20. 测试结果记录模板
```markdown
### [用例 ID] 用例名称
- 结果:通过 / 失败 / 阻塞 / 不适用
- 环境:测试环境 / 浏览器版本
- 账号:A、B
- 时间:YYYY-MM-DD HH:mm:ss
- Request ID:
- Topic / Seq:
- 前置条件:
- 操作步骤:
1. [填写操作一]
2. [填写操作二]
- 预期结果:
- 实际结果:
- 刷新后结果:
- 接收方结果:
- Network / WS 证据:
- 截图或录像:
- 缺陷编号:
```
## 21. 发版通过门禁
Web 版本满足以下条件才可判定通过:
1. P0 冒烟用例全部通过。
2. 所有 P0 业务用例通过,P1 无数据丢失、越权、安全或核心流程阻塞问题。
3. 单聊、群聊各完成一次在线实时、离线补偿、断线重连和多端已读闭环。
4. 文件上传、下载、无权限下载、Range 续传和 OSS 链路通过。
5. 好友、拉黑、群治理均完成 UI 权限和服务端绕过校验。
6. 工作台全部请求已切到 `/v1/workbench/*`,没有旧路径 404。
7. 跨租户专项全部通过,浏览器缓存中不出现上一租户数据。
8. 无未解释的 401、403、404、409、500、502、503;预期错误必须有正确 `reason`。
9. Console 和请求日志不泄露密码、Token、身份证号、完整手机号或 OSS 临时签名。
10. 所有失败用例均有 Request ID、复现步骤和页面/网络证据。
## 22. 三域名实测记录(2026-10-17)
本节记录本轮实际页面操作,不把准备状态当作注册成功。测试商户为 `system`,浏览器为 Codex 内置浏览器。测试账号密码、Token 不写入本文。
| 用例 | 结果 | 实测记录 |
| --- | --- | --- |
| ENV-01~03、SMK-01 | 通过 | `web1`、`web2`、`web3` 的企业码页均能以 `system` 进入登录页,显示 `system merchant`。 |
| FLOW-REG-01、SMK-01A、WEB-AUTH-10(入口) | 通过 | 首次复用的 `web2`、`web3` 登录页标签未显示注册链接;重新打开三个域名的登录页后,均可见“注册账号”,目标 URL 分别为各自域名的 `/register`。旧标签状态未作为当前版本缺陷。 |
| FLOW-REG-02、04、05(表单准备) | 部分通过 | 三个域名的 `/register` 均显示 `system merchant`,可填写用户名、手机号、密码、确认密码;本轮先准备三个不同的仅用户名账号,未填写手机号,也未触发短信发送。 |
| FLOW-REG-03~05、SMK-01B(注册过程) | 未见证 | 用户随后自行完成三个账号的注册并登录;本轮只核对登录结果,未观察协议勾选、注册请求和验证码/邀请码分支,不能据此判这些注册流程通过。 |
| FLOW-REG-06A、SMK-04(身份/首页) | 通过 | 三域名同时登录 `qa261017a` (`usruLg9kkkulUg`)、`qa261017b` (`usrOTmpE042BPc`)、`qa261017c` (`usradhJQqzgP8U`);各自资料页身份不同,初始会话空态正常。 |
| FLOW-REG-07、WEB-FRIEND-02/05/07、SMK-08 | 通过,实时刷新待改进 | A 搜索用户名找到 B/C 的正确 `usr...` ID,发送申请;B/C 的“新朋友”各出现待处理申请并可通过。B 再申请 C,C 通过。三对关系均持久化。审批方列表立即更新;申请方原通讯录未立即出现新好友,刷新后才出现。 |
| FLOW-REG-07、WEB-P2P-01/05/07、SMK-05/07 | 通过 | A/B 在同一 P2P Topic `p2puLg9kkkulUg5OakTTjYE9w` 双向发文本;双方在线实时收到,发送端出现“已送达/已读”。C 无关会话未显示 A-B 消息。 |
| FLOW-REG-08、WEB-GRP-01/03/06、SMK-09/10 | 通过 | A 创建 `Web联调三人群-20261017`,Topic `grpW1fvFD1CU8w`;A/B/C 的群列表均显示 3 人,A 群成员页列出 A/B/C,群主为 A。三人各发一条群消息,另外两端实时收到,群消息顺序一致。 |
| FLOW-REG-10、WEB-GRP-09、WEB-GRP-21/22 | 部分通过 | A 将 B 设为管理员,A/B/C 页面均显示 B 的管理员角色;B 可发布群公告,C 可阅读但无编辑入口。B 刷新后公告已读数为 2/3。未测撤销管理员、公告编辑/删除和入群审批。 |
| FLOW-REG-09、WEB-GRP-28、WEB-HOME-07 | 部分通过 | C 页首次用网络模拟显示“无网络”,但旧 WS 仍接收三条消息,不能计作离线补偿。随后断网重载令 C 页真正离开应用;新 web3 标签重新登录 C 后,群会话显示 3 条未读,打开后按 1/3、2/3、3/3 顺序显示且无重复。**同一标签自动重连和 seq 增量补偿未验证**。后续原 web3 群聊标签也恢复可用,刷新后三端均保持登录。 |
| FLOW-REG-11、SMK-06、WEB-FILE-01(小图) | 部分通过 | A 发送仓库公开测试 PNG `docs/ios-pill-128.png`(4,587 字节);B/C 实时收到并可预览原图,A 看到 2 人已读。但图片正文以 `data:` 内联,不能作为 OSS 上传通过的证据。 |
| FLOW-REG-11、WEB-FILE-01(大图/OSS) | **失败,P0 阻塞** | A 两次选择 `docs/note/文件-预览.png`(344,005 字节)发送到同一群,页面均提示 `Failed to fetch`,群内无大图消息。浏览器网络事件显示 `POST https://api.118im.com/v0/file/u/` 发起前,`OPTIONS` 预检返回 204,但响应无 `Access-Control-Allow-Origin`;POST 随后为 `net::ERR_FAILED`。预检 Origin 为 `https://web1.118im.com`,请求头含 `authorization,x-meetingim-apikey`。需修复上传入口 CORS 并覆盖 web1/web2/web3 三个 Origin,再重测大图/PDF、下载和 OSS。 |
| WEB-GRP-15(普通成员) | 部分通过 | A 开启全员禁言后,C 收到群系统提示;C 页面仍允许输入并点发送,但服务端拒绝,消息显示“发送失败,点击重试”。刷新后该消息不在群历史。页面应在已知禁言状态下禁用发送并提示具体原因。 |
| WEB-GRP-15(管理员豁免) | **失败,P0 权限缺陷** | 群设置明确写“仅群主和管理员可以发言”,B 已由 A 提升为管理员且各端均显示“管理员”。全员禁言期间 B 发送文本两次均失败;第二次 WS `pub` 到 `grpW1fvFD1CU8w` 后,服务端 `ctrl.code=403`、`ctrl.text=permission denied`。刷新后消息不在群历史。A 随后关闭全员禁言,设置恢复为 0。需核查群管理员身份与全员禁言的服务端权限判定。 |
| WEB-SEARCH-01(群内关键词搜索) | 通过 | B 在群会话搜索唯一关键词 `离线补偿测试 2/3`,结果命中 B 原消息;点击后关闭搜索层并定位到群历史。 |
| WEB-GRP-15(刷新复测) | **失败,P0 权限缺陷仍在** | A/B/C 三端刷新后,A 的群管理页显示“群管理员 1/10”,B 的成员角色和消息标签均为“管理员”。A 再次开启全员禁言,B 发送唯一文本后出现“发送失败,点击重试”,A/C 未收到。A 随后关闭全员禁言,开关恢复 0。禁言关闭后 B 重新发送另一条文本成功,C 收到,说明失败与全员禁言状态有关,并非 B 普通发言能力故障。 |
| WEB-FILE-01(刷新及跨域复测) | **失败,P0 阻塞仍在** | A 刷新后从 web1 再传 344,005 字节 PNG,仍提示 `Failed to fetch`;B 从 web2 上传同一文件也失败。B 在“群文件”面板上传 4,587 字节 PNG,同样提示 `Failed to fetch`,列表仍为 0。此轮未重新采集预检头,结合此前 CORS 证据可判上传功能仍阻塞,但不能仅凭本轮页面提示单独断定原因。 |
| WEB-HOME-04/06(群会话设置) | 通过 | A 开启群聊置顶、消息免打扰后刷新,两项开关均恢复为 1;之后均关闭并确认恢复 0。 |
| WEB-ME-01(个人资料签名) | 通过 | A 在个人资料页将签名改为 `Web联调签名测试-20261017`,保存后刷新,输入框仍显示相同内容,保存按钮恢复禁用。该测试签名保留在测试账号上。 |
| WEB-HOME-07(在线未读) | 通过 | C 停在无选中会话的消息页时,B 在解除全员禁言后发送群文本,C 群会话未读变为 1;打开群聊后出现该文本,未读标记清零。会话摘要在打开/加载完成前短暂显示上一条系统消息,随后更新为新文本,未计作稳定缺陷。 |
| WEB-MSG-01(多行 Unicode) | 通过 | C 发送含表情、中文及换行的两行群消息;B 实时收到,B/C 刷新后两行内容仍完整且顺序一致。 |
| WEB-GRP-23(群内昵称) | **部分失败,P1 展示缺陷** | C 将“我在本群的昵称”设为 `联调成员C`,刷新后设置页仍显示该值,说明保存成功。但 B 的新群消息发送者按钮仍为 `qa261017c`;C 刷新后的“群成员 3”列表中自己仍为 `qa261017c`。需确认群成员昵称的读取字段与消息发送者展示优先级。 |
| WEB-GRP-21/22(公告编辑和已读) | 通过 | B 将原公告编辑为含“编辑复测 2026-10-17”的版本并发布;B/C 群聊实时出现新的公告消息。C 打开公告详情,看到新内容和已读 2/3;B 的公告详情显示发布者为 B。未测删除和旧版本并发冲突。 |
| FLOW-REG-06、12~15、SMK-11~15 | 待执行 | 三域名登出隔离、PDF 下载、昵称等其他资料字段、负向注册、工作台及跨租户等尚未测试;大图上传失败后不继续假定文件链路可用。 |
本轮页面操作发生在 09:00~09:19(Asia/Shanghai)。首次文件上传失败采集了预检/POST 网络事件,首次管理员禁言失败采集了 WS `403` 控制帧;后续复测主要以页面状态为证。其他用例未系统采集 Request ID 和消息 Seq,因此当前记录不能代替协议与数据库层验收。仅用户名注册无法自助找回密码;短信验证码、邀请码和推荐关系仍需使用受控测试手机号及有效邀请码单独验证。
## 23. 缺陷修复与复测状态
第 22 节记录的是修复前版本的实测结果。以下是本轮发布后的复测。首次复测时 web3 测试账号曾被另一设备登录策略踢出;之后恢复登录并完成了普通成员禁言、昵称和群消息复测。
| 缺陷 | 代码修复 | 本地验证 | 发布后复测 |
| --- | --- | --- | --- |
| 大图及群文件上传预检失败 | IM Server 上传 `OPTIONS` 按上传方法集校验 `POST`;允许来源匹配后明确回显请求头名,不再用 `Access-Control-Allow-Headers: *` 处理带鉴权预检。 | 上传预检单测通过,Go server/media 测试通过。 | **预检通过,群文件上传未通过**:对 `https://web1/2/3.118im.com` 分别请求 `OPTIONS /v0/file/u/`,均返回 204,正确回显对应 Origin,允许 `POST, PUT, HEAD, OPTIONS` 及 `authorization,x-meetingim-apikey,content-type`。三域名各从群文件页选择 344 KB PNG,均发起 `POST https://api.118im.com/v0/file/u/`;web2 的 OPTIONS 为 204,web1 请求使用已缓存预检。各 POST 观察 8~15 秒均无响应事件,页面持续转圈、列表为 0,浏览器无 Console 错误;刷新后未见文件记录。小图 `docs/ios-pill-128.png` 在群聊正文走 `data:` 内联,不能证明 OSS 上传。需排查上传 POST/服务端处理或 OSS 回源链路,并重测下载。 |
| 管理员在全员禁言时无法发言 | 群发言检查读取当前业务成员角色,避免依赖已加载 Topic 的旧权限快照;非活跃成员不允许绕过禁言。 | 群角色测试及 Go store/MySQL 构建测试通过。 | **通过**:A 开启全员禁言,C 普通成员发送失败且 A/B 均未收到;同一状态下 B 管理员发送 `发布后复测-禁言管理员重连后-B-20261017`,A/C 均收到。首次管理员尝试遇到 504;刷新 B 重连后再测成功。解除禁言后 C 的新消息及先前失败消息重试均送达。全员禁言最终已关闭。 |
| 群内昵称未用于成员及发送者显示 | Web 的群成员预览/列表、群消息发送者、引用、历史搜索和 @ 提示优先取 `group_nickname`;IM Server 为群昵称更新广播 `group.member.profile.updated`,Web 收到后定向重取成员数据。 | Web 单测 21/21、生产构建通过;群事件可见性单测通过。 | **通过(跨端成员列表及发送者)**:A 群主修改本人群昵称后,B 管理员无需刷新,群成员列表立即显示新昵称;恢复为账号名后,B 列表也实时恢复。C 登录恢复后,B/A 的成员列表和 C 新发消息均显示 `联调成员C`。 |
补充:本轮实际使用的测试用户为 web1 `qa261017a`(群主)、web2 `qa261017b`(管理员)、web3 `qa261017c`(普通成员)。web3 登录恢复后继续完成普通成员权限复测。web2 首次禁言发言遇到 `CommError: timeout (504)`,刷新重连后管理员豁免发送成功。普通成员禁言失败消息解除禁言后点“重试”成功送达;搜索该消息可返回结果并定位群历史。C→A 私聊及 A→C 回复均实时送达,第三个账号 B 未看到私聊正文。C 通过 `@` 选择管理员 B 后发送群提及,A/B 实时收到并正确呈现提及对象。测试结束时全员禁言为关闭状态。三域名均用 344 KB PNG 测群文件上传,`POST /v0/file/u/` 长时间无响应、状态持续转圈,刷新后列表仍为 0;群聊小图走 `data:` 内联,不应视作 OSS 上传证据。Web 本地开发地址为 `http://127.0.0.1:8094/`,其 API 代理需要本机 IM Server 监听 `127.0.0.1:6060`。
### 本轮追加实测(2026-10-17)
| 用例 | 结果 | 实测记录 |
| --- | --- | --- |
| WEB-FAV-01~03(空态/分类筛选) | 部分通过 | A 的收藏页正常加载“暂无收藏内容”;全部、文本、图片、视频、语音、文件、链接分类均可切换,空态保持。暂无收藏记录,无法验证各类型入库、取消和分页。 |
| WEB-FRIEND-01/10(通讯录/备注) | 通过 | A 通讯录列出好友 B/C;好友资料显示 username、昵称及 `usr...` ID。B 备注临时改为“回归备注-临时”,保存后通讯录和资料标题更新;随后清空并确认恢复“未设置”。 |
| WEB-FRIEND-10(好友昵称同步回滚) | 部分通过(实时列表缓存延迟) | A 临时修改昵称后,B 通讯录即时显示新昵称;A 恢复原昵称后,B 好友资料面板显示恢复后的 `qa261017a`,但通讯录列表短暂残留临时昵称。B 刷新页面并恢复登录后列表更新为 `qa261017a`,跨端实时列表缓存失效仍有延迟。 |
| WEB-FRIEND-11(黑名单空态) | 部分通过 | A 的黑名单页正常显示空态,当前 0 人;未把好友加入黑名单,故阻断拉黑/解除及被拉黑发送权限验证。 |
| WEB-GRP-03/06/07/08(群分类、成员、详情、搜索) | 通过 | 群列表分类计数与角色一致:A“我创建的”1,B“我管理的”1;B 群成员面板显示 3/3,角色为群主/管理员/普通成员,单成员详情可打开。按“联调成员C”搜索只保留 C,清空前未改变成员数据。 |
| WEB-GRP-08(无匹配成员搜索) | 部分通过 | A 搜索不存在成员名后结果列表清空;清空关键词后 3 名成员恢复。页面未提供可访问文本形式的“无匹配结果”提示。 |
| WEB-GRP-16/17/19(治理配置/邀请审批入口) | 部分通过 | B 管理员可查看进群验证、全员禁言、群文件上传权限、成员私聊权限、邀请入群权限、禁言成员和进群申请入口,当前设置均未被修改。没有可用于入群审批的第四个账号,审批通过/拒绝、邀请权限变更未执行。 |
| WEB-ME-01/06、WEB-PRIV-01(资料/二维码/隐私只读) | 部分通过 | A 资料页显示 username、`usr...` ID、昵称、性别、生日、地区和签名;页面未显示手机号字段。个人二维码可生成并展示。隐私当前“资料可见范围”和“最近在线可见范围”均为“仅好友”,添加方式和已读回执开关可见;未切换隐私设置,无法据此验收非好友受限表现。 |
| WEB-SET-01/02/04(设置页入口) | 部分通过 | 通用页提供“清空缓存”;安全页提供修改密码和注销账号入口。未点击清空缓存;密码修改需账号持有人操作,注销是不可恢复动作,均未执行。 |
| WEB-HOME-10/12(系统消息) | 部分通过 | A 系统消息中心可读到两条“好友申请已处理”通知,内容可读且标记为新;未执行全部标记已读或通知跳转校验。 |
| WEB-CALL-10(通话记录空态) | 部分通过 | 通话记录页正常加载并显示“暂无通话记录”;未发起真实音视频通话,无法验证记录字段和声网链路。 |
| WEB-ABUSE-01(群举报入口) | **失败,功能未开放** | A 在群设置点击“投诉举报”,页面提示“该功能暂未开放,如需投诉举报请联系客服。”没有举报表单,无法提交举报或取得 report_id。 |
| WEB-MSG-02(群聊文本/链接消息) | 通过(A/B/C) | B 发送 `链接卡片验收-20261017 https://example.com`,A 收到可点击链接;随后 C 发送 `三人群回归消息-C-20261017`,A/B 均收到文本且显示群昵称“联调成员C”。 |
| WEB-HOME-02(群未读清零复测) | **受控场景通过,曾出现一次异常** | 早先 B 发消息时 A 已处于群聊,正文可见但旧未读 `1` 未即时清除;刷新后该旧徽标消失,未能稳定复现。本轮将 A 留在私聊,由 C 发群消息,A 会话列表出现未读 `1`;A 打开群聊并加载该消息后,徽标清零,C 端显示 2 人已读。需继续观察同会话在线实时更新与多消息连续到达,不据一次旧徽标直接判定服务端游标故障。 |
| WEB-WB-01~02、WEB-ORG-01~04 等工作台场景 | 本轮不处理 | 按用户本轮要求,工作台网页版部分场景不纳入本轮验收。 |
| WEB-TENANT-01~05(跨租户隔离) | 阻塞 | 当前只有 `system` 商户三账号;web1/web2/web3 是不同域名,不是不同租户。需要第二个可登录商户及其测试账号,不能用域名替代租户。 |
| WEB-AUTH-11~16、WEB-PRIV-05、WEB-ABUSE-01~04、WEB-FB-01~05 | 阻塞/未执行 | 邀请码、短信验证码和密码重置需受控手机号/验证码;入群审批需要第四位申请人;举报/反馈会创建后台业务单,需后续按独立测试数据执行。注册异常分支未通过手工数据库方式伪造。 |
本轮仅在测试账号间操作;好友备注已恢复未设置,A 昵称已恢复,群角色/成员/治理开关、A 的隐私设置均保持原值。全功能验收仍未完成:OSS 上传当前仅验证小型 PNG 最终成功,下载链路未通过;缺少第二租户及短信/邀请码测试凭据;通话、举报反馈、消息复杂类型/转发撤回、跨端登出隔离及若干群治理状态转换也尚未实测。工作台网页场景按用户要求排除。未读受控路径本轮通过,旧徽标残留现象暂未稳定复现;B 通讯录昵称在 A 修改后存在实时列表缓存延迟,页面刷新后恢复。
### 本轮定向复测(2026-10-17)
| 用例 | 结果 | 实测记录 |
| --- | --- | --- |
| WEB-FILE-01/02(群文件 OSS 上传) | 部分通过 | B 管理员在三人群“群文件”上传 `docs/ios-pill-128.png`(4.5 KB);上传按钮约 10 秒保持转圈后,列表新增文件记录,上传者、文件名、大小和时间正确。证明群文件上传请求最终完成并生成业务记录,但 UI 仍需明确的上传进度/耗时反馈;单次 UI 记录不能独立证明对象存储 Bucket 内容及审核状态。 |
| WEB-FILE-06(群文件下载) | 部分通过,存在服务端间歇失败 | 前一轮 C 普通成员从群文件行触发下载,6 次读取中 5 次 `200 image/png`(一次完成传输 5,169 字节),1 次 `500 application/json`,错误体为 `ctrl.code=500, text=internal error`。本轮追加一次下载,收到 `200 image/png` 且 `Network.loadingFinished`,传输长度记录为 531 字节;自动化环境仍未暴露系统下载完成事件,不能确认文件落盘/内容。Web 前端本地已将 Blob URL 撤销延后 1 秒,`npm test` 21/21、`npm run build` 通过,尚未发布复验。 |
| WEB-FILE-01(普通成员群文件上传及跨成员可见) | 部分通过,曾间歇 500 | 群文件权限为“所有成员”。C 第一次上传 4.5 KB PNG 收到 HTTP 500,响应 `ctrl.code=500, text=internal error`;第二次重试成功,群文件数由 1 增至 2,新增条目上传者为 `qa261017c`,文件名及大小正确。A、B 各自打开群文件列表后也都看到该条目。说明普通成员在该权限下可以上传,文件索引对其他成员可见,但一次失败、一次成功仍提示上传链路有间歇问题;测试集群日志此前因 Kubernetes API 超时尚未取到。源码检查另发现 `member_upload_files` 之前只控制前端入口、服务端未强制执行;已在本地补服务端授权和单测,但尚未部署,且本次在线配置为“所有成员”,该改动不能解释间歇 500。 |
| WEB-GRP-治理入口与角色边界 | 部分通过 | A 群主管理面板可见进群验证、全员禁言、群文件上传权限、成员私聊权限、邀请入群权限、禁言成员、进群申请及转让群主等控制。普通成员 C 的群资料面板没有“群管理”入口(可访问按钮数 0);A 可见该入口。当前进群验证/全员禁言关闭,群文件权限为“所有成员”,邀请权限为“所有成员”;本轮只读检查,未改变配置。审批状态转换、管理员移除/禁言对象和权限变更后的服务端拒绝仍需第四位申请人及独立成员数据。 |
| WEB-HOME-02(未读清零及按用户隔离) | 通过(受控路径) | A、B 均离开群会话,C 发送 `未读复测-C-20261017-1618` 后,A、B 群会话各自显示未读 `1`。A 打开群聊后 A 的未读清零,B 仍显示 `1`;B 再打开群聊后 B 的未读清零。C 端显示 2 人已读。验证未读数按用户独立维护,读取一方不清除另一方未读。 |
工作台网页场景按用户要求不纳入本轮。OSS 链路仍未完整验收:下载有间歇 `500`,且尚未从真实浏览器验证落盘文件;上传覆盖的仍只有 4.5 KB PNG,较大文件、PDF/视频和审核状态尚未覆盖。Web 本地下载修复已将 Blob URL 撤销延后 1 秒,测试 21/21、生产构建通过,尚未发布到测试环境验证。普通成员上传复测由首次 500 转为重试成功,仍需日志定位间歇错误。
+29
View File
@@ -0,0 +1,29 @@
# IM 文件存储切换(OSS / MinIO)
IM Server 继续提供 `/v0/file/u/` 上传和 `/v0/file/s/` 鉴权下载接口。客户端不直连对象存储。后台 `hotgo.hg_sys_config` 的 `im_third_party.media_handler` 是存储开关:`oss` 写阿里云 OSS,`minio` 写自建 MinIO。部署配置中的 `media.use_handler` 必须为 `sys_config`(`server/meetingim.conf` 已如此配置);开关在服务启动时读取,修改后滚动重启 IM Server。
## 后台配置
先在 `hotgo` 执行 [IM-MinIO存储配置.sql](IM-MinIO存储配置.sql),再通过后台填写:
| 配置 key | 值或用途 |
| --- | --- |
| `media_handler` | `oss` / `minio`,默认保留现有值 |
| `minio_endpoint` | `https://minio.118im.com`;容器内如有可达的 HTTPS 内网地址,可改用内网地址 |
| `minio_bucket` | `xgjs`,需预先创建 |
| `minio_region` | `us-east-1` |
| `minio_access_key_id` | MinIO Access Key,后台敏感配置 |
| `minio_secret_access_key` | MinIO Secret Key,后台敏感配置 |
阿里云 OSS 的 `oss_*` 配置需继续保留,用于读取切换前的文件。回切 OSS 后,MinIO 的配置也需保留。新写入的 MinIO 对象在文件记录中保存为 `minio://<bucket>/<key>`;原有 OSS 文件位置不改。下载和清理按文件位置选择存储后端,不会因为切换开关迁移已有对象。
## 上线顺序
1. 确认 IM Server Pod 能访问 MinIO S3 API,且 `xgjs` 桶及访问密钥可用;不要使用 Console URL 作为 API endpoint。
2. 执行配置 SQL 并在后台填写密钥;保留 `media_handler=oss`,发布包含 MinIO handler 的 IM Server。
3. 将 `media_handler` 改为 `minio`,滚动重启 IM Server,在客户端做小文件上传、下载、Range 下载、删除和历史 OSS 文件下载验证。
4. 回切时将 `media_handler` 改为 `oss` 并滚动重启;已有 MinIO 文件继续从 MinIO 读取。
MinIO 对象默认按私有文件处理。示例中的直连公网 URL 只有在桶策略允许公开读取时才可访问;IM 客户端应使用服务返回的文件 URL。当前阿里云内容安全接口接受阿里云 OSS 对象参数,MinIO 文件的第三方审核任务需另行接入可用的文件提交方式,不能把 MinIO 对象直接当 OSS 对象提交。
2026-10-17:本地和测试环境的 `hotgo` 均已加入 MinIO 字典与配置项,凭据已分别填入数据库,`media_handler` 仍为 `oss`。MinIO 公网 S3 API 的独立上传、读取、删除冒烟测试已通过;切换后仍需从测试环境 IM Server Pod 验证完整客户端上传链路。
+44
View File
@@ -0,0 +1,44 @@
-- 在 hotgo 后台库执行。只增加配置项,不切换当前存储后端。
USE hotgo;
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM 存储服务商', 'im_storage_provider', 210, 'IM 文件存储 provider 字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_storage_provider');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 'MinIO', 'minio', 'string', 'im_storage_provider', 'info', 0, 20, '自建 MinIO 对象存储', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_storage_provider' AND `value` = 'minio');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Endpoint', 'string', 'minio_endpoint', 'https://minio.118im.com', 'https://minio.118im.com', 22, 'MinIO S3 API 地址;优先使用内网 HTTPS 地址', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_endpoint');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Bucket', 'string', 'minio_bucket', 'xgjs', 'xgjs', 23, 'MinIO 已创建的存储桶', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_bucket');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Region', 'string', 'minio_region', 'us-east-1', 'us-east-1', 24, 'MinIO 签名地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_region');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Access Key', 'string', 'minio_access_key_id', '', '', 25, '敏感配置;后台脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_access_key_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'MinIO Secret Key', 'string', 'minio_secret_access_key', '', '', 26, '敏感配置;后台脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'minio_secret_access_key');
UPDATE hg_sys_config SET `tip`='IM 文件存储开关:oss 或 minio;修改后滚动重启 IM Server'
WHERE `group`='im_third_party' AND `key`='media_handler';
-- 核验上传、下载、旧 OSS 文件读取后再执行:
-- UPDATE hg_sys_config SET `value`='minio', updated_at=NOW()
-- WHERE `group`='im_third_party' AND `key`='media_handler';
@@ -45,6 +45,8 @@ tenants/{tenantID}/{yyyy}/{mm}/{fileID}
| `verify_bucket` | 启动时是否联网校验 Bucket 存在 |
| `use_sys_config` | 为 `true` 时从 `hotgo.hg_sys_config` 读取 OSS 配置 |
OSS endpoint 填写 `https://oss-cn-shenzhen.aliyuncs.com`。IM Server 初始化 OSS handler 时也会给裸域名补 HTTPS,并将阿里云 OSS 的显式 HTTP endpoint 改为 HTTPS。修改后台 `im_third_party.oss_endpoint` 后需重启 IM Server;发布后用真实文件上传验证 `POST /v0/file/u/`,确认 Pod 到 OSS 走 443 端口且不再出现 TCP 写超时。若 HTTPS 仍超时,继续检查 Pod 到 OSS 的出网链路。
当前 `server/tinode.conf` 仍默认使用 `fs`。切换 OSS 时,将 `media.use_handler` 改为 `oss`,并在 `media.handlers.oss` 中设置 `use_sys_config=true` 后,运行时会从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取 OSS 配置。
代码入口:
@@ -142,7 +142,7 @@ WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' A
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS Endpoint', 'string', 'oss_endpoint', '', '', 12, '例如 oss-cn-shenzhen.aliyuncs.com,禁止写 AccessPoint endpoint', 1, 1, NOW(), NOW()
SELECT 'im_third_party', 'OSS Endpoint', 'string', 'oss_endpoint', '', '', 12, '例如 https://oss-cn-shenzhen.aliyuncs.com;阿里云 OSS 上传使用 HTTPS,禁止写 AccessPoint endpoint', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_endpoint');
INSERT INTO hg_sys_config
@@ -60,9 +60,35 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
| 用户条款 | `https://{host}/legal/user-terms` | 无需认证、无需 API Key | 登录、注册页通过浏览器或 WebView 打开 |
| 隐私政策 | `https://{host}/legal/privacy-policy` | 无需认证、无需 API Key | 登录、注册及设置页通过浏览器或 WebView 打开 |
HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
#### 0.2.3 用户条款与隐私政策公开页面
客户端不解析或内置协议正文,只保存以下 HTTPS 地址并直接打开:
| 页面 | 通用地址 | 测试/生产域名示例 |
| --- | --- | --- |
| 用户条款 | `https://{host}/legal/user-terms` | `https://api.118im.com/legal/user-terms` |
| 隐私政策 | `https://{host}/legal/privacy-policy` | `https://api.118im.com/legal/privacy-policy` |
两个页面支持 `GET`、`HEAD`,返回 `text/html; charset=utf-8`,不要求 `X-MeetingIM-APIKey`、`X-IM-Schema` 或用户 Token。客户端登录前即可访问;链接应使用系统浏览器或支持 HTTPS 的 WebView 打开,不要把协议正文缓存为业务数据。服务端通过 ETag 和短时公共缓存控制版本刷新,协议更新并重新发布服务后客户端仍使用同一 URL。
客户端不需要硬编码协议域名。`POST /v1/im/tenants/resolve` 的 `data.endpoints` 会同步返回:
```json
{
"legal": {
"user_terms": "https://api.example.com/legal/user-terms",
"privacy_policy": "https://api.example.com/legal/privacy-policy"
}
}
```
登录、注册页点击对应文字时直接打开上述 URL,不附加 Token、API Key 或租户参数,并允许用户滚动、缩放和返回。
WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
```json
@@ -220,6 +246,18 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。
##### 0.5.1.1 WebSocket 心跳与在线状态
当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 15 秒发送一次 WebSocket Ping 帧(`pingPeriod=15s`),并期望客户端在 45 秒读超时窗口内返回 Pong(`idleSessionTimeout=45s`);如果没有收到 Pong,或底层读写失败,服务端会关闭当前 Session。
客户端要求:
1. 使用系统 WebSocket 库时,必须确认库会自动回复 Pong;如果库不会自动回复,需要在收到 Ping 时显式发送 Pong。
2. 不要把 `{"note":{"what":"kp"}}` 当作心跳。`kp/kpa/kpv` 只是“正在输入/正在录音/正在视频输入”等 UI 状态通知,不落库,也不能替代 WebSocket Pong。
3. 连接关闭或报错后,客户端本地应立即把 IM 长连接视为离线,并按指数退避重连。重连成功后完整执行 `WebSocket Upgrade -> hi -> token login -> sub me -> sub topics -> get data/del`,用本地最后完整持久化的 `seq + 1` 增量补消息。
4. 服务端收到任意客户端上行 JSON 消息或 WebSocket Pong 后,都会刷新在线状态。当前在线状态写入 Redis,默认 TTL 为 120 秒,用于好友在线状态、群成员在线状态等展示。
5. iOS/Android 进入后台后系统可能挂起 WebSocket。后台场景以推送唤醒和前台重连补偿为主,不要求客户端在后台无限重连。
#### 0.5.2 Long Polling 兼容流程:先取得的是 sid
如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。
@@ -292,11 +330,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` |
| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
**注册 `username` 规则(服务端强校验,2026-10-14 确认)**:3~10 个字符,仅允许小写字母 `a-z` 和数字 `0-9`;不允许大写、下划线、点、汉字及任何符号。不传 `username` 只传 `phone` 时服务端自动生成随机用户名(此时 `secret` 使用 `base64(手机号:密码)`)。违规表现:过短返回 `422 policy violation, params.what=auth`(本次联调实测,用户名 `zz` 即此情形);超长或含非法字符返回 `422, params.what=username, reason=USERNAME_INVALID_FORMAT`。最小长度来自部署配置 `basic` 认证的 `min_login_length`(当前部署模板为 3),服务端代码上限为 10。
##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 |
@@ -313,6 +353,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 类型 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` |
| 引用回复文本 | `{"pub":{"id":"pub-reply-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_reply_1","x-im-reply":{"seq":128,"type":"text","text":"今天晚上一起吃饭吗?","from":"usr..."}},"content":{"text":"好啊"}}}` | `x-im-reply` 必须保留在 `head` 中;服务端不解析业务语义,入库、实时下行和历史读取均原样返回 |
| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` |
| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` |
| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` |
@@ -324,7 +365,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
##### 0.5.4.3.1 会话内系统提示和可点击动作消息
截图中的灰色提示、入群邀请说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。
截图中的灰色提示、群内邀请确认说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。发给被邀请人的“加入群聊”邀请卡片不写入邀请人与被邀请人的 P2P 会话,展示以 `GET /v1/im/system-notifications` 返回的系统通知记录为准。
客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。
@@ -365,6 +406,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` |
| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` |
`kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。
**单聊已读/已收回执协议约定(服务端已验证,2026-09-29):**
1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。
@@ -414,7 +457,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"event": "group.member.joined",
"payload": {
"topic": "grp_xxx",
"user_id": "usr_xxx"
"user_id": "usr_xxx",
"member_sync": "single",
"visibility": "all_members"
},
"ts": "2026-08-25T10:00:00Z"
}
@@ -431,7 +476,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 |
| 单聊消息变更脏标记 | P2P 双方在线会话 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"message.mutation","payload":{"topic":"p2p...","seq":12,"version":2,"event":"message.recalled"}}}` | 当前未订阅该 P2P 也要标记会话脏,并调用 `message-mutations` 做补偿同步 |
| 成员加入 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"user_id":"usr_xxx","member_sync":"single","visibility":"all_members"}}}` | 全体成员可见;调用 `GET /v1/im/groups/{topic}/members/{user_id}` 刷新单个成员。加入事件同时写入群消息,离线后可按 Topic seq 补拉 |
| 成员退出/移除、角色或禁言变化 | 群主、管理员和被操作人 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.left","payload":{"user_id":"usr_xxx","member_sync":"remove","visibility":"admins_and_affected"}}}` | 普通成员不可见;`single` 拉取单成员,`remove` 本地删除,`full` 重新分页拉取全部成员 |
| 其他群业务变化 | 按具体事件授权范围 | `info.what="group"` | 刷新群详情、入群申请、公告或治理权限 |
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
@@ -469,16 +517,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
客户端侧要求:
- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。
- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。
- 当前测试环境使用 `registration_id` 模式,服务端配置 `jpush_use_alias=0`,不依赖客户端 alias;alias 绑定可保留但不是投递依据。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。`device_id` 必须稳定,同一台设备不要每次启动变化。
- 切换账号、切换租户、清登录态后必须重新上报 Push Token;客户端本地的 `user_id/token/registration_id` 去重缓存必须按 `tenant_code + user_id + device_id + registration_id` 隔离,避免把设备继续绑在旧账号上。
- 若后续切回 alias 模式,登录 IM 后将极光 alias 绑定为当前 `user_id` 的安全别名:`user_id.replace("-", ".").replace("_", "|")`,例如 `usrd6Ag5opa-ww` 绑定为 `usrd6Ag5opa.ww`;同一用户多设备允许绑定同一个 alias。
- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。
服务端侧要求:
- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。
- 当前测试环境按 `registration_id` 投递:服务端从 `im_device_push_tokens` 按目标 `user_id + provider=jpush` 查询设备 token,并调用极光 `audience.registration_id`。
- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。
- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。
- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。
- iOS 测试环境固定使用 `options.apns_production=false`;客户端包也必须是开发 APNs 环境。TestFlight/App Store 生产包需要后端与客户端同时切到生产 APNs。
极光 Push extras 统一格式:
@@ -497,7 +549,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- |
| `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_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id,caller_display,expires_at` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
@@ -507,22 +559,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json
{
"platform": "all",
"platform": "ios",
"audience": {
"alias": ["usr_target"]
"registration_id": ["161a3797c95ee4a8bd3"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
@@ -550,25 +591,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json
{
"platform": "all",
"platform": "ios",
"audience": {
"alias": ["usr_callee"]
"registration_id": ["161a3797c95ee4a8bd3"]
},
"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": "语音通话",
@@ -576,6 +603,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
},
"sound": "default",
"badge": "+1",
"category": "im_call",
"content-available": 1,
"extras": {
"type": "call_invite",
"tenant_code": "default",
@@ -583,18 +612,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
"caller_id": "usr_caller",
"caller_display": "张三",
"expires_at": "2026-09-18T10:00:30Z"
}
}
},
"options": {
"time_to_live": 60,
"time_to_live": 30,
"apns_production": false,
"classification": 1
}
}
```
来电推送必须走普通 APNs/极光 alert 通知,不使用 PushKit VoIP topic;iOS `notification.ios.category` 固定为 `im_call`,用于客户端通知按钮,同时带 `content-available: 1` 唤醒后台 App 拉取 `topic/seq` 并启动 Live Activity。`time_to_live` 和 `expires_at` 以 30 秒左右短有效期为准,过期后服务端不再投递。
#### 0.5.5 Long Polling 与文件基础示例
##### 0.5.5.1 WebSocket 与文件基础接口
@@ -603,10 +636,17 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- |
| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` |
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;聊天附件必须带 `topic=<消息所在会话 topic>`(`p2p.../grp...`),可选 `purpose=chat_file`、`id=<幂等ID>` | `{"ctrl":{"code":200,"params":{"expires":"2026-10-13T06:21:20Z","file_id":"s6yhMyiOfaU","url":"/v0/file/s/s6yhMyiOfaU.txt"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-`;URL 可带文件扩展名(服务端自动剥离) | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 |
**文件上传/下载鉴权与回收规则(已实现,客户端必须遵守):**
1. 上传聊天附件(图片/视频/语音/文件)必须携带 `topic=<消息将要发送到的会话 topic>`。服务端校验上传者是该 `p2p`/`grp` 会话的订阅成员(`modewant`/`modegiven` 含读权限),不是成员返回 `403 permission denied`;校验通过则在服务端建立「文件-会话」引用,该会话全体成员凭有效 Token 即可下载。
2. 不带 `topic` 的上传只有上传者本人可下载。任何文件若一直未被任何会话/消息引用,服务端在创建 1 小时后由后台任务物理回收;上传后必须及时发送消息或携带 `topic`,否则对端将拿到 404。用户头像/资料类文件不受此限:客户端设置头像(PATCH `avatar_file_id`)时服务端自动写入文件引用,该文件不会被回收。
3. 下载放行范围(2026-10-14 更新):上传者本人、文件所属会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、**用户头像/资料文件(对任何登录用户放行,2026-10-14 修复:此前仅上传者可读,群成员拉取他人头像 403)**。非授权返回 `403 permission denied`(即 `FILE_FORBIDDEN`),客户端不自动重试。
4. `avatar_url` 统一回填规则(2026-10-14 起,所有返回用户/群摘要的接口一致):`avatar_file_id` 为完整 URL 时 `avatar_url` 原样返回;为裸 file_id 时返回 `"/v0/file/s/{file_id}"`(可直接下载,无需客户端拼接);头像为空时回落默认头像 URL(男/女按性别,`avatar_file_id` 与 `avatar_url` 同值)。涉及接口:`/v1/im/groups/{topic}/members`、`/members/{userId}/profile`、`/v1/im/users/{id}/profile`、`/v1/im/me/profile`、转发目标列表等。
### 0.6 Topic 与产品消息
@@ -633,6 +673,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
引用回复使用 `head.x-im-reply` 承载客户端生成的轻量快照,字段为 `seq:number`、`type:string`、`text:string`,可选 `from:string`。该对象必须放在 `head`,不能放入 `content`;服务端只负责把未知 `head` 字段随消息存储、实时转发、历史拉取和 HTTP 查询一起原样返回,不对被引用消息做二次查询或语义校验。客户端兼容历史服务端将 `x-im-reply` 作为 JSON 字符串返回的情况,但新版本客户端发送时必须使用 JSON 对象。
消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。
### 0.7 HTTP 幂等、重试和安全
@@ -665,7 +707,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](../IM-个人无企业码注册前后端对接说明.md)。
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](IM-个人无企业码注册前后端对接说明.md)。
成功响应示例:
@@ -675,7 +717,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"data": {
"tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}},
"endpoints": {
"websocket": "wss://im.example.com/v0/channels",
"websocket": "wss://im.example.com/v0/channels?apikey=AQ...",
"api": "https://im.example.com/v1/im",
"file": "https://im.example.com/v0/file"
},
@@ -840,20 +882,21 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/注册页面.png" alt="注册页面" width="360">
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。
- 旧版独立 `invite_code` 字段仅保留协议兼容,新客户端不得再展示独立邀请码输入框。
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。
- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。
- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。
- 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `tel credential`,同时写入邀请码推荐使用关系。
- 提交注册:`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
@@ -861,19 +904,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
用户名方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}}
```
手机号验证方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
仅手机号注册方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","registration_type":1,"secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
@@ -913,7 +956,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式接口通过 Header `X-Action-Token` 携带。
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式、群主转让、解散群聊接口通过 Header `X-Action-Token` 携带。
- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
### 1.8 实名认证.png
@@ -973,18 +1016,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 邀请码 | `{"code":"AbCdE"}` | `{"verification_mode":"invite_code","invite_accepted":true}` |
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
| `POST /v1/im/auth/logout` | `{}`(可选 `device_id`;或 Header `X-Device-ID`/`X-IM-Device-ID`) | `{"status":"ok","device_id":"...","push_unbound":true}`;当前 Token 写入 Redis 撤销集合(TTL=剩余有效期),仅当前设备失效,不影响其他设备;带 `device_id` 时同步解绑该设备推送 |
| `GET /v1/im/app/releases/latest?platform=ios\|android\|web&current_version=1.0.0&channel=stable` | Query only | `{"platform":"web","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` |
| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
| `GET /v1/im/app/releases/latest?platform=ios\|android\|web&current_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"web","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"","release_notes":"修复已知问题。","remind_after_seconds":86400}` |
## 2. 消息首页、全局导航与系统通知
@@ -1118,11 +1162,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/双方沟通状态.png" alt="双方沟通状态" width="360">
**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。**
**状态:上传带 `topic` 与下载按会话授权已在服务端实现;客户端按本节接入。**
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传(**必须带 `topic=<该会话 topic>`**,服务端校验成员资格并自动建立文件-会话引用),得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。
- 服务端在上传阶段即建立文件与会话的引用(带 `topic`);消息发布成功后按消息附件引用建立消息级 ACL;文件下载校验会话成员身份与消息可见性。
- 媒体文件(缩略图、原图、视频、语音)一律走同一次上传流程并带同一个 `topic`;缩略图也必须挂到会话,否则对端只能看到占位图。
- 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
@@ -1367,8 +1412,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":12}],"target_topics":["grp..."],"comment":"..."}`。`seq` 兼容数字和字符串;P2P 源会话也可以传对方 `usr...` 用户 ID,服务端会归一化为 P2P topic。
- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。
- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。
- 返回每个目标和源消息的 `client_mid/seq/status`;`status` 取值为 `sent`、`pending`、`failed`。`pending` 表示服务端已分配 `seq/client_mid`,但 Kafka 投递结果暂不确定,客户端应按“发送中”展示,保留同一个 `Idempotency-Key` 重试或等待后续消息同步,不要立即生成第二条本地消息。服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。
- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送,返回项可带 `comment_client_mid/comment_seq/comment_status/comment_reason`;转发消息本身仍保持原 `head/content`,不得把留言包装进图片、文件等原消息 `content`。
### 4.20 转发—合并转发—多选状态.png
@@ -1377,6 +1422,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。**
- 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。
- `items[]` 和 `preview[]` 保留 `from` 原发送人 ID,并新增 `from_user` 用户摘要:`{"user_id":"usr...","display_name":"张三","avatar_file_id":"file_avatar"}`。头像为空时可缺省;若资料查询失败,服务端至少返回 `user_id/display_name` 兜底。
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
@@ -1572,9 +1618,32 @@ Body:
#### 4.21.3 消息变更同步水位
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;单聊中如果客户端在线但当前没有订阅该 P2P,服务端会额外向双方用户通道下发 `message.mutation` 脏标记事件;离线或错过实时事件时,需要靠会话级水位补偿。
单聊场景中,客户端在线但未订阅该 P2P 时,服务端还会向双方用户通道下发 `message.mutation` 脏标记事件(`what:"im"`、`event:"message.mutation"`,payload 含 `topic/seq/version/event`),客户端收到后同样按下方水位补偿流程拉取;群聊不下发脏标记,仅靠会话列表水位 + 拉取补偿。
单聊未订阅脏标记示例:
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "message.mutation",
"payload": {
"topic": "p2pJaOWxMvALjB4i1K_h_NZgQ",
"seq": 12,
"version": 2,
"event": "message.recalled"
}
}
}
```
收到 `message.mutation` 后不要直接改本地消息内容,先把 `payload.topic` 标记为脏,再调用:
```http
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
```
会话列表同步时,`me.meta.sub[].private` 会带:
@@ -1642,7 +1711,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增产品通讯录接口。**
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,avatar_url,initial,online,online_visibility`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。
- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。
- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1705,7 +1774,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。**
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,avatar_url,gender,region,public_account,signature,relationship,capabilities`(`avatar_url` 为可直接下载的头像地址,统一回填规则见 0.5.5.1 第 4 条);`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1803,7 +1872,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","display_name":"张三","remark":"","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
@@ -1841,7 +1910,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增邀请卡片和加入接口。**
- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。
- 邀请卡展示以群邀请接口返回和系统通知记录为准;系统通知 `event_type=group.invitation.created`,`action.type=group_invitation_accept`,`action.invitation_id` 用于点击加入,`action.topic` 为目标群。相关实时刷新使用 `info.what="group"` 群业务事件。
- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
@@ -1861,7 +1930,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。**
- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。
- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`;申请人展示字段直接带 `display_name/avatar_file_id/user`,邀请人展示字段带 `inviter_display_name/inviter_avatar_file_id/inviter`。
- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。
- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。
@@ -1944,6 +2013,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。
- 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。
### 6.13.1 群成员列表拉取与缓存策略
@@ -2128,8 +2198,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:需改造。**
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。
- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。
- 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例
@@ -2151,18 +2221,20 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` |
| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` |
| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` |
| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` |
| `DELETE /v1/im/groups/{topic}` | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true,"status":"dissolved","version":2}` |
| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` |
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` |
| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group":{"topic":"grp...","name":"项目群","member_count":2},"group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` |
| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` |
| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","user_id":"usr...","display_name":"张三","avatar_file_id":"avatar.jpg","user":{"user_id":"usr...","display_name":"张三","avatar_file_id":"avatar.jpg"},"inviter_id":"usr_inviter","inviter_display_name":"李四","inviter_avatar_file_id":"inviter.jpg","inviter":{"user_id":"usr_inviter","display_name":"李四","avatar_file_id":"inviter.jpg"},"status":"pending","source":"invitation","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` |
@@ -2269,10 +2341,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/文件-下载.png" alt="文件-下载" width="360">
**状态:底层下载已实现租户校验;资源 ACL 需改造。**
**状态:下载授权已实现(按上传者/会话成员/消息引用/群成员/好友/收藏/头像放行)。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。
- 服务端按文件 ACL 放行:上传者本人、文件所属 `p2p`/`grp` 会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用、用户头像/资料文件(对任何登录用户放行,2026-10-14 更新);非授权返回 `403 permission denied`(`FILE_FORBIDDEN`)。对象存储模式下鉴权通过返回 302 短期签名 URL。
- 头像排障口径:群成员/资料接口返回的 `avatar_url` 可直接下载;若对端头像显示占位图且下载 `404`,说明该头像文件从未被引用或已被回收,让对方重新上传并设置头像即可;`403` 为非登录态(Token 失效),重新登录。
- 前提:发送方上传附件时带了 `topic`(见 0.5.5.1)。若对端下载一直 404,说明该文件从未关联会话/消息且已被 1 小时回收任务删除,应让发送方重新上传并携带 `topic`。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
### 7.10 文件-下载中.png
@@ -2361,10 +2435,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` |
| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` |
| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` |
| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` |
| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20,"comment_seq":19,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` |
| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` |
| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1"}],"failed":[]}` |
| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent","comment_seq":29,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1","comment_seq":30,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` |
| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` |
@@ -2384,11 +2458,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/对方状态.png" alt="对方状态" width="360">
**状态:在线邀请已实现;Push 与产品鉴权需改造。**
**状态:在线邀请、短有效期普通 Push 与产品鉴权已实现。**
- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。
- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。
- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。
- 后台 Push 只携带 `callId,topic,seq,caller_id,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;iOS 来电 Push 使用普通 alert,`category=im_call`,`content-available=1`,短 TTL 约 30 秒,不使用 PushKit VoIP。
- 被叫在未发送 `ringing/accept` 前也可以直接发送 `hang-up`,服务端按 `topic+seq+callId` 校验后立即结束主叫侧振铃,并把原通话消息替换为 `declined`;主叫主动取消则替换为 `missed`。
### 8.3 语音通话-对方待接受状态.png
@@ -2510,7 +2585,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。**
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,sort_order,unread_count,enabled,permission}]`、布局版本和企业配置;服务端按 `sort_order ASC` 返回,默认顺序为打卡、月报、日报、部门框架、举报信箱、会议通知、会议笔记。`meetings` 模块的 `unread_count` 由服务端按未读系统通知实时补齐。
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
@@ -2558,6 +2633,16 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.4.1 会议笔记
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20` 返回可查看会议笔记的会议列表、`note_status/file_count/permissions`。
- 详情:`GET /v1/im/meetings/{meeting_id}/notes` 返回会议信息和 `files[]`。
- 上传并登记:调用通用文件上传 `POST /v0/file/u/`,multipart 包含 `file=@...`、`purpose=meeting_note`、`meeting_id=<meeting_id>`、`id=<本次上传幂等ID>`,请求头按文件上传鉴权携带 `X-MeetingIM-APIKey`、`X-MeetingIM-Auth`。服务端上传成功后会立即登记会议笔记文件,响应 `ctrl.params` 包含 `url,file_id,purpose,meeting_id,note_file_id,note_file_status,version`。
- 兼容绑定:若客户端先完成普通文件上传,也可以调用 `POST /v1/im/meetings/{meeting_id}/notes/files`,Body `{"file_id":"..."}`,并携带 `Idempotency-Key` 完成登记。
- 替换/删除:`PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}`、`DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}?version=n`。只有记录人/发起人按权限可上传、替换和删除。
### 9.5 日报.png
<img src="images/日报.png" alt="日报" width="360">
@@ -2634,7 +2719,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。**
- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。
- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。
- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。`subject` 最多 200 字符,`content` 最多 5000 字符;超限返回 `VALIDATION_FAILED`,不返回 500。
- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。
### 9.13 总经理信箱-文案提示.png
@@ -2723,7 +2808,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。两个接口都必须校验当前用户仍有会议笔记查看权限。
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。对象存储模式下服务端鉴权通过后返回 302 到短期签名 URL,客户端跟随 `Location` 获取文件内容。
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
@@ -2733,7 +2818,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:复用统一下载任务;会议笔记 ACL 新增。**
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传。
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传;对象存储模式下客户端跟随 302 `Location`。
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
@@ -2766,7 +2851,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `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","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"processing","mode":"converted"}}` |
| `PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_2","version":1}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_2","name":"会议纪要-v2.pdf","version":2,"updated_at":"2026-09-05T03:10:00Z"}` |
| `DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Query `version=2` | `{"note_file_id":"mnote_1","deleted":true,"deleted_at":"2026-09-05T03:20:00Z"}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 返回文件字节流及 `Content-Type`、`Content-Length`、`ETag`、`Accept-Ranges`;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件内容 |
### 9.14.10 字段与状态约定
@@ -2845,6 +2930,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=` | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","note_status":"available","file_count":1,"permissions":{"can_upload":true,"can_download":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` | Path meeting ID | `{"meeting_id":"mtg_1","note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","file_name":"会议纪要.pdf","mime_type":"application/pdf","permissions":{"can_download":true}}]}` |
| `POST /v0/file/u/` 会议笔记上传并登记 | Multipart `purpose=meeting_note&meeting_id=mtg_1&id=upload_1&file=@note.pdf` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file_1.pdf","file_id":"file_1","purpose":"meeting_note","meeting_id":"mtg_1","note_file_id":"mnote_1","note_file_status":"active","version":1}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","status":"active","version":1}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
@@ -2882,8 +2971,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,avatar_url,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`(`avatar_url` 统一回填规则见 0.5.5.1 第 4 条);`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`(乐观锁必填,`version` 取自最近一次 `GET /v1/im/me/profile` 的返回值);缺失或 `<=0` 返回 `400/20000 MALFORMED_REQUEST`,与当前版本不一致返回 `409/20009 VERSION_CONFLICT`(重新 GET 取最新 `version` 后重试即可);服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png
@@ -2894,7 +2984,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突(`409/20009`)先拉最新资料再由用户确认。
### 10.4 出生日期.png
@@ -2957,8 +3047,13 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增 App 配置接口。**
- `GET /v1/im/app/releases/latest?platform=ios|android|web&current_version=1.0.0&channel=stable`,未登录可在企业解析后调用。
- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。
完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
- `GET /v1/im/app/releases/latest?platform=ios|android|web&current_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
- 返回 `current_version,has_update,force_update,latest_version,min_supported_version,update_type=none|recommended|force,release_notes,store_url,package_url,checksum,published_at`。
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
### 10.11 发现新版本.png
@@ -3082,8 +3177,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"file_avatar","avatar_url":"/v0/file/s/file_avatar","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":true},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":true},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
+20 -10
View File
@@ -24,6 +24,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0
github.com/jmoiron/sqlx v1.4.0
github.com/minio/minio-go/v7 v7.4.1
github.com/mozillazg/go-pinyin v0.20.0
github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2
@@ -34,13 +35,13 @@ require (
github.com/tinode/jsonco v1.0.0
github.com/tinode/snowflake v1.0.0
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.53.0
golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
golang.org/x/sync v0.23.0
golang.org/x/text v0.42.0
google.golang.org/api v0.287.0
google.golang.org/grpc v1.82.0
google.golang.org/protobuf v1.36.11
google.golang.org/protobuf v1.36.12
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2
)
@@ -80,6 +81,7 @@ require (
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -88,15 +90,22 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
@@ -106,8 +115,9 @@ require (
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.60.0 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
)
require (
@@ -129,18 +139,18 @@ require (
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/twilio/twilio-go v1.30.9
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
+51 -24
View File
@@ -176,6 +176,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
@@ -264,8 +266,13 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -281,6 +288,12 @@ github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZyc
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w=
github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -304,6 +317,8 @@ github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoT
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -311,8 +326,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -326,13 +339,15 @@ github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgv
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -341,20 +356,26 @@ github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnO
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE=
github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8=
github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=
github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
@@ -372,6 +393,10 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -400,6 +425,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -415,8 +442,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -451,8 +478,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -467,8 +494,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -492,8 +519,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -517,8 +544,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -569,8 +596,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU=
@@ -581,8 +608,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -590,7 +618,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+13
View File
@@ -72,6 +72,19 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
}
}
func TestPrivateMessageQueryUsesTenantShardTables(t *testing.T) {
spec := privateMessagesInternalQuerySpecForTenant(types.TenantID(9))
query := strings.ToLower(spec.From)
for _, table := range []string{"im_009_messages", "im_009_subscriptions"} {
if !strings.Contains(query, table) {
t.Fatalf("private message query must use tenant shard table %q: %s", table, query)
}
}
if strings.Contains(query, "from im_messages ") || strings.Contains(query, "from im_subscriptions ") {
t.Fatalf("private message query must not use legacy shared tables: %s", query)
}
}
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil)
query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1))
+11
View File
@@ -4729,6 +4729,17 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active'
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
-- 用户头像/资料文件:设置头像时写入的链接行有两种形态——
-- userid 形态(topic/msgid 为 NULL,userid=头像主人,旧版写入)与
-- 纯数字 uid topic 形态(现行写入;合法业务 topic 均带字母前缀,不会误伤)。
-- 头像属于公开资料,对任何登录用户放行。
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.fileid=fu.id AND fml.msgid IS NULL AND (
(fml.topic IS NULL AND fml.userid IS NOT NULL)
OR fml.topic REGEXP '^[0-9]+$'
)
)
) LIMIT 1`, args
}
run := func() error {
+6 -1
View File
@@ -6,6 +6,7 @@ package mysql
import (
"context"
"database/sql"
"strings"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
@@ -16,6 +17,10 @@ type fileReviewExecContext interface {
}
func insertFileReviewTasks(ctx context.Context, exec fileReviewExecContext, fd *t.FileDef) error {
storageProvider := ""
if strings.HasPrefix(fd.Location, "minio://") {
storageProvider = "minio"
}
fileID := decodeUID(fd.Uid())
userID := int64(0)
if fd.User != "" {
@@ -37,7 +42,7 @@ func insertFileReviewTasks(ctx context.Context, exec fileReviewExecContext, fd *
_, err = exec.ExecContext(ctx, `INSERT IGNORE INTO im_file_review_tasks
(id,task_id,file_id,user_id,task_type,provider,storage_provider,object_name,mime_type,file_size,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, taskID, fileID, userID, task.taskType, task.provider, "", fd.Location, fd.MimeType, fd.Size,
id, taskID, fileID, userID, task.taskType, task.provider, storageProvider, fd.Location, fd.MimeType, fd.Size,
t.FileReviewStatusPending, fd.UpdatedAt, fd.UpdatedAt)
if err != nil {
return err
+11
View File
@@ -5585,6 +5585,17 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
WHERE gf.tenant_id=fu.tenant_id AND (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%%')) AND gf.status='active'
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
-- 用户头像/资料文件:设置头像时写入的链接行有两种形态——
-- userid 形态(topic/msgid 为 NULL,userid=头像主人)与
-- 纯数字 uid topic 形态(store 层已把 usr 前缀 topic 归一为 userid 形态)。
-- 头像属于公开资料,对任何登录用户放行;与 FileUserHasAccess 的第 9 条保持一致。
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.msgid IS NULL AND (
(fml.topic IS NULL AND fml.userid IS NOT NULL)
OR fml.topic REGEXP '^[0-9]+$'
)
)
) LIMIT 1`, tenantFriendshipsTable(id), tenantMessagesTable(id))),
id, decodeUID(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), t.RelationshipFriend,
decodeUID(uid), decodeUID(uid), t.GroupMemberStatusActive, decodeUID(uid), decodeUID(uid),
+14 -8
View File
@@ -1555,27 +1555,33 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
return tx.Commit()
}
func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, admin bool, now time.Time) (bool, error) {
func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, _ bool, now time.Time) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now)
}
if admin {
return true, nil
return tenantAdp.TenantGroupCanPost(id, topic, uid, false, now)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var role string
var muteAll, memberMuted bool
err := a.db.QueryRowContext(ctx, `SELECT
err := a.db.QueryRowContext(ctx, tenantShardSQL(id, `SELECT
COALESCE((SELECT role FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=? LIMIT 1),''),
COALESCE((SELECT mute_all FROM im_group_settings WHERE tenant_id=? AND topic=? AND group_status=? LIMIT 1),0),
EXISTS(SELECT 1 FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`,
id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted)
EXISTS(SELECT 1 FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`),
id, topic, decodeUID(uid), t.GroupMemberStatusActive,
id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&role, &muteAll, &memberMuted)
if err != nil {
return false, err
}
if role == t.GroupMemberRoleOwner || role == t.GroupMemberRoleAdmin {
return true, nil
}
if role != t.GroupMemberRoleMember {
return false, nil
}
return !muteAll && !memberMuted, nil
}
+6
View File
@@ -36,6 +36,12 @@ func applyAvatarURL(data map[string]any) {
avatar := strings.TrimSpace(stringFromAny(data["avatar_file_id"]))
if isAvatarURLValue(avatar) {
data["avatar_url"] = avatar
return
}
// 裸文件 ID:统一补一个可直接下载的 avatar_url。
// 下载走 /v0/file/s/{file_id},自定义头像的 ACL 对登录用户放行(见 adapter.FileUserHasAccess)。
if avatar != "" && !types.ParseUid(avatar).IsZero() {
data["avatar_url"] = "/v0/file/s/" + avatar
}
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os"
"strings"
"testing"
)
func TestDockerConfigUsesKafkaBrokersFromEnvironment(t *testing.T) {
content, err := os.ReadFile("../docker/tinode/config.template")
if err != nil {
t.Fatalf("read Docker config template: %v", err)
}
template := string(content)
if !strings.Contains(template, `"brokers": $KAFKA_BROKERS_JSON`) {
t.Fatal("Docker config template must render Kafka brokers from KAFKA_BROKERS_JSON")
}
if strings.Contains(template, "kafka.118im.com:30835") {
t.Fatal("Docker config template must not hard-code the public Kafka NodePort")
}
}
+14 -1
View File
@@ -203,7 +203,7 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
// Preflight request: process before any security checks.
if req.Method == http.MethodOptions {
headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, true)
headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, false)
if err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return
@@ -518,9 +518,22 @@ func authorizeUploadTopicLink(tenantID types.TenantID, uid types.Uid, topic stri
if !sub.ModeWant.IsReader() || !sub.ModeGiven.IsReader() {
return types.ErrPermissionDenied
}
if types.GetTopicCat(topic) == types.TopicCatGrp {
settings, err := store.GroupBusiness.GetSettings(tenantID, topic)
if err != nil {
return err
}
if settings != nil && !canUploadGroupFile(settings, sub.ModeGiven) {
return types.ErrPermissionDenied
}
}
return nil
}
func canUploadGroupFile(settings *types.GroupSettings, mode types.AccessMode) bool {
return settings == nil || settings.MemberUploadFiles || mode.IsAdmin()
}
func authorizeLargeFileDownload(tenantID types.TenantID, uid types.Uid, rawURL string, fd *types.FileDef) error {
if fd == nil {
return types.ErrNotFound
+60 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -23,11 +24,16 @@ type testLargeFileMediaHandler struct {
data string
fid types.Uid
headersCalled bool
corsOrigins []media.AllowedOrigin
}
func (h *testLargeFileMediaHandler) Init(string) error { return nil }
func (h *testLargeFileMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) {
func (h *testLargeFileMediaHandler) Headers(_ types.TenantID, method string, _ *url.URL, headers http.Header, serve bool) (http.Header, int, error) {
h.headersCalled = true
if h.corsOrigins != nil {
response, status := media.CORSHandler(method, headers, h.corsOrigins, serve)
return response, status, nil
}
return nil, 0, nil
}
func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) {
@@ -46,6 +52,59 @@ func (h *testLargeFileMediaHandler) GetIdFromUrl(string) types.Uid {
return h.fid
}
func TestCanUploadGroupFileHonorsGovernance(t *testing.T) {
settings := &types.GroupSettings{MemberUploadFiles: false}
if canUploadGroupFile(settings, types.ModeRead|types.ModeWrite) {
t.Fatal("ordinary member must not upload when group files are admin-only")
}
if !canUploadGroupFile(settings, types.ModeRead|types.ModeApprove) {
t.Fatal("group admin must be allowed to upload when group files are admin-only")
}
settings.MemberUploadFiles = true
if !canUploadGroupFile(settings, types.ModeRead|types.ModeWrite) {
t.Fatal("ordinary member must be allowed when group files are open to all")
}
if !canUploadGroupFile(nil, types.ModeRead|types.ModeWrite) {
t.Fatal("groups without business settings must retain the default allow behavior")
}
}
func TestLargeFileUploadPreflightAllowsPost(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
defer func() { store.Store = oldStore }()
origins, err := media.ParseCORSAllow([]string{"https://web1.118im.com"})
if err != nil {
t.Fatal(err)
}
handler := &testLargeFileMediaHandler{corsOrigins: origins}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetMediaHandler().Return(handler)
store.Store = mockStore
req := httptest.NewRequest(http.MethodOptions, "/v0/file/u/", nil)
req.Header.Set("Origin", "https://web1.118im.com")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
req.Header.Set("Access-Control-Request-Headers", "authorization,x-meetingim-apikey")
wrt := httptest.NewRecorder()
largeFileReceiveHTTP(wrt, req)
if wrt.Code != http.StatusNoContent || !handler.headersCalled {
t.Fatalf("preflight response: status=%d headers_called=%t", wrt.Code, handler.headersCalled)
}
if got := wrt.Header().Get("Access-Control-Allow-Origin"); got != "https://web1.118im.com" {
t.Fatalf("allowed origin = %q", got)
}
if got := wrt.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPost) {
t.Fatalf("POST missing from allowed methods: %q", got)
}
if got := wrt.Header().Get("Access-Control-Allow-Headers"); got != "authorization,x-meetingim-apikey" {
t.Fatalf("requested headers not allowed explicitly: %q", got)
}
}
func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
oldSessionStore := globals.sessionStore
globals.sessionStore = NewSessionStore(time.Minute)
+2 -2
View File
@@ -162,7 +162,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
switch eventType {
case "group.member.left":
copyPayload["member_sync"] = "remove"
case "group.member.joined", "group.member.updated", "group.role.updated", "group.mute.updated":
case "group.member.joined", "group.member.updated", "group.member.profile.updated", "group.role.updated", "group.mute.updated":
if _, ok := copyPayload["user_id"]; ok {
copyPayload["member_sync"] = "single"
} else {
@@ -171,7 +171,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
}
if isAdminOnlyIMGroupEvent(eventType) {
copyPayload["visibility"] = "admins_and_affected"
} else if eventType == "group.member.joined" {
} else if eventType == "group.member.joined" || eventType == "group.member.profile.updated" {
copyPayload["visibility"] = "all_members"
}
return copyPayload
+11
View File
@@ -11,6 +11,17 @@ import (
"github.com/tinode/chat/server/store/types"
)
func TestGroupMemberProfileEventIsVisibleToAllMembers(t *testing.T) {
const event = "group.member.profile.updated"
if isAdminOnlyIMGroupEvent(event) {
t.Fatal("group nickname update must reach ordinary members")
}
payload := enrichIMGroupMemberEventPayload(event, map[string]any{"user_id": "usrMember"}).(map[string]any)
if payload["member_sync"] != "single" || payload["visibility"] != "all_members" {
t.Fatalf("unexpected group profile event payload: %#v", payload)
}
}
func TestPublishIMGroupRealtimeEventRestrictsMemberRemoval(t *testing.T) {
oldGroupBusiness := store.GroupBusiness
oldSessions := globals.sessionStore
+6 -4
View File
@@ -4892,7 +4892,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
if errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.profile.updated",
map[string]any{"user_id": principal.Uid.UserId()})
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
return
@@ -4903,7 +4903,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.profile.updated",
map[string]any{"user_id": principal.Uid.UserId()})
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
}
@@ -7439,13 +7439,15 @@ func forwardTargetPayload(sub types.Subscription) map[string]any {
default:
return nil
}
return map[string]any{
target := map[string]any{
"topic": sub.Topic,
"type": topicType,
"display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic),
"avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()),
"can_receive_forward": true,
}
applyAvatarURL(target)
return target
}
func forwardTargetMatches(topic, displayName, query string) bool {
@@ -7643,7 +7645,7 @@ func groupBusinessMemberPayload(member types.GroupMember, remark string) map[str
}
func maskIMGroupMemberProfileEntrypoint(data map[string]any) {
for _, key := range []string{"avatar_file_id", "public_account", "signature", "gender", "region"} {
for _, key := range []string{"avatar_file_id", "avatar_url", "public_account", "signature", "gender", "region"} {
delete(data, key)
}
data["can_add"] = false
+6
View File
@@ -147,6 +147,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
type testRelationsStore struct {
context *types.RelationshipContext
err error
directAdded bool
created *types.FriendRequest
blocked *types.UserBlock
deleted bool
@@ -964,6 +965,11 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr
return req, nil
}
func (s *testRelationsStore) AddFriendDirect(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
s.directAdded = true
return s.err
}
func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) {
return s.friendRequests, "", nil
}
+11
View File
@@ -127,6 +127,9 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
if resource == "private-messages" {
spec = privateMessagesInternalQuerySpecForTenant(tenantID)
}
if resource == "group-messages" {
var err error
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
@@ -400,6 +403,14 @@ func privateMessagesInternalQuerySpec() *internalQuerySpec {
}
}
func privateMessagesInternalQuerySpecForTenant(tenantID types.TenantID) *internalQuerySpec {
spec := privateMessagesInternalQuerySpec()
prefix := fmt.Sprintf("im_%03d_", tenantID)
spec.From = strings.Replace(spec.From, "FROM im_messages ", "FROM "+prefix+"messages ", 1)
spec.From = strings.ReplaceAll(spec.From, "FROM im_subscriptions ", "FROM "+prefix+"subscriptions ")
return spec
}
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`
+30
View File
@@ -116,6 +116,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct":
serveInternalFriendDirectAdd(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
@@ -166,9 +168,37 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
func isInternalAdminPostRoute(parts []string) bool {
return (len(parts) == 3 && parts[2] == "users") ||
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") ||
(len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct") ||
(len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release")
}
type internalDirectFriendRequest struct {
UserID string `json:"user_id"`
PeerID string `json:"peer_user_id"`
}
func serveInternalFriendDirectAdd(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
var body internalDirectFriendRequest
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
userID := types.ParseUserId(strings.TrimSpace(body.UserID))
peerID := types.ParseUserId(strings.TrimSpace(body.PeerID))
if userID.IsZero() || peerID.IsZero() || userID == peerID {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed user IDs", nil)
return
}
if err := store.Relations.AddFriendDirect(tenantID, userID, peerID, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"relationship": "friend",
"topic": userID.P2PName(peerID),
})
}
type internalUserCreateRequest struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
+30
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -37,6 +38,35 @@ func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[s
return nil, types.ErrNotFound
}
func TestServeInternalFriendDirectAddCreatesRelationshipWithoutRequest(t *testing.T) {
oldRelations := store.Relations
relations := &testRelationsStore{}
store.Relations = relations
t.Cleanup(func() { store.Relations = oldRelations })
req := httptest.NewRequest(http.MethodPost, "/internal/admin-query/tenants/default/friends/direct", strings.NewReader(`{"user_id":"usrAQAAAAAAAAA","peer_user_id":"usrAgAAAAAAAAA"}`))
wrt := httptest.NewRecorder()
serveInternalFriendDirectAdd(wrt, req, types.TenantID(1))
if wrt.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", wrt.Code, http.StatusOK, wrt.Body.String())
}
if !relations.directAdded || relations.created != nil {
t.Fatalf("direct_added=%v friend_request=%v, want direct add and no request", relations.directAdded, relations.created)
}
var payload struct {
Data struct {
Relationship string `json:"relationship"`
} `json:"data"`
}
if err := json.Unmarshal(wrt.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Data.Relationship != types.RelationshipFriend {
t.Fatalf("relationship = %q, want %q", payload.Data.Relationship, types.RelationshipFriend)
}
}
func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+5 -4
View File
@@ -281,17 +281,17 @@ func resolveMediaHandlerConfig(config *mediaConfig) (string, string, error) {
conf = string(params)
}
}
if handler == "oss" {
ossConf, err := ensureOSSUsesSysConfig(conf)
if handler == "oss" || handler == "minio" {
storageConf, err := ensureObjectStorageUsesSysConfig(conf, handler)
if err != nil {
return "", "", err
}
return handler, ossConf, nil
return handler, storageConf, nil
}
return handler, conf, nil
}
func ensureOSSUsesSysConfig(conf string) (string, error) {
func ensureObjectStorageUsesSysConfig(conf, provider string) (string, error) {
params := map[string]any{}
if strings.TrimSpace(conf) != "" {
if err := json.Unmarshal([]byte(conf), &params); err != nil {
@@ -299,6 +299,7 @@ func ensureOSSUsesSysConfig(conf string) (string, error) {
}
}
params["use_sys_config"] = true
params["provider"] = provider
encoded, err := json.Marshal(params)
if err != nil {
return "", err
+3 -1
View File
@@ -298,7 +298,9 @@ func CORSHandler(method string, reqHeader http.Header, allowedOrigins []AllowedO
return respHeader, http.StatusNoContent
}
respHeader["Access-Control-Allow-Headers"] = []string{"*"}
if requestedHeaders := reqHeader.Get("Access-Control-Request-Headers"); requestedHeaders != "" {
respHeader["Access-Control-Allow-Headers"] = []string{requestedHeaders}
}
respHeader["Access-Control-Allow-Credentials"] = []string{"true"}
respHeader["Access-Control-Allow-Methods"] = []string{strings.Join(allowMethods, ", ")}
respHeader["Access-Control-Max-Age"] = []string{"86400"}
+161
View File
@@ -0,0 +1,161 @@
package oss
import (
"context"
"errors"
"io"
"net/url"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const minioLocationPrefix = "minio://"
type minioConfig struct {
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
}
type minioStorage struct {
client *minio.Client
bucket string
}
func newMinIOStorage(conf minioConfig) (*minioStorage, error) {
if conf.Endpoint == "" || conf.Bucket == "" || conf.AccessKeyID == "" || conf.SecretAccessKey == "" {
return nil, nil
}
endpoint := strings.TrimSpace(conf.Endpoint)
secure := true
if strings.Contains(endpoint, "://") {
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("invalid MinIO endpoint")
}
endpoint = parsed.Host
secure = parsed.Scheme == "https"
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(conf.AccessKeyID, conf.SecretAccessKey, ""),
Secure: secure,
Region: conf.Region,
})
if err != nil {
return nil, err
}
return &minioStorage{client: client, bucket: conf.Bucket}, nil
}
func (oh *ossHandler) uploadMinIO(fd *types.FileDef, file io.Reader) (string, int64, error) {
key, err := tenantObjectKey(fd.TenantID, fd.Uid().String32(), fd.CreatedAt)
if err != nil {
return "", 0, err
}
if err := store.Files.StartUpload(fd.TenantID, fd); err != nil {
return "", 0, err
}
result, err := oh.minio.client.PutObject(context.Background(), oh.minio.bucket, key, file, remainingSize(file), minio.PutObjectOptions{
ContentType: fd.MimeType,
CacheControl: oh.conf.CacheControl,
})
if err != nil {
return "", 0, err
}
fd.Location = minioLocation(oh.minio.bucket, key)
fd.ETag = strings.Trim(result.ETag, "\"")
return oh.conf.ServeURL + media.FileURLName(fd), result.Size, nil
}
func (oh *ossHandler) uploadPublicMinIO(fd *media.PublicFileDef, file io.Reader) (string, int64, error) {
key, err := publicObjectKey(fd.Purpose, fd.ID, fd.CreatedAt)
if err != nil {
return "", 0, err
}
result, err := oh.minio.client.PutObject(context.Background(), oh.minio.bucket, key, file, remainingSize(file), minio.PutObjectOptions{
ContentType: fd.MimeType,
CacheControl: oh.conf.CacheControl,
})
if err != nil {
return "", 0, err
}
fd.Location = minioLocation(oh.minio.bucket, key)
fd.ETag = strings.Trim(result.ETag, "\"")
return fd.Location, result.Size, nil
}
func (oh *ossHandler) downloadMinIO(fd *types.FileDef) (*types.FileDef, media.ReadSeekCloser, error) {
bucket, key, err := oh.minioObject(fd.Location)
if err != nil {
return nil, nil, err
}
if fd.Size <= 0 {
meta, err := oh.minio.client.StatObject(context.Background(), bucket, key, minio.StatObjectOptions{})
if err != nil {
return nil, nil, err
}
fd.Size = meta.Size
}
reader := &ossReadSeekCloser{size: fd.Size}
reader.open = func(offset int64) (io.ReadCloser, error) {
options := minio.GetObjectOptions{}
if offset > 0 {
if err := options.SetRange(offset, fd.Size-1); err != nil {
return nil, err
}
}
return oh.minio.client.GetObject(context.Background(), bucket, key, options)
}
return fd, reader, nil
}
func (oh *ossHandler) deleteMinIO(location string) error {
bucket, key, err := oh.minioObject(location)
if err != nil {
return err
}
return oh.minio.client.RemoveObject(context.Background(), bucket, key, minio.RemoveObjectOptions{})
}
func (oh *ossHandler) minioObject(location string) (string, string, error) {
if oh.minio == nil {
return "", "", errors.New("MinIO is not configured for stored file")
}
bucket, key, ok := strings.Cut(strings.TrimPrefix(location, minioLocationPrefix), "/")
if !strings.HasPrefix(location, minioLocationPrefix) || !ok || bucket != oh.minio.bucket || key == "" {
return "", "", types.ErrMalformed
}
return bucket, key, nil
}
func minioLocation(bucket, key string) string {
return minioLocationPrefix + bucket + "/" + key
}
func remainingSize(reader io.Reader) int64 {
seeker, ok := reader.(io.Seeker)
if !ok {
return -1
}
current, err := seeker.Seek(0, io.SeekCurrent)
if err != nil {
return -1
}
end, err := seeker.Seek(0, io.SeekEnd)
if err != nil {
_, _ = seeker.Seek(current, io.SeekStart)
return -1
}
if _, err := seeker.Seek(current, io.SeekStart); err != nil {
return -1
}
return end - current
}
+51
View File
@@ -0,0 +1,51 @@
package oss
import (
"bytes"
"context"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/minio/minio-go/v7"
)
func TestLiveMinIOUploadDownload(t *testing.T) {
endpoint := os.Getenv("LIVE_MINIO_ENDPOINT")
accessKey := os.Getenv("LIVE_MINIO_ACCESS_KEY")
secretKey := os.Getenv("LIVE_MINIO_SECRET_KEY")
bucket := os.Getenv("LIVE_MINIO_BUCKET")
if endpoint == "" || accessKey == "" || secretKey == "" || bucket == "" {
t.Skip("live MinIO configuration is not set")
}
storage, err := newMinIOStorage(minioConfig{
Endpoint: endpoint, Bucket: bucket, Region: "us-east-1",
AccessKeyID: accessKey, SecretAccessKey: secretKey,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
key := fmt.Sprintf("im-smoke/%d.txt", time.Now().UnixNano())
defer func() {
if err := storage.client.RemoveObject(context.Background(), bucket, key, minio.RemoveObjectOptions{}); err != nil {
t.Errorf("cleanup object: %v", err)
}
}()
content := []byte("im-minio-smoke-test")
if _, err := storage.client.PutObject(ctx, bucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{ContentType: "text/plain"}); err != nil {
t.Fatalf("upload: %v", err)
}
object, err := storage.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
if err != nil {
t.Fatalf("download: %v", err)
}
defer object.Close()
got, err := io.ReadAll(object)
if err != nil || !bytes.Equal(got, content) {
t.Fatalf("downloaded content differs: %v", err)
}
}
+129
View File
@@ -0,0 +1,129 @@
package oss
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func TestMinIOUploadDownloadDelete(t *testing.T) {
const body = "hello minio"
var stored []byte
var deleted bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/xgjs/public/") {
t.Errorf("unexpected request path: %s", r.URL.Path)
http.Error(w, "wrong path", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPut:
stored, _ = io.ReadAll(r.Body)
if strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") {
separator := bytes.Index(stored, []byte("\r\n"))
if separator < 0 || len(stored) < separator+2+len(body) {
t.Errorf("invalid signed chunked request")
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
stored = stored[separator+2 : separator+2+len(body)]
}
w.Header().Set("ETag", `"test-etag"`)
case http.MethodGet:
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
w.Header().Set("ETag", `"test-etag"`)
if r.Header.Get("Range") == "bytes=6-10" {
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(stored[6:])
return
}
_, _ = w.Write(stored)
case http.MethodDelete:
deleted = true
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected request method: %s", r.Method)
http.Error(w, "wrong method", http.StatusMethodNotAllowed)
}
}))
defer server.Close()
previous := store.ThirdPartyConfig
store.ThirdPartyConfig = fakeThirdPartyConfig{
"oss_enabled": "0",
"minio_endpoint": server.URL,
"minio_bucket": "xgjs",
"minio_region": "us-east-1",
"minio_access_key_id": "test-key",
"minio_secret_access_key": "test-secret",
}
defer func() { store.ThirdPartyConfig = previous }()
handler := &ossHandler{}
if err := handler.Init(`{"provider":"minio","use_sys_config":true}`); err != nil {
t.Fatal(err)
}
if handler.bucket != nil || handler.minio == nil {
t.Fatal("MinIO provider should initialize without OSS credentials")
}
file := &media.PublicFileDef{ID: "file_test", Purpose: "avatar", MimeType: "text/plain", CreatedAt: time.Now()}
location, size, err := handler.UploadPublic(file, bytes.NewReader([]byte(body)))
if err != nil {
t.Fatal(err)
}
if size != int64(len(body)) || string(stored) != body || !strings.HasPrefix(location, "minio://xgjs/public/avatar/") {
t.Fatalf("upload: location=%q size=%d data=%q", location, size, stored)
}
fd := &types.FileDef{Location: location, Size: size}
_, reader, err := handler.downloadMinIO(fd)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
buf := make([]byte, len(body))
if _, err := io.ReadFull(reader, buf); err != nil || string(buf) != body {
t.Fatalf("download: %q, %v", buf, err)
}
if _, err := reader.Seek(6, io.SeekStart); err != nil {
t.Fatal(err)
}
if got, err := io.ReadAll(reader); err != nil || string(got) != "minio" {
t.Fatalf("range download: %q, %v", got, err)
}
if err := handler.Delete([]string{location}); err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
}
func TestMinIOLocationRejectsDifferentBucket(t *testing.T) {
handler := &ossHandler{minio: &minioStorage{bucket: "xgjs"}}
if _, _, err := handler.minioObject("minio://other/public/file"); err != types.ErrMalformed {
t.Fatalf("wrong bucket should be rejected, got %v", err)
}
}
func TestMinIOPublicEndpointUsesHTTPSPathStyle(t *testing.T) {
storage, err := newMinIOStorage(minioConfig{
Endpoint: "https://minio.118im.com", Bucket: "xgjs", Region: "us-east-1",
AccessKeyID: "test-key", SecretAccessKey: "test-secret",
})
if err != nil {
t.Fatal(err)
}
url, err := storage.client.PresignedGetObject(context.Background(), storage.bucket, "images/test.jpg", time.Minute, nil)
if err != nil {
t.Fatal(err)
}
if url.Scheme != "https" || url.Host != "minio.118im.com" || url.Path != "/xgjs/images/test.jpg" {
t.Fatalf("unexpected MinIO URL: %s", url)
}
}
+102 -40
View File
@@ -27,29 +27,33 @@ const (
defaultServeURL = "/v0/file/s/"
defaultCacheControl = "no-cache, must-revalidate"
handlerName = "oss"
handlerName = "oss"
minioHandlerName = "minio"
// Presign GET URLs for this number of seconds.
defaultPresignDuration = 120
)
type ossConfig struct {
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
Endpoint string `json:"endpoint"`
BucketName string `json:"bucket"`
RegionID string `json:"region_id"`
CorsOrigins []string `json:"cors_origins"`
ServeURL string `json:"serve_url"`
PresignTTL int `json:"presign_ttl"`
CacheControl string `json:"cache_control"`
VerifyBucket bool `json:"verify_bucket"`
UseSysConfig bool `json:"use_sys_config"`
Provider string `json:"provider"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
Endpoint string `json:"endpoint"`
BucketName string `json:"bucket"`
RegionID string `json:"region_id"`
CorsOrigins []string `json:"cors_origins"`
ServeURL string `json:"serve_url"`
PresignTTL int `json:"presign_ttl"`
CacheControl string `json:"cache_control"`
VerifyBucket bool `json:"verify_bucket"`
UseSysConfig bool `json:"use_sys_config"`
MinIO minioConfig `json:"minio"`
}
type ossHandler struct {
client *aliyunoss.Client
bucket *aliyunoss.Bucket
conf ossConfig
minio *minioStorage
corsOrigins []media.AllowedOrigin
}
@@ -138,21 +142,18 @@ func (oh *ossHandler) Init(jsconf string) error {
if err = json.Unmarshal([]byte(jsconf), &oh.conf); err != nil {
return errors.New("failed to parse config: " + err.Error())
}
if oh.conf.Provider == "" {
oh.conf.Provider = handlerName
}
if oh.conf.Provider != handlerName && oh.conf.Provider != minioHandlerName {
return errors.New("unsupported storage provider: " + oh.conf.Provider)
}
if err = oh.loadSysConfig(); err != nil {
return err
}
if oh.conf.AccessKeyID == "" {
return errors.New("missing Access Key ID")
}
if oh.conf.AccessKeySecret == "" {
return errors.New("missing Access Key Secret")
}
if oh.conf.Endpoint == "" {
return errors.New("missing Endpoint")
}
if oh.conf.BucketName == "" {
return errors.New("missing Bucket")
if oh.conf.Provider == handlerName && (oh.conf.AccessKeyID == "" || oh.conf.AccessKeySecret == "" || oh.conf.Endpoint == "" || oh.conf.BucketName == "") {
return errors.New("incomplete OSS configuration")
}
if oh.conf.PresignTTL <= 0 {
oh.conf.PresignTTL = defaultPresignDuration
@@ -169,21 +170,46 @@ func (oh *ossHandler) Init(jsconf string) error {
return errors.New("failed to parse CORS allowed origins: " + err.Error())
}
oh.client, err = aliyunoss.New(oh.conf.Endpoint, oh.conf.AccessKeyID, oh.conf.AccessKeySecret)
if err != nil {
return err
}
if oh.conf.VerifyBucket {
exists, err := oh.client.IsBucketExist(oh.conf.BucketName)
if oh.conf.AccessKeyID != "" && oh.conf.AccessKeySecret != "" && oh.conf.Endpoint != "" && oh.conf.BucketName != "" {
oh.conf.Endpoint = normalizeOSSEndpoint(oh.conf.Endpoint)
oh.client, err = aliyunoss.New(oh.conf.Endpoint, oh.conf.AccessKeyID, oh.conf.AccessKeySecret)
if err != nil {
return err
}
if !exists {
return errors.New("bucket does not exist")
if oh.conf.VerifyBucket && oh.conf.Provider == handlerName {
exists, err := oh.client.IsBucketExist(oh.conf.BucketName)
if err != nil {
return err
}
if !exists {
return errors.New("bucket does not exist")
}
}
oh.bucket, err = oh.client.Bucket(oh.conf.BucketName)
if err != nil {
return err
}
}
oh.bucket, err = oh.client.Bucket(oh.conf.BucketName)
return err
oh.minio, err = newMinIOStorage(oh.conf.MinIO)
if err != nil {
return err
}
if oh.conf.Provider == minioHandlerName && oh.minio == nil {
return errors.New("incomplete MinIO configuration")
}
return nil
}
func normalizeOSSEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if !strings.Contains(endpoint, "://") {
return "https://" + endpoint
}
parsed, err := url.Parse(endpoint)
if err == nil && parsed.Scheme == "http" && strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".aliyuncs.com") {
return "https://" + strings.TrimPrefix(endpoint, "http://")
}
return endpoint
}
func (oh *ossHandler) loadSysConfig() error {
@@ -191,6 +217,11 @@ func (oh *ossHandler) loadSysConfig() error {
return nil
}
values, err := store.ThirdPartyConfig.Get("im_third_party",
"minio_endpoint",
"minio_bucket",
"minio_region",
"minio_access_key_id",
"minio_secret_access_key",
"oss_enabled",
"oss_provider",
"oss_endpoint",
@@ -207,12 +238,17 @@ func (oh *ossHandler) loadSysConfig() error {
if err != nil {
return errors.New("failed to load OSS config from sys config: " + err.Error())
}
if !truthy(values["oss_enabled"]) {
if oh.conf.Provider == handlerName && !truthy(values["oss_enabled"]) {
return errors.New("OSS is disabled in sys config")
}
if provider := strings.TrimSpace(values["oss_provider"]); provider != "" && provider != handlerName {
if provider := strings.TrimSpace(values["oss_provider"]); oh.conf.Provider == handlerName && provider != "" && provider != handlerName {
return errors.New("unsupported storage provider in sys config: " + provider)
}
applyString(&oh.conf.MinIO.Endpoint, values["minio_endpoint"])
applyString(&oh.conf.MinIO.Bucket, values["minio_bucket"])
applyString(&oh.conf.MinIO.Region, values["minio_region"])
applyString(&oh.conf.MinIO.AccessKeyID, values["minio_access_key_id"])
applyString(&oh.conf.MinIO.SecretAccessKey, values["minio_secret_access_key"])
applyString(&oh.conf.Endpoint, values["oss_endpoint"])
applyString(&oh.conf.BucketName, values["oss_bucket"])
applyString(&oh.conf.RegionID, values["oss_region_id"])
@@ -308,6 +344,9 @@ func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.U
// Upload processes request for a file upload.
func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {
if oh.conf.Provider == minioHandlerName {
return oh.uploadMinIO(fdef, file)
}
key, err := tenantObjectKey(fdef.TenantID, fdef.Uid().String32(), fdef.CreatedAt)
if err != nil {
return "", 0, err
@@ -342,6 +381,9 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
}
func (oh *ossHandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
if oh.conf.Provider == minioHandlerName {
return oh.uploadPublicMinIO(fdef, file)
}
key, err := publicObjectKey(fdef.Purpose, fdef.ID, fdef.CreatedAt)
if err != nil {
return "", 0, err
@@ -378,6 +420,12 @@ func (oh *ossHandler) Download(tenantID types.TenantID, url string) (*types.File
if err != nil {
return nil, nil, err
}
if strings.HasPrefix(fd.Location, minioLocationPrefix) {
return oh.downloadMinIO(fd)
}
if oh.bucket == nil {
return nil, nil, errors.New("OSS is not configured for legacy file download")
}
objectKey, err := oh.resolveObjectKey(fd.Location)
if err != nil {
return nil, nil, err
@@ -405,12 +453,25 @@ func (oh *ossHandler) Download(tenantID types.TenantID, url string) (*types.File
// Delete deletes files from OSS by provided object keys.
func (oh *ossHandler) Delete(locations []string) error {
for i := 0; i < len(locations); i += 1000 {
end := i + 1000
if end > len(locations) {
end = len(locations)
ossLocations := make([]string, 0, len(locations))
for _, location := range locations {
if strings.HasPrefix(location, minioLocationPrefix) {
if err := oh.deleteMinIO(location); err != nil {
return err
}
continue
}
if _, err := oh.bucket.DeleteObjects(locations[i:end], aliyunoss.DeleteObjectsQuiet(true)); err != nil {
ossLocations = append(ossLocations, location)
}
if len(ossLocations) > 0 && oh.bucket == nil {
return errors.New("OSS is not configured for legacy file deletion")
}
for i := 0; i < len(ossLocations); i += 1000 {
end := i + 1000
if end > len(ossLocations) {
end = len(ossLocations)
}
if _, err := oh.bucket.DeleteObjects(ossLocations[i:end], aliyunoss.DeleteObjectsQuiet(true)); err != nil {
return err
}
}
@@ -504,4 +565,5 @@ func etagFromLocation(location string) string {
func init() {
store.RegisterMediaHandler(handlerName, &ossHandler{})
store.RegisterMediaHandler(minioHandlerName, &ossHandler{})
}
+22
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"io"
"net/http"
"strings"
"testing"
"time"
@@ -123,3 +124,24 @@ func TestLoadSysConfig(t *testing.T) {
t.Fatalf("unexpected OSS cors origins: %+v", handler.conf.CorsOrigins)
}
}
func TestOSSEndpointUsesHTTPS(t *testing.T) {
for _, endpoint := range []string{
"oss-cn-shenzhen.aliyuncs.com",
"http://oss-cn-shenzhen.aliyuncs.com",
"https://oss-cn-shenzhen.aliyuncs.com",
} {
handler := &ossHandler{}
config := `{"endpoint":"` + endpoint + `","access_key_id":"ak","access_key_secret":"sk","bucket":"im-files"}`
if err := handler.Init(config); err != nil {
t.Fatalf("Init(%q): %v", endpoint, err)
}
signedURL, err := handler.bucket.SignURL("test.txt", aliyunoss.HTTPGet, 60)
if err != nil {
t.Fatalf("SignURL(%q): %v", endpoint, err)
}
if !strings.HasPrefix(signedURL, "https://") {
t.Fatalf("endpoint %q produced non-HTTPS URL %q", endpoint, signedURL)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"encoding/json"
"testing"
"github.com/tinode/chat/server/store"
)
type testMediaConfigProvider struct{ handler string }
func (p testMediaConfigProvider) Get(string, ...string) (map[string]string, error) {
return map[string]string{"media_handler": p.handler}, nil
}
func TestResolveObjectStorageMediaHandler(t *testing.T) {
for _, provider := range []string{"oss", "minio"} {
t.Run(provider, func(t *testing.T) {
handler, raw, err := resolveMediaHandlerConfig(&mediaConfig{UseHandler: provider})
if err != nil {
t.Fatal(err)
}
if handler != provider {
t.Fatalf("handler = %q", handler)
}
var config struct {
Provider string `json:"provider"`
UseSysConfig bool `json:"use_sys_config"`
}
if err := json.Unmarshal([]byte(raw), &config); err != nil {
t.Fatal(err)
}
if config.Provider != provider || !config.UseSysConfig {
t.Fatalf("resolved config = %+v", config)
}
})
}
}
func TestResolveMediaHandlerFromSysConfig(t *testing.T) {
previous := store.ThirdPartyConfig
store.ThirdPartyConfig = testMediaConfigProvider{handler: "minio"}
defer func() { store.ThirdPartyConfig = previous }()
handler, raw, err := resolveMediaHandlerConfig(&mediaConfig{UseHandler: "sys_config"})
if err != nil {
t.Fatal(err)
}
var config struct {
Provider string `json:"provider"`
UseSysConfig bool `json:"use_sys_config"`
}
if err := json.Unmarshal([]byte(raw), &config); err != nil {
t.Fatal(err)
}
if handler != "minio" || config.Provider != "minio" || !config.UseSysConfig {
t.Fatalf("handler=%q config=%+v", handler, config)
}
}
+14
View File
@@ -0,0 +1,14 @@
package store
import (
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestValidUserPrivacyPatchAcceptsFriendVerificationOnly(t *testing.T) {
disabled := false
if !validUserPrivacyPatch(types.UserPrivacyPatch{FriendRequestVerification: &disabled}) {
t.Fatal("friend_request_verification should be a valid standalone privacy patch")
}
}
+63 -5
View File
@@ -2628,15 +2628,22 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
return nil
}
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, _ bool, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
if admin {
return true, nil
}
topic = strings.TrimSpace(topic)
mapper := groupBusinessMapper{}
member, err := mapper.GetMember(tenantID, topic, uid)
if errors.Is(err, types.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
if allowed, checkMute := groupMemberPostRole(member); !checkMute {
return allowed, nil
}
settings, err := mapper.GetSettings(tenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
return false, err
@@ -2655,6 +2662,21 @@ func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid ty
(mute.ExpiresAt != nil && !mute.ExpiresAt.After(now)), nil
}
func groupMemberPostRole(member *types.GroupMember) (allowed, checkMute bool) {
if member == nil || member.MemberStatus != types.GroupMemberStatusActive {
return false, false
}
// The loaded Topic access snapshot can lag behind a role change.
switch member.Role {
case types.GroupMemberRoleOwner, types.GroupMemberRoleAdmin:
return true, false
case types.GroupMemberRoleMember:
return false, true
default:
return false, false
}
}
type groupMuteCacheValue struct {
Found bool `json:"found"`
Mute *types.GroupMute `json:"mute,omitempty"`
@@ -2996,7 +3018,7 @@ func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch
}
func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool {
if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil && patch.GroupInviteVerification == nil {
if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil && patch.FriendRequestVerification == nil && patch.GroupInviteVerification == nil {
return false
}
for method := range patch.AddMeMethods {
@@ -3043,6 +3065,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
}
type RelationsPersistenceInterface interface {
AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
@@ -3131,6 +3154,41 @@ func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.F
return tenantAdp.TenantFriendRequestCreate(tenantID, req)
}
// AddFriendDirect creates a symmetric friendship for trusted server-side
// workflows, such as opening a customer-service conversation. It deliberately
// does not create a friend-request record, but it still honors either user's
// block list.
func (relationsMapper) AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error {
if userID.IsZero() || peerID.IsZero() || userID == peerID {
return types.ErrMalformed
}
if _, err := Users.Get(tenantID, userID); err != nil {
return err
}
if _, err := Users.Get(tenantID, peerID); err != nil {
return err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
relationship, err := relationshipContext(tenantAdp, tenantID, userID, peerID, "")
if err != nil {
return err
}
if relationship.Relationship == types.RelationshipBlockedByMe || relationship.Relationship == types.RelationshipBlockedByPeer {
return types.ErrPermissionDenied
}
if now.IsZero() {
now = time.Now().UTC()
}
if err = tenantAdp.TenantFriendshipUpsertPair(tenantID, userID, peerID, userID.P2PName(peerID), now); err != nil {
return err
}
cacheFriendPair(tenantID, userID, peerID)
return nil
}
func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed
+24
View File
@@ -15,6 +15,30 @@ func TestForTenantRejectsZero(t *testing.T) {
}
}
func TestGroupMemberPostRoleUsesCurrentBusinessRole(t *testing.T) {
tests := []struct {
name string
member *types.GroupMember
allowed bool
checkMute bool
}{
{name: "missing member"},
{name: "ordinary member", member: &types.GroupMember{Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive}, checkMute: true},
{name: "administrator", member: &types.GroupMember{Role: types.GroupMemberRoleAdmin, MemberStatus: types.GroupMemberStatusActive}, allowed: true},
{name: "owner", member: &types.GroupMember{Role: types.GroupMemberRoleOwner, MemberStatus: types.GroupMemberStatusActive}, allowed: true},
{name: "departed administrator", member: &types.GroupMember{Role: types.GroupMemberRoleAdmin, MemberStatus: types.GroupMemberStatusLeft}},
{name: "unknown role", member: &types.GroupMember{Role: "unknown", MemberStatus: types.GroupMemberStatusActive}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
allowed, checkMute := groupMemberPostRole(test.member)
if allowed != test.allowed || checkMute != test.checkMute {
t.Fatalf("got allowed=%t checkMute=%t, want allowed=%t checkMute=%t", allowed, checkMute, test.allowed, test.checkMute)
}
})
}
}
func TestValidateMessageHeadersProductContract(t *testing.T) {
valid := []types.KVMap{
{"x-im-schema": float64(1), "x-im-type": "text"},
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import unittest
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
class DeployWorkflowContractTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
def test_targets_pro_branch_and_single_runner(self):
self.assertIn("push:", self.workflow)
self.assertIn("- pro", self.workflow)
self.assertIn("workflow_dispatch:", self.workflow)
self.assertIn("runs-on: deploy-host", self.workflow)
self.assertIn("concurrency:", self.workflow)
def test_builds_and_pushes_pro_image(self):
self.assertIn("git.olns.shop/pro/im_server", self.workflow)
self.assertIn("docker push", self.workflow)
self.assertIn(":pro", self.workflow)
def test_deploys_compose_service_with_pinned_host_key(self):
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
self.assertIn("docker compose pull im-server", self.workflow)
self.assertIn("docker compose up -d --no-deps --wait im-server", self.workflow)
def test_removes_old_kubernetes_and_registry_targets(self):
self.assertNotIn("kubectl", self.workflow)
self.assertNotIn("KUBECONFIG", self.workflow)
self.assertNotIn("git.118im.com", self.workflow)
def test_deploy_does_not_mutate_shared_caddy_configuration(self):
self.assertNotIn("Ensure production IM CORS routing", self.workflow)
self.assertNotIn("Caddyfile", self.workflow)
self.assertNotIn("perl -0pi", self.workflow)
self.assertNotIn("force-recreate caddy", self.workflow)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
import unittest
TEMPLATE = Path(__file__).resolve().parents[1] / "docker/tinode/config.template"
class TinodeConfigTemplateTest(unittest.TestCase):
def test_admin_internal_api_enabled_comes_from_environment(self):
template = TEMPLATE.read_text(encoding="utf-8")
section = template.split('"admin_internal_api": {', 1)[1].split("},", 1)[0]
self.assertIn('"enabled": $ADMIN_INTERNAL_API_ENABLED', section)
if __name__ == "__main__":
unittest.main()