Compare commits

...
44 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
richjohnrambo 027dda786e fix media downloads through authenticated origin 2026-10-14 14:51:24 +08:00
richjohnrambo 87ec8f9833 fix kafka attachment message id binding 2026-10-14 12:13:45 +08:00
richjohnrambo 6930b23cce fix media download CORS headers 2026-10-14 10:09:59 +08:00
Austin e8804883fc feat: 新增群组成员数量统计功能并在接口中返回
实现了群组成员数量统计的接口,包含数据库层统计逻辑,在群组详情、设置等接口中返回实时成员数,同时修复了测试用例中参数空格问题。
2026-10-13 17:58:05 +08:00
Austin 43808c36d1 fix: 将im_filemsglinks.msgid字段从INT改为BIGINT适配雪花ID
修复msgid为INT类型无法容纳雪花ID的问题,避免插入时数值溢出导致Kafka消费分区停滞
同时新增数据库 schema 校验逻辑,提前发现不符合要求的字段类型
2026-10-13 17:07:41 +08:00
Austin b126b13b77 fix(server): Kafka消息持久化重试耗尽时死信处理避免无限重试
在Kafka消息持久化重试耗尽时,新增将消息死信的逻辑,而非保留未提交的偏移量,避免无法解决的故障导致分区消息无限重试阻塞后续消息。同时补充死信处理的日志,确保问题可追踪。
2026-10-13 15:40:19 +08:00
Austin f3b1a06da5 feat(server): 支持上传文件关联会话话题并做权限校验
新增上传时读取会话话题参数,校验上传者是否为该话题的成员,通过后将文件与话题关联,允许其他成员下载。权限校验仅针对点对点和群组话题,关联失败不会阻止文件上传。
2026-10-13 11:59:36 +08:00
Austin 6be7796049 feat: 为应用版本查询增加appCode参数支持
新增应用版本类型的AppCode字段,修改相关接口和实现以支持通过appCode查询指定应用的最新版本,增加appCode合法性校验,完善缓存键以区分不同应用的版本信息
2026-10-12 14:59:23 +08:00
Austin 0a33d21eb8 feat: 新增禾圃集的法律页面及路由 2026-10-12 09:33:34 +08:00
richjohnrambo 5d792f9f96 fix kafka message persistence completion 2026-10-12 09:19:28 +08:00
richjohnrambo c219ef6455 fix(server): suppress expected websocket close write errors 2026-10-11 22:50:54 +08:00
richjohnrambo 1e0b4c181d fix(server): raise topic publish queue capacity 2026-10-10 09:37:42 +08:00
Austin 54c594f39b docs: 更新清除聊天记录功能的服务端实现说明 2026-10-09 19:54:50 +08:00
Austin 2e1ceec717 docs: 更新系统通知相关接口文档及问题修复记录 2026-10-09 10:00:48 +08:00
Austin 1d8f9e3be4 feat: 为系统通知添加明确的操作结果摘要
为风险挑战确认和好友申请处理场景添加对应操作结果的中文摘要,替代原有的状态文本,让用户能更清晰地理解系统通知内容。
2026-10-08 20:25:28 +08:00
Austin 8228ceb89f fix: 处理撤回消息解密时的500错误
撤回消息会清空content但保留key_version,新增判断跳过此类消息解密,避免整个查询窗口被毒化
2026-10-08 19:43:27 +08:00
richjohnrambo f24133f5de fix(server): coalesce platform evict fanout 2026-10-08 00:23:12 +08:00
richjohnrambo e30b70a3a7 fix(server): limit implicit large group subscription meta 2026-10-07 23:13:47 +08:00
richjohnrambo ac6cd3c6e8 fix(server): shed overloaded group websocket fanout 2026-10-07 21:23:22 +08:00
richjohnrambo 58c15efad5 fix(loadtest): submit observed after quiet timeout 2026-10-07 00:41:54 +08:00
richjohnrambo 2732e05a83 fix(server): harden user cache update queue under load 2026-10-06 23:32:17 +08:00
richjohnrambo d3054f62b5 test(load): add idle and tunable group scenarios 2026-10-05 17:36:18 +08:00
richjohnrambo 965f94f154 fix(kafka): preserve partially fetched message batches 2026-10-04 23:37:10 +08:00
richjohnrambo b05e8f60dd perf(group): reduce websocket fanout serialization 2026-10-04 10:15:18 +08:00
richjohnrambo c46209f4e7 fix(deploy): diagnose stalled statefulset rollouts 2026-10-04 01:06:26 +08:00
89 changed files with 5027 additions and 566 deletions
+53 -81
View File
@@ -1,97 +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
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
kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s
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}')"
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}"
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" ]; 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
@@ -32,6 +32,12 @@ spec:
value: "3"
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
value: "25"
- name: TOPIC_CLIENT_QUEUE_BUFFER
value: "4096"
- name: TOPIC_SERVER_QUEUE_BUFFER
value: "512"
- name: TOPIC_PUBLISH_ENQUEUE_WAIT_MS
value: "200"
- name: MYSQL_MAX_OPEN_CONNS
value: "24"
- name: MYSQL_MAX_IDLE_CONNS
@@ -40,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"
+3
View File
@@ -193,6 +193,9 @@ You can specify the following environment variables when issuing `docker run` co
| `CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS` | number | `300` | Maximum wait for a full proxy-to-master shard before returning 503. |
| `CLUSTER_ROUTE_RETRIES` | number | `3` | Number of retries after a remote server-message route is rejected as overloaded. |
| `CLUSTER_ROUTE_RETRY_BACKOFF_MS` | number | `25` | Initial exponential retry delay for remote server-message routes. |
| `TOPIC_CLIENT_QUEUE_BUFFER` | number | `4096` | Per-topic client publish queue capacity. Increase for large groups with bursty publishers. |
| `TOPIC_SERVER_QUEUE_BUFFER` | number | `512` | Per-topic server-message queue capacity. |
| `TOPIC_PUBLISH_ENQUEUE_WAIT_MS` | number | `200` | Maximum wait for an attached session publish to enter a full topic queue before returning 503. |
| `DEBUG_EMAIL_VERIFICATION_CODE`[^2] | string | | Enable dummy email verification code, e.g. `123456`. Disabled by default (empty string). |
| `DEFAULT_COUNTRY_CODE`[^2] | string | `US` | 2-letter country code to assign to sessions by default when the country isn't specified by the client explicitly and it's impossible to infer it. |
| `EXT_CONFIG`[^1] | string | | Path to external config file to use instead of the built-in one. If this parameter is used, most other variables are ignored[^1]. |
+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",
+33 -19
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 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 |
@@ -636,8 +638,15 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 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","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-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 |
| `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 与产品消息
@@ -1073,8 +1082,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:需改造。**
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
- `summary` 为服务端生成的可直接展示文案(如「对方已同意你的好友申请」「对方已婉拒你的好友申请」),前端直接渲染,无需再做状态码到文案的映射;`action` 读路径统一归一为 `{type,target_id,payload,...原始键}`。
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;重复已读返回首次持久化的 `read_at`,不回显本次提交值。跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例
@@ -1093,7 +1103,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","target_id":"usr...","payload":{"request_id":"frq_1"},"request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
## 3. 单聊会话、会话建立与消息状态
@@ -1152,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` 幂等去重。
@@ -1700,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"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1763,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` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1832,9 +1843,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/黑名单提示.png" alt="黑名单提示" width="360">
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。**
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施。服务端已实现并实测(2026-10-09),剩余前端改造。**
- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 服务端现状(2026-10-09 测试环境实测通过):REST 接口仅写 `im_dellog` 本人区间并递增 `deleted_id`,不改动消息行、对方视角不受影响,本人其他设备经 `deleted_id` 同步感知清空;WS `{del}` 语义相同,两条路径二选一即可。对非成员或不存在会话返回 403 FORBIDDEN。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1860,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":""}` |
@@ -2329,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
@@ -2957,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;任何字段的可见性由隐私策略裁剪。
@@ -2970,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
@@ -3163,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"}` |
@@ -0,0 +1,226 @@
# IM R11 Traefik 入口故障运维排查手册
## 1. 目标与已知事实
需要定位 2026-10-04 北京时间 `10:39:30–10:42:30`,即 UTC `02:39:30–02:42:30` 的入口整体中断。
已知现象:
- `api.118im.com/metrics` 先后出现超时、HTTP 521 和 HTTP 503。
- 三个源 IP 的 443 在约 10:40:16 同时 `connection refused`。
- 2000 个 WebSocket 几乎同时断开,A/B 两台压测机均受影响。
- Tinode cluster 采样仍为 2/2 live peers。
- 故障后三个源 IP 返回同一张 `TRAEFIK DEFAULT CERT`,证书 `notBefore` 为 `2026-10-04 10:41:32 +08:00`,与入口恢复窗口重合。
- 运维已确认 `216.236.31.189:6443` 按安全策略不对公网开放,只允许集群网络访问;外部调试机 TCP 超时属于预期行为。
排查过程中不要先重启 Traefik、节点或 k3s,也不要清理容器和日志。先完成只读取证。
## 2. 运维进入方式
优先在控制面节点直接执行 `kubectl`。如果控制面节点没有配置 kubeconfig,k3s 通常可使用:
```bash
sudo k3s kubectl get nodes -o wide
```
普通 Kubernetes 可使用:
```bash
kubectl get nodes -o wide
kubectl cluster-info
```
不建议把 6443 直接向公网完全开放。长期方案应采用 VPN/堡垒机或仅允许固定运维出口 IP。短期也可以让运维直接在控制面节点采集下面的结果。
## 3. 第一优先级:确认 Traefik 是否重启
先找到 Traefik 所在 namespace、工作负载和 Pod:
```bash
kubectl get deploy,ds,sts,pod -A -o wide | grep -i traefik
kubectl get pods -A -l app.kubernetes.io/name=traefik -o wide
kubectl get pods -A -l app=traefik -o wide
```
对每个 Traefik Pod 执行:
```bash
kubectl -n kube-system get pod TRAEFIK_POD -o jsonpath='{.metadata.name}{"\nstarted="}{.status.startTime}{"\nrestarts="}{.status.containerStatuses[*].restartCount}{"\nlastReason="}{.status.containerStatuses[*].lastState.terminated.reason}{"\nlastExit="}{.status.containerStatuses[*].lastState.terminated.exitCode}{"\nlastFinished="}{.status.containerStatuses[*].lastState.terminated.finishedAt}{"\n"}'
kubectl -n kube-system describe pod TRAEFIK_POD
kubectl -n kube-system logs TRAEFIK_POD --all-containers --timestamps --since-time=2026-10-04T02:39:30Z
kubectl -n kube-system logs TRAEFIK_POD --all-containers --previous --timestamps --since-time=2026-10-04T02:39:30Z
```
重点检查:
- `OOMKilled`、exit code 137、liveness/readiness failure。
- `Killing`、`Evicted`、`NodeNotReady`、`Preempted`。
- `Configuration loaded`、provider reconnect、TLS store/default certificate 重建。
- 10:41:32 附近是否有容器启动日志。
如果 Traefik 不在 `kube-system`,将命令中的 namespace 替换为实际值。
## 4. 第二优先级:查滚动发布和配置变更
```bash
kubectl -n kube-system get deploy,ds,rs -l app.kubernetes.io/name=traefik -o wide
kubectl -n kube-system rollout history deploy/traefik
kubectl -n kube-system get deploy traefik -o yaml
kubectl -n kube-system get cm,secret | grep -i traefik
```
若由 Helm 管理:
```bash
helm list -A | grep -i traefik
helm history traefik -n kube-system
helm get values traefik -n kube-system -a
```
若由 ArgoCD 管理:
```bash
kubectl get applications.argoproj.io -A
kubectl get applications.argoproj.io -A -o yaml | grep -n -i -C 5 traefik
kubectl get pods -n argocd -o wide
kubectl logs -n argocd -l app.kubernetes.io/name=argocd-application-controller --all-containers --timestamps --since-time=2026-10-04T02:39:30Z
```
同时检查 CI/CD 平台在北京时间 10:39–10:42 是否执行过 Traefik、Ingress、Service、证书或节点部署。需要记录操作者、commit、同步 revision 和开始/结束时间。
## 5. 第三优先级:节点是否重启或资源耗尽
```bash
kubectl get nodes -o wide
kubectl describe nodes
kubectl get events -A --sort-by=.metadata.creationTimestamp
kubectl get events -A --field-selector type=Warning --sort-by=.metadata.creationTimestamp
```
在承载 Traefik 的节点执行:
```bash
uptime -s
last -x | head -30
sudo journalctl --list-boots
sudo journalctl --since '2026-10-04 10:39:30' --until '2026-10-04 10:42:30' -u k3s -u k3s-agent -u kubelet -u containerd --no-pager
sudo journalctl -k --since '2026-10-04 10:39:30' --until '2026-10-04 10:42:30' --no-pager
sudo dmesg -T | grep -Ei 'oom|killed process|memory cgroup|conntrack|nf_conntrack|link.*down|reset|I/O error'
```
重点判断:
- 节点是否在 10:41 左右重启。
- 内核是否 OOM kill Traefik、containerd 或 k3s。
- 磁盘是否满、只读或发生 I/O error。
- 网卡是否 link down、驱动 reset。
- kubelet 是否因 liveness probe 杀掉 Traefik。
## 6. 第四优先级:Service 与 Endpoint 是否短暂清空
```bash
kubectl get svc,endpoints,endpointslices -A | grep -Ei 'traefik|im-server'
kubectl -n kube-system describe svc traefik
kubectl -n kube-system get endpoints traefik -o yaml
kubectl -n kube-system get endpointslices -l kubernetes.io/service-name=traefik -o yaml
kubectl -n im-server-system get pod,svc,endpoints,endpointslices -o wide
kubectl -n im-server-system describe svc im-server
kubectl -n im-server-system get endpoints im-server -o yaml
kubectl -n im-server-system get endpointslices -l kubernetes.io/service-name=im-server -o yaml
```
检查 10:40–10:41 是否发生:
- Traefik Service 没有可用 endpoint。
- `im-server` 三个 Pod 同时 NotReady,导致上游 503。
- selector、port、targetPort 或 Ingress backend 被 ArgoCD/Helm 临时改写。
- StatefulSet 滚动时 readiness 配置错误,所有 endpoint 被同时摘除。
## 7. 第五优先级:连接数和 conntrack
在入口节点执行:
```bash
sudo ss -lntp | grep -E ':443|:6443'
sudo ss -s
sudo sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
sudo conntrack -S 2>/dev/null || true
sudo nstat -az | grep -Ei 'ListenDrops|ListenOverflows|RetransSegs|InErrors|OutDiscards'
sudo ip -s link
```
如果有节点监控,导出 10:35–10:45 的:
- CPU usage、load、CPU throttling。
- 内存、OOM、working set。
- conntrack 使用率。
- TCP listen overflow、reset、retransmit。
- 网卡吞吐、drop、error。
- Traefik active connection、open connection、5xx、upstream duration。
conntrack 达到 `nf_conntrack_max` 通常更偏向 timeout/drop;本轮观察到短时间 `connection refused`,因此还应优先确认 443 监听进程是否消失或入口规则是否被重建。
## 8. im-server 同期状态
```bash
kubectl -n im-server-system get pods -o wide
kubectl -n im-server-system get sts im-server -o yaml
kubectl -n im-server-system describe sts im-server
for pod in $(kubectl -n im-server-system get pods -l app=im-server -o name); do
kubectl -n im-server-system logs "$pod" --all-containers --timestamps --since-time=2026-10-04T02:39:30Z > "${pod##*/}.log"
kubectl -n im-server-system logs "$pod" --all-containers --previous --timestamps --since-time=2026-10-04T02:39:30Z > "${pod##*/}.previous.log" 2>&1 || true
done
```
如果 `app=im-server` 标签不匹配,先执行 `kubectl -n im-server-system get pods --show-labels`,再替换 selector。
需要确认三个 im-server Pod 的:
- `startTime` 和 `restartCount`。
- readiness/liveness failure。
- 10:40 左右是否仍持续处理 Kafka、cluster RPC 和 WS。
- 是否有 `too many open files`、OOM、accept error、DNS error、MySQL/Kafka timeout。
## 9. Kubernetes API 访问方式
从当前调试机访问三个源 IP 的 443 均正常。运维已确认 `216.236.31.189:6443` 只允许集群网络访问,因此外部连接超时符合既定安全策略,也与 VPN/TUN 无关。
运维应检查控制面节点:
```bash
sudo ss -lntp | grep 6443
sudo nft list ruleset | grep -n -C 3 6443
sudo iptables -S | grep 6443
sudo iptables -t nat -S | grep 6443
```
后续排查采用以下方式之一:
1. 让排查命令直接在控制面节点运行并回传结果包。
2. 如确需远程调试,为固定运维出口 IP 临时增加 6443 allowlist,完成后撤销。
3. 通过堡垒机/VPN 进入控制面网络。
不要为了调试把 6443 对 `0.0.0.0/0` 永久开放。
## 10. 运维需要回传的结果
至少回传以下内容:
1. Traefik Pod YAML、describe、当前日志和 previous 日志。
2. Traefik Deployment/DaemonSet、ReplicaSet 和 rollout history。
3. 10:39:30–10:42:30 的 k3s/kubelet/containerd/kernel journal。
4. 全集群 Warning Event、节点 describe。
5. Traefik 和 im-server 的 Service、EndpointSlice、Ingress YAML。
6. ArgoCD/Helm/CI 在故障窗口的发布历史。
7. 入口节点 CPU、内存、conntrack、TCP overflow/retransmit 和网卡 drop。
拿到这些材料后,按照以下优先级定责:
1. Traefik 容器或节点在 10:41:32 重启。
2. ArgoCD/Helm/CI 触发 Traefik 滚动或配置重建。
3. Traefik/im-server endpoint 短暂清空。
4. OOM、liveness、conntrack、监听队列或节点网络故障。
5. 其他上游负载均衡或云网络事件。
+2 -30
View File
@@ -22,7 +22,6 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
|---|---|---|
| [问题25](#问题25) | `POST /messages/forward`、`/favorites/forward` | 带附言转发时 `head` 声明类型与 `content` 实际结构对不上 |
| [问题27](#问题27) | `GET /reports/daily` | `status` 参数不做取值校验,传任何值都返回200+空列表 |
| [问题30](#问题30) | `GET /system-notifications` | `summary`/`action`结构不一致,且会议响应事件被写成两条重复通知 |
| [问题33](#问题33) | `GET /reports/daily` | `permissions` 四个字段恒为同一组硬编码值,与用户/参数无关 |
| [问题35](#问题35) | `GET /users/{id}/profile` | 设了备注之后拿不到对方的原昵称(`GET /friends`一侧已修复) |
| [新增3](#新增3) | `PATCH /conversations/{topic}/settings` | 单聊置顶没有批量查询接口,会话列表永远看不出置顶效果 |
@@ -32,7 +31,6 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
| [新增25](#新增25) | 原生WS `{pub}` | link类型消息协议层可构造,但没有真实用户可从UI触达的发送路径 |
| [新增30](#新增30) | `GET /settings/youth-mode` | 开启后不对任何功能做实际限制,`enforced` 恒为硬编码false(前端已移除此功能,仅保留记录完整性) |
| [新增31](#新增31) | `POST /reports/abuse` | 不校验 `requires_description`,可对必填原因提交空描述 |
| [新增32](#新增32) | `POST /system-notifications/{id}/read` | 对已读通知重复标记,响应回显请求值但真实存储值不变 |
| [新增34](#新增34) | `POST /tenant-applications` | 统一社会信用代码无去重机制,可无限重复提交 |
| [新增41](#新增41) | `POST /groups/{topic}/mutes` | 无角色层级保护,管理员可禁言群主、任何角色可自我禁言 |
| [新增47](#新增47) | `POST /v0/file/u/` | 真实文件上传上限是32MB而非声明的100MB,且从未用于前端预校验 |
@@ -68,6 +66,8 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
- **新增42**(群公告按id查询恒定返回最新公告):**已修复**。代码确认现在正确解析路径里的`announcement_id`并调用`GetAnnouncement`按id查询,不再无视这个参数。
- **新增43**(群文件登记接口无长度/存在性校验):**已修复**。代码确认现在对`file_name`(255)/`mime_type`(128)做长度校验,对`file_size`做负数校验,且会调用`store.Files.Get`验证`file_id`真实存在。
- **新增45**(收藏/搜索/宫格分页off-by-one永久丢失边界记录):**已修复**。实测:24条真实收藏,用limit=10分3页拉取(10+10+4),合并后24条一条不少、无重复。
- **问题30**(系统通知`summary`/`action`结构不一致 + 会议响应事件双写):**已修复/已失效**(2026-10-09复核)。①会议响应双写随上游`0e2f4e7`(2026-09-17把整个workbench/会议模块从im_server移除)消失,现仅剩Kafka单路径写入且按固定event_id幂等upsert,不再适用;②`action`由读路径`normalizeSystemNotificationAction`统一归一为`{type,target_id,payload,...}`;③空`summary`读路径回退`title`;④机器码`summary`(`accepted`/`rejected`/`continue`/`cancel`)已在4处写入点改为人类可读文案(`serveIMFriendRequestAction`/`serveIMRiskChallengeConfirm`/`TenantFriendRequestAccept`/`TenantFriendRequestResolve`)。实测:A对B发好友申请、B拒绝后,A的通知列表返回`summary:"对方已婉拒你的好友申请"`、`action`为归一化结构。
- **新增32**(系统通知重复已读,响应回显请求值但存储不变):**已修复**(2026-10-09复核)。存储层`TenantSystemNotificationMarkRead`在`COALESCE`更新后回读真实行返回,handler用返回值构造响应(单测`TestServeIMSystemNotificationsListAndRead`覆盖)。实测:同一通知先以`read_at=T1`标记已读、再以`T2`重复标记,两次响应均返回持久化的`T1`,不再回显本次提交值。
## 本次复核确认仍是历史误判(从未是真正的后端bug,不应再作为后端问题提出)
@@ -163,21 +163,6 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
---
<a id="问题30"></a>
## 问题 30(P2):系统通知的 `summary` 要么是空串、要么是机器码,`action` 的结构因来源而异,且会议响应事件会被写成两条重复通知
**证据强度**:✅ 实测确认(三种summary情况+两条重复通知具体案例)+代码确认(重复通知根因)
**现象**:`summary`有时空串、有时机器码(如`accepted`/`rejected`)、有时真实数据;`action`是自由结构,不同来源放的键完全不同;会议响应更新会被两条独立代码路径各写一条内容几乎相同、结构不同的重复通知。
**根因(重复通知)**:`serveIMMeetingRespond`同时调用受开关`imReliableHTTPEventsEnabled`控制的`publishIMMeetingEvent`(生成`{meeting_id}`形态)和无条件执行的`publishIMWorkbenchAndAudit`(经Kafka产生`{module,resource_id}`形态),该开关对标准请求头调用恒为true。
**接口**:`GET /v1/im/system-notifications`
**前端现状**:`summary`能在已知事件码表里查到就替换成中文,查不到就原样显示;跳转按钮按action里出现的键分派;重复通知没有做任何去重(不能安全启发式判断"内容相同就是重复")。
---
<a id="问题33"></a>
## 问题 33(P2):`permissions` 的四个字段在不同参数下恒为同一组值
@@ -285,19 +270,6 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
---
<a id="新增32"></a>
## 新增 32(P2):系统通知已读接口对已有 `read_at` 的通知,响应会原样回显客户端提交的值,但真实存储值不变
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:对已有read_at的通知重复调用已读接口,响应200并原样回显本次提交的read_at(哪怕故意传明显不同值),但真实存储的read_at纹丝不动。
**根因**:`TenantSystemNotificationMarkRead`执行`UPDATE...SET read_at=COALESCE(read_at,?)`,已非空时保留原值;`serveIMSystemNotificationRead`(`server/im_http.go:3576`)直接回显请求体局部变量`readAt`,从未回读真实值。
**接口**:`POST /v1/im/system-notifications/{id}/read`
---
<a id="新增34"></a>
## 新增 34(P2):企业注册申请接口对同一个统一社会信用代码没有任何去重/防重复提交机制
@@ -0,0 +1,44 @@
-- IM file-message link msgid Snowflake compatibility migration.
-- Scope: the shared business database (for example meeting_im).
-- Fixes: im_filemsglinks.msgid was INT (max 2147483647) while message ids are
-- snowflake int64 values (~1.58e18), so every INSERT into the link table
-- failed with MySQL error 1264 "Out of range value for column 'msgid'".
-- Under Kafka persistence that failure retried forever and stalled the
-- consumer partition, blocking persistence for every message queued
-- behind it.
-- Notes: im_filemsglinks is a global (non tenant-sharded) table, but in
-- tenant-scoped-DB deployments every tenant database has its own copy —
-- run this script once per database.
-- Re-running is safe (MODIFY to the same shape is idempotent).
-- NOT REVERSIBLE: converting back to INT would truncate snowflake ids.
-- Review and back up before execution. This script does not replay dead letters.
-- 1. Preflight: confirm the live column shape before touching anything.
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_TYPE, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'im_filemsglinks'
AND COLUMN_NAME = 'msgid';
-- 2. Upgrade the column (keeps the nullable semantics of the original schema).
ALTER TABLE `im_filemsglinks`
MODIFY COLUMN `msgid` BIGINT DEFAULT NULL;
-- 3. Post-check: this query must return zero rows.
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'im_filemsglinks'
AND COLUMN_NAME = 'msgid'
AND DATA_TYPE <> 'bigint';
-- 4. Observe pending recovery work caused by the overflow (and, before the
-- startup schema-contract check existed, by any other drift). Replay only
-- after the post-check passes and the referenced files still exist.
SELECT status, last_reason, COUNT(*) AS entry_count,
MIN(created_at) AS first_created_at, MAX(created_at) AS last_created_at
FROM im_kafka_dead_letters
WHERE last_error LIKE '%Out of range value for column %'
OR last_error LIKE '%file lifecycle enqueue failed: not found%'
GROUP BY status, last_reason
ORDER BY status, last_reason;
+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 转为重试成功,仍需日志定位间歇错误。
@@ -0,0 +1,159 @@
# GROUP-2000 R10 双机群聊压测分析与改进
## 1. 测试结论
`group2k-r10-20261004-01` 的 A/B 同步有效,可以作为 2000 人群聊的真实双机容量结论。本轮持久化链路基本正常,但实时扇出已明显超过当前服务容量,整体判定为 **失败**。
- A/B 各建立 1000 个 WS 连接,共 2000/2000 就绪;两机实际开始时间相差约 84ms。
- 2000 条发送尝试中,1984 条收到 202 ACK,16 条因 socket 中断而结果未知,无显式 403/502/503 拒绝。
- 数据库最终找到 1999 条,无重复、无字段不一致;仅 `g-a-0098-02` 未落库。
- 按 1985 条已在服务端确认的消息计算,期望实时投递 3,970,000 次,实际收到 1,979,444 次,到达率 49.86%,实时缺失率 50.14%。
- 1,990,556 次实时缺失全部可从历史消息查回,说明主要问题是实时投递容量,不是 Kafka/MySQL 丢数据。
- 负载期间共 152 次 WS 断连:147 次 connection reset、4 次 timeout、1 次 closed connection。
- ACK P95 为 71.18s,本地实时投递 P95 为 329.68s,已不是普通抖动,而是持续性排队。
JPush 空设备查询放大、MySQL 连接频繁回收和 A/B 起跑错位等 R9 问题在本轮已解决。当前的主要容量瓶颈是大群每条消息的单 Topic 有序处理、跨 Pod multiplex 转发和各 Pod WS 逐连接序列化/写出组合成的实时扇出链路。
## 2. 四层对账
| 层级 | 结果 | 结论 |
| --- | ---: | --- |
| 发送尝试 | 2000 | A/B 各 1000 |
| 即时收到 202 | 1733 | 251 条在超时后迟到 |
| 最终收到 202 | 1984 | 发送成功率 99.2% |
| 发送结果未知 | 16 | 全部为 socket 中断,无业务拒绝 |
| 历史回查 | 1985/1985 | 服务端可确认消息全部可查 |
| 数据库实际落库 | 1999/2000 | 14 条“结果未知”也已落库 |
| 真正未落库 | 1 | A 机 `g-a-0098-02` |
| 实时投递 | 1,979,444/3,970,000 | 到达率 49.86% |
| 重复 / 乱序 | 0 / 0 | 已收到数据的正确性正常 |
16 条结果未知中,14 条 B 机消息已在数据库找到;A 机 `g-a-0102-02` 被实时观测到且已落库;只有 `g-a-0098-02` 在连接断开前未被服务端处理。因此不能把“客户端未收到 ACK”直接等同于“消息未落库”。
## 3. A/B 表现
| 指标 | 机器 A | 机器 B |
| --- | ---: | ---: |
| 就绪连接 | 1000 | 1000 |
| 发送尝试 | 1000 | 1000 |
| 最终观测 ACK | 998 | 986 |
| 结果未知 | 2 | 14 |
| 收到群消息 | 996,563 | 982,881 |
| 断连 | 7 | 145 |
B 机的断连明显更多,但 reset 分布在多个公网源 IP,没有单一坏 IP 的证据。机器差异会影响结果,但 A 机同样出现大量投递缺失和长尾,不能将本轮失败只归因于 B 机网络。
## 4. 指标与时间线
- 负载窗口约为 09:21:43–09:26:32。
- A 机断连集中在 09:23:20–09:25:24;B 机采样到的 reset 集中在 09:24:20–09:25:23。
- 服务端 `/metrics` 在约 09:23:01–09:25:37 多次超过 10s 采集超时;233 次 im-server 采样中 14 次失败,17 次超过 5s。
- Kafka consumer lag 和 oldest age 在收敛阶段均为 0。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 合计 1999,与数据库实际条数一致。
- `im_push_dispatch_queue_enqueue_total{result="ok"}` 为 1999,未观测到 push queue full/error;推送平均处理约 22ms。
- Topic group queue wait 未超过 1s,Cluster P2M/route 计数未观测到 full/error。
- 三个 Pod 的 ACK enqueue/write 指标都在 1s 内。注意该计时从服务端 WS 读循环完成解析后开始,因此客户端几十秒至数分钟的 ACK 延迟,主要发生在服务端 dispatch 之前的读取/调度阶段,或写入 socket 后的网络/客户端接收阶段。
`/metrics` 与 WS 同时出现长时间停顿,说明不只是某个业务队列满,而是 Pod 的 CPU/调度/网络写路径在大规模扇出下被长时间占用。本轮无法通过集群 API 读取 Pod Event 和完整日志,因此不将 CPU throttling、Ingress 和节点网卡中的任何一项单独定为唯一根因。
## 5. 代码链路分析
### 5.1 Topic 主节点
`Topic.broadcastToSessions` 遍历主 Topic 上的本地 session 和远程 Pod multiplex session,为每个目标复制、整理消息并入队。一个 2000 人群的 2000 条消息会生成约 400 万次终端投递。
当 multiplex 队列暂时满时,Topic goroutine 会等待最多 250ms;一个主 Topic 又需要保持消息顺序,所以无法通过对单条消息无界并发来解决。
### 5.2 跨 Pod multiplex
每个远程 Pod 使用一个 multiplex session。它的 drain 为保证 Topic 顺序,对每条 `Cluster.TopicProxy` RPC 同步等待远程返回。这保证了顺序,但对单条 RPC 往返、gob 编解码和远程 proxy Topic 处理的抖动很敏感。
### 5.3 Pod 内 WS 扇出
proxy Topic 收到一条群消息后,仍然遍历本 Pod 上的所有订阅 session,为每个 WS 连接复制消息并入队。每个 WS 只有单个 write loop 串行做序列化和 socket write。当扇出速度超过写出速度,session 队列积压,随后出现连接 reset、客户端重连以及更大的订阅恢复压力。
## 6. 已排除和未排除项
### 已排除为本轮主因
- Kafka 积压或持久化丢失:最终 lag=0,1999 条 batch_ok 与 DB 一致。
- MySQL 批量落库能力:无重复、无字段不一致,只有 1 条在服务端未接收。
- JPush 设备查询放大:1999 次异步入队成功,无队列满,平均耗时低。
- A/B 起跑错位:本轮最终屏障通过,两机实际开始差距小于 100ms。
- 显式限流或内部队列拒绝:没有 429/502/503,未观测到 Cluster、Topic、push 队列 full/error。
### 仍需补齐证据
- 每个 Topic 单条消息的完整处理耗时和扇出目标数。
- multiplex 队列等待、RPC 耗时、远程 proxy 接收到入队完成的分段耗时。
- WS send queue 深度、队列等待 P95/P99、full/purge 和按 Pod 的发生时间。
- Pod CPU usage/throttling、Go scheduler latency、GC pause、goroutine 数、网卡吞吐/丢包/retransmit。
- Ingress/LB 上游 reset、upstream response time、连接数和每 Pod 连接分布。
## 7. 改进方案
### P0:先定位每段长尾
1. 增加 Topic 处理总耗时、单条扇出目标数和 `broadcastToSessions` 耗时直方图,区分 master/proxy。
2. 增加 multiplex 入队等待、队列深度、`Cluster.TopicProxy` RPC 耗时和远程处理耗时。
3. 补齐 WS send queue P95/P99/max、full、purged,并区分 data/ctrl。ACK 必须能从大量 data 帧中被单独观测。
4. 增加 WS 读取到 dispatch 前的时间戳,避免现有 ACK 指标忽略服务端读循环前的停顿。
5. 恢复压测期间的 Pod/Event/CPU 可观测性,指标采集超时不应导致整段监控空白。
### P1:降低实时扇出成本
1. 将 master 到每个 proxy Pod 的多条消息改为有界批量 RPC,批内保持 SeqId 顺序,减少 2000 次同步 RPC 往返、gob 编码和调度开销。
2. 为每个 Topic-Pod 建立单写者有界队列,允许不同 Pod 并行处理,同一 Pod 内严格按 SeqId 投递;不对单 Topic 做无界 goroutine 并发。
3. 研究一次序列化、多 session 复用字节的可行性,避免同一条群消息在每个连接重复做等价 JSON 序列化。用户相关 Topic/权限字段必须先完成安全性分类,不可盲目全局复用。
4. ctrl/ACK 与普通 data 帧分级调度,保证发送者在大群扇出积压时仍能快速获得明确结果。需保持单连接协议语义,不得导致客户端对 data/ctrl 的依赖关系错乱。
5. 对慢消费 WS 定义明确策略:队列达阈值后记录可追踪原因、主动断开并依靠历史补拉,避免一批慢连接长时间拖住整个 Topic。
## 8. 实施和复测顺序
1. 先完成 P0 指标,用相同 2000 人、2000 条参数复现一轮,定量确认 Topic、multiplex RPC、WS queue 和 socket write 各占多少。
2. 实施 multiplex 批量化和有界单写者,先做单元测试、顺序测试、队列满/节点断开/重连测试。
3. 用 500、1000、1500、2000 人阶梯复测,每档至少两轮,不直接只跑最高档。
4. 最后重复 R10 同参数双机测试,必须同时通过实时、历史、DB 和基础设施门禁。
## 9. 下一轮通过门禁
- 2000/2000 WS 就绪,A/B 实际开始差小于 1s。
- 2000 条发送全部获得明确 ACK,无 outcome unknown,无 5xx。
- 数据库精确找到 2000 条,缺失、重复、字段不一致均为 0,Kafka lag 最终归零。
- 实时投递到达率不低于 99.99%,重复和乱序为 0。
- ACK P95 小于 2s,投递 P95 小于 5s;本轮已明确不以 500ms 作为阻断门禁。
- 断连、Topic/multiplex/WS queue full、Pod restart、OOM、liveness failure 均为 0。
- 负载窗口内 `/metrics` 可持续采集,无连续 10s 超时;CPU throttling、GC、网络 retransmit 和 Ingress reset 有完整记录。
## 10. 产物
- 结果目录:`loadtest/im-pressure/results/group2k-r10-20261004-01/`
- 汇总报告:`report.json`
- 数据库核验:`db-verification.json`
- A/B 明细:`agent-a-summary.json`、`agent-b-summary.json`
- coordinator 台账:`coordinator.json`
- 服务指标:`metrics.jsonl`
- 代码版本:`c46209f`
## 11. 本轮已实施改造
R10 分析后已完成以下代码改造,待发布后进行 R11 复测:
- 普通非频道群消息在每个 Pod 仅执行一次 JSON 序列化,同 Pod 的所有 WS 连接复用同一份不可变字节。权限过滤、`SkipSid`、频道匿名、P2P 别名和非 WS 协议仍保持原路径。
- 增加 Topic 完整处理耗时、广播耗时和按 websocket/multiplex/other 分类的扇出目标数。
- 增加 master-to-proxy multiplex 入队深度和排队时间,以及远程 proxy Topic 接收后的队列等待时间。
- 增加 WS 完成读帧后的 JSON 解析和本地 dispatch 耗时。
- 新快路仍在 WS write loop 记录原有 `OutgoingMessageSize` 统计,不会因预序列化丢失原有监控口径。
本次没有直接引入新的批量 `net/rpc` 方法。StatefulSet 滚动发布时会短暂出现新旧 Pod 混合,新 RPC 端点与旧 Pod 不兼容,会产生发布窗口风险。R11 先用新增指标确认 multiplex RPC 在优化后是否仍为主瓶颈;若需要批量化,应采用“先发布双协议接收端,再开启批量发送端”的两阶段发布。
新增指标:
- `im_topic_process_duration_seconds`
- `im_topic_broadcast_duration_seconds`
- `im_topic_broadcast_targets`
- `im_topic_shared_serialization_total`
- `im_cluster_m2p_queue_wait_duration_seconds`
- `im_cluster_m2p_queue_depth`
- `im_cluster_proxy_queue_wait_duration_seconds`
- `im_ws_read_dispatch_duration_seconds`
@@ -0,0 +1,161 @@
# GROUP-2000 R11 双机群聊复测分析
## 1. 结论
`group2k-r11-20261004-01` 判定为 **失败且不能作为稳定容量结论**。
本轮确认了 R10 后的群消息共享序列化优化已经生效,正常运行阶段的实时到达率明显高于 R10;但压测末段发生了约 1 分钟的公网入口/上游整体不可用,2000 个连接几乎同时断开。与此同时,Kafka 群消息持久化链路仅落库 1165 条,662 条服务端已确认或已观测消息未落库,暴露出批消费可靠性缺陷。
必须先修复 Kafka 批消费和查清入口整体中断,再重复相同参数。当前不能把 93.83% 到达率当作 2000 人容量通过,也不能继续直接提高压力。
## 2. 核心结果
| 指标 | R11 结果 | 判定 |
| --- | ---: | --- |
| WS 就绪 | 2000/2000 | 通过 |
| A/B 实际起跑差 | 约 290ms | 通过 |
| 发送尝试 | 2000 | A/B 各 1000 |
| 最终明确 ACK | 1718 | 85.90% |
| 发送结果未知 | 282 | 失败 |
| ACK P95 | 58.37s | 失败 |
| 期望实时投递 | 3,654,000 | 按 1827 条服务端确认/回查消息计算 |
| 实际实时投递 | 3,428,697 | 到达率 93.83% |
| 实时缺失 | 225,303 | 6.17% |
| 数据库核验 | 1165/1827 | 缺失 662 条 |
| 重复 / 乱序 | 0 / 0 | 已收到部分正确 |
| WS 断连 | 2036 | 失败,包含重连后的再次断开 |
R10 的实时到达率为 49.86%,R11 提升到 93.83%,说明共享序列化方向有效;但 R11 同时出现了全局入口故障和持久化缺失,两个结果的运行条件不同,不能据此计算严谨的容量提升比例。
## 3. A/B 分机结果
| 指标 | 机器 A | 机器 B |
| --- | ---: | ---: |
| 就绪连接 | 1000 | 1000 |
| 发送尝试 | 1000 | 1000 |
| 最终观测 ACK | 919 | 799 |
| 结果未知 | 81 | 201 |
| 收到群消息 | 1,816,953 | 1,611,744 |
| 断连 | 1005 | 1031 |
| 主要断连原因 | abnormal close 996 | abnormal close 653、reset 378 |
B 的网络表现仍弱于 A,但 A 也几乎全量断开,因此本轮不能归因于单独一台压测机。
## 4. 故障时间线
时间均为北京时间 2026-10-04:
- 10:35:36:A/B 同步开始,2000 个连接全部就绪。
- 10:35:44 起:公网 `/metrics` 出现高延迟和多次 10s 超时,表明服务在正式断开前已经出现调度或网络拥塞。
- 10:40:06、10:40:12:`/metrics` 返回 HTTP 521。
- 10:40:16 起:A 机对三个直连源 IP 的 443 均收到 `connection refused`,随后登录出现 500、WS 握手出现 503。
- 10:40:17:A 的就绪连接从 1000 降到 8,约 992 个连接集中断开;B 同期发生同类大规模断开。
- 10:40:48–10:41:03:公网入口持续返回 503。
- 10:41:09 后:入口恢复,客户端自动重连;A 约在 10:43:37 恢复到 1000 个连接。
直连源 IP 同时拒绝 443,且公网出现 521/503,这不是 Cloudflare DNS 或压测机代理造成的普通客户端故障。采样期间 Tinode cluster 指标仍为 2/2 live peers,没有证据表明原生 cluster 先失联。
2026-10-04 11:31–11:32 重新探测时,三个源 IP 的 443 均已恢复并可通过 `api.118im.com` Host/SNI 返回健康检查 200。三个 IP 返回的是同一张 `TRAEFIK DEFAULT CERT`,SHA-256 指纹均为 `7C:9E:00:6F:56:D6:C9:87:44:62:7D:26:93:D8:92:51:F8:DA:43:66:61:DB:8B:F9:1F:47:17:C8:93:13:C4:8C`,证书 `notBefore` 为 **2026-10-04 10:41:32**。该时间与 R11 的 521/503 和源站拒绝连接恢复窗口精确重合,是 Traefik/入口组件在故障期发生重启、重建或重新初始化的强证据。
目前可以把故障范围收敛到 Traefik/入口层,但仅凭默认证书时间仍不能区分 Traefik Pod 重启、Deployment 滚动、节点重启、配置重载或入口服务重建。
运维已确认 Kubernetes API `216.236.31.189:6443` 按安全策略不对公网开放,只允许集群网络访问;当前调试机超时属于预期行为,不是本轮故障的一部分。Pod Event、Ingress controller 日志和节点事件必须由运维从控制面节点或集群内采集。在这些证据回传前,仍不把某一个基础设施组件写成唯一根因。
## 5. 共享序列化与实时链路
三个 Pod 的 `im_topic_shared_serialization_total` 在采集窗口分别增加约 5392、6664、6831,证明新快路已实际执行。
按采集窗口增量估算:
| 阶段 | 结果 |
| --- | --- |
| master Topic broadcast P95 | 不超过 5ms |
| proxy-0 Topic broadcast P95 | 不超过 5ms |
| proxy-1 Topic broadcast P95 | 不超过 10ms |
| master 到 peer-0 的 multiplex 排队 P95 | 不超过 100ms |
| master 到 peer-1 的 multiplex 排队 P95 | 不超过 250ms |
| WS read-dispatch P95 | 三个 Pod 均不超过 50ms |
Topic 广播本身已不再呈现分钟级长尾,说明“同一群消息对每个 WS 重复 JSON 序列化”得到明显缓解。当前长尾更多出现在 multiplex 排队、socket/入口可用性以及故障后的重连恢复阶段。WS read-dispatch 仍有少量超过 5s 的样本,三个 Pod 分别约 13、10、18 次,需要保留观察,但不是本轮全量断开的充分解释。
## 6. Kafka 与数据库异常
### 6.1 对账证据
- 1827 条服务端确认或历史回查消息中,数据库精确找到 1165 条,缺失 662 条。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 观测上限停在 1165,此后不再增长。该 counter 没有 `pod` 标签,公网采样又会随机落到不同 Pod,因此不能直接把它当作集群总数;这里只作为持久化停止推进的旁证。
- 缺失消息全部来自第二轮:A 缺 339 条,序号 0085–0423;B 缺 323 条,序号 0085–0414。A 是完整连续区间,B 仅有少量间隙,不符合随机单条 MySQL 写失败的形态。
- 采样结束时 group persist 的 lag 仍显示 0,但 `im_kafka_backlog_oldest_age_seconds` 在一个 Pod 上达到约 1082s。二者互相矛盾,现有 lag 指标不能作为“已消费完成”的依据。
- 压测结束约 30 分钟后再次直连 MySQL 核验,结果仍为 1165/1827,缺失 662、错配 0、重复 0、额外消息 0。缺失没有后台追平,不能解释为普通消费延迟。
### 6.2 已确认的代码风险一:部分批次被丢弃
`fetchKafkaMessageBatch` 先连续调用 `FetchMessage` 组装批次。如果已经抓到若干消息,后续抓取返回非 `DeadlineExceeded` 错误,函数直接返回 `nil, err`,已经抓到的部分消息不会交给 handler,也不会立即提交。
Kafka reader 的内部读取位置已经前移。恢复后若继续抓取并提交更高 offset,之前被丢弃的消息可能随高 offset 一起被越过。这可以直接造成“生产成功、最终 lag 为 0、数据库却缺少一段连续消息”。
### 6.3 已确认的代码风险二:批量幂等 claim 可能活锁
`handleKafkaMessagePersistBatch` 在真正批量落库前逐条执行 `BeginKafkaConsume`。如果批次中途发生 Redis/基础设施错误,前面已经 claim 的消息不会释放;外层重试从批首重新开始,又会遇到 `processing` 状态。不同 claim 的过期时刻略有差异时,重试还可能反复续上前面的 claim,长期无法走到后面的消息。
这能解释 oldest age 长时间增长且持久化计数停止。当前“失败不提交 offset”的修复方向正确,但还没有覆盖“抓取半批失败”和“准备半批失败”两个边界。
### 6.4 lag 指标口径不可靠
当前指标直接读取 `kafka-go Reader.Stats().Lag`,同时在每次批处理结束后把 oldest age 清零。consumer group 模式下,该 lag 不是可靠的已提交 offset 差值;消息被 reader 抓取但尚未持久化时,也可能显示 0。必须改为按 partition 查询 high watermark 与 committed offset,并分别暴露 fetched、processed、committed offset。
## 7. 优先改进项
### P0:Kafka 持久化可靠性
1. `fetchKafkaMessageBatch` 在抓到部分消息后发生可恢复错误时,应返回已抓取批次供 handler 处理;只有一条都没抓到时才返回 fetch error。
2. 批量准备阶段不能留下半批 claim。可先完成无副作用的解码与校验,再逐条 claim;claim 冲突时按消息拆分处理,不能让整个批次从头活锁。
3. offset 只允许在该 partition 对应消息全部持久化成功后提交;增加 fetched/processed/committed offset 和 commit failure 指标。
4. 增加回归测试:半批 fetch 后网络错误、准备第 N 条失败、Redis 短暂不可用、处理成功但 commit 失败、Pod 在 handler 中重启。
5. 修复后先执行 2000 条“只验证 Kafka/MySQL”的持久化专项,要求 DB 2000/2000、lag/offset 对账一致,再恢复大群实时压测。
### P0:入口整体中断
1. 从 Ingress controller、Service endpoint、入口节点和云负载均衡拉取 10:40:00–10:41:10 的日志与事件。
2. 核对是否发生 Ingress reload、endpoint 短暂清空、节点 conntrack/端口耗尽、进程监听中断、liveness 误杀或网络策略更新。
3. 增加入口黑盒探针:Cloudflare 域名、源站 IP、Service、Pod 四层同时采样 TCP connect、TLS handshake、HTTP status 和耗时。
4. 把“源站三个 IP 同时 connection refused”设为基础设施门禁,发生时本轮自动标记无效并保留现场。
### P1:实时链路
1. 保留共享序列化实现;从本轮数据看无需回退。
2. 继续降低 master-to-proxy multiplex 排队,优先考虑兼容发布的有界批量 RPC 或单写者批发送。
3. 补采 Pod CPU throttling、Go scheduler、GC、网卡 retransmit、Ingress upstream reset,解决 `/metrics` 本身在高压阶段失去可观测性的问题。
## 8. 下一轮顺序
1. 修复 Kafka 两个批消费边界并完成单元/故障注入测试。
2. 运行持久化专项,确认 2000/2000 精确落库。
3. 恢复 Kubernetes 与 Ingress 现场采集能力,查清 R11 的入口中断。
4. 使用 R11 完全相同参数执行 R12,要求连续两轮无入口故障、无 outcome unknown、DB 2000/2000。
5. 通过后再判断 2000 人容量;不要在问题未清零前提高并发。
## 9. 产物
- 结果目录:`loadtest/im-pressure/results/group2k-r11-20261004-01/`
- 汇总:`report.json`
- 数据库核验:`db-verification.json`
- A/B 明细:`agent-a-summary.json`、`agent-b-summary.json`
- 协调器台账:`coordinator.json`
- 指标:`metrics.jsonl`
- 被测代码:`b05e8f6 perf(group): reduce websocket fanout serialization`
## 10. 分析后已实施修复
已完成以下 Kafka 持久化修复,待发版后先执行持久化专项,再进行 R12:
1. 批量抓取在取得部分消息后遇到 broker/网络错误时,不再丢弃已抓消息;消费者会先处理并提交这部分消息,同时保留错误日志。
2. 批准备过程中后续消息 claim 失败时,先持久化并完成前面已经 claim 的消息,再将准备错误交给外层重试,避免整批 processing claim 活锁。
3. 新增半批 fetch error、首次 fetch error、部分 claim 后准备失败、刷盘与准备同时失败等回归测试。
验证结果:
- Kafka 定向单元测试通过。
- `go test ./server ./server/store/... ./loadtest/im-pressure` 通过。
- Kafka 定向 `go test -race` 通过。
- `go vet` 仍报告 `server/internal_cultivate_test.go` 中既有的 `sync.Map` 锁值复制问题,与本次改动无关。
@@ -0,0 +1,168 @@
# GROUP-2000 R12 双机群聊复测分析
## 1. 结论
`group2k-r12-20261004-01` 判定为 **失败,2000 人群聊容量仍未通过**。
本轮有一个明确的正向结果:R11 后的 Kafka 部分批次修复已经生效。服务端实际接收并可从投递或历史中确认的 1694 条消息,MySQL 精确找到 1694 条,缺失、错配、重复和额外记录均为 0。
实时链路仍然失败。A/B 虽然均以 1000/1000 条连接起跑,但负载开始约 33 秒后公网 `/metrics` 开始连续超时,随后出现两波大规模 WebSocket reset。最终实时到达率只有 74.739%,ACK P95 为 74.72 秒,包含迟到 ACK 的控制响应 P95 达 407.90 秒。
与 R11 不同,本轮 Pod 没有重启,日志也没有再次出现 CoreDNS 超时。证据表明本轮先发生群 Topic 串行处理积压和入口响应失速,连接被成批 reset 后又触发重连风暴,三个 Pod 的业务库连接池均出现约 9,200-11,350 秒累计等待,最终形成放大回路。
## 2. 核心结果
| 指标 | R12 结果 | 判定 |
| --- | ---: | --- |
| WS 起跑就绪 | 2000/2000 | 通过 |
| A/B 起跑差 | 约 33ms | 通过 |
| 发送尝试 | 2000 | A/B 各 1000 |
| 最终明确 ACK | 1501 | 75.05%,失败 |
| 发送结果未知 | 499 | 失败 |
| 服务端接收/回查确认消息 | 1694 | 另有 306 条未确认进入服务端 |
| ACK P95 | 74.72s | 失败 |
| 含迟到 ACK 的控制响应 P95 | 407.90s | 失败 |
| 期望实时投递 | 3,388,000 | 1694 x 2000 |
| 实际实时投递 | 2,532,160 | 到达率 74.739% |
| 实时缺失 | 855,840 | 丢失率 25.261% |
| MySQL 精确核验 | 1694/1694 | 通过 |
| 重复 / 乱序 | 0 / 0 | 已收到部分正确 |
| WS 断连 | 1257 | 失败 |
| Pod 重启 | 0 | 无应用容器重启 |
`publish_final_accepted=1501` 只统计客户端最终观察到的 ACK。数据库和消息台账共同证明实际有 1694 条进入服务端,因此其中 193 条属于客户端未拿到 ACK、但服务端已经处理的消息。
## 3. A/B 分机结果
| 指标 | 机器 A | 机器 B |
| --- | ---: | ---: |
| 起跑连接 | 1000 | 1000 |
| 发送尝试 | 1000 | 1000 |
| 及时 ACK | 547 | 601 |
| 迟到 ACK | 327 | 26 |
| 结果未知 | 126 | 373 |
| 收到群消息 | 1,389,624 | 1,142,536 |
| 实时缺失 | 304,376 | 551,464 |
| 断连 | 433 | 824 |
| 主要断连原因 | reset 413、timeout 20 | abnormal close 405、reset 419 |
B 在第一波连接重置中受影响更重;第二波则同时影响 A/B,不能归因于单台压测机。
## 4. 故障时间线
时间均为 UTC,北京时间加 8 小时:
- 15:57:31:A/B 同步起跑,2000 个连接全部就绪。
- 15:57:34-15:57:41:公网 `/metrics` 已从亚秒上升到约 2.5 秒。
- 15:58:04:公网 `/metrics` 首次 10 秒超时;此后连续 28 次超时。
- 15:58:24-15:58:59:第一波约 394 条服务端连接集中关闭,主要对应 B;B 汇总为 405 条 abnormal close。
- 16:00:15 起:`im-server-0` 开始出现用户缓存更新队列满,窗口内共 78 次。
- 16:01:50-16:02:10:第二波约 810 条服务端连接集中关闭;A/B 汇总的 reset 合计为 832 条。
- 16:02:06 起:大量重连登录出现 `context deadline exceeded`。
- 16:02:08:`im-server-1` readiness 请求本身超时;期间还出现一次 liveness 超时,但未达到重启阈值。
- 16:02:39-16:02:47:三个 Pod 均产生 readiness 503 事件。
- 16:02:48、16:02:50:`im-server-2`、`im-server-1` 恢复 Ready;`im-server-0` 未形成持续 NotReady。
- 16:05:04:公网 `/metrics` 恢复 200,首次恢复请求仍耗时约 4.6 秒。
- 16:11:55:A/B 汇总完成。
三个 Pod 的 `restartCount` 均为 0。R11 出现过的 `10.43.0.10:53`、`no such host`、DNS connection refused 在本轮窗口内均未出现,因此不能继续把本轮失败归因于 CoreDNS。
## 5. 容量瓶颈
群 Topic master 位于 `im-server-2`。最终指标显示:
| 指标 | 数值 |
| --- | ---: |
| 群发布处理次数 | 1694 |
| Topic publish 处理总耗时 | 116.59s |
| 单条平均处理耗时 | 约 68.8ms |
| Topic 队列等待总耗时 | 2279.92s |
| 单条平均排队 | 约 1.35s |
| 队列等待最大桶 | 30s 内 |
| master 广播总耗时 / 次数 | 21.33s / 4803 |
| master 单次广播平均耗时 | 约 4.4ms |
按 68.8ms 的串行 Topic 处理耗时估算,单群持续处理能力约为 14.5 条/秒,低于本轮约 20 条/秒的目标发送速率。共享序列化后的广播本身仍然只有毫秒级,不是主要瓶颈;耗时主要位于 Topic publish 的保存、Kafka produce、权限检查和推送准备等关键路径。
这也解释了为何在正式断连前 ACK 已经出现 20-90 秒长尾,并且 `/metrics` 先失去响应。
## 6. 断连与入口
Pod 日志中的两波连接关闭均来自入口上游连接;客户端看到的是三个源站 IP 返回 `connection reset by peer`,不是主动退出压测程序:
- 第一波关闭连接的服务端会话寿命平均约 122 秒,集中影响 B。
- 第二波同时影响 A/B;旧连接寿命主要集中在约 330 秒和 660 秒,说明它是同一时刻的批量重置,而非每条连接独立超时。
- 三个源 IP 最终汇聚到同一个入口上游地址的现象仍然存在,入口层缺少副本、连接上限、reload/restart、upstream reset 和节点 conntrack 证据。
应用负载先失速,入口随后批量 reset,因此当前不能只改客户端重连参数来宣告通过。运维仍需补齐 Traefik、入口节点和 Service endpoint 的同窗口证据,判断 reset 是入口进程、节点网络、连接上限还是上游超时策略触发。
## 7. 重连放大与数据库
压测前后业务库连接池增量如下:
| Pod | 新增等待次数 | 新增累计等待 | 平均每次等待 |
| --- | ---: | ---: | ---: |
| im-server-0 | 817 | 9956.29s | 约 12.2s |
| im-server-1 | 770 | 9218.27s | 约 12.0s |
| im-server-2 | 768 | 11348.20s | 约 14.8s |
每个 Pod 的 `meeting_im` 最大连接数为 24。连接集中断开后,压测端自动重连触发登录、用户状态、IP 白名单和认证版本查询,连接池等待迅速升高;登录进一步超时,readiness 中的数据库检查也随之失败,Pod 被入口摘除,形成反馈回路。
不能简单地只提高连接池上限。下一轮前应先定位连接被长时间占用的 SQL/事务,并为登录重连增加服务端限流、排队和抖动;否则只是把压力转移到 MySQL。
## 8. Kafka 与落库
数据库直连核验结果:
| 项目 | 结果 |
| --- | ---: |
| tenant | allrich / tenant_id=8 |
| 期望消息 | 1694 |
| 精确找到 | 1694 |
| 缺失 | 0 |
| 字段错配 | 0 |
| 重复行 | 0 |
| 额外记录 | 0 |
| 分表 | `im_008_group_messages_202610`、`im_008_messages` |
这证明 R11 的 Kafka 部分批次丢弃问题已修复。最后一次公网指标采样中某个 reader 的本地 `im_kafka_consumer_lag` 仍显示 111,但稍后的数据库核验已经完整;再次说明 `kafka-go Reader.Stats().Lag` 不能作为 consumer group 已提交 offset 的最终依据。
## 9. 改进优先级
### P0:群 Topic 关键路径
1. 对 Topic publish 的 save、Kafka produce、权限查询、推送准备分别补充 P95/P99 和并发占用,确认 68.8ms 的主要组成。
2. 将不要求阻塞 ACK 的推送准备、通知类工作移出 Topic 单写者关键路径。
3. 对必须保持顺序的持久化和序列分配设计有界流水线;保持同群顺序和失败可重试,不能重新引入已移除的业务 outbox。
4. 优化后先跑 10、12、15、18、20 msg/s 阶梯,连续两轮确认 Topic 队列不持续增长,再重新判定 20 msg/s 容量。
### P0:入口批量 reset
1. 由运维提取 15:57:30-16:05:10 的 Traefik Pod 日志、事件、restart、reload、upstream reset 和打开连接数。
2. 核对三个公网源 IP 是否最终只有一个 Traefik 实例承载;入口至少配置两个副本、反亲和和 PDB。
3. 同步采集节点 conntrack、TCP retransmit、listen overflow、文件描述符、网卡丢包和 CPU throttling。
4. 下一轮同时探测公网域名、每个源 IP、Service 和三个 Pod,避免 `/metrics` 单点失明。
### P1:重连保护
1. 客户端维持自动重连,但使用指数退避和随机抖动,避免 2000 条连接同步登录。
2. 服务端对登录重建增加有界并发和快速过载响应,不让请求无限占用数据库连接池。
3. 分解登录 SQL 指标,定位连接池长等待对应的查询或事务。
4. readiness 的数据库检查应使用独立的小连接预算或短路缓存,避免业务池耗尽导致全部 Endpoint 同时被摘除;liveness 继续只检查进程存活。
## 10. 下一轮顺序
1. 先做 2000 连接、0 发消息的 15 分钟稳定性测试,排除入口固定连接寿命和 B 网络路径问题。
2. 再做单群阶梯:10、12、15、18、20 msg/s,每档 5 分钟,观察 Topic queue 是否线性增长。
3. 完成入口和数据库现场采集后,重复 R12 原参数两轮。
4. 通过标准:2000/2000 连接全程稳定、发送成功率不低于 99.99%、实时到达率不低于 99.99%、DB 精确落库、无批量 reset、无持续 Topic/DB 队列增长。
## 11. 产物
- 结果目录:`loadtest/im-pressure/results/group2k-r12-20261004-01/`
- 汇总:`report.json`
- 数据库核验:`db-verification.json`
- A/B 明细:`agent-a-summary.json`、`agent-b-summary.json`
- 协调器台账:`coordinator.json`
- 指标:`metrics.jsonl`
- 被测代码:`965f94f fix(kafka): preserve partially fetched message batches`
+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. 消息首页、全局导航与系统通知
@@ -1038,8 +1082,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:需改造。**
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
- `summary` 为服务端生成的可直接展示文案(如「对方已同意你的好友申请」「对方已婉拒你的好友申请」),前端直接渲染,无需再做状态码到文案的映射;`action` 读路径统一归一为 `{type,target_id,payload,...原始键}`。
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;重复已读返回首次持久化的 `read_at`,不回显本次提交值。跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例
@@ -1058,7 +1103,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","target_id":"usr...","payload":{"request_id":"frq_1"},"request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
## 3. 单聊会话、会话建立与消息状态
@@ -1117,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` 幂等去重。
@@ -1366,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
@@ -1376,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 内容但分别生成目标消息与授权记录。
@@ -1571,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` 会带:
@@ -1641,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"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1704,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` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1773,9 +1843,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/黑名单提示.png" alt="黑名单提示" width="360">
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。**
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施。服务端已实现并实测(2026-10-09),剩余前端改造。**
- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 服务端现状(2026-10-09 测试环境实测通过):REST 接口仅写 `im_dellog` 本人区间并递增 `deleted_id`,不改动消息行、对方视角不受影响,本人其他设备经 `deleted_id` 同步感知清空;WS `{del}` 语义相同,两条路径二选一即可。对非成员或不存在会话返回 403 FORBIDDEN。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1801,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":""}` |
@@ -1839,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`。
@@ -1859,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` 和最终状态。
@@ -1942,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 群成员列表拉取与缓存策略
@@ -2126,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 接口示例
@@ -2149,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}}` |
@@ -2267,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
@@ -2359,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"}` |
@@ -2382,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
@@ -2508,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"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
@@ -2556,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">
@@ -2632,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
@@ -2721,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 秒;离开页面立即停止轮询。
@@ -2731,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` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
@@ -2764,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 字段与状态约定
@@ -2843,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}}` |
@@ -2880,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
@@ -2892,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
@@ -2955,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
@@ -3080,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=
+7 -2
View File
@@ -197,8 +197,13 @@ mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
两个 agent 都建立 1000 条连接后,协调器下发统一的 UTC `start_at`。场景可替换为:
- `p2p`:1000 对用户两侧同时发送,默认每人 10 条、间隔 1 秒。
- `group`:`users_per_machine=1000` 时每个 agent 有 500 个发送者,每人 2 条;全局每 100ms 一条,共 2000 条。其他容量按每个 agent 用户数的 50% 选发送者。
- `mixed`:`users_per_machine=1000` 时使用 300 对私聊用户和每个 agent 250 个群发送者;其他容量分别按 30% 和 25% 同比计算。
- `group`:默认每个 agent 有 `group_senders_per_machine` 个发送者,每人 `group_rounds` 条;两机合并后按 `group_publish_interval` 控制全局发送间隔。双机默认 500+500 发送者、100ms 一条,共 2000 条;单机 60% 默认 300+300 发送者。
- `mixed`:默认使用 `mixed_p2p_users_per_machine` 对私聊用户和 `mixed_group_senders_per_machine` 个群发送者。双机默认每机 300 个私聊用户、250 个群发送者;单机 60% 默认每机 180 个私聊用户、150 个群发送者。
- `idle`:只建立并维持 WS 连接,不发送业务消息,持续 `idle_duration`。该场景用于先确认 2000 长连接在入口、Pod、DNS、心跳和自动重连下是否稳定。
群聊容量阶梯建议只改 `group_publish_interval`,保持发送者和轮数不变:`125ms` 约等于 8 msg/s,`100ms` 约等于 10 msg/s,`83ms` 约等于 12 msg/s,`67ms` 约等于 15 msg/s,`56ms` 约等于 18 msg/s。先跑 `idle`,再从低速 `group` 逐级上探;某一级出现连接波动、ACK 长尾或 DB pool wait 明显上升时,重复该级确认后再进入下一档。
自动重连由 `reconnect_workers`、`reconnect_min_wait`、`reconnect_max_wait` 控制。断线账号会按指数退避并附带确定性抖动后重连,避免两台压测机在入口抖动时同时打出登录洪峰。
## 机器 A:回收并合并报告
+88 -13
View File
@@ -49,7 +49,7 @@ type agentRun struct {
}
func validScenario(scenario string) bool {
return scenario == "p2p" || scenario == "group" || scenario == "mixed"
return scenario == "p2p" || scenario == "group" || scenario == "mixed" || scenario == "idle"
}
func preflightP2P(ctx context.Context, cfg *Config, manifest *Manifest, local []AccountRecord, apiKey string) error {
@@ -222,8 +222,12 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
quietPeriod, _ := configDuration(cfg.DeliveryQuiet)
quietTimeout, _ := configDuration(cfg.DeliveryTimeout)
log.Printf("agent=%s waiting for %s of delivery quiescence", machine, quietPeriod)
if err = waitForDeliveryQuiescence(ctx, run.recorder, run.callbackQueue, quietPeriod, quietTimeout); err != nil {
return err
var quiescenceErr error
if quiescenceErr = waitForDeliveryQuiescence(ctx, run.recorder, run.callbackQueue, quietPeriod, quietTimeout); quiescenceErr != nil {
if ctx.Err() != nil {
return quiescenceErr
}
log.Printf("agent=%s delivery quiescence wait timed out; continuing with observed submission: %v", machine, quiescenceErr)
}
observedLocal := run.recorder.unackedObservedMessages(ledger, start.Scenario)
if err = coord.submitObserved(ctx, machine, observedLocal); err != nil {
@@ -247,6 +251,9 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
run.mu.Lock()
base.Errors = append(base.Errors, run.errors...)
run.mu.Unlock()
if quiescenceErr != nil {
base.Errors = append(base.Errors, quiescenceErr.Error())
}
// Include messages observed over WebSocket without a timely ctrl ACK. They
// are confirmed server-side publishes and must participate in realtime
// delivery accounting instead of being reported as unexpected traffic.
@@ -680,10 +687,25 @@ func (r *agentRun) connect(ctx context.Context, accounts []AccountRecord, groupT
}
func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRecord, groupTopic, apiKey, password string) {
type reconnectState struct {
attempt int
nextAt time.Time
}
byIndex := make(map[int]AccountRecord, len(accounts))
for _, account := range accounts {
byIndex[account.Index] = account
}
states := make(map[int]reconnectState, len(accounts))
var stateMu sync.Mutex
minWait := mustDuration(r.cfg.ReconnectMinWait)
maxWait := mustDuration(r.cfg.ReconnectMaxWait)
if maxWait < minWait {
maxWait = minWait
}
reconnectWorkers := r.cfg.ReconnectWorkers
if reconnectWorkers <= 0 {
reconnectWorkers = 8
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
@@ -692,7 +714,7 @@ func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRe
}
jobs := make(chan AccountRecord)
var wg sync.WaitGroup
workers := 16
workers := reconnectWorkers
if workers > len(accounts) {
workers = len(accounts)
}
@@ -714,8 +736,17 @@ func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRe
if ctx.Err() == nil {
log.Printf("account=%s reconnect failed: %v", account.Username, err)
}
stateMu.Lock()
state := states[index]
state.attempt++
state.nextAt = time.Now().Add(reconnectBackoff(minWait, maxWait, state.attempt, index))
states[index] = state
stateMu.Unlock()
continue
}
stateMu.Lock()
delete(states, index)
stateMu.Unlock()
r.setClient(index, client)
log.Printf("account=%s connection restored", account.Username)
}
@@ -732,6 +763,12 @@ func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRe
if current != nil && current.alive() {
continue
}
stateMu.Lock()
nextAt := states[index].nextAt
stateMu.Unlock()
if !nextAt.IsZero() && time.Now().Before(nextAt) {
continue
}
select {
case jobs <- account:
case <-ctx.Done():
@@ -750,6 +787,25 @@ func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRe
}
}
func reconnectBackoff(minWait, maxWait time.Duration, attempt, index int) time.Duration {
if attempt < 1 {
attempt = 1
}
delay := minWait
for i := 1; i < attempt && delay < maxWait; i++ {
delay *= 2
}
if delay > maxWait {
delay = maxWait
}
jitterBase := delay / 4
if jitterBase <= 0 {
return delay
}
jitter := time.Duration((index*97+attempt*53)%1000) * jitterBase / 1000
return delay + jitter
}
func (r *agentRun) connectAccountWithRetry(ctx context.Context, account AccountRecord, groupTopic, apiKey, password string) (*WSClient, error) {
var lastErr error
for attempt := 1; attempt <= 6; attempt++ {
@@ -787,12 +843,25 @@ func (r *agentRun) execute(ctx context.Context, start time.Time, groupTopic stri
case "p2p":
return r.scheduleP2P(ctx, start, r.cfg.UsersPerMachine, r.cfg.P2PMessages, mustDuration(r.cfg.P2PInterval))
case "group":
return r.scheduleGroup(ctx, start, groupTopic, 1, scaledUsers(r.cfg.UsersPerMachine, 1, 2), 2)
senders := r.cfg.GroupSenders
if senders <= 0 {
senders = scaledUsers(r.cfg.UsersPerMachine, 1, 2)
}
if senders > r.cfg.UsersPerMachine {
return fmt.Errorf("group_senders_per_machine=%d exceeds users_per_machine=%d", senders, r.cfg.UsersPerMachine)
}
return r.scheduleGroup(ctx, start, groupTopic, 1, senders, r.cfg.GroupRounds, mustDuration(r.cfg.GroupInterval))
case "mixed":
p2pUsers := scaledUsers(r.cfg.UsersPerMachine, 3, 10)
groupSenders := scaledUsers(r.cfg.UsersPerMachine, 1, 4)
p2pUsers := r.cfg.MixedP2PUsers
if p2pUsers <= 0 {
p2pUsers = scaledUsers(r.cfg.UsersPerMachine, 3, 10)
}
groupSenders := r.cfg.MixedGroupSenders
if groupSenders <= 0 {
groupSenders = scaledUsers(r.cfg.UsersPerMachine, 1, 4)
}
if p2pUsers+groupSenders > r.cfg.UsersPerMachine {
groupSenders = r.cfg.UsersPerMachine - p2pUsers
return fmt.Errorf("mixed_p2p_users_per_machine + mixed_group_senders_per_machine exceeds users_per_machine")
}
if groupSenders <= 0 {
return fmt.Errorf("users_per_machine=%d is too small for mixed scenario", r.cfg.UsersPerMachine)
@@ -803,7 +872,7 @@ func (r *agentRun) execute(ctx context.Context, start time.Time, groupTopic stri
go func() { defer wg.Done(); errCh <- r.scheduleP2P(ctx, start, p2pUsers, 5, 20*time.Second) }()
go func() {
defer wg.Done()
errCh <- r.scheduleGroup(ctx, start, groupTopic, p2pUsers+1, p2pUsers+groupSenders, 2)
errCh <- r.scheduleGroup(ctx, start, groupTopic, p2pUsers+1, p2pUsers+groupSenders, r.cfg.GroupRounds, mustDuration(r.cfg.GroupInterval))
}()
wg.Wait()
close(errCh)
@@ -813,6 +882,8 @@ func (r *agentRun) execute(ctx context.Context, start time.Time, groupTopic stri
}
}
return nil
case "idle":
return waitContext(ctx, mustDuration(r.cfg.IdleDuration))
default:
return fmt.Errorf("unknown scenario %s", r.scenario)
}
@@ -867,15 +938,19 @@ func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, mess
return ctx.Err()
}
func (r *agentRun) scheduleGroup(ctx context.Context, start time.Time, topic string, first, last, rounds int) error {
func (r *agentRun) scheduleGroup(ctx context.Context, start time.Time, topic string, first, last, rounds int, globalInterval time.Duration) error {
if first < 1 || last > r.clientCount() || first > last {
return fmt.Errorf("invalid group publisher range %d-%d", first, last)
}
count := last - first + 1
roundDuration := time.Duration(count) * 200 * time.Millisecond
if globalInterval <= 0 {
return fmt.Errorf("group publish interval must be positive")
}
perMachineInterval := 2 * globalInterval
roundDuration := time.Duration(count) * perMachineInterval
offset := time.Duration(0)
if r.machine == "b" {
offset = 100 * time.Millisecond
offset = globalInterval
}
var wg sync.WaitGroup
for index := first; index <= last; index++ {
@@ -884,7 +959,7 @@ func (r *agentRun) scheduleGroup(ctx context.Context, start time.Time, topic str
go func(index, position int) {
defer wg.Done()
for round := 1; round <= rounds; round++ {
target := start.Add(time.Duration(round-1)*roundDuration + time.Duration(position)*200*time.Millisecond + offset)
target := start.Add(time.Duration(round-1)*roundDuration + time.Duration(position)*perMachineInterval + offset)
if waitUntil(ctx, target) != nil {
return
}
@@ -76,6 +76,33 @@ func TestScenarioUsersScaleWithConfiguredCapacity(t *testing.T) {
}
}
func TestReconnectBackoffCapsAndJitters(t *testing.T) {
minWait := 2 * time.Second
maxWait := 30 * time.Second
first := reconnectBackoff(minWait, maxWait, 1, 10)
if first < minWait || first >= minWait+minWait/4 {
t.Fatalf("first backoff=%s outside expected jitter range", first)
}
capped := reconnectBackoff(minWait, maxWait, 10, 10)
if capped < maxWait || capped >= maxWait+maxWait/4 {
t.Fatalf("capped backoff=%s outside expected jitter range", capped)
}
if reconnectBackoff(minWait, maxWait, 3, 10) == reconnectBackoff(minWait, maxWait, 3, 11) {
t.Fatal("different account indexes should receive different jitter")
}
}
func TestValidScenarioIncludesIdle(t *testing.T) {
for _, scenario := range []string{"p2p", "group", "mixed", "idle"} {
if !validScenario(scenario) {
t.Fatalf("scenario %s should be valid", scenario)
}
}
if validScenario("unknown") {
t.Fatal("unknown scenario should be rejected")
}
}
func TestNewHistoryQueryIncludesInterleavedSequenceRange(t *testing.T) {
messages := make([]AcceptedMessage, 10)
for i := range messages {
+9
View File
@@ -13,6 +13,9 @@
"insecure_tls": false,
"prepare_workers": 20,
"connect_ramp": "20s",
"reconnect_workers": 8,
"reconnect_min_wait": "2s",
"reconnect_max_wait": "30s",
"command_timeout": "20s",
"ws_ping_interval": "20s",
"preflight_workers": 32,
@@ -26,6 +29,12 @@
"close_ramp": "10s",
"p2p_messages_per_user": 10,
"p2p_interval": "1s",
"group_senders_per_machine": 500,
"group_rounds": 2,
"group_publish_interval": "100ms",
"mixed_p2p_users_per_machine": 300,
"mixed_group_senders_per_machine": 250,
"idle_duration": "15m",
"drain_p2p": "30s",
"drain_group": "60s",
"delivery_quiet_period": "10s",
@@ -17,6 +17,9 @@
"insecure_tls": false,
"prepare_workers": 20,
"connect_ramp": "30s",
"reconnect_workers": 8,
"reconnect_min_wait": "2s",
"reconnect_max_wait": "30s",
"command_timeout": "90s",
"ws_ping_interval": "20s",
"preflight_workers": 32,
@@ -30,6 +33,12 @@
"close_ramp": "60s",
"p2p_messages_per_user": 10,
"p2p_interval": "1s",
"group_senders_per_machine": 300,
"group_rounds": 2,
"group_publish_interval": "100ms",
"mixed_p2p_users_per_machine": 180,
"mixed_group_senders_per_machine": 150,
"idle_duration": "15m",
"drain_p2p": "30s",
"drain_group": "60s",
"delivery_quiet_period": "10s",
+1 -1
View File
@@ -101,7 +101,7 @@ func coordinatorCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("coordinator", flag.ContinueOnError)
listen := fs.String("listen", ":8090", "coordinator listen address")
runID := fs.String("run-id", "", "unique run ID, max 24 characters")
scenario := fs.String("scenario", "", "p2p, group, or mixed")
scenario := fs.String("scenario", "", "p2p, group, mixed, or idle")
output := fs.String("output", "loadtest/im-pressure/results/coordinator.json", "coordinator result path")
expected := fs.Int("expected-agents", 2, "number of agents")
delay := fs.Duration("start-delay", 30*time.Second, "delay after all agents are ready")
+85 -29
View File
@@ -16,35 +16,44 @@ import (
const manifestVersion = 1
type Config struct {
Tenant string `json:"tenant"`
HTTPBase string `json:"http_base"`
APIKeyEnv string `json:"api_key_env"`
PasswordEnv string `json:"password_env"`
UsersPerMachine int `json:"users_per_machine"`
Endpoints map[string][]string `json:"endpoints"`
DialIPs map[string][]string `json:"dial_ips,omitempty"`
InsecureTLS bool `json:"insecure_tls"`
PrepareWorkers int `json:"prepare_workers"`
ConnectRamp string `json:"connect_ramp"`
CommandTimeout string `json:"command_timeout"`
WSPingInterval string `json:"ws_ping_interval"`
P2PMessages int `json:"p2p_messages_per_user"`
P2PInterval string `json:"p2p_interval"`
DrainP2P string `json:"drain_p2p"`
DrainGroup string `json:"drain_group"`
DeliveryQuiet string `json:"delivery_quiet_period"`
DeliveryTimeout string `json:"delivery_quiet_timeout"`
PhonePrefix string `json:"registration_phone_prefix"`
VerifyCodeEnv string `json:"verification_code_env"`
HistoryWorkers int `json:"history_workers"`
HistoryPageSize int `json:"history_page_size"`
HistoryInterval string `json:"history_query_interval"`
HistoryPageWait string `json:"history_page_wait"`
HistorySettle string `json:"history_settle"`
HistoryTimeout string `json:"history_timeout"`
CloseRamp string `json:"close_ramp"`
PreflightWorkers int `json:"preflight_workers"`
SkipP2PPreflight bool `json:"skip_p2p_preflight"`
Tenant string `json:"tenant"`
HTTPBase string `json:"http_base"`
APIKeyEnv string `json:"api_key_env"`
PasswordEnv string `json:"password_env"`
UsersPerMachine int `json:"users_per_machine"`
Endpoints map[string][]string `json:"endpoints"`
DialIPs map[string][]string `json:"dial_ips,omitempty"`
InsecureTLS bool `json:"insecure_tls"`
PrepareWorkers int `json:"prepare_workers"`
ConnectRamp string `json:"connect_ramp"`
ReconnectWorkers int `json:"reconnect_workers"`
ReconnectMinWait string `json:"reconnect_min_wait"`
ReconnectMaxWait string `json:"reconnect_max_wait"`
CommandTimeout string `json:"command_timeout"`
WSPingInterval string `json:"ws_ping_interval"`
P2PMessages int `json:"p2p_messages_per_user"`
P2PInterval string `json:"p2p_interval"`
GroupSenders int `json:"group_senders_per_machine"`
GroupRounds int `json:"group_rounds"`
GroupInterval string `json:"group_publish_interval"`
MixedP2PUsers int `json:"mixed_p2p_users_per_machine"`
MixedGroupSenders int `json:"mixed_group_senders_per_machine"`
IdleDuration string `json:"idle_duration"`
DrainP2P string `json:"drain_p2p"`
DrainGroup string `json:"drain_group"`
DeliveryQuiet string `json:"delivery_quiet_period"`
DeliveryTimeout string `json:"delivery_quiet_timeout"`
PhonePrefix string `json:"registration_phone_prefix"`
VerifyCodeEnv string `json:"verification_code_env"`
HistoryWorkers int `json:"history_workers"`
HistoryPageSize int `json:"history_page_size"`
HistoryInterval string `json:"history_query_interval"`
HistoryPageWait string `json:"history_page_wait"`
HistorySettle string `json:"history_settle"`
HistoryTimeout string `json:"history_timeout"`
CloseRamp string `json:"close_ramp"`
PreflightWorkers int `json:"preflight_workers"`
SkipP2PPreflight bool `json:"skip_p2p_preflight"`
}
func loadConfig(path string) (*Config, error) {
@@ -68,6 +77,29 @@ func loadConfig(path string) (*Config, error) {
if cfg.ConnectRamp == "" {
cfg.ConnectRamp = "20s"
}
if cfg.ReconnectWorkers <= 0 {
cfg.ReconnectWorkers = 8
}
if cfg.ReconnectWorkers > 128 {
return nil, errors.New("reconnect_workers must not exceed 128")
}
if cfg.ReconnectMinWait == "" {
cfg.ReconnectMinWait = "2s"
}
if _, err = configDuration(cfg.ReconnectMinWait); err != nil {
return nil, fmt.Errorf("reconnect_min_wait: %w", err)
}
if cfg.ReconnectMaxWait == "" {
cfg.ReconnectMaxWait = "30s"
}
minReconnect, _ := configDuration(cfg.ReconnectMinWait)
maxReconnect, err := configDuration(cfg.ReconnectMaxWait)
if err != nil {
return nil, fmt.Errorf("reconnect_max_wait: %w", err)
}
if maxReconnect < minReconnect {
return nil, errors.New("reconnect_max_wait must be greater than or equal to reconnect_min_wait")
}
if cfg.CommandTimeout == "" {
cfg.CommandTimeout = "20s"
}
@@ -83,6 +115,30 @@ func loadConfig(path string) (*Config, error) {
if cfg.P2PInterval == "" {
cfg.P2PInterval = "1s"
}
if cfg.GroupRounds <= 0 {
cfg.GroupRounds = 2
}
if cfg.GroupInterval == "" {
cfg.GroupInterval = "100ms"
}
if _, err = configDuration(cfg.GroupInterval); err != nil {
return nil, fmt.Errorf("group_publish_interval: %w", err)
}
if cfg.GroupSenders < 0 {
return nil, errors.New("group_senders_per_machine must not be negative")
}
if cfg.MixedP2PUsers < 0 {
return nil, errors.New("mixed_p2p_users_per_machine must not be negative")
}
if cfg.MixedGroupSenders < 0 {
return nil, errors.New("mixed_group_senders_per_machine must not be negative")
}
if cfg.IdleDuration == "" {
cfg.IdleDuration = "15m"
}
if _, err = configDuration(cfg.IdleDuration); err != nil {
return nil, fmt.Errorf("idle_duration: %w", err)
}
if cfg.DrainP2P == "" {
cfg.DrainP2P = "30s"
}
+1 -1
View File
@@ -467,7 +467,7 @@ CREATE TABLE IF NOT EXISTS `im_filemsglinks` (
`id` int NOT NULL AUTO_INCREMENT,
`createdat` datetime(3) NOT NULL,
`fileid` bigint NOT NULL,
`msgid` int DEFAULT NULL,
`msgid` bigint DEFAULT NULL,
`topic` char(25) DEFAULT NULL,
`userid` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
+2 -1
View File
@@ -276,8 +276,9 @@ func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenant
return &result, nil
}
func (c *adminInternalClient) LatestAppRelease(platform, channel string) (*types.AppRelease, error) {
func (c *adminInternalClient) LatestAppRelease(appCode, platform, channel string) (*types.AppRelease, error) {
query := url.Values{}
query.Set("app_code", strings.ToLower(strings.TrimSpace(appCode)))
query.Set("platform", strings.ToLower(strings.TrimSpace(platform)))
query.Set("channel", strings.ToLower(strings.TrimSpace(channel)))
var dto struct {
+1 -1
View File
@@ -38,7 +38,7 @@ func TestAdminInternalClientLatestAppRelease(t *testing.T) {
config: adminInternalAPIConfig{BaseURL: server.URL, ServiceName: "im-server", Secret: "test-secret"},
http: server.Client(),
}
release, err := client.LatestAppRelease(" IOS ", "STABLE")
release, err := client.LatestAppRelease(" IM ", " IOS ", "STABLE")
if err != nil {
t.Fatal(err)
}
+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))
+6
View File
@@ -284,6 +284,9 @@ type ClusterResp struct {
// Original request type.
OrigReqType ProxyReqType
// Set after RPC decoding on the receiving node; not serialized by gob.
proxyQueuedAt time.Time
}
// ClusterPing is used to detect node restarts.
@@ -743,6 +746,7 @@ func (Cluster) TopicProxy(msg *ClusterResp, unused *bool) error {
// Find appropriate topic, send the message to it.
if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil {
msg.SrvMsg.uid = types.ParseUserId(msg.SrvMsg.AsUser)
msg.proxyQueuedAt = time.Now()
select {
case t.proxy <- msg:
immetrics.ObserveInternalQueueEnqueue("cluster_topic_proxy", len(t.proxy), cap(t.proxy), true)
@@ -1420,6 +1424,8 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
return
}
srvMsg := msg.(*ServerComMessage)
immetrics.ObserveClusterM2PDequeue(sess.clnode.name, srvMsg.clusterQueuedAt)
srvMsg.clusterQueuedAt = time.Time{}
response := &ClusterResp{TenantID: srvMsg.TenantID, SrvMsg: srvMsg}
if srvMsg.sess == nil {
response.OrigSid = "*"
+3
View File
@@ -881,6 +881,8 @@ type ServerComMessage struct {
PreserveTopicName bool `json:"-"`
// User id affected by this message.
uid types.Uid
// Timestamp when this message entered a master-to-proxy multiplex queue.
clusterQueuedAt time.Time
}
// Deep-shallow copy of ServerComMessage. Deep copy of service fields,
@@ -899,6 +901,7 @@ func (src *ServerComMessage) copy() *ServerComMessage {
SkipSid: src.SkipSid,
PreserveTopicName: src.PreserveTopicName,
uid: src.uid,
clusterQueuedAt: src.clusterQueuedAt,
}
dst.Ctrl = src.Ctrl.copy()
+1
View File
@@ -405,6 +405,7 @@ type TenantBusinessAdapter interface {
TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error)
TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error)
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error)
TenantGroupMembersCount(t.TenantID, []string) (map[string]int64, error)
TenantGroupMemberUpdate(t.TenantID, string, t.Uid, t.GroupMemberPatch) (*t.GroupMember, error)
TenantUsersShareOpenPrivateGroup(t.TenantID, t.Uid, t.Uid) (bool, error)
TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
+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
+105 -27
View File
@@ -2259,57 +2259,120 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
return err
}
// schemaColumnContract describes the required shape of one column.
type schemaColumnContract struct {
dataType string
notNull bool
autoInc bool
}
func (c schemaColumnContract) describe() string {
out := strings.ToUpper(c.dataType)
if c.notNull {
out += " NOT NULL"
}
if c.autoInc {
out += " AUTO_INCREMENT"
}
return out
}
// TenantMessageSchemaValidate verifies the storage contracts asynchronous
// persistence depends on: message ids and topic sequence counters are written
// as explicit snowflake int64 values, and file-message links reference them.
// A column that is still INT overflows on the first snowflake write and, under
// Kafka persistence, stalls the consumer partition instead of failing fast.
func (a *adapter) TenantMessageSchemaValidate(tenantIDs []t.TenantID) error {
if len(tenantIDs) == 0 {
return errors.New("mysql: no active tenants available for message schema validation")
}
// Message ids are explicit snowflake values stored in the auto-increment
// id column; sequence counters and file ids are snowflake-derived too.
var (
bigintPK = schemaColumnContract{dataType: "bigint", notNull: true, autoInc: true}
bigintReq = schemaColumnContract{dataType: "bigint", notNull: true}
bigintOpt = schemaColumnContract{dataType: "bigint"}
)
type scopedCheck struct {
table string
column string
want schemaColumnContract
}
perTenant := func(tenantID t.TenantID, scopedDB bool) []scopedCheck {
msgTable, topicsTable, subsTable := tenantMessagesTable(tenantID), tenantTopicsTable(tenantID), tenantSubscriptionsTable(tenantID)
if scopedDB {
msgTable, topicsTable, subsTable = "im_messages", "im_topics", "im_subscriptions"
}
return []scopedCheck{
{msgTable, "id", bigintPK},
{topicsTable, "seqid", bigintReq},
{subsTable, "recvseqid", bigintOpt},
{subsTable, "readseqid", bigintOpt},
// Global file tables exist once per database and are shared by all tenants.
{"im_filemsglinks", "fileid", bigintReq},
{"im_filemsglinks", "msgid", bigintOpt},
{"im_fileuploads", "id", bigintReq},
}
}
validated := make(map[*adapter]map[string]struct{})
run := func(tenantAdp *adapter, chk scopedCheck) error {
key := chk.table + "." + chk.column
if _, ok := validated[tenantAdp][key]; ok {
return nil
}
if err := tenantAdp.validateColumnContract(chk.table, chk.column, chk.want); err != nil {
return err
}
if validated[tenantAdp] == nil {
validated[tenantAdp] = make(map[string]struct{})
}
validated[tenantAdp][key] = struct{}{}
return nil
}
for _, tenantID := range tenantIDs {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return fmt.Errorf("mysql: resolve tenant %d for message schema validation: %w", tenantID, err)
}
table := tenantMessagesTable(tenantID)
if tenantAdp.tenantScopedDB {
table = "im_messages"
for _, chk := range perTenant(tenantID, tenantAdp.tenantScopedDB) {
if err = run(tenantAdp, chk); err != nil {
return err
}
}
if validated[tenantAdp] == nil {
validated[tenantAdp] = make(map[string]struct{})
}
if _, ok := validated[tenantAdp][table]; ok {
continue
}
if err = tenantAdp.validateMessageIDColumn(table); err != nil {
return err
}
validated[tenantAdp][table] = struct{}{}
}
if !a.tenantScopedDB {
if err := a.validateMessageIDColumn("im_messages"); err != nil {
return err
for _, chk := range perTenant(0, true) {
if err := run(a, chk); err != nil {
return err
}
}
}
return nil
}
func (a *adapter) validateMessageIDColumn(table string) error {
if !regexp.MustCompile(`^im_(?:[0-9]+_)?messages$`).MatchString(table) {
return fmt.Errorf("mysql: unsafe message table name %q", table)
// validateColumnContract fails when the live column shape does not match the
// contract. The caller turns the error into a fatal startup failure so a
// drifted schema is caught before the first write instead of as a stuck
// persistence partition.
func (a *adapter) validateColumnContract(table, column string, want schemaColumnContract) error {
if !regexp.MustCompile(`^im_(?:[0-9]{3}_)?[a-z_]+$`).MatchString(table) {
return fmt.Errorf("mysql: unsafe table name %q for schema validation", table)
}
var dataType, nullable, extra string
err := a.db.QueryRow(`SELECT DATA_TYPE,IS_NULLABLE,EXTRA FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME='id'`, table).
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?`, table, column).
Scan(&dataType, &nullable, &extra)
if err == sql.ErrNoRows {
return fmt.Errorf("mysql: required message table %s is missing", table)
return fmt.Errorf("mysql: required table %s is missing", table)
}
if err != nil {
return fmt.Errorf("mysql: inspect %s.id: %w", table, err)
return fmt.Errorf("mysql: inspect %s.%s: %w", table, column, err)
}
if !strings.EqualFold(dataType, "bigint") || !strings.EqualFold(nullable, "NO") ||
!strings.Contains(strings.ToLower(extra), "auto_increment") {
return fmt.Errorf("mysql: incompatible %s.id schema: type=%s nullable=%s extra=%s; want BIGINT NOT NULL AUTO_INCREMENT",
table, dataType, nullable, extra)
if !strings.EqualFold(dataType, want.dataType) || want.notNull != strings.EqualFold(nullable, "NO") ||
(want.autoInc && !strings.Contains(strings.ToLower(extra), "auto_increment")) {
return fmt.Errorf("mysql: incompatible %s.%s schema: type=%s nullable=%s extra=%s; want %s "+
"(apply scripts/IM-租户业务库初始化.sql equivalents or the matching BIGINT migration before starting)",
table, column, dataType, nullable, extra, want.describe())
}
return nil
}
@@ -5522,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),
@@ -6887,7 +6961,7 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
eventType := "friend.request." + t.FriendRequestAccepted
applicantEventID := t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted, eventType, applicant.UserId())
if err = insertSystemNotificationTx(tx, id, applicant, t.StableEventID(applicantEventID, "notification"), eventType,
"好友申请已处理", t.FriendRequestAccepted, map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
"好友申请已处理", "对方已同意你的好友申请", map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
@@ -6939,8 +7013,12 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac
}
eventType := "friend.request." + status
eventID := t.StableEventID("relation", req.RequestID+":"+status, eventType, req.ApplicantID.UserId())
summary := "对方已婉拒你的好友申请"
if status == t.FriendRequestAccepted {
summary = "对方已同意你的好友申请"
}
if err = insertSystemNotificationTx(tx, id, req.ApplicantID, t.StableEventID(eventID, "notification"), eventType,
"好友申请已处理", status, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
"好友申请已处理", summary, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
+58 -8
View File
@@ -394,6 +394,50 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
return items, next, rows.Err()
}
// TenantGroupMembersCount counts active members for the given topics using the
// same visibility rules as TenantGroupMemberList: membership active, subscription
// not deleted, user not deleted. Returns counts keyed by topic; topics with no
// active members are absent from the map.
func (a *adapter) TenantGroupMembersCount(id t.TenantID, topics []string) (map[string]int64, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupMembersCount(id, topics)
}
if len(topics) == 0 {
return map[string]int64{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(topics)), ",")
args := make([]any, 0, len(topics)+3)
args = append(args, t.StateDeleted, id, t.GroupMemberStatusActive)
for _, topic := range topics {
args = append(args, topic)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryContext(ctx, tenantShardSQL(id, `SELECT gm.topic, COUNT(DISTINCT gm.user_id) FROM im_group_members AS gm
JOIN im_subscriptions AS s ON s.tenant_id=gm.tenant_id AND s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
JOIN im_users AS u ON u.tenant_id=gm.tenant_id AND u.id=gm.user_id AND u.state!=?
WHERE gm.tenant_id=? AND gm.member_status=? AND gm.topic IN (`+placeholders+`)
GROUP BY gm.topic`), args...)
if err != nil {
return nil, err
}
defer rows.Close()
counts := make(map[string]int64, len(topics))
for rows.Next() {
var topic string
var count int64
if err = rows.Scan(&topic, &count); err != nil {
return nil, err
}
counts[topic] = count
}
return counts, rows.Err()
}
func (a *adapter) TenantUsersShareOpenPrivateGroup(id t.TenantID, userID, peerID t.Uid) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
@@ -1511,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")
}
}
+52 -2
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
@@ -239,6 +239,9 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
}
msgID := req.FormValue("id")
// When the uploader passes a conversation topic, the file is linked to it so other
// members of that conversation can download it (see TenantFileUserHasAccess).
uploadTopic := strings.TrimSpace(req.FormValue("topic"))
// Check authorization: either auth information or SID must be present
authMethod, secret := getHttpAuth(req)
principal, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
@@ -250,11 +253,17 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
writeHttpResponse(InfoChallenge(msgID, now, challenge), nil)
return
}
if principal.UID.IsZero() && req.FormValue("topic") != "newacc" {
if principal.UID.IsZero() && uploadTopic != "newacc" {
// Not authenticated and not signup.
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
return
}
if !principal.UID.IsZero() && uploadTopic != "" {
if err = authorizeUploadTopicLink(principal.TenantID, principal.UID, uploadTopic); err != nil {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
}
}
// Check if uploads are handled elsewhere.
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
if err != nil {
@@ -361,6 +370,13 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
logs.Warn.Printf("media upload: failed to enqueue file lifecycle event file=%s: %v", fdef.Id, err)
}
if uploadTopic != "" {
if err = store.Files.LinkAttachments(principal.TenantID, uploadTopic, types.ZeroUid, []string{fdef.Uid().String()}); err != nil {
// The file itself is valid; linking is best-effort so do not fail the upload.
logs.Warn.Printf("media upload: failed to link file %s to topic %s: %v", fdef.Id, uploadTopic, err)
}
}
params := map[string]any{"url": url, "file_id": fdef.Uid().String()}
if globals.mediaGcPeriod > 0 {
// How long this file is guaranteed to exist without being attached to a message or a topic.
@@ -484,6 +500,40 @@ func authorizeLargeFileRequestURL(tenantID types.TenantID, uid types.Uid, rawURL
return authorizeLargeFileDownload(tenantID, uid, rawURL, fd)
}
// authorizeUploadTopicLink verifies the uploader is a reading member of the conversation
// topic, so the uploaded file may be linked to it and downloaded by other members.
func authorizeUploadTopicLink(tenantID types.TenantID, uid types.Uid, topic string) error {
switch types.GetTopicCat(topic) {
case types.TopicCatP2P, types.TopicCatGrp:
default:
return types.ErrPermissionDenied
}
if store.Store == nil || !store.Store.IsOpen() {
return types.ErrUnsupported
}
sub, err := store.Subs.Get(tenantID, topic, uid, false)
if err != nil || sub == nil || sub.DeletedAt != nil {
return types.ErrPermissionDenied
}
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)
+20 -2
View File
@@ -105,7 +105,9 @@ func (sess *Session) readLoop() {
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
statsInc("IncomingMessagesWebsockTotal", 1)
dispatchStarted := time.Now()
sess.dispatchRaw(raw)
immetrics.ObserveWSReadDispatch(dispatchStarted)
}
}
@@ -160,6 +162,11 @@ func (sess *Session) writeLoop() {
if isAcceptedCtrl(v) {
immetrics.ObserveCtrlACKDelivery("write", v.Timestamp)
}
case sharedServerMessage:
statsAddHistSample("OutgoingMessageSize", float64(len(v)))
if !sess.sendMessage([]byte(v)) {
return
}
default: // serialized message
if !sess.sendMessage(v) {
return
@@ -207,11 +214,22 @@ func (sess *Session) writeWSFrame(mt int, msg any) error {
}
reason := classifyWSError(err)
immetrics.ObserveWSWrite(frame, "error", reason, started, payloadBytes)
logs.Err.Printf("ws: writeLoop sid=%s uid=%s frame=%s queue=%d capacity=%d reason=%s err=%v",
sess.sid, sess.uid.UserId(), frame, len(sess.send), cap(sess.send), reason, err)
if shouldLogWSWriteError(reason) {
logs.Err.Printf("ws: writeLoop sid=%s uid=%s frame=%s queue=%d capacity=%d reason=%s err=%v",
sess.sid, sess.uid.UserId(), frame, len(sess.send), cap(sess.send), reason, err)
}
return err
}
func shouldLogWSWriteError(reason string) bool {
switch reason {
case "close_normal", "close_going_away", "closed", "eof", "broken_pipe", "connection_reset":
return false
default:
return true
}
}
func classifyWSError(err error) string {
if err == nil {
return "none"
+25
View File
@@ -37,6 +37,31 @@ func TestClassifyWSError(t *testing.T) {
}
}
func TestShouldLogWSWriteError(t *testing.T) {
tests := []struct {
reason string
want bool
}{
{reason: "close_normal", want: false},
{reason: "close_going_away", want: false},
{reason: "closed", want: false},
{reason: "eof", want: false},
{reason: "broken_pipe", want: false},
{reason: "connection_reset", want: false},
{reason: "timeout", want: true},
{reason: "close_abnormal", want: true},
{reason: "other", want: true},
}
for _, tc := range tests {
t.Run(tc.reason, func(t *testing.T) {
if got := shouldLogWSWriteError(tc.reason); got != tc.want {
t.Fatalf("shouldLogWSWriteError(%q)=%v want %v", tc.reason, got, tc.want)
}
})
}
}
func TestWebSocketOutboundQueueTiming(t *testing.T) {
sess := &Session{proto: WEBSOCK, send: make(chan any, 2)}
payload := []byte("message")
+42 -2
View File
@@ -10,6 +10,8 @@
package main
import (
"os"
"strconv"
"strings"
"sync"
"time"
@@ -30,6 +32,44 @@ var requestLatencyDistribution = []float64{1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20,
var outgoingMessageSizeDistribution = []float64{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 16384,
65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}
const (
defaultTopicClientQueue = 4096
defaultTopicServerQueue = 512
defaultTopicPublishWait = 200 * time.Millisecond
topicClientQueueEnv = "TOPIC_CLIENT_QUEUE_BUFFER"
topicServerQueueEnv = "TOPIC_SERVER_QUEUE_BUFFER"
topicPublishEnqueueWaitEnv = "TOPIC_PUBLISH_ENQUEUE_WAIT_MS"
)
var topicQueueConfig = loadTopicQueueConfig()
type topicQueueRuntimeConfig struct {
clientBuffer int
serverBuffer int
publishEnqueueWait time.Duration
}
func loadTopicQueueConfig() topicQueueRuntimeConfig {
return topicQueueRuntimeConfig{
clientBuffer: intEnvOrDefault(topicClientQueueEnv, defaultTopicClientQueue),
serverBuffer: intEnvOrDefault(topicServerQueueEnv, defaultTopicServerQueue),
publishEnqueueWait: time.Duration(intEnvOrDefault(topicPublishEnqueueWaitEnv, int(defaultTopicPublishWait/time.Millisecond))) * time.Millisecond,
}
}
func intEnvOrDefault(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
logs.Warn.Printf("invalid %s=%q, using %d", name, raw, fallback)
return fallback
}
return parsed
}
// Request to hub to remove the topic
type topicUnreg struct {
// Tenant which owns the topic.
@@ -261,8 +301,8 @@ func (h *Hub) run() {
// Indicates a proxy topic.
isProxy: globals.cluster.isRemoteTopic(key),
sessions: make(map[*Session]perSessionData),
clientMsg: make(chan *ClientComMessage, 192),
serverMsg: make(chan *ServerComMessage, 64),
clientMsg: make(chan *ClientComMessage, topicQueueConfig.clientBuffer),
serverMsg: make(chan *ServerComMessage, topicQueueConfig.serverBuffer),
reg: make(chan *ClientComMessage, 256),
unreg: make(chan *ClientComMessage, 256),
meta: make(chan *ClientComMessage, 64),
+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
+78 -18
View File
@@ -1494,8 +1494,12 @@ func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) {
if imReliableHTTPEventsEnabled(req) {
eventType := "security.risk." + decision
eventID := stableIMEventID("risk", challengeID, decision, challenge.State)
summary := "你已取消本次操作"
if decision == "continue" {
summary = "你已确认继续执行该操作"
}
if err = createIMSystemNotification(principal.TenantID, principal.Uid,
stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision,
stableIMEventID(eventID, "notification"), eventType, "安全操作确认", summary,
types.KVMap{"risk_challenge_id": challengeID}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
@@ -2283,10 +2287,18 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
}
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
appCode := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("app_code")))
currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version"))
if channel == "" {
channel = "stable"
}
if appCode == "" {
appCode = "im"
}
if !isValidAppCode(appCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "app_code"})
return
}
if platform != "ios" && platform != "android" && platform != "web" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
return
@@ -2296,7 +2308,7 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "current_version"})
return
}
release, err := store.AppReleases.Latest(platform, channel)
release, err := store.AppReleases.Latest(appCode, platform, channel)
if err != nil {
if platform == "web" {
logs.Warn.Println("im app release latest: using web fallback", err)
@@ -4205,6 +4217,25 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
items = append(items, item)
seen++
}
if len(items) > 0 {
listTopics := make([]string, 0, len(items))
for _, item := range items {
if topic, ok := item["topic"].(string); ok && topic != "" {
listTopics = append(listTopics, topic)
}
}
if counts, err := store.GroupBusiness.MembersCount(principal.TenantID, listTopics); err == nil {
for _, item := range items {
if topic, ok := item["topic"].(string); ok {
if count, ok := counts[topic]; ok {
item["member_count"] = count
}
}
}
} else {
logs.Warn.Printf("serveIMGroupList: failed to count members for %d topics: %v", len(listTopics), err)
}
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
}
@@ -4298,7 +4329,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub))
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub, groupMemberCountFor(principal.TenantID, topicName)))
}
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
@@ -4595,7 +4626,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
payload := groupDetailPayload(group, selfSub)
payload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
if settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
@@ -4691,7 +4722,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupDetailPayload(updated, selfSub)
payload := groupDetailPayload(updated, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settingsUpdated {
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
@@ -4757,7 +4788,7 @@ func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic stri
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupSettingsPayload(group, selfSub)
payload := groupSettingsPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
@@ -4861,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
@@ -4872,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)})
}
@@ -4934,7 +4965,7 @@ func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic strin
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
groupPayload := groupDetailPayload(group, selfSub)
groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
writeIMSuccess(wrt, req, map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
@@ -6076,7 +6107,7 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) {
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
status = "joined"
}
groupPayload := groupDetailPayload(group, selfSub)
groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, claims.Topic))
writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
@@ -6410,11 +6441,13 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status
action := types.KVMap{}
summary := "对方已婉拒你的好友申请"
if status == types.FriendRequestAccepted {
action["topic"] = updated.ApplicantID.P2PName(updated.TargetID)
summary = "对方已同意你的好友申请"
}
if err = publishIMRelationEventWithAction(principal.TenantID, principal.Uid, updated.ApplicantID,
updated.RequestID+":"+status, eventType, "好友申请已处理", status, action); err != nil {
updated.RequestID+":"+status, eventType, "好友申请已处理", summary, action); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
@@ -7406,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 {
@@ -7491,15 +7526,27 @@ func mergeGroupListSettingsPayload(payload map[string]any, settings *types.Group
return payload
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
// groupMemberCountFor returns the live member count for a single group topic,
// mirroring the visibility rules of the group members list endpoint.
func groupMemberCountFor(tenantID types.TenantID, topic string) int64 {
if topic == "" {
return 0
}
counts, err := store.GroupBusiness.MembersCount(tenantID, []string{topic})
if err != nil {
logs.Warn.Printf("group[%s]: failed to count members: %v", topic, err)
return 0
}
return counts[topic]
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any {
public := map[string]any{}
topic := ""
memberCount := 0
ownerID := ""
if group != nil {
public = groupPublicMap(group.Public)
topic = group.Id
memberCount = group.SubCnt
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
ownerID = owner.UserId()
}
@@ -7528,8 +7575,8 @@ func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[str
}
}
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
data := groupDetailPayload(group, selfSub)
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any {
data := groupDetailPayload(group, selfSub, memberCount)
private := map[string]any{}
if selfSub != nil {
private = groupPrivateMap(selfSub.Private)
@@ -7598,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
@@ -8776,6 +8823,19 @@ func appReleasePayload(release *types.AppRelease, currentVersion string, hasUpda
}
}
func isValidAppCode(raw string) bool {
if raw == "" || len(raw) > 32 {
return false
}
for _, r := range raw {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
continue
}
return false
}
return true
}
func parseNumericAppVersion(raw string) ([]uint64, error) {
raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v")
parts := strings.Split(raw, ".")
+20 -2
View File
@@ -41,7 +41,8 @@ type testVerificationStore struct {
}
type testHTTPInfrastructureStore struct {
deadLetters []types.KafkaDeadLetter
deadLetters []types.KafkaDeadLetter
markSucceededErr error
}
type testTenantEntryStore struct {
@@ -110,6 +111,9 @@ func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record
}
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeSucceeded(_ types.TenantID, _, _ string, _ time.Time) error {
if s.markSucceededErr != nil {
return s.markSucceededErr
}
return nil
}
@@ -143,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
@@ -269,6 +274,7 @@ type testGroupBusinessStore struct {
listMembers []types.GroupMember
listMembersOpts types.GroupMemberListOptions
listMembersNext string
membersCounts map[string]int64
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode
@@ -350,6 +356,13 @@ func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts ty
return s.listMembers, s.listMembersNext, nil
}
func (s *testGroupBusinessStore) MembersCount(_ types.TenantID, _ []string) (map[string]int64, error) {
if s.membersCounts == nil {
return map[string]int64{}, nil
}
return s.membersCounts, nil
}
func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
if s.updateMemberErr != nil {
return nil, s.updateMemberErr
@@ -726,7 +739,7 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
return privacy, nil
}
func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) {
func (s *testAppReleaseStore) Latest(_, platform, channel string) (*types.AppRelease, error) {
if s.err != nil {
return nil, s.err
}
@@ -952,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
}
+36
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
@@ -20,6 +21,9 @@ const (
imYouthActionMessage = "message"
imYouthActionCall = "call"
imSecurityPolicyCacheTTL = 5 * time.Second
imSecurityPolicyErrorCacheTTL = 2 * time.Second
)
type imPolicyDecision struct {
@@ -29,6 +33,17 @@ type imPolicyDecision struct {
Details map[string]any
}
type imSecurityPolicyCacheEntry struct {
policy *types.SecurityPolicy
err error
expires time.Time
}
var imSecurityPolicyCache = struct {
sync.Mutex
entries map[string]imSecurityPolicyCacheEntry
}{entries: make(map[string]imSecurityPolicyCacheEntry)}
func imAllowDecision() imPolicyDecision {
return imPolicyDecision{Allowed: true}
}
@@ -119,7 +134,28 @@ func imSecurityPolicy(tenantID types.TenantID, tenantCode string) (*types.Securi
if tenantCode == "" {
return nil, types.ErrNotFound
}
key := fmt.Sprintf("%d:%s", tenantID, tenantCode)
now := time.Now()
imSecurityPolicyCache.Lock()
if cached, ok := imSecurityPolicyCache.entries[key]; ok && now.Before(cached.expires) {
imSecurityPolicyCache.Unlock()
return cached.policy, cached.err
}
if store.ModerationConfig == nil {
imSecurityPolicyCache.Unlock()
return nil, types.ErrUnsupported
}
policy, err := store.ModerationConfig.SecurityPolicy(tenantCode)
ttl := imSecurityPolicyCacheTTL
if err != nil {
ttl = imSecurityPolicyErrorCacheTTL
}
imSecurityPolicyCache.entries[key] = imSecurityPolicyCacheEntry{
policy: policy,
err: err,
expires: now.Add(ttl),
}
imSecurityPolicyCache.Unlock()
if err != nil {
return nil, err
}
+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()
+9 -2
View File
@@ -93,6 +93,12 @@ func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) {
func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var uidGenerator types.UidGenerator
if err := uidGenerator.Init(1, []byte("0123456789abcdef")); err != nil {
t.Fatalf("initialize UID generator: %v", err)
}
store.SetTestUidGenerator(uidGenerator)
defer store.SetTestUidGenerator(types.UidGenerator{})
oldKafkaBus := kafkaBus
oldFiles := store.Files
@@ -104,7 +110,8 @@ func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
kafkaBus = &kafkaRuntime{}
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
msgID := types.Uid(101)
databaseMsgID := types.Uid(101)
msgID := store.EncodeUid(int64(databaseMsgID))
msg := &types.Message{
ObjHeader: types.ObjHeader{Id: msgID.String(), CreatedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)},
TenantID: 7,
@@ -114,7 +121,7 @@ func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
Head: types.KVMap{"x-im-type": "image"},
}
attachments := []string{"https://api.118im.com/v1/im/files/file_001"}
files.EXPECT().LinkAttachments(types.TenantID(7), "", msgID, attachments).Return(nil)
files.EXPECT().LinkAttachments(types.TenantID(7), "", databaseMsgID, attachments).Return(nil)
if err := enqueueKafkaFileAttachmentsBind(context.Background(), 7, msg, attachments); err != nil {
t.Fatalf("enqueueKafkaFileAttachmentsBind returned error: %v", err)
+8 -1
View File
@@ -128,7 +128,14 @@ func executeKafkaFileAttachmentsBind(tenantID types.TenantID, payload kafkaFileL
return types.ErrMalformed
}
if types.GetTopicCat(payload.Topic) != types.TopicCatGrp {
return store.Files.LinkAttachments(tenantID, "", msgID, payload.AttachmentURLs)
// Kafka persistence writes the decoded UID as the SQL message primary key,
// while the lifecycle payload carries the externally encoded UID. The
// file-link adapter expects the raw SQL ID, so decode it before binding.
databaseMsgID := types.Uid(store.DecodeUid(msgID))
if databaseMsgID.IsZero() {
return types.ErrMalformed
}
return store.Files.LinkAttachments(tenantID, "", databaseMsgID, payload.AttachmentURLs)
}
uploader := types.ParseUserId(payload.UserID)
if uploader.IsZero() {
+53 -8
View File
@@ -159,13 +159,29 @@ type kafkaMessagePersistItem struct {
envelope kafkaEnvelope
tenantID types.TenantID
payload kafkaMessagePersistPayload
consume *types.KafkaConsumeRecord
}
type kafkaMessagePersistPreparer func(context.Context, kafka.Message, string) (*kafkaMessagePersistItem, bool, error)
type kafkaMessagePersistGroupsWriter func(context.Context, map[string][]*kafkaMessagePersistItem) error
func handleKafkaMessagePersistBatch(ctx context.Context, messages []kafka.Message, sourceTopicName string) error {
return processKafkaMessagePersistBatch(ctx, messages, sourceTopicName,
prepareKafkaMessagePersistItem, persistKafkaMessagePersistGroups)
}
func processKafkaMessagePersistBatch(ctx context.Context, messages []kafka.Message, sourceTopicName string,
prepare kafkaMessagePersistPreparer, persist kafkaMessagePersistGroupsWriter) error {
groups := make(map[string][]*kafkaMessagePersistItem)
for _, message := range messages {
item, terminal, err := prepareKafkaMessagePersistItem(ctx, message, sourceTopicName)
item, terminal, err := prepare(ctx, message, sourceTopicName)
if err != nil {
// Preparing an item acquires its idempotency claim. If a later item
// fails, finish all earlier claimed items before retrying the batch;
// otherwise retries can keep colliding with those processing claims.
if flushErr := persist(ctx, groups); flushErr != nil {
return fmt.Errorf("persist claimed Kafka messages after prepare failure: %w (prepare error: %v)", flushErr, err)
}
return err
}
if terminal {
@@ -177,7 +193,14 @@ func handleKafkaMessagePersistBatch(ctx context.Context, messages []kafka.Messag
}
groups[key] = append(groups[key], item)
}
return persist(ctx, groups)
}
func persistKafkaMessagePersistGroups(ctx context.Context, groups map[string][]*kafkaMessagePersistItem) error {
for _, items := range groups {
if len(items) == 0 {
continue
}
kind := items[0].payload.MessageKind
if kind == "" {
kind = "p2p"
@@ -232,9 +255,12 @@ func prepareKafkaMessagePersistItem(ctx context.Context, message kafka.Message,
return nil, false, err
}
if !claimed {
if consume != nil && consume.Status != types.KafkaConsumeSucceeded && consume.Status != types.KafkaConsumeDead {
return nil, false, fmt.Errorf("%w: event=%s status=%s", errKafkaConsumeInProgress, envelope.EventID, consume.Status)
}
return nil, true, nil
}
item := &kafkaMessagePersistItem{envelope: envelope, tenantID: tenantID}
item := &kafkaMessagePersistItem{envelope: envelope, tenantID: tenantID, consume: consume}
if err = json.Unmarshal(envelope.Payload, &item.payload); err != nil {
return nil, true, markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
@@ -283,12 +309,23 @@ func persistKafkaMessageIndividually(ctx context.Context, item *kafkaMessagePers
}
err := runKafkaMessageSingleRetries(ctx, kafkaBus.config.MessageRetries, operation, markFailed, waitKafkaRetry)
if err != nil {
// Message persistence failures are recoverable infrastructure failures. Returning
// the error keeps the Kafka offset uncommitted so the outer consumer retries the
// message until it is durable. Only malformed payloads are dead-lettered while
// preparing the item.
immetrics.KafkaMessagePersistTotal.WithLabelValues(kind, "retry_exhausted").Inc()
immetrics.MessagePersistDuration.WithLabelValues(kind, "retry_exhausted").Observe(time.Since(started).Seconds())
logs.Err.Printf("kafka message persist retry exhausted: tenant=%d event=%s topic=%s seq=%d kind=%s err=%v",
item.tenantID, item.envelope.EventID, item.payload.Message.Topic, item.payload.Message.SeqId, kind, err)
// Dead-letter the message instead of leaving the offset uncommitted: an
// unresolvable failure (e.g. an attachment file already reclaimed by GC)
// would otherwise be retried forever and stall every message behind it
// on the partition. The dead letter keeps the envelope for replay once
// the underlying issue is fixed.
if deadErr := markKafkaDeadLetter(ctx, item.tenantID, item.envelope, item.consume, "PERSIST_RETRY_EXHAUSTED", err.Error()); deadErr != nil {
logs.Err.Printf("kafka message persist dead letter failed, keeping offset uncommitted: tenant=%d event=%s topic=%s seq=%d err=%v",
item.tenantID, item.envelope.EventID, item.payload.Message.Topic, item.payload.Message.SeqId, deadErr)
return err
}
logs.Warn.Printf("kafka message persist dead-lettered after retries: tenant=%d event=%s topic=%s seq=%d kind=%s err=%v",
item.tenantID, item.envelope.EventID, item.payload.Message.Topic, item.payload.Message.SeqId, kind, err)
return nil
}
return err
}
@@ -319,8 +356,16 @@ func finishKafkaMessagePersist(ctx context.Context, item *kafkaMessagePersistIte
return fmt.Errorf("file lifecycle enqueue failed: %w", err)
}
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(item.tenantID, item.envelope.EventID,
kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
if err := store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(item.tenantID, item.envelope.EventID,
kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)); err != nil {
if errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("kafka message persist consume state missing after durable write: tenant=%d event=%s topic=%s seq=%d",
item.tenantID, item.envelope.EventID, item.payload.Message.Topic, item.payload.Message.SeqId)
return nil
}
return err
}
return nil
}
func kafkaMessagePersistFailureReason(err error) string {
+20 -8
View File
@@ -301,6 +301,7 @@ type kafkaMessageCommitter func(context.Context, kafka.Message) error
type kafkaRetryWaiter func(context.Context, time.Duration) error
type kafkaBatchHandler func(context.Context, []kafka.Message) error
type kafkaBatchCommitter func(context.Context, ...kafka.Message) error
type kafkaMessageFetcher func(context.Context) (kafka.Message, error)
func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
startKafkaConsumerWithGroup(topicName, "", handler)
@@ -389,19 +390,23 @@ func startKafkaBatchConsumer(topicName string, handler kafkaBatchHandler) {
defer kafkaBus.wg.Done()
defer reader.Close()
for {
messages, err := fetchKafkaMessageBatch(kafkaBus.ctx, reader, kafkaBus.config.MessageBatchSize,
messages, fetchErr := fetchKafkaMessageBatch(kafkaBus.ctx, reader, kafkaBus.config.MessageBatchSize,
time.Duration(kafkaBus.config.MessageBatchWait)*time.Millisecond)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
if fetchErr != nil && len(messages) == 0 {
if errors.Is(fetchErr, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return
}
logs.Err.Println("Kafka batch fetch failed:", err)
logs.Err.Println("Kafka batch fetch failed:", fetchErr)
continue
}
if fetchErr != nil {
logs.Warn.Printf("Kafka batch fetch interrupted; processing retained messages: topic=%s size=%d err=%v",
topic, len(messages), fetchErr)
}
if len(messages) > 0 {
readerMetrics.observe(messages[0].Time)
}
if err = consumeKafkaMessageBatch(kafkaBus.ctx, messages, handler, reader.CommitMessages, waitKafkaRetry); err != nil {
if err := consumeKafkaMessageBatch(kafkaBus.ctx, messages, handler, reader.CommitMessages, waitKafkaRetry); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return
}
@@ -468,7 +473,11 @@ func (m *kafkaReaderMetrics) update() {
}
func fetchKafkaMessageBatch(ctx context.Context, reader *kafka.Reader, maxSize int, maxWait time.Duration) ([]kafka.Message, error) {
first, err := reader.FetchMessage(ctx)
return fetchKafkaMessageBatchWith(ctx, maxSize, maxWait, reader.FetchMessage)
}
func fetchKafkaMessageBatchWith(ctx context.Context, maxSize int, maxWait time.Duration, fetch kafkaMessageFetcher) ([]kafka.Message, error) {
first, err := fetch(ctx)
if err != nil {
return nil, err
}
@@ -480,13 +489,16 @@ func fetchKafkaMessageBatch(ctx context.Context, reader *kafka.Reader, maxSize i
break
}
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
message, fetchErr := reader.FetchMessage(fetchCtx)
message, fetchErr := fetch(fetchCtx)
cancel()
if errors.Is(fetchErr, context.DeadlineExceeded) && ctx.Err() == nil {
break
}
if fetchErr != nil {
return nil, fetchErr
// FetchMessage advances the reader's internal position before offsets are
// committed. Preserve a partial batch so a later higher-offset commit cannot
// skip messages already fetched into this batch.
return messages, fetchErr
}
messages = append(messages, message)
}
+125
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -75,6 +76,103 @@ func TestConsumeKafkaMessageBatchDoesNotCommitWhileHandlerFails(t *testing.T) {
}
}
func TestFetchKafkaMessageBatchWithRetainsPartialBatchOnFetchError(t *testing.T) {
fetchCalls := 0
fetchErr := errors.New("broker connection reset")
messages, err := fetchKafkaMessageBatchWith(context.Background(), 10, time.Second,
func(context.Context) (kafka.Message, error) {
fetchCalls++
if fetchCalls == 1 {
return kafka.Message{Partition: 2, Offset: 41}, nil
}
return kafka.Message{}, fetchErr
})
if !errors.Is(err, fetchErr) {
t.Fatalf("fetchKafkaMessageBatchWith() error = %v, want %v", err, fetchErr)
}
if len(messages) != 1 || messages[0].Partition != 2 || messages[0].Offset != 41 {
t.Fatalf("fetchKafkaMessageBatchWith() messages = %#v, want retained offset 41", messages)
}
}
func TestFetchKafkaMessageBatchWithReturnsInitialFetchErrorWithoutMessages(t *testing.T) {
fetchErr := errors.New("broker unavailable")
messages, err := fetchKafkaMessageBatchWith(context.Background(), 10, time.Second,
func(context.Context) (kafka.Message, error) {
return kafka.Message{}, fetchErr
})
if !errors.Is(err, fetchErr) {
t.Fatalf("fetchKafkaMessageBatchWith() error = %v, want %v", err, fetchErr)
}
if len(messages) != 0 {
t.Fatalf("fetchKafkaMessageBatchWith() messages = %#v, want none", messages)
}
}
func TestProcessKafkaMessagePersistBatchFlushesClaimsBeforePrepareRetry(t *testing.T) {
prepareErr := errors.New("redis temporarily unavailable")
prepareCalls := 0
persistCalls := 0
err := processKafkaMessagePersistBatch(context.Background(), []kafka.Message{{Offset: 10}, {Offset: 11}}, "group_message_persist",
func(_ context.Context, message kafka.Message, topic string) (*kafkaMessagePersistItem, bool, error) {
prepareCalls++
if topic != "group_message_persist" {
t.Fatalf("prepare topic = %q", topic)
}
if message.Offset == 11 {
return nil, false, prepareErr
}
return &kafkaMessagePersistItem{
tenantID: types.TenantID(7),
payload: kafkaMessagePersistPayload{
MessageKind: "group",
ShardMonth: "202610",
Message: types.Message{ShardMonth: "202610"},
},
}, false, nil
},
func(_ context.Context, groups map[string][]*kafkaMessagePersistItem) error {
persistCalls++
items := groups["7:202610"]
if len(items) != 1 {
t.Fatalf("persisted groups = %#v, want one claimed item", groups)
}
return nil
})
if !errors.Is(err, prepareErr) {
t.Fatalf("processKafkaMessagePersistBatch() error = %v, want %v", err, prepareErr)
}
if prepareCalls != 2 || persistCalls != 1 {
t.Fatalf("calls prepare=%d persist=%d, want 2/1", prepareCalls, persistCalls)
}
}
func TestProcessKafkaMessagePersistBatchReportsFlushAndPrepareErrors(t *testing.T) {
prepareErr := errors.New("claim failed")
persistErr := errors.New("database unavailable")
prepareCalls := 0
err := processKafkaMessagePersistBatch(context.Background(), []kafka.Message{{Offset: 10}, {Offset: 11}}, "message_persist",
func(_ context.Context, _ kafka.Message, _ string) (*kafkaMessagePersistItem, bool, error) {
prepareCalls++
if prepareCalls == 2 {
return nil, false, prepareErr
}
return &kafkaMessagePersistItem{tenantID: types.TenantID(7)}, false, nil
},
func(context.Context, map[string][]*kafkaMessagePersistItem) error {
return persistErr
})
if !errors.Is(err, persistErr) || !strings.Contains(err.Error(), prepareErr.Error()) {
t.Fatalf("processKafkaMessagePersistBatch() error = %v, want both flush and prepare errors", err)
}
}
func TestConsumeKafkaMalformedMessageRetriesCommit(t *testing.T) {
commitCalls := 0
waitCalls := 0
@@ -156,3 +254,30 @@ func TestRunKafkaMessageSingleRetriesStopsOnSuccess(t *testing.T) {
t.Fatalf("calls operation=%d failed=%d, want 3/2", operationCalls, markFailedCalls)
}
}
func TestFinishKafkaMessagePersistTreatsMissingConsumeStateAsSucceeded(t *testing.T) {
oldInfrastructure := store.HTTPInfrastructure
oldKafkaBus := kafkaBus
defer func() {
store.HTTPInfrastructure = oldInfrastructure
kafkaBus = oldKafkaBus
}()
store.HTTPInfrastructure = &testHTTPInfrastructureStore{markSucceededErr: types.ErrNotFound}
kafkaBus = &kafkaRuntime{config: kafkaConfig{ConsumerGroup: "test-consumer"}}
err := finishKafkaMessagePersist(context.Background(), &kafkaMessagePersistItem{
envelope: kafkaEnvelope{EventID: "msg.persist.8.p2pA.1"},
tenantID: types.TenantID(8),
payload: kafkaMessagePersistPayload{
MessageKind: "p2p",
Message: types.Message{
Topic: "p2pA",
SeqId: 1,
},
},
})
if err != nil {
t.Fatalf("finishKafkaMessagePersist() error = %v, want nil", err)
}
}
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title></title>
<meta name="Generator" content="Cocoa HTML Writer">
<meta name="CocoaVersion" content="2575.7">
<style type="text/css">
p.p1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Times; -webkit-text-stroke: #000000}
p.p2 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Times; -webkit-text-stroke: #000000; min-height: 14.0px}
span.s1 {font-kerning: none}
</style>
</head>
<body>
<p class="p1"><span class="s1">禾圃集隐私政策</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">更新日期:2026年10月10日</span></p>
<p class="p1"><span class="s1">生效日期:2026年10月10日</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">引言</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">禾圃集是由【南京赫顺名网络科技有限公司】(以下简称“我们”)提供的花园养成互动应用。我们深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">同时,我们承诺:我们不会主动共享或提供您的个人信息给第三方;我们不会将您的个人信息用于本政策未明确说明的目的;我们只会收集实现功能所必需的信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">本政策将帮助您了解以下内容:</span></p>
<p class="p1"><span class="s1">1、个人信息的定义与范围;</span></p>
<p class="p1"><span class="s1">2、我们收集的信息;</span></p>
<p class="p1"><span class="s1">3、我们如何使用收集的信息;</span></p>
<p class="p1"><span class="s1">4、我们如何共享、转让、公开披露您的信息;</span></p>
<p class="p1"><span class="s1">5、我们如何保护您的个人信息;</span></p>
<p class="p1"><span class="s1">6、我们如何保存您的个人信息;</span></p>
<p class="p1"><span class="s1">7、我们如何使用Cookie及同类技术;</span></p>
<p class="p1"><span class="s1">8、您如何管理自己的信息;</span></p>
<p class="p1"><span class="s1">9、您共享的信息;</span></p>
<p class="p1"><span class="s1">10、未成年人保护。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">请您在使用本软件前仔细阅读本政策并确定了解我们对您个人信息的处理规则。对本政策中我们认为与您的权益存在重大关系的条款,我们采用加粗方式提示您注意。您点击“同意”或开始使用本软件,即视为您已阅读并同意本政策的全部内容。如有疑问,您可通过本政策公布的方式联系我们(联系邮箱:support@heshunming.top)。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>一、个人信息的定义与范围</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1.1 个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1.2 本政策中所涉及的个人信息包括:手机号码、密码、昵称、头像、花园养成数据(种植记录、养护动作记录、花朵成长阶段与成长值记录、收割记录、花园统计数据)、您主动发布的动态与分享内容、设备信息、日志信息等。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1.3 个人敏感信息是指一旦泄露、非法提供或滥用可能危害人身和财产安全,极易导致个人名誉、身心健康受到损害或歧视性待遇等的个人信息。本政策中涉及的个人信息中符合个人敏感信息类型的,我们将以加粗形式提示您注意。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>二、我们收集的信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.1 禾圃集软件功能分为核心功能和附加功能,我们将详细向您说明各业务功能所收集的个人信息以及收集方法、目的、拒绝提供的影响等。业务功能具体包括:账号注册与登录、花园种植与养护、花朵成长与收割、花园统计与图鉴、动态分享与互动等。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2 您使用禾圃集软件核心功能前,需要创建账号并完善个人资料。通过用户主动提供的方式,我们会收集您的以下信息:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2.1 账号注册与登录:您需要向我们提供您的手机号码,并设置您的用户密码。手机号码属于个人敏感信息,收集它是为了创建账号、验证身份并保护您的账号安全。如果您拒绝提供手机号码,禾圃集将无法为您创建账号并提供服务。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2.2 个人资料:您可以选择向我们提供昵称、头像等信息。您可以在个人主页资料中选择是否填写上述信息。如您不提供这些个人信息,您依然可以使用禾圃集的核心功能,但可能影响您在动态互动等场景下的展示效果。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.3 花园种植与养护功能:当您使用种植、浇水、施肥、收割等功能时,我们会记录您的花园养成数据,包括但不限于您种植的花种、种植与养护时间、养护动作次数、花朵成长阶段、成长值变化、收割记录等。上述数据用于向您展示花朵成长状态、推送成长提醒、生成成长记录与花园统计。这是本服务的基础功能,若您拒绝使用相关功能,我们将不会产生相应的养成数据。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.4 动态分享功能:当您生成花园快照、花朵盛开或收割的分享素材、发布动态或对他人动态进行互动时,我们会收集您主动提交的分享文案、图片、互动行为记录及时间等信息。如果您拒绝提供上述信息,您将无法使用分享与互动功能,但不影响您使用花园养成核心功能。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.5 相机与相册权限:当您使用头像设置、动态图片上传、分享素材保存等功能时,我们会调用您的设备系统相册权限或系统摄像头权限。上述权限的调用需您在系统中主动授权,您可以随时在系统设置中关闭。您拒绝授权仅影响相关图片功能的使用,不影响其他功能。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.6 通知权限:为了向您推送花朵成长、盛开等养成提醒及系统通知,我们会向您申请设备通知权限。该权限需您主动授权,您可以随时在系统设置中关闭,关闭后您仍可正常使用软件的全部核心功能。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.7 剪贴板信息:为了读取您复制的花园分享链接或邀请码、便于您提交反馈与举报,本软件会在相应的功能场景下读取剪贴板信息。我们不会将剪贴板信息用于其他目的。您也可以随时在系统设置中关闭本软件的剪贴板读取权限。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.8 设备信息与日志信息:为了保障服务安全稳定运行、进行数据统计分析以及捕获崩溃问题,我们的产品在应用运行期间会收集您的设备信息(设备型号、操作系统及版本)以及服务运行时产生的日志信息(IP地址、访问时间、访问记录、崩溃记录等)。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.9 我们不会主动申请您的通讯录、精确位置、麦克风等与花园养成服务无关的权限,也不会收集您的银行账户、支付信息。如未来因新功能需要收集前述信息,我们将按照法律法规要求另行取得您的授权同意。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>三、我们如何使用收集的信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.1 我们会将收集的信息用于:创建账号与身份验证;提供花园种植、养护、成长、收割等养成服务;生成成长记录与花园统计;提供动态分享与互动功能;推送养成提醒与系统通知;改进产品功能与服务体验(例如优化花朵成长节奏);保障服务安全稳定运行(包括作弊、外挂与异常养成行为的监测)。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.2 我们会遵循隐私保护政策收集、使用您的信息,但不会仅因您同意本政策而采用强制捆绑的方式收集全部个人信息。我们会逐一说明所收集的您的个人信息类型及其对应的用途,以便您了解我们针对某一特定功能收集的具体个人信息类别、使用理由及收集方式。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.3 超出上述范围使用您的信息,或要将您的信息用于本政策未载明的其他目的,我们会再次征得您的同意。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.4 我们不会将您的花园养成数据用于任何形式的广告投放,也不会据此向您推送第三方商业广告。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>四、我们如何共享、转让、公开披露您的信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.1 共享:我们不会与任何第三方共享您的个人信息,除非获得您的明确同意,或属于以下情形:</span></p>
<p class="p1"><span class="s1">(1)相关法律法规或法院、政府机关的要求;</span></p>
<p class="p1"><span class="s1">(2)为完成合并、分立、收购或资产转让而转移;</span></p>
<p class="p1"><span class="s1">(3)为提供您要求的服务所必需,例如使用第三方推送、崩溃统计服务时必要的设备信息。我们会与第三方签署严格的数据保护约定,要求其仅为本服务之目的处理您的信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.2 本软件接入的第三方服务详见《禾圃集软件服务协议》第九条关于第三方SDK的说明,包括:Bugly SDK(应用崩溃捕获,收集设备型号、操作系统版本、崩溃日志)、苹果系统推送APNs(收集设备推送令牌)。上述第三方服务的个人信息处理规则,请您阅读其官方隐私政策。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.3 转让:我们不会将您的个人信息转让给任何公司、组织和个人,除非获得您的明确同意,或发生合并、分立、收购、资产转让等情形且我们将确保信息在转让后的安全。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.4 公开披露:我们仅会在以下情形公开披露您的个人信息:获得您明确同意;基于法律法规、司法或行政机关的强制性要求。您在动态中主动分享的花园快照、分享文案等内容,将按您选择的范围向其他用户展示。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>五、我们如何保护您的个人信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1 我们已使用符合行业标准的安全技术手段保护您提供的个人信息,包括数据加密存储与传输、访问权限控制、安全审计等,并建立完善的管理制度来防止您的个人信息遭到未经授权的访问、使用、篡改或泄露。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2 我们会采取合理可行的措施,尽力避免收集无关的个人信息。我们只会在实现本政策所述目的所必需的期限内保留您的个人信息,除非法律另有规定。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3 我们会举办安全和隐私保护培训课程,加强员工对于保护个人信息重要性的认识。我们建立了个人信息保护的专项制度、流程和组织,确保个人信息保护工作的持续有效。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4 如不幸发生个人信息安全事件,我们将按照法律法规的要求,及时向您告知安全事件的基本情况和可能的影响、我们已采取或将要采取的处置措施、您可自主防范和降低风险的建议等,并以邮件、推送通知等方式告知您。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>六、我们如何保存您的个人信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1 保存地点:我们在中华人民共和国境内运营中收集和产生的个人信息,将存储在中华人民共和国境内。如需跨境传输,我们会按照法律法规要求另行取得您的授权同意。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2 保存期限:在您使用本服务期间,我们会持续保存您的账号信息与花园养成数据。当您注销账号后,我们将在合理的期限内删除或匿名化处理您的个人信息及花园养成数据,法律法规另有规定的除外。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>七、我们如何使用Cookie及同类技术</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.1 本软件为原生客户端应用,我们在本软件内不使用浏览器Cookie。为实现登录状态的维持、崩溃统计与系统推送,我们会使用设备标识符、本地存储、推送令牌等同类技术。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.2 上述技术仅用于实现本政策所述的功能目的。您可以通过卸载软件、在系统设置中关闭通知权限、重置设备广告标识符等方式删除或停用相关信息,但可能影响相关功能的使用。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>八、您如何管理自己的信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.1 查看与更正:您可以在软件内随时查看、更正您的昵称、头像等个人资料信息;您的花园养成数据(种植记录、成长记录、统计信息)可在花园页面及成长记录中查看。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.2 删除:您可以删除您主动发布的动态与分享内容。删除后,该内容将不再对外展示。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.3 账号注销:您可以依照软件内提供的流程申请注销您的账号。注销成功后,我们将停止为您提供服务,并在合理期限内删除或匿名化处理您的个人信息与花园养成数据,法律法规另有规定的除外。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.4 权限管理:您可以在移动设备系统设置中,随时对本软件的相机、相册、通知、剪贴板等权限进行开启或关闭,相关操作不影响本软件核心养成功能的正常使用。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.5 如果您无法通过上述方式访问、更正、删除您的个人信息,或需要撤回授权同意,您可以通过本政策公布的联系方式与我们联系,我们将在15个工作日内回复您的请求。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>九、您共享的信息</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">9.1 您可以通过本软件的分享功能,将花园快照、花朵盛开与收割的分享素材、您的动态内容分享给其他用户或第三方平台(如社交媒体)。您共享的信息中可能包含您的昵称、头像、花园养成成果等内容,请注意避免在分享内容中包含您的个人敏感信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">9.2 您可以随时删除您已发布的动态与分享内容。对于他人对您内容的互动记录,您可以在软件内查看或向我们申请处理。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">9.3 请您审慎考虑通过本服务共享信息的范围与对象。因您主动共享信息而导致的个人信息泄露等风险与后果,由您自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十、未成年人保护</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.1 我们非常重视对未成年人个人信息的保护。若您是未满18周岁的未成年人,在使用本软件前,应事先取得您家长或法定监护人的同意,并在监护人的指导下使用本服务和阅读本政策。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.2 对于经监护人同意使用本服务的未成年人,我们将仅收集必要的个人信息,并在法律法规和监护人同意的范围内使用。我们不会向未成年人推送商业性信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.3 如果监护人发现未成年人未经其同意使用了本服务或提供了个人信息,您可以联系我们,我们将尽快删除相关数据。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>政策更新</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">我们可能适时对本政策进行修订。未经您明确同意,我们不会削减您按照本政策所应享有的权利。对于重大变更,我们会通过软件公告、系统消息或推送通知等方式向您说明,并在必要时再次征得您的同意。您可以在本软件内随时查阅最新版本的本政策。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>如何联系我们</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">如您对本政策有任何疑问、意见或建议,或需要行使您的个人信息权利,您可通过本平台联系我们,联系方式为【support@heshunming.top】。一般情况下,我们将在15个工作日内回复。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">如果您对我们的回复不满意,特别是我们的行为损害了您的合法权益,您还可以向网信、电信、公安及工商等主管机关投诉、举报,或向有管辖权的人民法院提起诉讼。</span></p>
</body>
</html>
+371
View File
@@ -0,0 +1,371 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title></title>
<meta name="Generator" content="Cocoa HTML Writer">
<meta name="CocoaVersion" content="2575.7">
<style type="text/css">
p.p1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Times; -webkit-text-stroke: #000000}
p.p2 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Times; -webkit-text-stroke: #000000; min-height: 14.0px}
span.s1 {font-kerning: none}
</style>
</head>
<body>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>禾圃集软件服务协议</span></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>前言</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">欢迎您使用【南京赫顺名网络科技有限公司】公司(下称“我公司”或“我们”)为您提供的禾圃集的服务!</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">特别提示:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">为使用禾圃集软件(以下简称“本软件”)及服务,您应当阅读并遵守《禾圃集软件服务协议》(以下简称“本协议”)。请您务必仔细阅读、充分理解本协议各条款内容,特别是豁免或者限制我公司责任的条款、对用户权利进行限制的条款、约定管辖与法律适用条款,以及开通或使用某项服务的单独协议。限制、免责条款或者其他涉及您重大权益的条款可能以加粗等形式提示您重点注意。如您对本协议有任何疑问,可通过文末联系方式联系我们。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">除非您已阅读并接受本协议所有条款,否则您无权注册或登录使用本软件及相关服务。您点击“同意”,或者您注册或登录本软件的行为,或者其他明示或者默示方式表示接受本协议的行为即视为您已阅读并同意接受本协议的约束。本协议即在您与我公司之间产生法律效力,成为对双方均具有约束力的法律文件。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">如果您未满18周岁,或存在其他不具备与用户行为相适应的民事行为能力的情形,请在法定监护人的陪同、指导下阅读本协议,并特别注意未成年人使用条款,在确保监护人同意本协议内容后使用本软件。您及您的监护人应依照法律法规规定,承担同意本协议及使用本软件而导致的相应后果。</span></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>一、协议的范围</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1.1 协议适用主体范围</span></p>
<p class="p1"><span class="s1">本协议是您与我们之间关于您下载、安装、注册、登录、使用本软件以及使用我们提供的相关服务所订立的协议。包括本协议正文、《禾圃集隐私政策》及我公司已经发布的或将来可能发布/更新的并采取合理途径通知的各类规则、规范、通知等。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1.2 协议关系</span></p>
<p class="p1"><span class="s1">本协议内容同时包括我们可能不断发布的关于本服务的相关协议、业务规则、通知等内容。上述内容一经正式发布,即为本协议不可分割的组成部分,您同样应当遵守。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1.3 相关定义</span></p>
<p class="p1"><span class="s1">“我公司或我们”,是指禾圃集及相关服务可能存在的运营关联单位,即【南京赫顺名网络科技有限公司】公司及其关联公司。</span></p>
<p class="p1"><span class="s1">“用户”,是指使用本软件及相关服务的使用者,在本协议中更多地称为“您”。</span></p>
<p class="p1"><span class="s1">“服务”,是指我们向您提供的禾圃集软件相关服务,具体以我们实际提供的为准。同时,我们有权对提供的软件服务进行增加、关停或其他调整。</span></p>
<p class="p1"><span class="s1">“账号”,指用户在使用服务时可能需要注册的账号。用户可在本软件注册并获得的账号,作为登录并使用本软件的凭证。</span></p>
<p class="p1"><span class="s1">“花园养成内容”,指您在使用本服务过程中种植、养护的花朵及相关成长数据,包括但不限于花种、成长阶段、成长值、养护动作记录(浇水、施肥)、收割与分享记录、花园统计信息等。</span></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>二、关于本服务</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.1 本服务的内容</span></p>
<p class="p1"><span class="s1">本服务内容是指我们向用户提供的花园养成互动应用(以下简称“禾圃集”),核心功能包括但不限于:在花房中选择花种并种植;通过浇水、施肥等养护动作促进花朵成长;花朵按阶段自然成长(种子、发芽、花苞、盛开等);花朵盛开后的收割与成长记录;花园统计与花朵图鉴;生成花园快照、花朵盛开与收割的分享素材;以及查看好友的花园动态并进行互动(以下简称“本服务”)。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2 本服务的形式</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2.1 您使用本服务需要下载禾圃集客户端软件,对于这些软件,我们给予您一项个人的、不可转让及非排他性的许可。您仅可为访问或使用本服务的目的而使用这些软件及服务。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2.2 本服务中禾圃集客户端软件可能会提供包括但不限于iOS、Android等多个应用版本,用户必须选择与所安装终端设备相匹配的软件版本。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.3 本服务许可的范围</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.3.1 我们给予您一项个人的、不可转让及非排他性的许可,以使用本软件。您可以为非商业目的在单一台终端设备上安装、使用、显示、运行本软件。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.3.2 本条及本协议其他条款未明确授权的其他一切权利仍由我们保留,您在行使这些权利时须另外取得我们的书面许可。我们如果未行使前述任何权利,并不构成对该权利的放弃。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.4 按现状提供服务</span></p>
<p class="p1"><span class="s1">您理解并同意,禾圃集的服务是按照现有技术和条件所能达到的现状提供的。禾圃集会尽最大努力向您提供服务,确保服务的连续性和安全性;但禾圃集不能随时预见和防范法律、技术以及其他风险,包括但不限于不可抗力、病毒、木马、黑客攻击、系统不稳定、第三方服务缺陷、政府行为等原因可能导致的服务中断、数据丢失以及其他的损失和风险。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.5 虚拟养成内容的性质</span></p>
<p class="p1"><span class="s1">2.5.1 您理解并同意,花朵、花种、成长值、养护次数等均为本服务中的虚拟养成内容,仅限在本服务内按照产品规则使用,不具有任何现实货币价值。我们不对任何用户之间就虚拟养成内容进行的现实交易提供保障,由此产生的纠纷与损失由您自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.5.2 养成内容的成长进度、阶段变化等均以我们的服务器记录的数据为准。您通过卸载、重装软件、更换设备等方式,仍可登录同一账号查看您的花园养成数据;但若您注销账号,相关养成数据将按本协议约定被删除且无法恢复。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>三、软件的获取与使用</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.1 您可以直接从我们的网站上获取本软件,也可以通过苹果App Store等得到我们授权的第三方应用商店获取。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.2 如果您从未经我们授权的第三方获取本软件或与本软件名称相同的安装程序,我们无法保证该软件能够正常使用,并对因此给您造成的损失不予负责。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.3 我们可能为不同的终端设备开发了不同的软件版本,您应当根据实际情况选择下载合适的版本进行安装。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.4 下载安装程序后,您需要按照该程序提示的步骤正确安装。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.5 为了改善用户体验、保证服务的安全性及产品功能的一致性,我们可能会不时对禾圃集软件进行更新。我们有权不经过向您特别通知而对软件进行更新,或者对软件的部分功能效果进行改变或限制。您应当将相关软件更新到最新版本,否则我们并不保证其能正常使用。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.6 如果您不再需要使用本软件或者需要安装新版软件,可以自行卸载。如果您愿意帮助我们改进产品服务,请告知卸载的原因。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>四、用户个人信息保护</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.1 保护用户个人信息是我们的一项基本原则,我们将按照本协议及《禾圃集隐私政策》的规定收集、使用、存储您的个人信息,并会采取合理的措施保护您的个人信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.2 您在注册账号或使用本服务的过程中,需要提供一些必要的信息,例如:为向您提供账号注册服务或进行用户身份识别,需要您提供手机号码并设置密码;本软件部分功能可能需要收集您的个人信息包括:昵称、头像、花园养成数据(种植记录、浇水施肥记录、花朵成长阶段与成长记录)、分享内容等。若国家法律法规或政策有特殊规定的,您需要提供真实的身份信息。若您提供的信息不完整,则无法使用本服务或在使用过程中受到限制。您了解并同意,您有义务保持您提供信息的真实性、有效性及完整性。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.3 一般情况下,您可随时查看、修改自己提交的信息,但出于安全性和身份识别的考虑,您可能无法修改注册时提供的初始注册信息及其他验证信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.4 我们将运用各种安全技术和程序建立完善的管理制度来保护您的个人信息,以免遭受未经授权的访问、使用或泄露。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.5 未经您的同意,我们不会将您的个人信息转移或披露给任何非关联的第三方,除非:</span></p>
<p class="p1"><span class="s1">(1)相关法律法规或法院、政府机关要求;</span></p>
<p class="p1"><span class="s1">(2)为完成合并、分立、收购或资产转让而转移;</span></p>
<p class="p1"><span class="s1">(3)为提供您要求的服务所必需;</span></p>
<p class="p1"><span class="s1">(4)依据其他相关协议规则可以转移或披露给任何第三方的情形。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.6 我们非常重视对未成年人个人信息的保护。若您是18周岁以下的未成年人,在使用我们的服务前,应事先取得您家长或法定监护人的书面同意。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.7 我们可能会在向您提供服务的过程之中所收集的信息用作下列用途:</span></p>
<p class="p1"><span class="s1">(1) 向您提供花园养成及相关互动服务;</span></p>
<p class="p1"><span class="s1">(2) 在我们提供服务时,用于身份验证、客户服务、安全防范、作弊与外挂监测、存档和备份用途,确保我们向您提供的产品和服务的安全性;</span></p>
<p class="p1"><span class="s1">(3) 帮助我们设计新服务,改进我们现有服务,例如优化花朵成长节奏与养护体验;</span></p>
<p class="p1"><span class="s1">(4) 使我们更加了解您如何接入和使用我们的服务,从而针对性地回应您的个性化需求,例如语言设置、个性化的帮助服务和指导;</span></p>
<p class="p1"><span class="s1">(5) 软件认证或管理软件升级;</span></p>
<p class="p1"><span class="s1">(6) 让您参与有关我们产品和服务的调查。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>五、主权利义务条款</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1 账号使用规范</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.1 您在使用本服务前需要注册一个禾圃集账号。本软件为您提供注册及登录通道,禾圃集账号通过手机号码进行注册,您可按照页面提示填写信息、阅读并同意本协议,在完成全部注册登录程序后,成为本软件用户。另外我们有权根据用户需求或产品需要对账号注册的方式进行变更,关于您使用账号的具体规则,请遵守相关账号使用协议以及我们为此发布的专项规则。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.2 禾圃集账号的所有权归我们公司所有,用户完成申请注册手续后,仅获得禾圃集账号的使用权,且该使用权仅属于初始申请注册人。同时,初始申请注册人不得赠与、借用、出租、转让或售卖禾圃集账号或者以其他方式许可非初始申请注册人使用禾圃集账号。非初始申请注册人不得通过受赠、继承、承租、受让或者其他任何方式使用禾圃集账号。如我们判断您账号的使用可能违反法律法规、危及您的账号安全及/或本软件信息安全的,我们可拒绝提供相应服务或终止本协议。我们有权视情况封禁或注销、回收该账号,由此带来的包括但不限于花园养成内容清空等损失由您本人及该账号使用者自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.3 您注册获得的账号、设置的密码是您登录并以用户身份使用本软件的凭证,您的账户仅限您本人使用,您有责任妥善保管您的注册用户信息及用户密码的安全,您需要对您的注册用户以及密码下的行为承担法律责任。您同意在任何情况下不向他人泄露用户及密码信息。当在您怀疑他人在使用您的账号时,您应立即通知我们。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.4 用户注册禾圃集账号后如果长期(超过【6】个月)不登录该账号,我们有权回收该账号,以免造成资源浪费,由此带来的任何损失均由用户自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.5 我们特别提醒,您应妥善保管您的账号和密码。当您使用完本服务后,应安全退出。您须对账号进行的所有活动和行为负责,因您保管不善导致遭受账号或密码失窃、账号被非法使用,相关责任由您自行承担,您可通过司法、行政等救济途径向侵权行为人追偿。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.6 您不得恶意注册账号,包括但不限于频繁注册、批量注册、使用他人身份注册或其他不以正常使用本软件服务为目的的账号注册行为。除此外,您的账户不得以任何方式转让,否则我们有权追究您的违约责任,且由此产生的责任及后果均由您自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2 用户注意事项</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.1 您理解并同意:为了向您提供有效的服务,本软件会利用您终端设备的处理器和带宽等资源。本软件使用过程中可能产生数据流量的费用,用户需自行向运营商了解相关资费信息,并自行承担相关费用。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.2 您理解并同意我们将尽其商业上的合理努力保障您在本软件及服务中的数据存储安全,但是,我们并不能就此提供完全保证,包括但不限于以下情形:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.2.1 我们不对您在本软件及服务中相关数据的删除或存储失败负责;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.2.2 我们有权根据实际情况自行决定单个用户在本软件及服务中数据的最长存储期限,并在服务器上为其分配数据最大存储空间等。您可根据自己的需要自行备份本软件及服务中的相关数据;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.2.3 如果您停止使用本软件及服务或服务被终止或取消,我们可以从服务器上永久地删除您的数据。服务停止、终止或取消后,我们没有义务向您返还任何数据。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.3 您理解并同意,禾圃集可能会不时发布并执行一些用户奖励活动,相关活动的具体规则的制定权和最终解释权归我们所有,我们有权根据自己的判断决定提前终止活动或延长活动时间。同一禾圃集账号、同一手机号、同一手机设备均视为同一用户。如果我们发现有用户利用不正当手段参与活动,或者如果用户存在或涉嫌违法违规行为情况(包括不限于外挂、作弊、脚本刷取、虚假养成的活动、其他风险行为),我们有权取消您参与活动的资格,并有权追回您已经获得的奖励。如果因非实名认证导致您无法参与活动的,视为您自动放弃活动资格。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3 第三方产品和服务</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.1 您在使用本软件第三方提供的产品或服务时,除遵守本协议约定外,还应遵守第三方的用户协议。我们和第三方对可能出现的纠纷在法律规定和约定的范围内各自承担责任。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.2 因用户使用本软件或要求我们提供特定服务时,本软件可能会调用第三方系统或者通过第三方支持用户的使用或访问,我们不保证通过第三方提供服务及内容的安全性、准确性、有效性及其它不确定的风险,您应自行判断此类第三方网站或者服务的安全性和可用性,并自行承担相关风险和责任,由此若引发的任何争议及损害,与我们无关,我们不承担任何责任。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4 账号信息设置及维护</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4.1 您在注册或使用本软件时设置的名称、头像和简介等账号信息资料应遵守法律法规、社会道德风尚和信息真实性等原则,不得出现不良信息,不得冒用他人姓名、名称、字号、头像等或采取其他足以引起混淆的方式设置账号,不得侵害第三方的合法权益。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4.2 在使用本软件服务时,您应当按本软件页面的提示准确完整地提供您的信息,以便本软件或其他用户与您联系。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4.3 如本软件按您最后一次提供的信息与您联系未果、您未按本软件的要求及时提供信息、您提供的信息存在明显不实或行政司法机关核实您提供的信息无效的,您将承担因此对您自身、他人及本软件造成的全部损失与不利后果。本软件可向您发出询问或要求整改的通知,并要求您进行重新认证,直至中止、终止对您提供部分或全部软件产品或服务,本软件对此不承担责任。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.5 账号注销及回收</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.5.1 您可以依照本软件的流程申请注销您的账号,但您仍应对您在注销账号前或使用本软件服务期间的行为承担相应责任。注销成功后,除非法律法规另有规定,我们不会为您提供账号记录、花园养成数据等恢复或提供服务,请您谨慎操作。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.5.2 您理解并同意,为了充分使用账号资源,如您在注册后未及时进行初次登录使用或连续超过【6】个月未登录账号并使用,且不存在未到期或未履行完毕的持续性本软件服务的,我们有权收回您的账号。如您的账号被收回,您无法通过您此前持有的账号登录并使用本软件,您账号下保存的花园养成数据和使用记录也将无法恢复。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.6 账号违规处置</span></p>
<p class="p1"><span class="s1">若您在注册或使用本软件账号时,存在任何违反法律法规或不符合本协议约定的行为,我们有权不予注册;已经注册的,我们有权视情况要求用户限期改正、或单方采取短期封禁、永久封禁、注销账号等措施。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>六、用户行为规范</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1 信息内容规范</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.1 本条所述信息内容是指用户使用本软件及服务过程中所制作、复制、发布、传播的任何内容,包括但不限于禾圃集账号、昵称、头像、用户说明等注册信息,或花园动态、分享文案、分享图片及相关链接页面,以及其他使用禾圃集账号或本软件及服务所产生的内容。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2 您理解并同意,禾圃集一直致力于为用户提供文明健康、规范有序的网络环境,您不得利用禾圃集账号或本软件及服务制作、复制、发布、传播如下干扰禾圃集正常运行,以及侵犯其他用户或第三方合法权益的内容,包括但不限于:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.1 发布、传送、传播、存储违反国家法律法规禁止的内容,包括但不限于危害国家安全、泄露国家秘密、破坏国家统一与主权和领土完整、损害国家荣誉和利益、煽动民族仇恨与歧视、宣扬邪教和封建迷信、散布谣言扰乱社会秩序、散布淫秽色情赌博暴力恐怖信息、诽谤或侮辱他人、侵害未成年人合法权益以及含有法律、行政法规禁止的其他内容的;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.2 发布、传送、传播、存储侵犯他人名誉权、肖像权、知识产权、商业秘密等合法权利的内容;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.3 涉及他人隐私、个人信息或资料的;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.4 发表、传送、传播骚扰、广告信息、过度营销信息及垃圾信息或含有任何性或性暗示的;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.5 其他违反法律法规、政策及公序良俗、社会公德或干扰禾圃集正常运行和侵犯其他用户或第三方合法权益内容的信息。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.6 您通过本服务发布或分享的内容(包括但不限于花园动态、文字、图片等)均由您自行承担责任。您发布或分享的内容应有合法来源,相关内容为您所有或您已获得权利人的授权。您同意禾圃集可为履行本协议或提供本服务的目的而使用您发布或分享的内容。如果我们收到权利人通知,主张您发布或分享的内容侵犯其相关权利的,您同意我们有权进行独立判断并采取删除、屏蔽等措施。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2 软件使用规范</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1 除非法律允许或我们书面许可,您使用本软件过程中不得从事下列行为:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.1 删除本软件及其副本上关于著作权的信息;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.2 对本软件进行反向工程、反向汇编、反向编译,或者以其他方式尝试发现本软件的源代码;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.3 对我们享有知识产权的内容进行使用、出租、出借、复制、修改、链接、转载、汇编、发表、出版、建立镜像站点等;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.4 通过修改或伪造软件运行中的指令、数据,或使用自动化脚本、插件、外挂等方式批量执行浇水、施肥等养护动作,或以其他方式异常刷取成长值、奖励或养成数据;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.5 通过非我们开发、授权、许可的第三方软件、插件、外挂、系统,登录或使用我们的软件及服务,或制作、发布、传播上述工具,或对本软件的正常运行进行干扰、破坏、修改或施加其他影响;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.6 以任何方式(包括但不限于盗链、冗余盗取、非法抓取、模拟下载、深度链接、假冒注册等)直接或间接盗取本软件的花种数据、分享内容、用户信息等信息内容;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.7 利用或针对本软件进行任何危害计算机网络安全的行为,包括但不限于:非法侵入网络、干扰网络正常功能、窃取网络数据等危害网络安全的活动,或为上述活动提供程序、工具等支持与帮助;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.8 其他以任何不合法的方式、为任何不合法的目的、或与我们为此制定的其他规范和标准不一致的方式使用本软件及未经我们明确授权的行为。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3 服务运营规范</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.1 除非您与禾圃集另有约定,您同意本服务仅为您个人非商业性质的使用。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.2 您应当通过禾圃集提供或认可的方式使用本服务。您依本协议条款所获得的权利不可转让。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.3 您不得使用未经禾圃集授权的插件、外挂或第三方工具对本协议项下的服务进行干扰、破坏、修改或施加其他影响。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.4 对自己行为负责</span></p>
<p class="p1"><span class="s1">您充分了解并同意,您必须为自己注册账号下的一切行为负责,包括您所发表的任何内容以及由此产生的任何后果。您应对本服务中的内容自行加以判断,并承担因使用内容而引起的所有风险,包括因对内容的正确性、完整性或实用性的依赖而产生的风险。我们无法且不会对因前述风险而导致的任何损失或损害承担责任。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.5 违约处理</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.5.1 如果我们发现或收到他人举报或投诉用户违反本协议约定的,我们有权不经通知随时对相关内容进行删除、屏蔽,并视行为情节对违规账号处以包括但不限于警告、限制或禁止使用部分或全部功能、清除异常养成数据、账号封禁直至注销的处罚,并公告处理结果。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.5.2 您理解并同意,因您违反本协议或相关服务条款的规定,导致或产生第三方主张的任何索赔、要求或损失,您应当独立承担责任;我们因此遭受损失的,您也应当一并赔偿,包括行政处罚或损害赔偿款等直接损失以及商誉损失、和解款、律师费、诉讼费等间接损失。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.5.3 我们可将对您上述违约行为的处置信息,以及其他经国家行政或司法机关生效法律文书确认的违法信息,在本软件上予以公示。对涉嫌违反法律法规的行为,我们有义务保存有关记录,向国家有关机关报告并配合调查。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>七、知识产权声明</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.1 禾圃集在本服务中提供的内容(包括但不限于花种形象与图鉴、花朵素材与动画、网页、文字、图片、图表、界面设计、版面框架、图标、商标等)的知识产权归禾圃集所有,受中华人民共和国著作权法、商标法、专利法、反不正当竞争法及国际条约等法律法规的保护。未经我们书面允许,用户不得为任何目的擅自使用、复制、再造这些内容、或创造与内容有关的派生产品。用户在使用本服务中所产生的自有内容的知识产权归用户或相关权利人所有。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.2 除另有特别声明外,禾圃集提供本服务时所依据软件的著作权、专利权及其他知识产权均归禾圃集所有。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.3 禾圃集在本服务中所使用的“禾圃集”等商业标识,其著作权或商标权归禾圃集所有。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.4 上述及其他任何本服务包含的内容的知识产权均受到法律保护,未经禾圃集、用户或相关权利人书面许可,任何人不得以任何形式进行使用或创作相关衍生作品。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>八、终端安全责任</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.1 您理解并同意,本软件同大多数互联网软件一样,可能会受多种因素影响,包括但不限于用户原因、网络服务质量、社会环境等;也可能会受各种安全问题的侵扰,包括但不限于他人非法利用用户资料,进行现实中的骚扰;您下载安装的其他软件或访问的其他网站中可能含有病毒、木马程序或其他恶意程序,威胁您的终端设备信息和数据安全,继而影响本软件的正常使用等。因此,您应加强信息安全及个人信息的保护意识,注意密码保护,以免遭受损失。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.2 您不得制作、发布、使用、传播用于窃取禾圃集账号及他人个人信息、财产的恶意程序。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.3 维护软件安全与正常使用是我们和您的共同责任,我们将按照行业标准合理审慎地采取必要技术措施保护您的终端设备信息和数据安全,但是您承认和同意我们并不能就此提供完全保证。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.4 在任何情况下,您不应轻信借款、索要密码或其他涉及财产的网络信息。涉及财产操作的,请一定先核实对方身份,并请经常留意我们有关防范诈骗犯罪的提示。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>九、第三方软件或技术</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">9.1 本软件可能会使用第三方软件或技术(包括本软件可能使用的开放源代码和公共领域代码等,下同),这种使用已经获得合法授权。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">9.2 本软件如果使用了第三方的软件或技术,我们将按照相关法规或约定,对相关的协议或其他文件,可能通过本协议附件、在本软件安装包特定文件夹中打包、或通过开源软件页面等形式进行展示,它们可能会以“软件使用许可协议”、“授权协议”、“开放源代码许可证”或其他形式来表达。前述通过各种形式展示的相关协议、其他文件及网页,均是本协议不可分割的组成部分,与本协议具有同等的法律效力,您应当遵守这些要求。如果您没有遵守这些要求,该第三方或者国家机关可能会对您提起诉讼、罚款或采取其他制裁措施,并要求我们给予配合,您应当自行承担法律责任。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">9.3 如因本软件使用的第三方软件或技术引发的任何纠纷,应由该第三方负责解决,我们不承担任何责任。我们不对第三方软件或技术提供客服支持,若您需要获取支持,请与第三方联系。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">APP涉及内嵌第三方代码、插件传输个人信息的情形逐项列举如下:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">**Bugly SDK(iOS版)**</span></p>
<p class="p1"><span class="s1">主体:深圳市腾讯计算机系统有限公司</span></p>
<p class="p1"><span class="s1">用途:应用崩溃问题捕获</span></p>
<p class="p1"><span class="s1">收集的个人信息类型:设备型号、操作系统及版本、设备传感器信息、应用崩溃日志、启动与卡顿日志;</span></p>
<p class="p1"><span class="s1">官网链接:</span></p>
<p class="p1"><span class="s1">https://privacy.qq.com/document/preview/fc748b3d96224fdb825ea79e132c1a56</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">**苹果系统推送(APNs)**</span></p>
<p class="p1"><span class="s1">主体:Apple Inc.</span></p>
<p class="p1"><span class="s1">用途:向您推送花园养成提醒(如花朵成长、盛开提醒)及系统通知</span></p>
<p class="p1"><span class="s1">涉及个人信息:设备推送令牌(Device Token);推送能力由苹果系统提供,您可以随时在系统设置中关闭本软件的通知权限。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">如后续版本接入其他第三方服务,我们将按照法律法规要求在本协议及《禾圃集隐私政策》中更新说明并向您提示。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十、不可抗力及其他免责事由</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.1 您理解并同意,在使用本服务的过程中,可能会遇到不可抗力等风险因素,使本服务发生中断。不可抗力是指不能预见、不能克服并不能避免且对一方或双方造成重大影响的客观事件,包括但不限于自然灾害如水灾、地震、瘟疫流行和风暴等以及社会事件如战争、动乱、政府行为等。出现上述情况时,禾圃集将努力在第一时间与相关单位配合,及时进行修复,但由此给您造成的损失禾圃集在法律允许的范围内免责。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.2 在法律允许的范围内,禾圃集对以下情形导致的服务中断或受阻不承担责任:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(1)受到计算机病毒、木马或其他恶意程序、黑客攻击的破坏;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(2)用户或禾圃集的电脑软件、系统、硬件和通信线路出现故障;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(3)用户操作不当;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(4)用户通过非禾圃集授权的方式使用本服务;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(5)其他禾圃集无法控制或合理预见的情形。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.3 您理解并同意,在使用本服务的过程中,可能会遇到网络信息或其他用户行为带来的风险,禾圃集不对任何信息的真实性、适用性、合法性承担责任,也不对因侵权行为给您造成的损害负责。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.4 禾圃集根据本协议约定获得处理违法违规内容的权利,该权利不构成禾圃集的义务或承诺,禾圃集不能保证及时发现违法行为或进行相应处理。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.5 在任何情况下,您不应轻信借款、索要密码或其他涉及财产的网络信息。涉及财产操作的,请一定先核实对方身份,并请经常留意禾圃集有关防范诈骗犯罪的提示。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十一、服务的变更、中断、终止</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.1 禾圃集可能会对服务内容进行变更,也可能会中断、中止或终止服务。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.2 您理解并同意,禾圃集有权自主决定经营策略。在禾圃集发生合并、分立、收购、资产转让时,禾圃集可向第三方转让本服务下相关资产;禾圃集也可在单方通知您后,将本协议下部分或全部服务转交由第三方经营或履行。具体受让主体以禾圃集通知的为准。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.3 如发生下列任何一种情形,禾圃集有权不经通知而中断或终止向您提供的服务:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(1)根据法律规定您应提交真实信息,而您提供的个人资料不真实、或与注册时信息不一致又未能提供合理证明;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(2)您违反相关法律法规或本协议的约定;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(3)按照法律规定或主管部门的要求;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(4)出于安全的原因或其他必要的情形。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.4 我们始终在不断变更和改进本软件服务,我们会尽最大努力向您提供服务,确保服务的连贯性和安全性。基于此,我们可能会对本软件进行更新,您应将软件更新到最新版本,否则我们不保证您可正常使用本软件。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.5 您理解并同意,我公司根据自身商业决策、政府行为、不可抗力等原因可能会选择中止、中断及终止本软件的部分或全部服务。如有此等情形发生,我们会采取合理的方式并以合理的期限提前通知您,除法律法规另有规定外,我们不承担由此对您造成的损失。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.6 您有责任自行备份存储在本服务中的数据。如果您的服务被终止,禾圃集可以从服务器上永久地删除您的数据,但法律法规另有规定的除外。服务终止后,禾圃集没有义务向您返还数据。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十二、未成年人使用条款</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">12.1 若用户未满18周岁,则为未成年人,应在监护人监护、指导下阅读本协议和使用本服务。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">12.2 未成年人用户涉世未深,容易被网络虚拟迷惑,且好奇心强,遇事缺乏随机应变的处理能力,很容易被别有用心的人利用而又缺乏自我保护能力。因此,未成年人用户在使用本服务时应注意以下事项,提高安全意识,加强自我保护:</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(1)认清网络世界与现实世界的区别,合理安排使用时间,避免沉迷于网络,影响日常的学习生活;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(2)填写个人资料时,加强个人保护意识,以免不良分子对个人生活造成骚扰;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(3)在监护人或老师的指导下,学习正确使用网络;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(4)避免与陌生网友随意见面或参加联谊活动,以免不法分子有机可乘,危及自身安全。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">12.3 监护人、学校均应对未成年人使用本服务时多做引导。特别是家长应关心子女的成长,注意与子女的沟通,指导子女上网应该注意的安全问题,防患于未然。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十三、协议变更与终止</span></p>
<p class="p1"><span class="s1">13.1 协议变更通知</span></p>
<p class="p1"><span class="s1">我们有权在必要时修改本协议内容,并将通过合理的方式(包括但不限于本软件公告、系统消息、推送通知、手机短信、电子邮件等)及合理的期限提前通知您,该等通知于发送之日视为已送达您。您可以通过软件相关页面查阅最新版本的协议内容。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">13.2 变更协议生效</span></p>
<p class="p1"><span class="s1">本协议变更生效后,如您继续使用本软件,即视为您已接受修改后的协议。如您不接受修改后的协议,应当停止使用本软件,我们将根据公平原则与您终止协议。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">13.3 协议终止</span></p>
<p class="p1"><span class="s1">您注销您的账号或我们通知您终止为您提供本软件服务(包括但不限于封禁账号、注销账号等)时,本协议将同时终止,我们将不再另行通知您终止本协议。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十四、通知</span></p>
<p class="p1"><span class="s1">14.1 联系方式</span></p>
<p class="p1"><span class="s1">14.1.1 您在注册成为本软件用户,并接受本软件服务时,您应该向本软件提供真实有效的联系方式(包括您的联系电话、电子邮件地址等),对于联系方式发生变更的,您有义务及时更新有关信息,并保持可被联系的状态。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">14.1.2 您在注册本软件用户时生成的用于接收系统消息的账号,也作为您的有效联系方式。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">14.1.3 本软件将向您的上述联系方式向您送达各类通知,而此类通知的内容可能对您的权利义务产生重大的有利或不利影响,请您务必及时关注。您如果不愿意接收商业性信息,您有权通过本软件提供的相应的退订功能进行退订。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">14.2 通知送达</span></p>
<p class="p1"><span class="s1">14.2.1 本软件通过上述联系方式向您发出通知,其中以电子的方式发出的书面通知,包括但不限于在本软件公告,向您提供的联系电话发送手机短信,向您提供的电子邮件地址发送电子邮件,向您的账号发送信息、系统消息以及推送通知,在发送成功后即视为送达;以纸质载体发出的书面通知,按照提供联系地址交邮后的第【7】个自然日即视为送达。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">14.2.2 对于在本软件上因使用本服务引起的任何纠纷,您同意司法机关(包括但不限于人民法院)可以通过手机短信、电子邮件等现代通讯方式或邮寄方式向您送达法律文书(包括但不限于诉讼文书)。您指定接收法律文书的手机号码、电子邮箱或软件账号等联系方式为您在本软件注册、更新时提供的联系方式以及在注册本软件用户时生成的账号,司法机关向上述联系方式发出法律文书即视为送达。您指定的邮寄地址为您的法定联系地址或您提供的有效联系地址。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">14.2.3 您同意司法机关可采取以上一种或多种送达方式向您送达法律文书,司法机关采取多种方式向您送达法律文书,送达时间以上述送达方式中最先送达的为准。您同意上述送达方式适用于各个司法程序阶段。如进入诉讼程序的,包括但不限于一审、二审、再审、执行以及督促程序等。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">14.2.4 您应当保证所提供的联系方式是准确、有效的,并进行实时更新。如果因提供的联系方式不确切,或不及时告知变更后的联系方式,使法律文书无法送达或未及时送达,由您自行承担由此可能产生的法律后果。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>十五、其他</span></p>
<p class="p1"><span class="s1">15.1 您使用本软件即视为您已阅读并同意受本协议的约束。我们有权在必要时修改本协议条款。您可以在本软件的最新版本中查阅相关协议条款。本协议条款变更后,如果您继续使用本软件,即视为您已接受修改后的协议。如果您不接受修改后的协议,应当停止使用本软件。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.2 本协议的成立、生效、履行、解释及争议解决,适用中华人民共和国大陆地区法律法规;如法律无相关规定的,参照商业惯例及/或行业惯例。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.3 若您和我们之间发生任何纠纷或争议,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交我公司所在地人民法院管辖。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.4 本协议所有条款的标题仅为阅读方便,本身并无实际含义,不能作为本协议含义解释的依据。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.5 本协议可能存在包括中文、英文在内的多种语言的版本,若存在中文版本与其他语言的版本相冲突的地方,以中文版本为准。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.6 本协议条款无论因何种原因部分无效或不可执行,其余条款仍有效,对双方具有约束力。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.7 如您对本协议有任何意见或建议,您可通过本平台联系我们,联系方式为【support@heshunming.top】,我们会给予您必要的帮助。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"><span class="Apple-converted-space"> </span>读取剪贴板信息说明</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">1、用于读取您复制的花园分享链接或邀请码,方便您快速进入对应花园或完成好友邀请;</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2、用于复制平台联系方式,方便您提交意见反馈、举报等。</span></p>
</body>
</html>
+4 -2
View File
@@ -62,8 +62,10 @@ type legalDocument struct {
}
var legalDocuments = map[string]legalDocument{
"/legal/user-terms": loadLegalDocument("legal/user-terms.html", "讯工捷使软件服务协议"),
"/legal/privacy-policy": loadLegalDocument("legal/privacy-policy.html", "讯工捷使隐私政策"),
"/legal/user-terms": loadLegalDocument("legal/user-terms.html", "讯工捷使软件服务协议"),
"/legal/privacy-policy": loadLegalDocument("legal/privacy-policy.html", "讯工捷使隐私政策"),
"/legal/garden/user-terms": loadLegalDocument("legal/garden-user-terms.html", "禾圃集软件服务协议"),
"/legal/garden/privacy-policy": loadLegalDocument("legal/garden-privacy-policy.html", "禾圃集隐私政策"),
}
func loadLegalDocument(name, title string) legalDocument {
+8 -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
@@ -897,6 +898,9 @@ func main() {
// Public legal pages are opened directly by clients before login.
mux.HandleFunc("/legal/user-terms", serveLegalDocument)
mux.HandleFunc("/legal/privacy-policy", serveLegalDocument)
// Garden app (禾圃集) legal pages.
mux.HandleFunc("/legal/garden/user-terms", serveLegalDocument)
mux.HandleFunc("/legal/garden/privacy-policy", serveLegalDocument)
// Handle websocket clients.
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
+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)
}
}
+214 -86
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
}
@@ -58,33 +62,98 @@ type readerCounter struct {
reader io.Reader
}
type ossReadSeekCloser struct {
open func(offset int64) (io.ReadCloser, error)
size int64
pos int64
body io.ReadCloser
closed bool
}
func (rc *readerCounter) Read(buf []byte) (int, error) {
n, err := rc.reader.Read(buf)
atomic.AddInt64(&rc.count, int64(n))
return n, err
}
func (r *ossReadSeekCloser) Read(buf []byte) (int, error) {
if r.closed {
return 0, errors.New("read from closed OSS object")
}
if r.pos >= r.size {
return 0, io.EOF
}
if r.body == nil {
var err error
r.body, err = r.open(r.pos)
if err != nil {
return 0, err
}
}
n, err := r.body.Read(buf)
r.pos += int64(n)
if err != nil {
_ = r.body.Close()
r.body = nil
}
return n, err
}
func (r *ossReadSeekCloser) Seek(offset int64, whence int) (int64, error) {
var next int64
switch whence {
case io.SeekStart:
next = offset
case io.SeekCurrent:
next = r.pos + offset
case io.SeekEnd:
next = r.size + offset
default:
return 0, errors.New("invalid seek origin")
}
if next < 0 {
return 0, errors.New("negative seek position")
}
if next == r.pos {
return next, nil
}
if r.body != nil {
_ = r.body.Close()
r.body = nil
}
r.pos = next
return next, nil
}
func (r *ossReadSeekCloser) Close() error {
if r.closed {
return nil
}
r.closed = true
if r.body != nil {
return r.body.Close()
}
return nil
}
// Init initializes the media handler.
func (oh *ossHandler) Init(jsconf string) error {
var err 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
@@ -101,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 {
@@ -123,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",
@@ -139,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"])
@@ -207,7 +311,9 @@ func truthy(value string) bool {
}
}
// Headers adds CORS headers and redirects GET and HEAD requests to OSS.
// Headers adds CORS and cache headers. Downloads stay on the IM origin: forwarding the authenticated
// browser request through a signed OSS redirect also forwards custom headers, which forces an OSS
// preflight and fails unless the bucket exposes those private headers through CORS.
func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) {
headers, status := media.CORSHandler(method, headers, oh.corsOrigins, serve)
if status != 0 || method == http.MethodPost || method == http.MethodPut {
@@ -225,55 +331,22 @@ func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.U
}
if fdef.ETag != "" && strings.Trim(headers.Get("If-None-Match"), "\"") == fdef.ETag {
return http.Header{
"ETag": {`"` + fdef.ETag + `"`},
"Cache-Control": {oh.conf.CacheControl},
}, http.StatusNotModified, nil
headers.Set("ETag", `"`+fdef.ETag+`"`)
headers.Set("Cache-Control", oh.conf.CacheControl)
return headers, http.StatusNotModified, nil
}
objectKey, err := oh.resolveObjectKey(fdef.Location)
if err != nil {
return nil, 0, err
if fdef.ETag != "" {
headers.Set("ETag", `"`+fdef.ETag+`"`)
}
var redirURL string
switch method {
case http.MethodGet:
options := []aliyunoss.Option{
aliyunoss.ResponseCacheControl(oh.conf.CacheControl),
}
if isAttachment, _ := strconv.ParseBool(url.Query().Get("asatt")); isAttachment {
options = append(options, aliyunoss.ResponseContentDisposition("attachment"))
}
redirURL, err = oh.bucket.SignURL(objectKey, aliyunoss.HTTPGet, int64(oh.conf.PresignTTL), options...)
case http.MethodHead:
redirURL, err = oh.bucket.SignURL(objectKey, aliyunoss.HTTPHead, int64(oh.conf.PresignTTL))
}
if err != nil {
return nil, 0, err
}
if redirURL != "" {
return http.Header{
"Location": {normalizeSignedRedirectURL(redirURL)},
"ETag": {`"` + fdef.ETag + `"`},
"Content-Type": {"application/json; charset=utf-8"},
"Cache-Control": {oh.conf.CacheControl},
}, http.StatusFound, nil
}
return nil, 0, nil
}
func normalizeSignedRedirectURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "http" || parsed.Host == "" {
return rawURL
}
parsed.Scheme = "https"
return parsed.String()
headers.Set("Cache-Control", oh.conf.CacheControl)
return headers, 0, nil
}
// 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
@@ -308,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
@@ -332,19 +408,70 @@ func (oh *ossHandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (s
return key, rc.count, nil
}
// Download is unsupported because OSS objects are served through signed redirects.
// Download streams a private OSS object through the authenticated IM endpoint. The seekable reader
// reopens the object with a byte range after every seek, allowing http.ServeContent to handle HEAD
// and Range requests without buffering an entire image, video, or document in server memory.
func (oh *ossHandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
return nil, nil, types.ErrUnsupported
fid := oh.GetIdFromUrl(url)
if fid.IsZero() {
return nil, nil, types.ErrNotFound
}
fd, err := oh.getFileRecord(tenantID, fid)
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
}
if fd.Size <= 0 {
meta, err := oh.bucket.GetObjectDetailedMeta(objectKey)
if err != nil {
return nil, nil, err
}
fd.Size, err = strconv.ParseInt(meta.Get("Content-Length"), 10, 64)
if err != nil || fd.Size < 0 {
return nil, nil, types.ErrMalformed
}
}
reader := &ossReadSeekCloser{size: fd.Size}
reader.open = func(offset int64) (io.ReadCloser, error) {
if offset <= 0 {
return oh.bucket.GetObject(objectKey)
}
return oh.bucket.GetObject(objectKey, aliyunoss.Range(offset, fd.Size-1))
}
return fd, reader, nil
}
// 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
}
}
@@ -438,4 +565,5 @@ func etagFromLocation(location string) string {
func init() {
store.RegisterMediaHandler(handlerName, &ossHandler{})
store.RegisterMediaHandler(minioHandlerName, &ossHandler{})
}
+48 -7
View File
@@ -1,7 +1,10 @@
package oss
import (
"bytes"
"io"
"net/http"
"strings"
"testing"
"time"
@@ -58,14 +61,31 @@ func TestIsOSSObjectNotFound(t *testing.T) {
}
}
func TestNormalizeSignedRedirectURL(t *testing.T) {
rawURL := "http://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/tenants/1/avatar.jpg?Expires=1&OSSAccessKeyId=ak&Signature=sig"
want := "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/tenants/1/avatar.jpg?Expires=1&OSSAccessKeyId=ak&Signature=sig"
if got := normalizeSignedRedirectURL(rawURL); got != want {
t.Fatalf("normalizeSignedRedirectURL()=%q, want %q", got, want)
func TestOSSReadSeekCloser(t *testing.T) {
data := []byte("0123456789")
reader := &ossReadSeekCloser{
size: int64(len(data)),
open: func(offset int64) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data[offset:])), nil
},
}
if got := normalizeSignedRedirectURL("/v0/file/s/avatar.jpg"); got != "/v0/file/s/avatar.jpg" {
t.Fatalf("relative URL must be unchanged, got %q", got)
defer reader.Close()
buf := make([]byte, 3)
if n, err := reader.Read(buf); err != nil || n != 3 || string(buf) != "012" {
t.Fatalf("initial read = %q, %d, %v", buf, n, err)
}
if pos, err := reader.Seek(-2, io.SeekEnd); err != nil || pos != 8 {
t.Fatalf("seek end = %d, %v", pos, err)
}
if n, err := reader.Read(buf); err != nil && err != io.EOF || n != 2 || string(buf[:n]) != "89" {
t.Fatalf("ranged read = %q, %d, %v", buf[:n], n, err)
}
if pos, err := reader.Seek(4, io.SeekStart); err != nil || pos != 4 {
t.Fatalf("seek start = %d, %v", pos, err)
}
if n, err := reader.Read(buf[:2]); err != nil || n != 2 || string(buf[:2]) != "45" {
t.Fatalf("second ranged read = %q, %d, %v", buf[:2], n, err)
}
}
@@ -104,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)
}
}
+90
View File
@@ -190,6 +190,49 @@ var (
Help: "Time a client publish waited in a topic queue before processing by topic kind.",
Buckets: []float64{0.0005, 0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60},
}, []string{"pod", "kind"})
TopicProcessDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_topic_process_duration_seconds",
Help: "End-to-end topic event processing duration by pod, topic kind, role, and operation.",
Buckets: []float64{0.0005, 0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60},
}, []string{"pod", "kind", "role", "operation"})
TopicBroadcastDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_topic_broadcast_duration_seconds",
Help: "Time spent preparing and enqueueing one topic broadcast by pod, topic kind, and role.",
Buckets: []float64{0.0005, 0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
}, []string{"pod", "kind", "role"})
TopicBroadcastTargets = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_topic_broadcast_targets",
Help: "Number of local WebSocket, multiplex, or other targets considered by one topic broadcast.",
Buckets: []float64{0, 1, 10, 50, 100, 250, 500, 750, 1000, 1500, 2000, 3000},
}, []string{"pod", "kind", "role", "target_type"})
TopicSharedSerializationTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "im_topic_shared_serialization_total",
Help: "Shared WebSocket serialization events by pod, topic kind, and result.",
}, []string{"pod", "kind", "result"})
TopicBroadcastDropTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "im_topic_broadcast_drop_total",
Help: "Realtime topic broadcasts skipped by pod, topic kind, role, and reason.",
}, []string{"pod", "kind", "role", "reason"})
ClusterM2PQueueWaitDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_cluster_m2p_queue_wait_duration_seconds",
Help: "Time a master-to-proxy response waited in the multiplex outbound queue by pod and peer.",
Buckets: []float64{0.0005, 0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
}, []string{"pod", "peer"})
ClusterM2PQueueDepth = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_cluster_m2p_queue_depth",
Help: "Master-to-proxy multiplex outbound queue depth observed after enqueue by pod and peer.",
Buckets: []float64{0, 1, 4, 8, 16, 32, 64, 96, 128, 160},
}, []string{"pod", "peer"})
ClusterProxyQueueWaitDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_cluster_proxy_queue_wait_duration_seconds",
Help: "Time a cluster response waited in the proxy topic queue by pod and message type.",
Buckets: []float64{0.0005, 0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60},
}, []string{"pod", "message_type"})
WSReadDispatchDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "im_ws_read_dispatch_duration_seconds",
Help: "Time from a completed WebSocket frame read through JSON decoding and local dispatch.",
Buckets: []float64{0.0001, 0.0005, 0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"pod"})
PushDispatchQueueEnqueueTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "im_push_dispatch_queue_enqueue_total",
Help: "Asynchronous push dispatch queue enqueue attempts by pod and result.",
@@ -242,6 +285,8 @@ func init() {
ClusterP2MQueueCapacity, ClusterP2MEnqueueTotal, ClusterRPCInflight, ClusterRPCDuration,
ClusterRouteTotal, ClusterProxyResponseTotal, InternalQueueEnqueueTotal, InternalQueueDepth,
InternalQueueCapacity, InternalQueueCurrent, MessagePersistDuration, MessagePublishStageDuration, TopicQueueWaitDuration,
TopicProcessDuration, TopicBroadcastDuration, TopicBroadcastTargets, TopicSharedSerializationTotal,
TopicBroadcastDropTotal, ClusterM2PQueueWaitDuration, ClusterM2PQueueDepth, ClusterProxyQueueWaitDuration, WSReadDispatchDuration,
PushDispatchQueueEnqueueTotal, PushDispatchQueueDepth, PushDispatchQueueWaitDuration,
PushDispatchDuration, PushRecipientCount,
KafkaMessagePersistTotal)
@@ -279,6 +324,51 @@ func ObserveTopicQueueWait(kind string, queuedAt time.Time) {
TopicQueueWaitDuration.WithLabelValues(metricPodName(), normalizeLabel(kind)).Observe(time.Since(queuedAt).Seconds())
}
func ObserveTopicProcess(kind, role, operation string, started time.Time) {
TopicProcessDuration.WithLabelValues(metricPodName(), normalizeLabel(kind), normalizeLabel(role),
normalizeLabel(operation)).Observe(time.Since(started).Seconds())
}
func ObserveTopicBroadcast(kind, role string, started time.Time, websocketTargets, multiplexTargets, otherTargets int) {
pod := metricPodName()
kind = normalizeLabel(kind)
role = normalizeLabel(role)
TopicBroadcastDuration.WithLabelValues(pod, kind, role).Observe(time.Since(started).Seconds())
TopicBroadcastTargets.WithLabelValues(pod, kind, role, "websocket").Observe(float64(websocketTargets))
TopicBroadcastTargets.WithLabelValues(pod, kind, role, "multiplex").Observe(float64(multiplexTargets))
TopicBroadcastTargets.WithLabelValues(pod, kind, role, "other").Observe(float64(otherTargets))
}
func IncTopicSharedSerialization(kind, result string) {
TopicSharedSerializationTotal.WithLabelValues(metricPodName(), normalizeLabel(kind), normalizeLabel(result)).Inc()
}
func IncTopicBroadcastDrop(kind, role, reason string) {
TopicBroadcastDropTotal.WithLabelValues(metricPodName(), normalizeLabel(kind), normalizeLabel(role), normalizeLabel(reason)).Inc()
}
func ObserveClusterM2PEnqueue(peer string, depth int) {
ClusterM2PQueueDepth.WithLabelValues(metricPodName(), normalizeLabel(peer)).Observe(float64(depth))
}
func ObserveClusterM2PDequeue(peer string, queuedAt time.Time) {
if queuedAt.IsZero() {
return
}
ClusterM2PQueueWaitDuration.WithLabelValues(metricPodName(), normalizeLabel(peer)).Observe(time.Since(queuedAt).Seconds())
}
func ObserveClusterProxyQueueWait(messageType string, queuedAt time.Time) {
if queuedAt.IsZero() {
return
}
ClusterProxyQueueWaitDuration.WithLabelValues(metricPodName(), normalizeLabel(messageType)).Observe(time.Since(queuedAt).Seconds())
}
func ObserveWSReadDispatch(started time.Time) {
WSReadDispatchDuration.WithLabelValues(metricPodName()).Observe(time.Since(started).Seconds())
}
func ObservePushDispatchEnqueue(depth int, accepted bool) {
result := "ok"
if !accepted {
+14 -1
View File
@@ -98,6 +98,14 @@ func TestServeMetricsIncludesWSConnections(t *testing.T) {
immetrics.ObserveWSSendQueueDequeue(time.Now(), false)
immetrics.ObserveCtrlACKDelivery("write", time.Now())
immetrics.ObserveTopicQueueWait("group", time.Now())
immetrics.ObserveTopicProcess("group", "master", "publish", time.Now())
immetrics.ObserveTopicBroadcast("group", "master", time.Now(), 1, 0, 0)
immetrics.IncTopicSharedSerialization("group", "serialized")
immetrics.IncTopicBroadcastDrop("group", "master", "ws_backpressure")
immetrics.ObserveClusterM2PEnqueue("peer", 1)
immetrics.ObserveClusterM2PDequeue("peer", time.Now())
immetrics.ObserveClusterProxyQueueWait("data", time.Now())
immetrics.ObserveWSReadDispatch(time.Now())
immetrics.ObservePushDispatchEnqueue(1, true)
immetrics.ObservePushDispatch(time.Now(), 1, time.Now(), nil)
wrt := httptest.NewRecorder()
@@ -117,7 +125,12 @@ func TestServeMetricsIncludesWSConnections(t *testing.T) {
"im_ctrl_ack_delivery_duration_seconds",
"im_cluster_p2m_enqueue_total", "im_cluster_rpc_duration_seconds", "im_cluster_route_total",
"im_internal_queue_enqueue_total", "im_internal_queue_depth", "im_internal_queue_capacity", "im_internal_queue_current",
"im_topic_queue_wait_duration_seconds", "im_push_dispatch_queue_enqueue_total",
"im_topic_queue_wait_duration_seconds", "im_topic_process_duration_seconds",
"im_topic_broadcast_duration_seconds", "im_topic_broadcast_targets", "im_topic_shared_serialization_total",
"im_topic_broadcast_drop_total",
"im_cluster_m2p_queue_wait_duration_seconds", "im_cluster_m2p_queue_depth",
"im_cluster_proxy_queue_wait_duration_seconds", "im_ws_read_dispatch_duration_seconds",
"im_push_dispatch_queue_enqueue_total",
"im_push_dispatch_queue_depth", "im_push_dispatch_queue_wait_duration_seconds",
"im_push_dispatch_duration_seconds", "im_push_recipient_count",
"im_kafka_message_persist_total", "im_kafka_consumer_lag", "im_kafka_dead_letters_total"} {
+1 -4
View File
@@ -389,9 +389,6 @@ func sendPush(rcpt *push.Receipt) {
}
if len(local.PushRcpt.To) > 0 || local.PushRcpt.Channel != "" {
select {
case globals.usersUpdate <- local:
default:
}
enqueueUserUpdate(local, "push", false)
}
}
+75 -18
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/auth"
@@ -27,6 +28,29 @@ const (
realtimeTargetUsers = "users"
)
const platformEvictFanoutDebounce = 750 * time.Millisecond
type platformEvictFanoutKey struct {
tenantID types.TenantID
uid types.Uid
platf string
reason string
}
type platformEvictFanoutPending struct {
skipSid string
timer *time.Timer
}
var platformEvictFanout = struct {
sync.Mutex
pending map[platformEvictFanoutKey]*platformEvictFanoutPending
}{
pending: make(map[platformEvictFanoutKey]*platformEvictFanoutPending),
}
var publishRealtimeFanout = store.PublishRealtimeFanout
type realtimeGroupUnreadRecipient struct {
User string `json:"user"`
Source string `json:"source"`
@@ -69,7 +93,7 @@ func publishRealtimeTopicMessage(topic string, msg *ServerComMessage) {
return
}
dedupKey := realtimeMessageDedupKey(topic, msg)
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetTopic, topic, msg.SkipSid, dedupKey, msg); err != nil {
if err := publishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetTopic, topic, msg.SkipSid, dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetTopic, "error")
logs.Warn.Printf("redis realtime fanout: publish topic=%s failed: %v", topic, err)
return
@@ -89,7 +113,7 @@ func publishRealtimeUserMessage(uid types.Uid, msg *ServerComMessage) {
return
}
dedupKey := realtimeMessageDedupKey(uid.UserId(), msg)
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetUser, uid.UserId(), msg.SkipSid, dedupKey, msg); err != nil {
if err := publishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindMessage, realtimeTargetUser, uid.UserId(), msg.SkipSid, dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "error")
logs.Warn.Printf("redis realtime fanout: publish user=%s failed: %v", uid.UserId(), err)
return
@@ -109,7 +133,7 @@ func publishRealtimeGroupUnread(tenantID types.TenantID, topic, actor string, se
Recipients: recipients,
}
dedupKey := "group-unread:" + topic + ":" + strconv.FormatInt(seqID, 10)
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindGroupUnread,
if err := publishRealtimeFanout(currentNodeID(), tenantID, realtimeKindGroupUnread,
realtimeTargetUsers, topic, "", dedupKey, payload); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindGroupUnread, realtimeTargetUsers, "error")
logs.Warn.Printf("redis realtime fanout: publish group unread topic=%s seq=%d failed: %v", topic, seqID, err)
@@ -140,7 +164,7 @@ func publishRealtimeCallEvent(msg *ClientComMessage) {
Timestamp: msg.Timestamp,
}
dedupKey := "call:" + msg.RcptTo + ":" + msg.AsUser + ":" + event.Event + ":" + strconv.FormatInt(msg.Note.SeqId, 10)
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindCallEvent, realtimeTargetTopic, msg.RcptTo, sid, dedupKey, event); err != nil {
if err := publishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindCallEvent, realtimeTargetTopic, msg.RcptTo, sid, dedupKey, event); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindCallEvent, realtimeTargetTopic, "error")
logs.Warn.Printf("redis realtime fanout: publish call event topic=%s from=%s event=%s seq=%d failed: %v",
msg.RcptTo, msg.AsUser, event.Event, msg.Note.SeqId, err)
@@ -162,7 +186,7 @@ func publishRealtimeEvictUser(tenantID types.TenantID, uid types.Uid, reason str
msg.Ctrl.Params = map[string]any{"reason": reason}
}
dedupKey := "evict:user:" + uid.UserId() + ":" + reason
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetUser, uid.UserId(), skipSid, dedupKey, msg); err != nil {
if err := publishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetUser, uid.UserId(), skipSid, dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetUser, "error")
logs.Warn.Printf("redis realtime fanout: publish evict user=%s failed: %v", uid.UserId(), err)
return
@@ -183,7 +207,7 @@ func publishRealtimeEvictDevice(tenantID types.TenantID, uid types.Uid, deviceID
}
target := uid.UserId() + ":" + strings.TrimSpace(deviceID)
dedupKey := "evict:device:" + target + ":" + reason
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetDevice, target, "", dedupKey, msg); err != nil {
if err := publishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetDevice, target, "", dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetDevice, "error")
logs.Warn.Printf("redis realtime fanout: publish evict device=%s failed: %v", target, err)
return
@@ -191,24 +215,52 @@ func publishRealtimeEvictDevice(tenantID types.TenantID, uid types.Uid, deviceID
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetDevice, "ok")
}
// publishRealtimeEvictUserPlatform evicts all sessions of the user on the given
// platform across cluster nodes. skipSid (the new login session) is part of the
// dedup key because repeated logins on the same platform within the dedup window
// must each reach the subscriber nodes.
func publishRealtimeEvictUserPlatform(tenantID types.TenantID, uid types.Uid, platf, reason, skipSid string) {
if !tenantID.IsValid() || uid.IsZero() || strings.TrimSpace(platf) == "" {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetPlatf, "skip_invalid")
return
}
key := platformEvictFanoutKey{
tenantID: tenantID,
uid: uid,
platf: strings.TrimSpace(platf),
reason: reason,
}
platformEvictFanout.Lock()
if pending := platformEvictFanout.pending[key]; pending != nil {
pending.skipSid = skipSid
pending.timer.Reset(platformEvictFanoutDebounce)
platformEvictFanout.Unlock()
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetPlatf, "coalesced")
return
}
pending := &platformEvictFanoutPending{skipSid: skipSid}
pending.timer = time.AfterFunc(platformEvictFanoutDebounce, func() {
platformEvictFanout.Lock()
current := platformEvictFanout.pending[key]
if current != pending {
platformEvictFanout.Unlock()
return
}
delete(platformEvictFanout.pending, key)
finalSkipSid := pending.skipSid
platformEvictFanout.Unlock()
publishRealtimeEvictUserPlatformNow(key.tenantID, key.uid, key.platf, key.reason, finalSkipSid)
})
platformEvictFanout.pending[key] = pending
platformEvictFanout.Unlock()
}
func publishRealtimeEvictUserPlatformNow(tenantID types.TenantID, uid types.Uid, platf, reason, skipSid string) {
msg := NoErrEvicted("", "", types.TimeNow())
msg.TenantID = tenantID
msg.AsUser = uid.UserId()
if reason != "" {
msg.Ctrl.Params = map[string]any{"reason": reason}
}
target := uid.UserId() + ":" + strings.TrimSpace(platf)
target := uid.UserId() + ":" + platf
dedupKey := "evict:platform:" + target + ":" + skipSid
if err := store.PublishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetPlatf, target, skipSid, dedupKey, msg); err != nil {
if err := publishRealtimeFanout(currentNodeID(), tenantID, realtimeKindSessionControl, realtimeTargetPlatf, target, skipSid, dedupKey, msg); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetPlatf, "error")
logs.Warn.Printf("redis realtime fanout: publish evict platform=%s failed: %v", target, err)
return
@@ -472,28 +524,33 @@ func deliverRealtimeSessionControl(envelope *store.RealtimeFanoutEnvelope, msg *
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
return
}
globals.sessionStore.EvictUserDevice(types.ParseUserId(uidText), deviceID, fanoutEvictReason(msg))
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
recordRealtimeSessionControlEvict(envelope, globals.sessionStore.EvictUserDevice(types.ParseUserId(uidText), deviceID, fanoutEvictReason(msg)))
case realtimeTargetPlatf:
uidText, platf, ok := strings.Cut(envelope.Target, ":")
if !ok {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
return
}
globals.sessionStore.EvictUserPlatform(types.ParseUserId(uidText), platf, envelope.SkipSid, fanoutEvictReason(msg))
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
recordRealtimeSessionControlEvict(envelope, globals.sessionStore.EvictUserPlatform(types.ParseUserId(uidText), platf, envelope.SkipSid, fanoutEvictReason(msg)))
case realtimeTargetUser:
uid := types.ParseUserId(envelope.Target)
if uid.IsZero() {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_invalid_target")
return
}
globals.sessionStore.EvictUser(uid, envelope.SkipSid, fanoutEvictReason(msg))
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
recordRealtimeSessionControlEvict(envelope, globals.sessionStore.EvictUser(uid, envelope.SkipSid, fanoutEvictReason(msg)))
}
_ = msg
}
func recordRealtimeSessionControlEvict(envelope *store.RealtimeFanoutEnvelope, evicted int) {
if evicted > 0 {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "evicted")
return
}
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "no_local_session")
}
func recordRealtimeFanoutDeliverResult(kind, targetType string, delivered int) {
if delivered > 0 {
immetrics.IncRealtimeFanout("deliver", kind, targetType, "local_delivered")
+66
View File
@@ -260,6 +260,61 @@ func TestRealtimeSessionControlStaleEvictDropped(t *testing.T) {
}
}
func TestRealtimePlatformEvictFanoutCoalescesToLatestSession(t *testing.T) {
oldPublish := publishRealtimeFanout
t.Cleanup(func() {
publishRealtimeFanout = oldPublish
resetPlatformEvictFanoutForTest()
})
resetPlatformEvictFanoutForTest()
type call struct {
target string
skipSid string
dedupKey string
}
calls := make(chan call, 3)
publishRealtimeFanout = func(_ string, _ types.TenantID, kind, targetType, target, skipSid, dedupKey string, _ any) error {
if kind == realtimeKindSessionControl && targetType == realtimeTargetPlatf {
calls <- call{target: target, skipSid: skipSid, dedupKey: dedupKey}
}
return nil
}
tenantID := types.TenantID(1)
uid := types.Uid(1001)
publishRealtimeEvictUserPlatform(tenantID, uid, "web", "replaced_by_new_login", "sid-old")
publishRealtimeEvictUserPlatform(tenantID, uid, "web", "replaced_by_new_login", "sid-new")
publishRealtimeEvictUserPlatform(tenantID, uid, "web", "replaced_by_new_login", "sid-final")
select {
case got := <-calls:
t.Fatalf("evict fanout published before debounce: %#v", got)
case <-time.After(platformEvictFanoutDebounce / 2):
}
select {
case got := <-calls:
if got.target != uid.UserId()+":web" {
t.Fatalf("target = %q", got.target)
}
if got.skipSid != "sid-final" {
t.Fatalf("skipSid = %q, want latest", got.skipSid)
}
if got.dedupKey != "evict:platform:"+uid.UserId()+":web:sid-final" {
t.Fatalf("dedupKey = %q", got.dedupKey)
}
case <-time.After(2 * platformEvictFanoutDebounce):
t.Fatal("timed out waiting for coalesced evict fanout")
}
select {
case got := <-calls:
t.Fatalf("expected one coalesced fanout, got extra %#v", got)
default:
}
}
func TestRealtimeP2PTopicFallbackRewritesVisibleInfoTopic(t *testing.T) {
oldStore := globals.sessionStore
oldHub := globals.hub
@@ -314,6 +369,17 @@ func TestRealtimeP2PTopicFallbackRewritesVisibleInfoTopic(t *testing.T) {
}
}
func resetPlatformEvictFanoutForTest() {
platformEvictFanout.Lock()
defer platformEvictFanout.Unlock()
for key, pending := range platformEvictFanout.pending {
if pending.timer != nil {
pending.timer.Stop()
}
delete(platformEvictFanout.pending, key)
}
}
func TestRealtimeCallEventRoutesToLocalTopic(t *testing.T) {
oldHub := globals.hub
defer func() { globals.hub = oldHub }()
+86 -8
View File
@@ -35,6 +35,11 @@ import (
// Maximum number of queued messages before session is considered stale and dropped.
const sendQueueLimit = 128
const (
lossyBroadcastQueueLimit = sendQueueLimit / 2
lossyBroadcastMaxWait = 2 * time.Second
)
// Multiplex sessions may receive a short burst of presence updates while many
// remote connections are closing. Give the cluster drain a bounded chance to
// free one slot before treating the proxy as stuck.
@@ -195,6 +200,9 @@ type Subscription struct {
supd chan<- *sessionUpdate
}
// sharedServerMessage is an immutable JSON payload reused by multiple WebSocket sessions.
type sharedServerMessage []byte
func (s *Session) enqueueOutbound(payload any) bool {
if s.isMultiplex() {
return s.enqueueMultiplexOutbound(payload, multiplexEnqueueWait)
@@ -234,8 +242,16 @@ func (s *Session) enqueueMultiplexOutbound(payload any, wait time.Duration) bool
s.clusterQueueMu.Unlock()
return true
}
if msg, ok := payload.(*ServerComMessage); ok {
msg.clusterQueuedAt = time.Now()
}
select {
case s.send <- payload:
peer := "unknown"
if s.clnode != nil {
peer = s.clnode.name
}
immetrics.ObserveClusterM2PEnqueue(peer, len(s.send))
s.clusterQueueMu.Unlock()
return true
default:
@@ -525,6 +541,49 @@ func (s *Session) queueOutBytes(data []byte) bool {
return true
}
func (s *Session) queueOutSharedBytes(data []byte) bool {
if s == nil || atomic.LoadInt32(&s.terminating) > 0 {
return true
}
if !s.enqueueOutbound(sharedServerMessage(data)) {
logs.Err.Println("s.queueOutSharedBytes: session's send queue full", s.sid)
return false
}
return true
}
func (s *Session) queueOutLossySharedBytes(data []byte) (queued bool, skipped bool) {
if s == nil || atomic.LoadInt32(&s.terminating) > 0 {
return true, false
}
if s.proto != WEBSOCK {
return s.queueOutSharedBytes(data), false
}
s.wsQueueMu.Lock()
depth := len(s.send)
stale := false
if s.wsQueueHead < len(s.wsQueueTimes) {
stale = time.Since(s.wsQueueTimes[s.wsQueueHead]) > lossyBroadcastMaxWait
}
if depth >= lossyBroadcastQueueLimit || stale {
immetrics.ObserveWSSendQueueEnqueue(depth, cap(s.send), false)
s.wsQueueMu.Unlock()
return true, true
}
select {
case s.send <- sharedServerMessage(data):
s.wsQueueTimes = append(s.wsQueueTimes, time.Now())
immetrics.ObserveWSSendQueueEnqueue(len(s.send), cap(s.send), true)
s.wsQueueMu.Unlock()
return true, false
default:
immetrics.ObserveWSSendQueueEnqueue(len(s.send), cap(s.send), false)
s.wsQueueMu.Unlock()
return true, true
}
}
func (s *Session) maybeScheduleClusterWriteLoop() {
if s.multi != nil {
s.multi.scheduleClusterWriteLoop()
@@ -960,14 +1019,33 @@ func (s *Session) publish(msg *ClientComMessage) {
if sub := s.getSub(msg.RcptTo); sub != nil {
// This is a post to a subscribed topic. The message is sent to the topic only
msg.queuedAt = time.Now()
select {
case sub.broadcast <- msg:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), true)
default:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), false)
// Reply with a 503 to the user.
s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
if topicQueueConfig.publishEnqueueWait > 0 {
timer := time.NewTimer(topicQueueConfig.publishEnqueueWait)
select {
case sub.broadcast <- msg:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), true)
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-timer.C:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), false)
// Reply with a 503 to the user.
s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
}
} else {
select {
case sub.broadcast <- msg:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), true)
default:
immetrics.ObserveInternalQueueEnqueue("session_topic_publish", len(sub.broadcast), cap(sub.broadcast), false)
// Reply with a 503 to the user.
s.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))
logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
}
}
} else if msg.RcptTo == "sys" {
if s.authLvl != auth.LevelRoot {
+4 -1
View File
@@ -338,7 +338,7 @@ func (ss *SessionStore) Shutdown() {
}
// EvictUser terminates all sessions of a given user.
func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string, reason ...string) {
func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string, reason ...string) int {
ss.lock.Lock()
defer ss.lock.Unlock()
@@ -348,6 +348,7 @@ func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string, reason ...strin
if len(reason) > 0 && reason[0] != "" {
evicted.Ctrl.Params = map[string]any{"reason": reason[0]}
}
count := 0
for _, s := range ss.sessCache {
if s.uid == uid && !s.isMultiplex() && s.sid != skipSid {
_, data := s.serialize(evicted)
@@ -356,10 +357,12 @@ func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string, reason ...strin
if s.proto == LPOLL {
ss.lru.Remove(s.lpTracker)
}
count++
}
}
statsSet("LiveSessions", int64(len(ss.sessCache)))
return count
}
// EvictUserDevice terminates all sessions of a given user connected with a device.
@@ -37,7 +37,7 @@ func (p localCryptoProvider) RegistrationSMSEnabled() (bool, error) {
func (p localCryptoProvider) ThirdPartyConfig(string, ...string) (map[string]string, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) LatestAppRelease(string, string) (*types.AppRelease, error) {
func (p localCryptoProvider) LatestAppRelease(string, string, string) (*types.AppRelease, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ModerationProvider() (*types.ModerationProviderConfig, error) {
+3 -1
View File
@@ -196,7 +196,9 @@ func encryptMessageContent(msg *types.Message) (*types.Message, error) {
}
func decryptMessageContent(msg *types.Message) error {
if msg == nil || msg.KeyVersion <= 0 {
// 撤回消息的内容已被清空(recall 会把 head/content 置 NULL,但 content_nonce/key_version 残留),
// 此类行没有密文可解,直接放行让上层按已撤回形态下发,否则整个 get data 窗口会被单条撤回消息毒化成 500。
if msg == nil || msg.KeyVersion <= 0 || msg.Content == nil {
return nil
}
entry, err := getMessageKey(msg.TenantID, msg.KeyVersion)
+21
View File
@@ -82,6 +82,27 @@ func TestMessageCryptoRejectsTampering(t *testing.T) {
}
}
// 撤回消息 content 已被清 NULL,但 recall UPDATE 保留了 content_nonce/key_version;
// 读路径解密必须跳过此类行,否则单条撤回消息会把整个 get data 窗口毒化成 500(2026-10-08 测试环境实案)。
func TestMessageCryptoSkipsRecalledContent(t *testing.T) {
tenantID := types.TenantID(9004)
key := bytes.Repeat([]byte{5}, 32)
expires := time.Now().Add(time.Minute)
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{enabled: true, version: 2, key: key, expires: expires}
messageKeyCache.items[messageKeyCacheID(tenantID, 2)] = cachedMessageKey{enabled: true, version: 2, key: key, expires: expires}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
recalled := &types.Message{TenantID: tenantID, Topic: "p2p-recalled", SeqId: 64, KeyVersion: 2, ContentNonce: bytes.Repeat([]byte{1}, 12), Content: nil}
if err := decryptMessageContent(recalled); err != nil {
t.Fatalf("expected recalled message with nil content to be skipped, got: %v", err)
}
if recalled.Content != nil {
t.Fatalf("nil content should stay nil: %#v", recalled.Content)
}
}
func TestMessageCryptoSearchMatchesDecryptedContent(t *testing.T) {
content := map[string]any{"txt": "项目 验收通过", "nested": map[string]any{"title": "日报"}}
if !messageContentMatchesSearch(content, "验收") {
+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")
}
}
+92 -11
View File
@@ -171,7 +171,7 @@ type AdminInternalConfigProvider interface {
ListActiveTenants(limit int) ([]types.Tenant, error)
RegistrationSMSEnabled() (bool, error)
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
LatestAppRelease(platform, channel string) (*types.AppRelease, error)
LatestAppRelease(appCode, platform, channel string) (*types.AppRelease, error)
ModerationProvider() (*types.ModerationProviderConfig, error)
ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error)
SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error)
@@ -1892,6 +1892,7 @@ type GroupBusinessPersistenceInterface interface {
UpsertMember(types.TenantID, *types.GroupMember) (*types.GroupMember, error)
GetMember(types.TenantID, string, types.Uid) (*types.GroupMember, error)
ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error)
MembersCount(types.TenantID, []string) (map[string]int64, error)
UpdateMember(types.TenantID, string, types.Uid, types.GroupMemberPatch) (*types.GroupMember, error)
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
AcceptInvitation(types.TenantID, string, types.Uid, any, time.Time) (*types.GroupInvitation, error)
@@ -2064,6 +2065,27 @@ func (groupBusinessMapper) GetMember(tenantID types.TenantID, topic string, uid
return member, err
}
func (groupBusinessMapper) MembersCount(tenantID types.TenantID, topics []string) (map[string]int64, error) {
cleaned := make([]string, 0, len(topics))
seen := make(map[string]bool, len(topics))
for _, topic := range topics {
topic = strings.TrimSpace(topic)
if topic == "" || seen[topic] {
continue
}
seen[topic] = true
cleaned = append(cleaned, topic)
}
if len(cleaned) == 0 {
return map[string]int64{}, nil
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupMembersCount(tenantID, cleaned)
}
func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
if strings.TrimSpace(topic) == "" {
return nil, "", types.ErrMalformed
@@ -2606,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
@@ -2633,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"`
@@ -2654,20 +2698,21 @@ type profileMapper struct{}
var Profiles ProfilePersistenceInterface
type AppReleasePersistenceInterface interface {
Latest(platform, channel string) (*types.AppRelease, error)
Latest(appCode, platform, channel string) (*types.AppRelease, error)
}
type appReleaseMapper struct{}
var AppReleases AppReleasePersistenceInterface
func (appReleaseMapper) Latest(platform, channel string) (*types.AppRelease, error) {
func (appReleaseMapper) Latest(appCode, platform, channel string) (*types.AppRelease, error) {
platform = strings.ToLower(strings.TrimSpace(platform))
channel = strings.ToLower(strings.TrimSpace(channel))
if platform == "" || channel == "" {
appCode = strings.ToLower(strings.TrimSpace(appCode))
if platform == "" || channel == "" || appCode == "" {
return nil, types.ErrMalformed
}
cacheKey := platform + ":" + channel
cacheKey := appCode + ":" + platform + ":" + channel
if cached, ok := appReleaseConfigCache.get(cacheKey); ok {
copy := *cached
return &copy, nil
@@ -2675,7 +2720,7 @@ func (appReleaseMapper) Latest(platform, channel string) (*types.AppRelease, err
if adminInternalConfigProvider == nil {
return nil, types.ErrUnsupported
}
release, err := adminInternalConfigProvider.LatestAppRelease(platform, channel)
release, err := adminInternalConfigProvider.LatestAppRelease(appCode, platform, channel)
if err != nil {
return nil, err
}
@@ -2973,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 {
@@ -3020,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)
@@ -3108,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"},
+1
View File
@@ -56,6 +56,7 @@ type UserPrivacyPatch struct {
type AppRelease struct {
ID int64
AppCode string
Platform string
Channel string
LatestVersion string
+125 -2
View File
@@ -202,6 +202,8 @@ const (
StopRehashing
)
const implicitGroupSubSelfOnlyThreshold = 1000
// Topic shutdown
type shutDown struct {
// Channel to report back completion of topic shutdown. Could be nil
@@ -650,12 +652,20 @@ func (t *Topic) runLocal(hub *Hub) {
// handleClientMsg is the top-level handler of messages received by the topic from sessions.
func (t *Topic) handleClientMsg(msg *ClientComMessage) {
started := time.Now()
kind := "other"
if t.cat == types.TopicCatP2P {
kind = "p2p"
} else if t.cat == types.TopicCatGrp {
kind = "group"
}
operation := "other"
if msg.Pub != nil {
operation = "publish"
} else if msg.Note != nil {
operation = "note"
}
defer immetrics.ObserveTopicProcess(kind, t.metricRole(), operation, started)
immetrics.ObserveTopicQueueWait(kind, msg.queuedAt)
msg.queuedAt = time.Time{}
if msg.TenantID.IsZero() {
@@ -1958,9 +1968,18 @@ func (t *Topic) handlePresence(msg *ServerComMessage) {
// broadcastToSessions writes message to attached sessions.
func (t *Topic) broadcastToSessions(msg *ServerComMessage) {
started := time.Now()
kind := t.metricKind()
role := t.metricRole()
websocketTargets, multiplexTargets, otherTargets := 0, 0, 0
defer func() {
immetrics.ObserveTopicBroadcast(kind, role, started, websocketTargets, multiplexTargets, otherTargets)
}()
msg.TenantID = t.tenantID
// List of sessions to be dropped.
var dropSessions []*Session
var sharedPayload []byte
sharedPayloadReady := false
// Broadcast the message. Only {data}, {pres}, {info} are broadcastable.
// {meta} and {ctrl} are sent to the session only
for sess, pssd := range t.sessions {
@@ -2027,7 +2046,49 @@ func (t *Topic) broadcastToSessions(msg *ServerComMessage) {
}
}
// Make a copy of msg since messages sent to sessions differ.
if sess.proto == WEBSOCK {
websocketTargets++
} else if sess.isMultiplex() {
multiplexTargets++
} else {
otherTargets++
}
// Ordinary group messages are identical for every local WebSocket subscriber.
// Serialize once and reuse immutable bytes to avoid O(recipients) JSON work.
if t.canShareSerializedBroadcast(msg, sess, pssd) {
if !sharedPayloadReady {
var err error
sharedPayload, err = json.Marshal(msg)
if err != nil {
immetrics.IncTopicSharedSerialization(kind, "error")
logs.Warn.Printf("topic[%s]: shared broadcast serialization failed: %v", t.name, err)
sharedPayload = nil
} else {
immetrics.IncTopicSharedSerialization(kind, "serialized")
}
sharedPayloadReady = true
}
if sharedPayload != nil {
if msg.Data != nil {
queued, skipped := sess.queueOutLossySharedBytes(sharedPayload)
if skipped {
immetrics.IncTopicBroadcastDrop(kind, role, "ws_backpressure")
}
if queued {
continue
}
} else if sess.queueOutSharedBytes(sharedPayload) {
continue
}
immetrics.IncTopicSharedSerialization(kind, "full")
logs.Warn.Printf("topic[%s]: connection stuck, detaching - %s", t.name, sess.sid)
dropSessions = append(dropSessions, sess)
continue
}
}
// Make a copy of msg since messages sent to sessions may differ.
msgCopy := msg.copy()
// Topic name may be different depending on the user to which the `sess` belongs.
t.prepareBroadcastableMessage(msgCopy, pssd.uid, pssd.isChanSub)
@@ -2046,6 +2107,48 @@ func (t *Topic) broadcastToSessions(msg *ServerComMessage) {
}
}
func (t *Topic) metricKind() string {
switch t.cat {
case types.TopicCatGrp:
return "group"
case types.TopicCatP2P:
return "p2p"
default:
return "other"
}
}
func (t *Topic) metricRole() string {
if t.isProxy {
return "proxy"
}
return "master"
}
func serverMessageType(msg *ServerComMessage) string {
switch {
case msg == nil:
return "unknown"
case msg.Data != nil:
return "data"
case msg.Ctrl != nil:
return "ctrl"
case msg.Pres != nil:
return "pres"
case msg.Info != nil:
return "info"
case msg.Meta != nil:
return "meta"
default:
return "unknown"
}
}
func (t *Topic) canShareSerializedBroadcast(msg *ServerComMessage, sess *Session, pssd perSessionData) bool {
return t.cat == types.TopicCatGrp && !t.isChan && sess.proto == WEBSOCK && !pssd.isChanSub &&
(msg.Data != nil || msg.Pres != nil || msg.Info != nil)
}
// subscriptionReply generates a response to a subscription request
func (t *Topic) subscriptionReply(asChan bool, msg *ClientComMessage) error {
// The topic is already initialized by the Hub
@@ -2793,7 +2896,6 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
if t.cat == types.TopicCatGrp {
desc.IsChan = t.isChan
desc.SubCnt = t.subCnt
logs.Info.Println("replyGetDesc: grp topic", t.name, "subs", t.subCnt)
}
if ifUpdated {
@@ -3252,6 +3354,12 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
// Channel subscribers are using chnXXX topic name rather than grpXXX.
topicName = msg.Original
}
if req = t.limitImplicitLargeGroupSubQuery(req, asUid, msg); req != nil {
ifModified = time.Time{}
if req.IfModifiedSince != nil {
ifModified = *req.IfModifiedSince
}
}
// Include sub.Public.
if ifModified.IsZero() {
// No cache management. Skip deleted subscriptions.
@@ -3456,6 +3564,21 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
return nil
}
func (t *Topic) limitImplicitLargeGroupSubQuery(req *MsgGetOpts, asUid types.Uid, msg *ClientComMessage) *MsgGetOpts {
if t.cat != types.TopicCatGrp || msg == nil || msg.Sub == nil || t.subCnt < implicitGroupSubSelfOnlyThreshold {
return req
}
if req != nil && (req.User != "" || req.Topic != "" || req.Limit != 0 || req.IfModifiedSince != nil) {
return req
}
limited := &MsgGetOpts{}
if req != nil {
*limited = *req
}
limited.User = asUid.UserId()
return limited
}
// replySetSub is a response to new subscription request or an update to a subscription {set.sub}:
// update topic metadata cache, save/update subs, reply to the caller as {ctrl} message,
// generate a presence notification, if appropriate.
+1
View File
@@ -213,6 +213,7 @@ func (t *Topic) handleProxyLeaveRequest(msg *ClientComMessage, killTimer *time.T
// proxyMasterResponse at proxy topic processes a master topic response to an earlier request.
func (t *Topic) proxyMasterResponse(msg *ClusterResp, killTimer *time.Timer) {
immetrics.ObserveClusterProxyQueueWait(serverMessageType(msg.SrvMsg), msg.proxyQueuedAt)
// Kills topic after a period of inactivity.
keepAlive := idleProxyTopicTimeout
+187
View File
@@ -2254,6 +2254,121 @@ func TestReplyGetDescInvalidOpts(t *testing.T) {
}
}
func TestReplyGetSubLimitsImplicitLargeGroupSubscriptionQuery(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, 2, types.TopicCatGrp, "grpLarge", true)
defer helper.tearDown()
uid := helper.uids[0]
helper.topic.subCnt = implicitGroupSubSelfOnlyThreshold
helper.topic.perUser[uid] = perUserData{
modeWant: types.ModeCReadOnly,
modeGiven: types.ModeCReadOnly,
}
helper.tt.EXPECT().
GetUsers(types.TenantID(1), "grpLarge", gomock.AssignableToTypeOf(&types.QueryOpt{})).
DoAndReturn(func(_ types.TenantID, _ string, opts *types.QueryOpt) ([]types.Subscription, error) {
if opts == nil {
t.Fatal("expected limited query options")
}
if opts.User != uid {
t.Fatalf("expected query to be limited to %s, got %s", uid.UserId(), opts.User.UserId())
}
return []types.Subscription{{
User: uid.String(),
Topic: "grpLarge",
ModeWant: types.ModeCReadOnly,
ModeGiven: types.ModeCReadOnly,
}}, nil
})
msg := &ClientComMessage{
Id: "sub1",
Original: "grpLarge",
AsUser: uid.UserId(),
TenantID: 1,
Sub: &MsgClientSub{Get: &MsgGetQuery{
What: "sub",
Sub: nil,
}},
}
if err := helper.topic.replyGetSub(helper.sessions[0], uid, auth.LevelAuth, false, msg); err != nil {
t.Fatalf("replyGetSub failed: %v", err)
}
helper.finish()
if len(helper.results[0].messages) != 1 {
t.Fatalf("expected one meta response, got %d", len(helper.results[0].messages))
}
resp := helper.results[0].messages[0].(*ServerComMessage)
if resp.Meta == nil {
t.Fatalf("expected meta response")
}
if len(resp.Meta.Sub) != 1 {
t.Fatalf("expected one subscription, got %d", len(resp.Meta.Sub))
}
if got := resp.Meta.Sub[0].User; got != uid.UserId() {
t.Fatalf("expected self subscription %s, got %s", uid.UserId(), got)
}
}
func TestReplyGetSubKeepsExplicitLargeGroupQuery(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, 2, types.TopicCatGrp, "grpLarge", true)
defer helper.tearDown()
uid := helper.uids[0]
peer := helper.uids[1]
helper.topic.subCnt = implicitGroupSubSelfOnlyThreshold
helper.topic.perUser[uid] = perUserData{
modeWant: types.ModeCReadOnly,
modeGiven: types.ModeCReadOnly,
}
helper.tt.EXPECT().
GetUsers(types.TenantID(1), "grpLarge", gomock.Nil()).
Return([]types.Subscription{
{
User: uid.String(),
Topic: "grpLarge",
ModeWant: types.ModeCReadOnly,
ModeGiven: types.ModeCReadOnly,
},
{
User: peer.String(),
Topic: "grpLarge",
ModeWant: types.ModeCReadOnly,
ModeGiven: types.ModeCReadOnly,
},
}, nil)
msg := &ClientComMessage{
Id: "get1",
Original: "grpLarge",
AsUser: uid.UserId(),
TenantID: 1,
Get: &MsgClientGet{MsgGetQuery: MsgGetQuery{What: "sub"}},
}
if err := helper.topic.replyGetSub(helper.sessions[0], uid, auth.LevelAuth, false, msg); err != nil {
t.Fatalf("replyGetSub failed: %v", err)
}
helper.finish()
if len(helper.results[0].messages) != 1 {
t.Fatalf("expected one meta response, got %d", len(helper.results[0].messages))
}
resp := helper.results[0].messages[0].(*ServerComMessage)
if resp.Meta == nil {
t.Fatalf("expected meta response")
}
if len(resp.Meta.Sub) != 2 {
t.Fatalf("expected explicit query to return all subscriptions, got %d", len(resp.Meta.Sub))
}
}
// Verifies ctrl codes in session outputs.
func registerSessionVerifyOutputs(t *testing.T, sessionOutput *responses, expectedCtrlCodes []int) {
t.Helper()
@@ -4334,3 +4449,75 @@ func TestMain(m *testing.M) {
globals.maxSubscriberCount = 1_000_000_000
os.Exit(m.Run())
}
func TestGroupBroadcastReusesSerializedWebSocketPayload(t *testing.T) {
uid1, uid2, uid3 := types.Uid(1), types.Uid(2), types.Uid(3)
sess1 := &Session{sid: "ws-1", proto: WEBSOCK, send: make(chan any, 2)}
sess2 := &Session{sid: "ws-2", proto: WEBSOCK, send: make(chan any, 2)}
sessNoRead := &Session{sid: "ws-no-read", proto: WEBSOCK, send: make(chan any, 2)}
topic := &Topic{
tenantID: 1,
name: "grpShared",
xoriginal: "grpShared",
cat: types.TopicCatGrp,
sessions: map[*Session]perSessionData{
sess1: {uid: uid1},
sess2: {uid: uid2},
sessNoRead: {uid: uid3},
},
perUser: map[types.Uid]perUserData{
uid1: {modeWant: types.ModeCFull, modeGiven: types.ModeCFull},
uid2: {modeWant: types.ModeCFull, modeGiven: types.ModeCFull},
uid3: {modeWant: types.ModeNone, modeGiven: types.ModeNone},
},
}
msg := &ServerComMessage{Data: &MsgServerData{
Topic: "grpShared", From: uid1.UserId(), SeqId: 42, Timestamp: time.Now(), Content: "hello",
}}
topic.broadcastToSessions(msg)
payload1, ok := (<-sess1.send).(sharedServerMessage)
if !ok {
t.Fatal("first WebSocket did not receive a serialized payload")
}
payload2, ok := (<-sess2.send).(sharedServerMessage)
if !ok {
t.Fatal("second WebSocket did not receive a serialized payload")
}
if len(payload1) == 0 || len(payload2) == 0 || &payload1[0] != &payload2[0] {
t.Fatal("group WebSocket recipients did not reuse the same serialized payload")
}
var decoded ServerComMessage
if err := json.Unmarshal(payload1, &decoded); err != nil {
t.Fatalf("invalid shared payload: %v", err)
}
if decoded.Data == nil || decoded.Data.Topic != "grpShared" || decoded.Data.SeqId != 42 || decoded.Data.Content != "hello" {
t.Fatalf("unexpected shared payload: %#v", decoded.Data)
}
if len(sessNoRead.send) != 0 {
t.Fatal("non-reader received a group broadcast")
}
}
func TestSharedBroadcastEligibility(t *testing.T) {
sess := &Session{proto: WEBSOCK}
msg := &ServerComMessage{Data: &MsgServerData{Topic: "grpShared"}}
for _, tc := range []struct {
name string
topic Topic
pssd perSessionData
want bool
}{
{name: "ordinary group", topic: Topic{cat: types.TopicCatGrp}, want: true},
{name: "channel", topic: Topic{cat: types.TopicCatGrp, isChan: true}},
{name: "channel subscription", topic: Topic{cat: types.TopicCatGrp}, pssd: perSessionData{isChanSub: true}},
{name: "p2p", topic: Topic{cat: types.TopicCatP2P}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.topic.canShareSerializedBroadcast(msg, sess, tc.pssd); got != tc.want {
t.Fatalf("eligibility=%v want=%v", got, tc.want)
}
})
}
}
+56 -21
View File
@@ -6,12 +6,14 @@ import (
"errors"
"math/rand"
"strings"
"sync/atomic"
"time"
"slices"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/push"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
@@ -23,8 +25,15 @@ const (
unreadUpdateIOPending = -1
// Counter initialization error.
unreadUpdateError = -2
usersUpdateQueueCapacity = 8192
usersUpdateEnqueueTimeout = 100 * time.Millisecond
usersUpdateQueueFullLogEvery = 5 * time.Second
usersUpdateInternalMetricQueue = "user_cache_update"
)
var usersUpdateQueueFullLastLog atomic.Int64
// Process request for a new account.
func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// The session cannot authenticate with the new account because it's already authenticated.
@@ -1325,7 +1334,9 @@ func (pq *pendingReceiptsQueue) fix(index int) {
// Initialize users cache.
func usersInit() {
globals.usersUpdate = make(chan *UserCacheReq, 1024)
globals.usersUpdate = make(chan *UserCacheReq, usersUpdateQueueCapacity)
immetrics.SetInternalQueueCurrent(usersUpdateInternalMetricQueue, 0)
immetrics.ObserveInternalQueueEnqueue(usersUpdateInternalMetricQueue, 0, usersUpdateQueueCapacity, true)
go userUpdater()
}
@@ -1347,10 +1358,7 @@ func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int64, inc bo
// Send request to remote node which owns the user.
globals.cluster.routeUserReq(upd)
} else {
select {
case globals.usersUpdate <- upd:
default:
}
enqueueUserUpdate(upd, "unread", false)
}
}
@@ -1368,10 +1376,7 @@ func usersRegisterUser(tenantID types.TenantID, uid types.Uid, add bool) {
// Send request to remote node which owns the user.
globals.cluster.routeUserReq(upd)
} else {
select {
case globals.usersUpdate <- upd:
default:
}
enqueueUserUpdate(upd, "user", true)
}
}
@@ -1383,10 +1388,7 @@ func usersRemoveUser(tenantID types.TenantID, uid types.Uid) {
upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Gone: true}
if !globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) {
select {
case globals.usersUpdate <- upd:
default:
}
enqueueUserUpdate(upd, "remove_user", true)
}
if globals.cluster != nil {
@@ -1431,24 +1433,56 @@ func usersRegisterTopic(t *Topic, add bool) {
}
if len(local.UserIdList) > 0 {
select {
case globals.usersUpdate <- local:
default:
logs.Err.Println("User cache: globals.usersUpdate queue full: ", len(globals.usersUpdate))
}
enqueueUserUpdate(local, "topic", true)
}
}
// usersRequestFromCluster handles requests which came from other cluser nodes.
func usersRequestFromCluster(req *UserCacheReq) {
if globals.usersUpdate == nil {
return
enqueueUserUpdate(req, "cluster", true)
}
func enqueueUserUpdate(req *UserCacheReq, kind string, waitOnFull bool) bool {
q := globals.usersUpdate
if req == nil || q == nil {
return false
}
select {
case globals.usersUpdate <- req:
case q <- req:
immetrics.ObserveInternalQueueEnqueue(usersUpdateInternalMetricQueue, len(q), cap(q), true)
immetrics.SetInternalQueueCurrent(usersUpdateInternalMetricQueue, len(q))
return true
default:
}
if waitOnFull {
timer := time.NewTimer(usersUpdateEnqueueTimeout)
defer timer.Stop()
select {
case q <- req:
immetrics.ObserveInternalQueueEnqueue(usersUpdateInternalMetricQueue, len(q), cap(q), true)
immetrics.SetInternalQueueCurrent(usersUpdateInternalMetricQueue, len(q))
return true
case <-timer.C:
}
}
immetrics.ObserveInternalQueueEnqueue(usersUpdateInternalMetricQueue, len(q), cap(q), false)
immetrics.SetInternalQueueCurrent(usersUpdateInternalMetricQueue, len(q))
logUsersUpdateQueueFull(kind, q)
return false
}
func logUsersUpdateQueueFull(kind string, q chan *UserCacheReq) {
now := time.Now().UnixNano()
last := usersUpdateQueueFullLastLog.Load()
if time.Duration(now-last) < usersUpdateQueueFullLogEvery ||
!usersUpdateQueueFullLastLog.CompareAndSwap(last, now) {
return
}
logs.Err.Printf("User cache: usersUpdate queue full kind=%s len=%d cap=%d wait=%s",
kind, len(q), cap(q), usersUpdateEnqueueTimeout)
}
var usersCache map[types.UserKey]userCacheEntry
@@ -1590,6 +1624,7 @@ func userUpdater() {
push.Push(rcpt)
}
case upd := <-globals.usersUpdate:
immetrics.SetInternalQueueCurrent(usersUpdateInternalMetricQueue, len(globals.usersUpdate))
if globals.shuttingDown {
// If shutdown is in progress we don't care to process anything.
// ignore all calls.
+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()