Compare commits

...
69 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
richjohnrambo 7b25365ec3 fix(group): bound push pressure and synchronize load agents 2026-10-04 00:49:27 +08:00
richjohnrambo 746f75ab0f fix(kafka): retain failed message persistence 2026-10-03 22:32:08 +08:00
richjohnrambo db92c41241 fix(loadtest): apply direct dial IP assignment 2026-10-03 20:35:31 +08:00
richjohnrambo 80808349fb fix: absorb multiplex close bursts 2026-10-02 15:53:03 +08:00
richjohnrambo a406d63154 fix: stop writes to closed cluster sessions 2026-10-02 00:28:02 +08:00
richjohnrambo d43b1b63d3 fix: preserve cross-pod message order 2026-10-01 19:00:16 +08:00
richjohnrambo dccd0969d8 fix: register cluster kv payloads 2026-10-01 16:08:00 +08:00
richjohnrambo 73a594146f fix: trace delayed websocket acknowledgements 2026-10-01 15:19:57 +08:00
richjohnrambo 19584d0939 fix: harden high-fanout delivery under load 2026-09-30 23:04:54 +08:00
richjohnrambo 2b631ab176 fix: harden websocket heartbeat under load 2026-09-30 17:36:33 +08:00
richjohnrambo b83800c0f3 docs: update legal agreements 2026-09-30 17:04:49 +08:00
richjohnrambo 05b3da55ac fix: align group private chat permission semantics 2026-09-30 16:11:30 +08:00
richjohnrambo 99ae6328ff fix: handle backend issue list regressions 2026-09-30 15:35:03 +08:00
richjohnrambo 7685fb0c02 fix: persist upload original filename 2026-09-30 14:25:44 +08:00
richjohnrambo 097b028a19 docs: record exact 600 regression runs 2026-09-30 11:54:04 +08:00
richjohnrambo dcb131dcea fix: make ai task events realtime only 2026-09-30 11:37:49 +08:00
richjohnrambo d75c6fa0b4 docs: record async push load regression 2026-09-30 10:52:01 +08:00
richjohnrambo 06e08a65e4 fix: handle opaque attachment references 2026-09-30 10:50:42 +08:00
richjohnrambo 9f30995787 fix: move group push work off topic loop 2026-09-30 10:01:59 +08:00
richjohnrambo da8efb6e38 feat: expose websocket delivery pressure metrics 2026-09-29 23:15:29 +08:00
richjohnrambo b1545b6876 fix: keep load connections through verification 2026-09-29 17:52:13 +08:00
richjohnrambo 4b00b5ae37 fix: harden single-machine load handling 2026-09-29 17:52:13 +08:00
Austin ab73b69870 feat: 新增修改密码和注销账号功能
新增修改密码和注销账号的API接口,实现用户通过验证旧密码修改密码,以及通过短信验证流程注销账号的功能,同时补充相关常量和文档说明。
2026-09-29 16:13:32 +08:00
Austin cf9d559944 fix: 修复资源不存在/已解散返回错误码及网关502问题
修复好友申请不存在时返回正确的 ErrNotFound 避免解引用 panic,新增通用函数判断群不存在或已解散并返回对应错误码,更新文档统一资源不存在类错误语义,避免服务端返回5xx错误。
2026-09-29 13:34:24 +08:00
Austin 1a8043bee2 docs: 更新 IM 消息历史分页的接口约定和向上翻页规则
补充向上翻页加载更早历史的参数约定,明确 REST 消息接口为空占位禁止使用,历史分页统一走 WebSocket 的 get data 接口,同步更新接口文档说明。
2026-09-29 11:08:48 +08:00
137 changed files with 10395 additions and 1186 deletions
+53 -81
View File
@@ -1,97 +1,69 @@
name: Deploy im-server (k3s test cluster) name: Deploy im-server production
on: on:
push:
branches:
- pro
workflow_dispatch: workflow_dispatch:
inputs:
image_tag: concurrency:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)' group: production-im-server
required: false 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: jobs:
deploy: deploy:
runs-on: deploy-host runs-on: deploy-host
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 确定镜像tag - name: Log in to Gitea Container Registry
id: vars env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="${{ github.event.inputs.image_tag }}" set -euo pipefail
[ -z "$TAG" ] && TAG=test printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 登录镜像仓库 - name: Build and push image
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin env:
GITHUB_SHA: ${{ github.sha }}
- name: 构建并推送镜像(从本仓库源码编译,不是官方预编译包)
run: | run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -f docker/tinode/Dockerfile.build \ docker build -f docker/tinode/Dockerfile.build \
-t git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} . -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} 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: | run: |
mkdir -p ~/.kube set -euo pipefail
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config install -d -m 700 "$HOME/.ssh"
chmod 600 ~/.kube/config KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
kubectl set resources statefulset/im-server -n im-server-system -c im-server \ cleanup() {
--requests=cpu=1,memory=512Mi --limits=cpu=2,memory=1Gi shred -u "$KEY_FILE" 2>/dev/null || true
kubectl set env statefulset/im-server -n im-server-system -c im-server \ }
CLUSTER_PROXY_TO_MASTER_BUFFER=4096 \ trap cleanup EXIT
CLUSTER_PROXY_TO_MASTER_WORKERS=16 \ printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS=300 \ chmod 600 "$KEY_FILE"
CLUSTER_ROUTE_RETRIES=3 \ printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25 \ chmod 600 "$HOME/.ssh/known_hosts"
MYSQL_MAX_OPEN_CONNS=24 \ SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
MYSQL_MAX_IDLE_CONNS=8 \ printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
MYSQL_REGISTRY_MAX_OPEN_CONNS=4 \ "docker login git.olns.shop -u admin --password-stdin"
MYSQL_REGISTRY_MAX_IDLE_CONNS=2 ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
kubectl patch statefulset/im-server -n im-server-system --type=strategic -p \ "cd /opt/olns && docker compose pull im-server && docker compose up -d --no-deps --wait im-server"
'{"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
+24 -1
View File
@@ -25,14 +25,37 @@ After ArgoCD sync, verify the effective configuration:
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].resources}' kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].resources}'
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.httpGet.path}' kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].livenessProbe.httpGet.path}'
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].env}' kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.containers[0].env}'
kubectl -n im-server-system get statefulset im-server -o jsonpath='{.spec.template.spec.dnsConfig.options}'
kubectl auth can-i list pods.metrics.k8s.io \ kubectl auth can-i list pods.metrics.k8s.io \
--as=system:serviceaccount:argocd:backend-debug \ --as=system:serviceaccount:argocd:backend-debug \
-n im-server-system -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 liveness path `/livez`. The MySQL pool environment must be
`MYSQL_MAX_OPEN_CONNS=24`, `MYSQL_MAX_IDLE_CONNS=8`, `MYSQL_MAX_OPEN_CONNS=24`, `MYSQL_MAX_IDLE_CONNS=8`,
`MYSQL_REGISTRY_MAX_OPEN_CONNS=4`, and `MYSQL_REGISTRY_MAX_IDLE_CONNS=2`. `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
`ClusterFirst` preserves short Kubernetes service-name resolution, while the
lower `ndots` value prevents fully qualified internal and external dependency
names from being expanded through every cluster search suffix first.
The application repository's deploy workflow waits for reconciliation and The application repository's deploy workflow waits for reconciliation and
fails if ArgoCD restores different values. fails if ArgoCD restores different values.
After the rollout, verify the generated resolver configuration on every Pod:
```bash
for pod in im-server-0 im-server-1 im-server-2; do
echo "--- ${pod}"
kubectl -n im-server-system exec "${pod}" -- cat /etc/resolv.conf
done
```
The output must retain the cluster DNS nameserver and search domains, with
`options ndots:1 timeout:2 attempts:2`. This mitigation reduces amplified DNS
traffic from the application Pods. If lookup timeouts remain under load,
CoreDNS capacity or NodeLocal DNSCache must be changed in the cluster
infrastructure repository by an administrator with `kube-system` access.
@@ -6,9 +6,22 @@ metadata:
spec: spec:
template: template:
spec: spec:
dnsPolicy: ClusterFirst
dnsConfig:
options:
- name: ndots
value: "1"
- name: timeout
value: "2"
- name: attempts
value: "2"
containers: containers:
- name: im-server - name: im-server
env: env:
- name: WS_PING_PERIOD_SECONDS
value: "15"
- name: WS_PONG_WAIT_SECONDS
value: "90"
- name: CLUSTER_PROXY_TO_MASTER_BUFFER - name: CLUSTER_PROXY_TO_MASTER_BUFFER
value: "4096" value: "4096"
- name: CLUSTER_PROXY_TO_MASTER_WORKERS - name: CLUSTER_PROXY_TO_MASTER_WORKERS
@@ -19,6 +32,12 @@ spec:
value: "3" value: "3"
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS - name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
value: "25" 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 - name: MYSQL_MAX_OPEN_CONNS
value: "24" value: "24"
- name: MYSQL_MAX_IDLE_CONNS - name: MYSQL_MAX_IDLE_CONNS
@@ -27,9 +46,11 @@ spec:
value: "4" value: "4"
- name: MYSQL_REGISTRY_MAX_IDLE_CONNS - name: MYSQL_REGISTRY_MAX_IDLE_CONNS
value: "2" value: "2"
- name: MYSQL_CONN_MAX_LIFETIME_SECONDS
value: "1800"
resources: resources:
requests: requests:
cpu: "1" cpu: "2"
memory: 512Mi memory: 512Mi
limits: limits:
cpu: "2" 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_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_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. | | `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). | | `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. | | `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]. | | `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
View File
@@ -194,6 +194,8 @@ ENV TNPG_ORG=
ENV JPUSH_PUSH_ENABLED=false ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push ENV JPUSH_PUSH_PATH=/v3/push
ENV JPUSH_WORKERS=8
ENV JPUSH_DEVICE_CACHE_TTL_SECONDS=30
ENV JPUSH_DEFAULT_TTL_SECONDS=86400 ENV JPUSH_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60 ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false ENV JPUSH_APNS_PRODUCTION=false
+2
View File
@@ -110,6 +110,8 @@ ENV TNPG_PUSH_ENABLED=false
ENV JPUSH_PUSH_ENABLED=false ENV JPUSH_PUSH_ENABLED=false
ENV JPUSH_API_DOMAIN=https://api.jpush.cn ENV JPUSH_API_DOMAIN=https://api.jpush.cn
ENV JPUSH_PUSH_PATH=/v3/push ENV JPUSH_PUSH_PATH=/v3/push
ENV JPUSH_WORKERS=8
ENV JPUSH_DEVICE_CACHE_TTL_SECONDS=30
ENV JPUSH_DEFAULT_TTL_SECONDS=86400 ENV JPUSH_DEFAULT_TTL_SECONDS=86400
ENV JPUSH_CALL_TTL_SECONDS=60 ENV JPUSH_CALL_TTL_SECONDS=60
ENV JPUSH_APNS_PRODUCTION=false ENV JPUSH_APNS_PRODUCTION=false
+8 -4
View File
@@ -5,6 +5,8 @@
"static_mount": "/", "static_mount": "/",
"grpc_listen": ":16060", "grpc_listen": ":16060",
"grpc_keepalive_enabled": true, "grpc_keepalive_enabled": true,
"ws_ping_period_seconds": $WS_PING_PERIOD_SECONDS,
"ws_pong_wait_seconds": $WS_PONG_WAIT_SECONDS,
"api_key_salt": "$API_KEY_SALT", "api_key_salt": "$API_KEY_SALT",
"max_message_size": 4194304, "max_message_size": 4194304,
"max_subscriber_count": 128, "max_subscriber_count": 128,
@@ -83,7 +85,7 @@
"max_idle_conns": $MYSQL_MAX_IDLE_CONNS, "max_idle_conns": $MYSQL_MAX_IDLE_CONNS,
"registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS, "registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS,
"registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS, "registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS,
"conn_max_lifetime": 60, "conn_max_lifetime": $MYSQL_CONN_MAX_LIFETIME_SECONDS,
"sql_timeout": 10, "sql_timeout": 10,
"tenant_registry_db": "$TENANT_REGISTRY_DB", "tenant_registry_db": "$TENANT_REGISTRY_DB",
"tenant_db_routing": true "tenant_db_routing": true
@@ -126,14 +128,14 @@
"kafka": { "kafka": {
"enabled": true, "enabled": true,
"brokers": ["kafka.118im.com:30835"], "brokers": $KAFKA_BROKERS_JSON,
"client_id": "im-server", "client_id": "im-server",
"consumer_group": "im-server", "consumer_group": "im-server",
"dial_timeout_ms": 3000, "dial_timeout_ms": 3000,
"write_timeout_ms": 3000, "write_timeout_ms": 3000,
"read_timeout_ms": 3000, "read_timeout_ms": 3000,
"batch_timeout_ms": 10, "batch_timeout_ms": 10,
"required_acks": 1, "required_acks": -1,
"allow_auto_topic_creation": false, "allow_auto_topic_creation": false,
"tls_enabled": false, "tls_enabled": false,
"sasl_enabled": false, "sasl_enabled": false,
@@ -175,7 +177,7 @@
} }
}, },
"admin_internal_api": { "admin_internal_api": {
"enabled": true, "enabled": $ADMIN_INTERNAL_API_ENABLED,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL", "base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME", "service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET", "secret": "$ADMIN_INTERNAL_API_SECRET",
@@ -264,6 +266,8 @@
"enabled": $JPUSH_PUSH_ENABLED, "enabled": $JPUSH_PUSH_ENABLED,
"api_domain": "$JPUSH_API_DOMAIN", "api_domain": "$JPUSH_API_DOMAIN",
"push_path": "$JPUSH_PUSH_PATH", "push_path": "$JPUSH_PUSH_PATH",
"workers": $JPUSH_WORKERS,
"device_cache_ttl_seconds": $JPUSH_DEVICE_CACHE_TTL_SECONDS,
"default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS, "default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS,
"call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS, "call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS,
"apns_production": $JPUSH_APNS_PRODUCTION, "apns_production": $JPUSH_APNS_PRODUCTION,
+3
View File
@@ -13,6 +13,8 @@ else
CONFIG=working.config CONFIG=working.config
: "${REDIS_KEY_PREFIX:=im}" : "${REDIS_KEY_PREFIX:=im}"
: "${WS_PING_PERIOD_SECONDS:=15}"
: "${WS_PONG_WAIT_SECONDS:=90}"
: "${CLUSTER_PROXY_TO_MASTER_BUFFER:=4096}" : "${CLUSTER_PROXY_TO_MASTER_BUFFER:=4096}"
: "${CLUSTER_PROXY_TO_MASTER_WORKERS:=16}" : "${CLUSTER_PROXY_TO_MASTER_WORKERS:=16}"
: "${CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS:=300}" : "${CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS:=300}"
@@ -22,6 +24,7 @@ else
: "${MYSQL_MAX_IDLE_CONNS:=8}" : "${MYSQL_MAX_IDLE_CONNS:=8}"
: "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}" : "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}"
: "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}" : "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}"
: "${MYSQL_CONN_MAX_LIFETIME_SECONDS:=1800}"
if [ -z "$CLUSTER_NODES_JSON" ] ; then if [ -z "$CLUSTER_NODES_JSON" ] ; then
CLUSTER_NODES_JSON='[{"name":"tinode-0","addr":"tinode-0:12000"},{"name":"tinode-1","addr":"tinode-1:12001"},{"name":"tinode-2","addr":"tinode-2:12002"}]' CLUSTER_NODES_JSON='[{"name":"tinode-0","addr":"tinode-0:12000"},{"name":"tinode-1","addr":"tinode-1:12001"},{"name":"tinode-2","addr":"tinode-2:12002"}]'
fi fi
@@ -737,7 +737,7 @@ loadtest/im-pressure/internal/collector/
loadtest/im-pressure/internal/report/ loadtest/im-pressure/internal/report/
``` ```
协调方式:Machine A 启动 coordinator,等待 A/B 两个 agent 都上报 1000 个 READY,然后发布一个至少晚于当前时间 30 秒的 UTC `start_at`。两台机器按相同 `run_id` 和确定性调度执行。任何 agent 失联都将本轮标记为 `INVALID`。 协调方式:Machine A 启动 coordinator,等待 A/B 两个 agent 都上报 1000 个 READY,然后发布一个至少晚于当前时间 30 秒的 UTC `start_at`。到达 `start_at` 后,A/B 必须在前后 3 秒内向 coordinator 完成最终起跑确认;只有双方都确认后才开始发送,任一方迟到则两边共同终止并将本轮标记为 `INVALID`。两台机器按相同 `run_id` 和确定性调度执行,任何 agent 失联同样判定为 `INVALID`。
`agent` 自身暴露 `/metrics`,提供连接状态、发布、接收、重复、乱序、断连及 Go/process 指标;ACK 和投递延迟分布写入最终 summary。`collect` 只执行只读查询,并把各数据源的原始快照与查询时间一起保存;连接凭证通过环境变量传入,不写入结果目录。 `agent` 自身暴露 `/metrics`,提供连接状态、发布、接收、重复、乱序、断连及 Go/process 指标;ACK 和投递延迟分布写入最终 summary。`collect` 只执行只读查询,并把各数据源的原始快照与查询时间一起保存;连接凭证通过环境变量传入,不写入结果目录。
+70 -23
View File
@@ -154,6 +154,12 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。
资源不存在类错误统一语义(2026-09-29 修订):
- 好友申请 `accept/reject` 在申请不存在时统一返回 `404/20004 RESOURCE_NOT_FOUND`,两个入口语义一致。
- 群类接口(`DELETE /v1/im/groups/{topic}`、`POST /v1/im/groups/{topic}/leave`、`POST /v1/im/groups/{topic}/qrcodes`、`PUT /v1/im/groups/{topic}/members/me/profile`、`GET/PATCH /v1/im/groups/{topic}/settings` 等经群成员校验的路径)在群不存在时返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散时返回 `410/30004 GROUP_DISSOLVED`;群存在但当前用户非成员仍是 `403/30005 PERMISSION_DENIED`。
- 服务端对不存在的资源不再返回 5xx 或网关错误。
### 0.5 WebSocket 建连、登录和恢复顺序 ### 0.5 WebSocket 建连、登录和恢复顺序
#### 0.5.1 现有服务端可直接执行的冷启动流程 #### 0.5.1 现有服务端可直接执行的冷启动流程
@@ -236,6 +242,8 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} {"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}
``` ```
**向上翻页(加载更早历史)最终参数约定(后端已确认,2026-09-29)**:复用同一 `get data`,最终参数为 `before`(排他上界,返回 `seq < before`),**没有 REST cursor 语义**。聊天页滚动到顶部时发 `data:{"before":<当前已加载最小 seq>,"limit":50}`(`since` 可省略,默认 0)。服务端返回该锚点之前**最接近 `before` 的 `limit` 条**,`{data}` 按 `seq` 从大到小下发(首条即锚点前最新一条);收到 `ctrl 204 no content`(`params.what=="data"`)表示已到会话顶部。`data` 参数全集:`since`(含下界)、`before`(排他上界)、`limit`、`ranges`。P2P 与群聊(含热缓存 fallback)语义一致。REST `GET /v1/im/conversations/{topic}/messages` 当前为空占位(见 4.21.1),不得用于历史分页。
订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。
##### 0.5.1.1 WebSocket 心跳与在线状态 ##### 0.5.1.1 WebSocket 心跳与在线状态
@@ -327,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-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"}}}` | | 租户不匹配 | `{"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 启动订阅、会话列表和增量恢复 ##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 | | 场景 | 客户端发送 | 服务端返回示例 |
@@ -628,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 `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"}}}` | | 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/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"}}}` | | `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-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 | | `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 与产品消息 ### 0.6 Topic 与产品消息
@@ -935,7 +952,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 - 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`;注销账号使用 `{"scene":"delete_account","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 - 发码/校验:服务端返回 `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` 继续倒计时,不要当成接口不可达或重新登录。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 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`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 - 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
@@ -961,7 +978,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 - 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。
- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 - 前端只读展示,不缓存原始证件信息,不提供直接修改接口。
- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 - 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,Body `{"reason":"...","attachments":[]}`(`reason` 必填、≤500 字;`attachments` 最多 9 个 file_id),Bearer 即可,当前无需二次身份验证;不复用本页保存按钮。
### 1.10 实名认证(安卓).png ### 1.10 实名认证(安卓).png
@@ -1065,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"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 - `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 只下发必要脱敏值。 - 新设备登录事件内容为 `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 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例 ### 2.6 接口示例
@@ -1085,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/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","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"}` | | `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. 单聊会话、会话建立与消息状态 ## 3. 单聊会话、会话建立与消息状态
@@ -1144,11 +1162,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/双方沟通状态.png" alt="双方沟通状态" width="360"> <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` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 - `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 回收,客户端不得再引用。 - 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。 - 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
@@ -1425,7 +1444,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | | `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | | `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | | `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | | `GET /v1/im/conversations/{topic}/messages` | **服务端当前为空占位**:GET 恒返回空列表,勿用 `?since=&limit=` 调它;历史消息(首屏与向上翻页)一律走 WS `get data` 的 `since`/`before`/`limit`(向上翻页见 0.5.1 约定) | `{"topic":"p2p...","items":[],"next_cursor":"","has_more":false}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` | | `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
@@ -1692,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` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 - 组织成员:`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"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 - 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1706,6 +1725,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 申请不存在时 `accept/reject` 统一返回 `404/20004 RESOURCE_NOT_FOUND`;通用语义见 0.4。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 - 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png ### 5.3 添加好友.png
@@ -1754,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` 做权限校验。 - 查看该用户二维码调用 `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":"..."}` 并由服务端验证好友关系。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1823,9 +1843,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/黑名单提示.png" alt="黑名单提示" width="360"> <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`。 - 确认调用 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 消息缓存并保留会话关系。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1851,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"}` | | `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=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/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"}` | | `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}` | | `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":""}` | | `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":""}` |
@@ -1960,6 +1981,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 - `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。
- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 - 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。
- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 - 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.11 群资料.png ### 6.11 群资料.png
@@ -1989,6 +2011,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 - `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 - 服务端校验成员资格、长度和敏感词,返回 `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`。 - 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。
@@ -2057,6 +2080,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 - `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群主/管理员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
@@ -2069,6 +2093,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 - 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。
- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 - 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。
- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 - 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;已解散重复解散返回 `410/30004 GROUP_DISSOLVED`;非群主返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.18 设置群管理员.png ### 6.18 设置群管理员.png
@@ -2316,10 +2341,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/文件-下载.png" alt="文件-下载" width="360"> <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=`。 - 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 服务端按文件 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` 显示失效。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
### 7.10 文件-下载中.png ### 7.10 文件-下载中.png
@@ -2944,8 +2971,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** **状态: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` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `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`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 更新统一使用 `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` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。 - 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
@@ -2957,7 +2984,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 - `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 - 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突(`409/20009`)先拉最新资料再由用户确认。
### 10.4 出生日期.png ### 10.4 出生日期.png
@@ -3150,8 +3177,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `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` | 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}}}` | | `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":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `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}}` | | `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"}` | | `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"}` | | `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"}` |
@@ -3166,9 +3193,29 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/security/realname/appeals` | `{"reason":"证件变更需要更正实名信息","attachments":["file_1"]}` | `{"appeal_id":"rna_1","reason":"...","attachments":["file_1"],"status":"pending"}`;当前无需二次身份验证(如后续启用,将复用 1.7 的 action_token 机制并在此处更新) |
| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | | `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` |
| `POST /v1/im/me/password` | `{"old_password":"OldPass123","new_password":"NewPass123"}` | `{"status":"ok"}`;旧密码错误返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`;新密码不满足策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}` |
| `POST /v1/im/security/challenges` 注销账号发码 | Header `Idempotency-Key`;Body `{"scene":"delete_account","channel":"sms"}` | `{"challenge_id":"sec_chg_9","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges/{id}/verify` 注销账号验证 | `{"code":"123456"}` | `{"action_token":"del_token","action":"delete_account","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/me/deletion` | Header `X-Action-Token: del_token`;Body `{}` | `{"status":"ok"}` |
#### 10.22.2 账号安全:修改密码与注销账号
**修改密码 `POST /v1/im/me/password`(已实现):**
- 已登录(Bearer)用户提交 `old_password`、`new_password`;两者必填,缺一返回 `400 MALFORMED_REQUEST`。
- 服务端先用旧密码走 `basic` 认证校验,失败返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`,不泄露账号状态。
- 新密码不满足密码策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}`。
- 成功后服务端递增 `auth_version` 并撤销该用户全部在线 Session(广播 `password_changed`);包括当前请求在内的所有旧 Token 全部失效,客户端收到成功响应后必须清理本租户凭证并跳转登录页。
**注销账号 `POST /v1/im/me/deletion`(已实现):**
- 两步挑战复用 1.7 敏感操作基建:`POST /v1/im/security/challenges`(Body `{"scene":"delete_account","channel":"sms"}`,发码到账号绑定手机号)→ `POST /v1/im/security/challenges/{id}/verify`(返回一次性 `action_token`)→ `POST /v1/im/me/deletion`(Header `X-Action-Token` 携带)。
- 服务端配置 `permanent_accounts=true` 时拒绝注销:HTTP `403` `{"code":30040,"reason":"FORBIDDEN","message":"account deletion is disabled"}`。
- 执行成功为**软删除**:撤销全部认证记录(含登录密码)、踢出所有在线 Session(`reason=user_deleted`)、退出并停用其拥有的群与 P2P 会话、向关注该用户的好友下发 `gone` 在线状态;账号不再可登录。客户端收到成功响应后清理本地数据并返回企业入口。
- 挑战阶段限流与错误码同 1.7(`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`);action token 缺失/无效返回 HTTP `403` `{"code":30042,"reason":"ACTION_TOKEN_INVALID"}`。
## 11. 反诈安全、举报与意见反馈 ## 11. 反诈安全、举报与意见反馈
@@ -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` 实际结构对不上 | | [问题25](#问题25) | `POST /messages/forward`、`/favorites/forward` | 带附言转发时 `head` 声明类型与 `content` 实际结构对不上 |
| [问题27](#问题27) | `GET /reports/daily` | `status` 参数不做取值校验,传任何值都返回200+空列表 | | [问题27](#问题27) | `GET /reports/daily` | `status` 参数不做取值校验,传任何值都返回200+空列表 |
| [问题30](#问题30) | `GET /system-notifications` | `summary`/`action`结构不一致,且会议响应事件被写成两条重复通知 |
| [问题33](#问题33) | `GET /reports/daily` | `permissions` 四个字段恒为同一组硬编码值,与用户/参数无关 | | [问题33](#问题33) | `GET /reports/daily` | `permissions` 四个字段恒为同一组硬编码值,与用户/参数无关 |
| [问题35](#问题35) | `GET /users/{id}/profile` | 设了备注之后拿不到对方的原昵称(`GET /friends`一侧已修复) | | [问题35](#问题35) | `GET /users/{id}/profile` | 设了备注之后拿不到对方的原昵称(`GET /friends`一侧已修复) |
| [新增3](#新增3) | `PATCH /conversations/{topic}/settings` | 单聊置顶没有批量查询接口,会话列表永远看不出置顶效果 | | [新增3](#新增3) | `PATCH /conversations/{topic}/settings` | 单聊置顶没有批量查询接口,会话列表永远看不出置顶效果 |
@@ -32,7 +31,6 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
| [新增25](#新增25) | 原生WS `{pub}` | link类型消息协议层可构造,但没有真实用户可从UI触达的发送路径 | | [新增25](#新增25) | 原生WS `{pub}` | link类型消息协议层可构造,但没有真实用户可从UI触达的发送路径 |
| [新增30](#新增30) | `GET /settings/youth-mode` | 开启后不对任何功能做实际限制,`enforced` 恒为硬编码false(前端已移除此功能,仅保留记录完整性) | | [新增30](#新增30) | `GET /settings/youth-mode` | 开启后不对任何功能做实际限制,`enforced` 恒为硬编码false(前端已移除此功能,仅保留记录完整性) |
| [新增31](#新增31) | `POST /reports/abuse` | 不校验 `requires_description`,可对必填原因提交空描述 | | [新增31](#新增31) | `POST /reports/abuse` | 不校验 `requires_description`,可对必填原因提交空描述 |
| [新增32](#新增32) | `POST /system-notifications/{id}/read` | 对已读通知重复标记,响应回显请求值但真实存储值不变 |
| [新增34](#新增34) | `POST /tenant-applications` | 统一社会信用代码无去重机制,可无限重复提交 | | [新增34](#新增34) | `POST /tenant-applications` | 统一社会信用代码无去重机制,可无限重复提交 |
| [新增41](#新增41) | `POST /groups/{topic}/mutes` | 无角色层级保护,管理员可禁言群主、任何角色可自我禁言 | | [新增41](#新增41) | `POST /groups/{topic}/mutes` | 无角色层级保护,管理员可禁言群主、任何角色可自我禁言 |
| [新增47](#新增47) | `POST /v0/file/u/` | 真实文件上传上限是32MB而非声明的100MB,且从未用于前端预校验 | | [新增47](#新增47) | `POST /v0/file/u/` | 真实文件上传上限是32MB而非声明的100MB,且从未用于前端预校验 |
@@ -68,6 +66,8 @@ _2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的
- **新增42**(群公告按id查询恒定返回最新公告):**已修复**。代码确认现在正确解析路径里的`announcement_id`并调用`GetAnnouncement`按id查询,不再无视这个参数。 - **新增42**(群公告按id查询恒定返回最新公告):**已修复**。代码确认现在正确解析路径里的`announcement_id`并调用`GetAnnouncement`按id查询,不再无视这个参数。
- **新增43**(群文件登记接口无长度/存在性校验):**已修复**。代码确认现在对`file_name`(255)/`mime_type`(128)做长度校验,对`file_size`做负数校验,且会调用`store.Files.Get`验证`file_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条一条不少、无重复。 - **新增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,不应再作为后端问题提出) ## 本次复核确认仍是历史误判(从未是真正的后端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> <a id="问题33"></a>
## 问题 33(P2):`permissions` 的四个字段在不同参数下恒为同一组值 ## 问题 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> <a id="新增34"></a>
## 新增 34(P2):企业注册申请接口对同一个统一社会信用代码没有任何去重/防重复提交机制 ## 新增 34(P2):企业注册申请接口对同一个统一社会信用代码没有任何去重/防重复提交机制
@@ -0,0 +1,5 @@
-- Persist client-provided upload filename for downstream business modules.
-- Apply before deploying im_server changes which read/write im_fileuploads.original_name.
ALTER TABLE im_fileuploads
ADD COLUMN original_name VARCHAR(255) NOT NULL DEFAULT '' AFTER location;
@@ -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`
@@ -0,0 +1,73 @@
# GROUP-2000 R9 双机群聊压测分析与改进
## 1. 结论
`group2k-r9-20261003-01` 不能作为 2000 人群聊容量结论。消息最终落库完整,但实时阶段同时发生机器 B 迟到、Pod 重启和集群 DNS 拒绝查询,导致大量连接断开和跨机器投递缺失。
- 发送尝试 2000 条,数据库精确找到 1774 条服务端已确认消息,缺失、重复、字段不一致均为 0。
- 实时期望投递 3,548,000 次,实际收到 1,384,872 次,到达率约 39.03%。
- 机器 B 比 coordinator 的计划起跑时间晚约 7 分 55 秒;B 未收到任何 A 消息,本轮并非有效的同步双机负载。
- `im-server-1` 在负载中退出,容器退出码 137;现有权限无法读取 Event 和 Pod metrics,尚不能把原因唯一归为 OOM、liveness 或节点压力。
- 三个 Pod 合计约 1715 次依赖域名解析失败,日志包含对集群 DNS `10.43.0.10:53` 的 `connection refused`。
- JPush 设备查询产生 792 次数据库错误;用户更新队列满 1132 次,属于依赖抖动和连接重建后的次生压力。
Kafka 持久化链路本轮表现正确:各 Pod 批量持久化计数合计 1774,与数据库精确核验一致,之前的失败消息保留修复已生效。
## 2. 根因链路
### 2.1 JPush 查询按群消息放大
JPush 使用 registration ID 模式时,每条群消息都会按全部收件人重新查询 `im_device_push_tokens`。2000 人群和 2000 条消息会形成约 400 万个用户查询输入;测试账号没有推送设备,旧实现仍重复查询空结果。JPush 回执又以无限 goroutine 并发处理,使查询、对象分配和内存压力同时放大。
### 2.2 MySQL 连接每 60 秒回收
旧配置的 `conn_max_lifetime=60` 会在持续压测期间频繁重建连接。每次建连都重新解析 `mysql.118im.com`;当集群 DNS 短时拒绝请求时,业务查询、JPush 查询和 Kafka 持久化会一起失败或等待。
### 2.3 双机只有计划时间,没有最终到达屏障
旧协议在双方 READY 后下发统一时间,但 agent 到达该时间后直接发送。机器 B 晚启动时,机器 A 无法知道对方没有真正进入发送阶段,因此形成两段错开的单机流量并生成无效的跨机器统计。
## 3. 已完成改进
### 3.1 JPush
- 设备查询结果按租户、provider、用户缓存 30 秒,包含“无设备”结果。
- 并发首次查询被合并,同一批群成员只访问一次数据库。
- 查询失败后退避 1 秒,已有缓存可在 5 分钟陈旧窗口内继续使用。
- 缓存定期清理,避免用户集合长期增长。
- JPush 回执限制为 8 个工作协程,取消无限 goroutine,队列负责形成背压。
该改动不删除在线多端静默推送语义。新注册或更新的设备最迟约 30 秒进入推送查询结果。
### 3.2 MySQL 与 DNS
- 容器默认 `conn_max_lifetime` 从 60 秒提高到 1800 秒,并支持 `MYSQL_CONN_MAX_LIFETIME_SECONDS` 覆盖。
- 测试环境 GitOps 显式设置 1800 秒,降低持续负载中的连接重建和 DNS 查询频率。
- 保留现有 `ndots:1`、`timeout:2`、`attempts:2`。
这会显著减少应用制造的 DNS 压力,但不能替代集群 DNS 修复。若复测仍出现 `10.43.0.10:53 connection refused`,需要集群管理员检查 CoreDNS 副本、CPU、转发上游、conntrack,并评估与当前 K3s 网络模式匹配的 NodeLocal DNSCache;不能在未知网络模式下直接套用 DaemonSet。
### 3.3 压测同步
- coordinator 增加最终起跑屏障。
- A/B 必须在计划时间前后 3 秒内到达,双方都到达后才开始发送。
- 任一机器迟到时,两边收到同一失败原因并终止本轮。
- coordinator 输出保存双方实际到达时间和 `start_error`。
### 3.4 诊断权限
`backend-debug` 只读权限增加 Event 和 `metrics.k8s.io/pods`,用于下一轮区分 OOM、探针失败、驱逐和节点资源压力。若集群未安装 metrics-server,Pod metrics 仍不可用,但 Event 读取不受影响。
## 4. 复测门禁
下一轮使用新 `run_id`,A/B 必须更新到包含起跑屏障的同一提交,并在应用和 GitOps 配置都发布后执行。
1. coordinator 输出中 A/B `started` 时间相差不超过 3 秒,`start_error` 为空。
2. 2000 条发送全部得到明确 ACK;数据库按 `client_mid` 精确匹配,缺失、重复、字段不一致均为 0。
3. 实时期望 4,000,000 次投递全部到达,重复、乱序和断连均为 0。
4. JPush 设备查询不再随群消息数线性增长,日志中 `jpush: db error` 为 0。
5. Pod 不重启,Event 中无 OOM、Unhealthy、Evicted;负载窗口内持续采集 CPU、内存和重启次数。
6. `10.43.0.10:53`、MySQL、Redis、Kafka 域名解析错误均为 0。
7. Kafka lag 最终归零,数据库最终消息数与 ACK 台账一致。
若业务正确性通过但 DNS 仍有拒绝请求,本轮仍判基础设施门禁失败,不继续用增加应用重试掩盖 DNS 容量问题。
@@ -0,0 +1,191 @@
# IM 单机 60% 容量压力测试方案
日期:2026-09-29
本方案首轮执行结果见 [IM-单机60%容量压力测试结果-20260929.md](./IM-单机60%容量压力测试结果-20260929.md)。
## 1. 目标与口径
本方案用一台物理压测机完成单聊、群聊和混合场景,容量按双机正式方案全局规模的 60% 计算。
单机不等于只运行一个 agent。为保留现有好友对和投递契约,同一台物理机上同时运行逻辑 agent A 和 agent B,每个 agent 承载 600 个连接,合计 1200 个 WebSocket。Coordinator 仍使用 `expected-agents=2`。
| 项目 | 双机正式方案 | 单机 60% 方案 |
|---|---:|---:|
| 物理压测机 | 2 | 1 |
| 逻辑 agent | 2 | 2 |
| 每个 agent 连接 | 1000 | 600 |
| WebSocket 总连接 | 2000 | 1200 |
| 好友对 | 1000 | 600 |
| 群成员 | 2000 | 1200 |
## 2. 场景容量
### 2.1 单聊
- 600 对好友,A/B 各 600 人。
- 每个用户发送 10 条,每秒 1 条。
- 总发送尝试 12000 条,是双机 20000 条的 60%。
- 每条 ACK 消息应在配对用户的 WebSocket 到达一次。
### 2.2 群聊
- 1200 个在线群成员。
- A/B 各取前 300 个用户作为发送者,合计 600 个发送者。
- 每人发送 2 条,全局每 100ms 一条,合计 1200 条群消息,耗时约 120 秒。
- 全部发送被 ACK 时,期望实时投递 `1200 x 1200 = 1,440,000` 次。
### 2.3 单聊和群聊混合
- 前 180 对好友互发,每人 5 条,共 1800 条单聊。
- A/B 各 150 个群发送者,每人 2 条,共 600 条群消息。
- 群消息期望实时投递 `600 x 1200 = 720,000` 次。
- 单聊和群聊同时开始,不分两轮单独执行。
工具按 `users_per_machine` 等比计算发送者数量。`users_per_machine=1000` 时保持原双机参数,设为 600 时自动使用上述单机参数。
## 3. 文件隔离
单机方案不复用双机的运行文件,避免把 1200 人结果和 2000 人结果混在一起。
```text
loadtest/im-pressure/config.single-machine.json
loadtest/im-pressure/manifest.single-machine.json
loadtest/im-pressure/results/<RUN_ID>/
```
从模板生成本地配置:
```bash
cp loadtest/im-pressure/config.single-machine.example.json \
loadtest/im-pressure/config.single-machine.json
chmod 600 loadtest/im-pressure/config.single-machine.json
```
修改租户、HTTP/WS 地址和注册手机前缀。A/B endpoint 可以填同一个公网入口;若需确认跨 Pod 投递,应分别填写两个定向入口。
## 4. 一次性数据准备
```bash
source loadtest/im-pressure/.env.test
go build -o loadtest/im-pressure/im-pressure ./loadtest/im-pressure
ulimit -n 100000
./loadtest/im-pressure/im-pressure prepare \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json
```
`verify` 必须输出 1200 个账号、600 对好友和 1200 个群成员。单机 manifest 可以复用已注册的 `lta0001..0600` 和 `ltb0001..0600` 账号,但必须使用独立群 Topic。
## 5. 正式运行
以群聊为例:
```bash
export RUN_ID='single60-group-r1-YYYYMMDD-01'
export SCENARIO='group'
mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
```
终端 1,启动 coordinator:
```bash
./loadtest/im-pressure/im-pressure coordinator \
-listen :8090 \
-run-id "${RUN_ID}" \
-scenario "${SCENARIO}" \
-expected-agents 2 \
-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json"
```
终端 2,启动逻辑 agent A:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json \
-machine a \
-coordinator http://127.0.0.1:8090 \
-metrics-listen :9105 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json"
```
终端 3,启动逻辑 agent B:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json \
-machine b \
-coordinator http://127.0.0.1:8090 \
-metrics-listen :9106 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
两个 agent 都报告 `600/600 READY` 并稳定通过 30 秒屏障后才会开始。任一 agent 未满连接时 coordinator 会撤销开始时间。
固定 drain 结束后,agent 还会等待入站投递连续静默后再生成实时 summary。默认 `delivery_quiet_period=10s`、`delivery_quiet_timeout=3m`;接收计数增长或回调队列不为空都会重置静默窗口,避免公网晚到帧被误判为实时丢失。
## 6. 指标采集
终端 4 在起压前至少 2 分钟启动:
```bash
./loadtest/im-pressure/im-pressure collect \
-duration 20m -interval 5s \
-coordinator http://127.0.0.1:8090 \
-coordinator-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-url agent-a=http://127.0.0.1:9105/metrics \
-url agent-b=http://127.0.0.1:9106/metrics \
-url node=http://127.0.0.1:9100/metrics \
-output "loadtest/im-pressure/results/${RUN_ID}/metrics.jsonl"
```
同时采集 Redis、Kafka、MySQL、Pod CPU/内存/网络、Kafka consumer lag、MySQL 连接池等指标。如本机没有 `node-exporter :9100`,删除 `node` URL,但不影响 agent 指标采集。
发版后必须在服务端 `/metrics` 确认 `im_ws_heartbeat_seconds{setting="ping_period"}=15`、`im_ws_heartbeat_seconds{setting="pong_wait"}=90`,并采集 `im_ws_heartbeat_total{event="pong_received"}` 和 `im_ws_disconnect_total{reason="timeout"}` 的分 Pod 增量。
混合场景还必须采集 `im_ctrl_ack_delivery_duration_seconds{stage="enqueue"}` 和 `im_ctrl_ack_delivery_duration_seconds{stage="write"}`。agent 端同时采集 `im_load_agent_publish_ctrl_observed_total`、`im_load_agent_publish_ctrl_over_20s_total`、`im_load_agent_publish_ctrl_late_total` 和 `im_load_agent_publish_ctrl_unresolved`,用于区分及时 ACK、超过 20 秒的 ACK、超过命令超时后晚到的 ACK 与最终未到 ACK。
## 7. 报告和独立落库核验
```bash
./loadtest/im-pressure/im-pressure merge \
-a "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json" \
-b "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json" \
-output "loadtest/im-pressure/results/${RUN_ID}/report.json"
export MYSQL_DSN='readonly:password@tcp(mysql-host:3306)/meeting_im?parseTime=true'
export TENANT='006' # 必须与 config.single-machine.json 中的 tenant 一致
./loadtest/im-pressure/im-pressure verify-db \
-coordinator "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-tenant "${TENANT}" \
-dsn-env MYSQL_DSN \
-registry-db hotgo \
-output "loadtest/im-pressure/results/${RUN_ID}/db-verification.json"
```
## 8. 验收门禁
- READY 必须是 `1200/1200`,统计窗口内断连为 0。
- publish 明确拒绝和结果未知均为 0,成功率至少 99.99%。
- `publish_ctrl_unresolved` 必须为 0;`publish_ctrl_over_20s` 和 `publish_ctrl_late` 作为定位指标单独报告,不与消息丢失混为一项。
- ACK P95 和投递 P95 保留记录用于趋势对比,不作为当前公网单机容量测试的阻断门禁。
- 实时缺失、重复、乱序、未知本轮消息均为 0。
- ACK 台账和无 ACK 但已观测台账的历史回查命中率必须 100%,独立 MySQL 核验丢失、错配、重复均为 0。
- 实时发送、历史回查和渐进关闭三个阶段分别统计 Topic/Proxy/Session 队列满,不能只看整轮总数。
## 9. 单机特有风险
1. 两个 agent 共用一台机器的 CPU、内存、FD、NAT 和出口带宽,测到的是“单机压测端 + 服务端”综合上限,不能代替双机最终验收。
2. 正式前做 120/600/1200 连接三档阶梯,观察 agent CPU、`go_goroutines`、FD、接收速率和 Ping/Pong 断连,确认压测端本身没有先饱和。当前工具已将投递观测按 `client_mid` 分片、接收账号顺序状态独立加锁,使用原子延迟直方图,并将数据统计回调从 WebSocket socket 读循环解耦;仍需用指标证明本机有余量。
3. 两个 agent 必须使用不同指标端口,不能同时绑定 `:9105`。
4. 压测域名如被 VPN/TUN 接管,起压前必须确认所有当前 IP 的路由都指向物理网卡。
5. 单机 60% 通过后只能进入双机 100% 验收,不能直接得出 2000 连接容量通过的结论。
断连时同时查看 agent summary 中的 `disconnect_reasons` 和 `disconnect_samples`。前者按 timeout、abnormal close、connection reset 等原因分类计数,后者最多保留 100 条带账号与时间的原始错误样本,用于区分压测端读循环阻塞、网络中断和服务端主动关闭。
@@ -0,0 +1,949 @@
# IM 单机 60% 容量压力测试结果
日期:2026-09-29
## 1. 结论
本轮使用一台物理压测机同时运行逻辑 agent A/B,每侧 600 个 WebSocket,合计 1200 连接。60% 容量的正确性和性能总验收未通过。
- 单聊实时和持久化正确性通过;ACK P95 和投递 P95 仅记录,不再作为当前公网单机压测的阻断条件。
- 群聊在 1200 条群消息、144 万设计投递量下出现 ACK 超时、连接重置和分钟级投递延迟。
- 混合场景明显越过容量拐点,737 个连接断开,仅 44.83% 发送得到 ACK。
- 独立 MySQL 核验确认:三个场景的所有 ACK 消息均精确落库,0 错配、0 重复;所有“无 ACK 但 WS 已观测”消息也均已落库。
- 压测工具本轮峰值 RSS 不超过 127 MB,每个 agent 峰值 FD 614,未显示本机内存或 FD 先饱和。
- 混合实时阶段的群 Topic 所在 `im-server-0` 出现 broadcast queue full,服务端 Topic 处理能力是已证实的瓶颈之一。
## 2. 环境与口径
- 租户:`allrich`
- HTTP/WS 入口:`api.118im.com`
- 物理压测机:1 台
- 逻辑 agent:A/B 各2个中的一个,每侧 600 连接
- 账号:1200,好友对:600,群成员:1200
- API 域名当前 3 个 IP 均经 `en1` 和局域网网关直连,未走 VPN/TUN
- IM Server:3 Pod,无重启;每 Pod request `1 CPU/512Mi`,limit `2 CPU/1Gi`
- 独立群与 manifest:`loadtest/im-pressure/manifest.single-machine.json`
准备阶段最终验证为 `accounts=1200 pairs=600 group_members=1200`。群批量邀请曾出现入口 502 和幂等任务长时间处理中,工具通过按实际成员补齐缺失用户完成恢复。
## 3. 结果汇总
| 场景 | Run ID | ACK | 实时投递 | 断连 | ACK P95 | 投递 P95 | 独立 DB 核验 | 结果 |
|---|---|---:|---:|---:|---:|---:|---:|---|
| 单聊 | `s60-p2p-r2-0929` | 12000/12000 | 12000/12000 | 0 | 1341.555ms | 2000ms | 12000 ACK 全部精确命中 | 正确性通过 |
| 群聊 | `s60-group-r1-0929` | 1145/1200 | 1350508/1374000 ACK 台账 | 47 | 11207.859ms | 94621.882ms | 1145 ACK + 39 无 ACK 落库 | 未通过 |
| 混合 | `s60-mixed-r1-0929` | 1076/2400 | 234829/263657 ACK 台账 | 737 | 17491.634ms | 101226.392ms | 1076 ACK + 1017 无 ACK 落库 | 未通过 |
表中群聊和混合的“期望投递”只按有 ACK 的 ledger 计算。发送结果未知的消息不能在没有 seq 时直接加入期望投递分母,因此该分母小于设计投递量。
## 4. 分场景记录
### 4.1 单聊
R1 的历史查询将 `limit=10` 错误地按目标消息数计算,没有计入 P2P Topic 中双方交错的 seq,造成 4619 条假缺失。修复后 R2 结果:
- 12000 条全部 ACK,无结果未知。
- 12000 次对端投递全部到达,无重复、乱序、断连。
- 12000 条历史回查全部命中,持久化缺失 0。
- ACK P95 1341.555ms,同机投递 P95 落在 2000ms 桶;两项作为公网链路观测数据,不影响正确性验收。
### 4.2 群聊
- 发送 1200 条,1145 条 ACK,43 条 ACK 超时,12 条 socket 结果未知,无明确拒绝。
- 47 个连接断开:22 个 abnormal close,25 个 connection reset by peer。
- ACK 台账下实时缺失 23492 次,这些消息最终均可从历史查到。
- 55 条结果未知中,39 条被接收端实时观测,且全部被独立 SQL 确认落库;另 16 条既无 ACK、无 WS 观测,也无数据库记录。
- 断连发生时间为 `03:40:10Z` 至 `03:41:15Z`,与群实时发送阶段重合。
### 4.3 混合
- 设计发送为 1800 条单聊和 600 条群聊,合计 2400 条。
- 1076 条 ACK,1324 条结果未知,其中 1036 条 ACK 超时、288 条 socket 失败,无明确拒绝。
- 737 个连接断开:69 个 abnormal close,668 个 connection reset by peer。
- ACK 台账下实时缺失 28828 次,其中 28633 次对应消息最终已查到持久化记录。
- 历史验证 ledger 含 2002 条,命中 1331 条,缺失 671 条全部为 P2P。独立 SQL 确认所有 1076 条 ACK 消息和全部 926 条 WS 已观测的无 ACK 消息均已落库,因此这 671 条是 verifier 连接掉线和 3 分钟总预算造成的验证不完整,不是数据库丢失。
- 1324 条结果未知中,1017 条实际落库:926 条被 WS 观测,另有 91 条未被 WS 观测但已落库。剩余 307 条无 ACK、无 WS 观测、无数据库记录。
### 4.4 独立 MySQL 核验
核验租户 ID 为 8,消息表为 `im_008_messages` 和 `im_008_group_messages_202609`。
| 场景 | ACK expected | 精确命中 | ACK 缺失 | 错配 | 重复 | 无 ACK 但落库 |
|---|---:|---:|---:|---:|---:|---:|
| 单聊 | 12000 | 12000 | 0 | 0 | 0 | 0 |
| 群聊 | 1145 | 1145 | 0 | 0 | 0 | 39 |
| 混合 | 1076 | 1076 | 0 | 0 | 0 | 1017 |
`verify-db` 对无 ACK 但落库的行按 unexpected 报告,所以群聊和混合命令会以非零状态结束;这不代表数据错误,而是发送结果未知但服务端已持久化的重要诊断证据。
## 5. 服务端观测
- `im-server-0/1/2` 全部 Running,本轮重启数均为 0。
- 混合实时阶段 `03:53:00Z..03:53:02Z`,`im-server-0` 记录 55 次 `topic's broadcast queue is full`。
- 混合渐进关闭末尾 `04:03:45Z`,`im-server-0` 再记录 375 次同类队列满。当前 60 秒 close ramp 仍未完全避免关闭风暴。
- 同一观测窗口的 `03:45:13Z`,`im-server-2` 在 `serveIMFriendRequestAction` (`server/im_http.go:6256`) 出现一次 nil pointer panic。该端点不属于群实时发送路径,不将其归因为本轮断连,但需单独修复。
- 当前 kubeconfig 无权读取 metrics-server,未能取得负载窗口的 Pod CPU/内存历史值。
## 6. 压测工具修复
1. 新增单机 60% 配置和执行方案,A/B 各 600 连接。
2. 群聊和混合发送者按 `users_per_machine` 比例缩放,1000 用户的原参数保持不变。
3. 投递观测按 `client_mid` 分为 64 片,每个接收账号独立维护 seq,延迟直方图改为原子计数,移除 WS 读循环中的全局锁争用。
4. summary/report 新增 `disconnect_reasons` 和最多 100 条 `disconnect_samples`。
5. 群创建后立即 checkpoint manifest;批量邀请失败后重读实际成员,仅邀请缺失用户。
6. P2P 历史查询 limit 按 seq 区间扩展,上限 200,修复双方消息交错造成的假缺失。
## 7. 已实施改进
1. 好友申请接受和自动接受路径增加 store 空结果保护。底层异常返回 `nil, nil` 时记录租户、申请、操作人和状态并返回 HTTP 500,不再触发进程 panic。
2. Hub 向已满的 Topic client queue 投递失败时,立即向原发送连接返回 `503 service unavailable`,并记录 topic、tenant、队列长度和容量。客户端可据此重试,压测也不再把这类请求归为等待超时的未知结果。
3. 群消息权限检查改为复用群设置缓存,并为个人禁言增加 Redis 正向和负向缓存;新增禁言和解除禁言会同步更新缓存,到期禁言会自动淘汰。此改动减少群 Topic 串行处理路径上的 MySQL 查询。
4. `message_publish_stage_duration_seconds` 新增 `group/permission_check` 和 `*/realtime_fanout` 阶段,下轮可区分权限查询、持久化和实时扩散耗时。
5. A/B 两个逻辑 agent 按账号顺序交错关闭连接。`close_ramp=60s` 时每个 agent 的本地关闭过程约 120 秒,A/B 相差半个时隙,整体约每 100ms 关闭一个连接,避免两侧同时下线形成双倍尖峰。
6. 已增加空结果防 panic、Topic queue 满返回 503、双 agent 关闭时隙的自动化测试。
## 8. 复测顺序
1. 发版后先跑单机群聊 60%,验收实时发送阶段和关闭阶段 `topic_client` enqueue failed 均为 0;若仍过载,发送端必须收到 503,不能再出现对应的纯 ACK timeout。
2. 对比 `permission_check`、`save`、`realtime_fanout` 三阶段的 P50/P95/P99,确认群权限缓存是否消除了数据库检查瓶颈。
3. 群聊通过后再跑单机混合 60%,要求 ACK、历史和独立 SQL 三方账本一致,连接断开数为 0。
4. 补齐 Prometheus/metrics-server 权限并同步保存 Pod CPU、内存、网络、Kafka lag、Redis、MySQL 指标;在此之前不进行双机 100% 最终验收。
## 9. 发版后群聊 R2
Run ID:`s60-group-r2-0929`。
本轮开始前 3 个 Pod 均为 Running、0 重启,账号和群成员核验为 `accounts=1200 pairs=600 group_members=1200`。测试完整运行到 A/B 历史验证完成,并执行了错峰关闭和独立 MySQL 核验。
| 指标 | 结果 |
|---|---:|
| 发送尝试 / ACK | 1200 / 871 |
| 结果未知 | 329(222 timeout、107 socket) |
| 断连 | 360(315 reset、44 abnormal close、1 timeout) |
| ACK P95 | 16630.441ms |
| 投递 P95 | 101823.156ms |
| ACK 台账期望 / 实时收到 | 1045200 / 889777 |
| 实时缺失 | 155423,最终全部查到持久化 |
| 历史回查 | 1080 / 1080 |
| DB:ACK 精确命中 | 871 / 871 |
| DB:无 ACK 但落库 | 209 |
| DB:缺失 / 错配 / 重复 | 0 / 0 / 0 |
329 条结果未知中,209 条已被 WS 观测且独立 SQL 确认落库,剩余 120 条数据库中不存在。断连样本主要是公网对端 `connection reset by peer`,且样本连接均指向 `64.118.158.131:443`;本机到域名全部当前 IP 均使用 `en1` 静态直连,没有经过 VPN/TUN。
服务端指标显示群 Topic 所在 `im-server-0` 的 1080 条 `save` 全部低于 100ms,Kafka consumer lag 为 0;但 `topic_server` 队列累计出现 393 次 full。压测客户端 CPU、RSS 不构成瓶颈。
本轮不能作为提交 `75bb448` 的有效回归:Pod 中运行的二进制不包含该提交新增的 `permission_check`、`realtime_fanout` 阶段字符串和好友申请空结果保护日志,Prometheus 也没有相应时序,说明实际部署镜像不是该提交构建结果。必须正确部署 `75bb448` 或后续提交后重跑。
此外,本轮发现 agent 只在正式开始前维护连接,统计窗口内断线后不会恢复,导致历史核验依赖剩余连接。后续已改为持续维护到 A/B 历史验证均完成,并将重连并发限制为每 agent 16,避免恢复风暴;每次计划发送前重新取得当前连接。
## 10. 产物
- 方案:`docs/test/IM-单机60%容量压力测试方案.md`
- 单聊 R2:`loadtest/im-pressure/results/s60-p2p-r2-0929/`
- 群聊 R1:`loadtest/im-pressure/results/s60-group-r1-0929/`
- 混合 R1:`loadtest/im-pressure/results/s60-mixed-r1-0929/`
- 群聊 R2:`loadtest/im-pressure/results/s60-group-r2-0929/`
结果目录包含 coordinator、A/B summary、合并 report 和 agent 指标 JSONL。
## 12. 精确成员规模诊断复测
为区分“在线连接数”和“群成员总数”的影响,新增 300、600 人独立群复测。每个测试群在起压前均按用户 ID 集合核验,要求实际成员与目标账号完全相等。
| 场景 | Run ID | 在线/群成员 | ACK | 实时投递 | 历史 | DB 精确命中 | ACK P95 | 投递 P95 |
|---|---|---:|---:|---:|---:|---:|---:|---:|
| 原 1200 人群、仅 300 人在线 | `s300-group-r1b-0929` | 300/1200 | 45/300 | 13500/13500(按 ACK 台账) | 206/206 | 45 ACK + 210 结果未知全部落库 | 18994ms | 89489ms |
| 精确 300 人群 | `s300m-group-r1-0930` | 300/300 | 300/300 | 90000/90000 | 300/300 | 300/300 | 235.177ms | 500ms 桶内 |
| 精确 600 人群 | `s600m-group-r1-0930` | 600/600 | 600/600 | 360000/360000 | 600/600 | 600/600 | 235.177ms | 500ms 桶内 |
| 精确 900 人群 | `s900m-group-r1-0930` | 900/900 | 890/900 | 800555/801000(按 ACK 台账) | 899/899 | 890 ACK + 9 结果未知落库 | >10s,最大19.272s | >10s,最大55.279s |
三轮均未出现 WS 发送队列满、WS 写失败或 Kafka lag。精确 300、600 人群在实时阶段也没有新增 `session_topic_publish`、`topic_client`、`topic_server` 满队列,说明 WS 写链路在 600 在线规模内不是瓶颈。
原 1200 人群仅 300 人在线时有约 900 个离线成员。群消息处理会在 Topic 串行路径中为每个离线成员逐个调用 Redis presence 查询;上一条消息 ACK 后的推送准备持续占用 Topic goroutine,使后续消息长时间等待,最终表现为 ACK 超时和分钟级投递延迟。精确成员群消除这部分离线成员后,同等 300 在线规模恢复正常,验证了该路径是主要根因。
600 人实时发送通过后,240 秒错峰下线仍使群主 Pod 的 `topic_server full` 从 36 增至 128,净增 92;其他两类内部队列无增量。该问题来自最后会话离线时的 presence 扩散,与消息保存、Kafka 消费和 WS 实时投递无关,仍需在服务端合并或限流离线状态广播。
精确 900 人群出现容量拐点:900 次发送中 890 次 ACK,9 次 ACK 超时、1 次 socket 失败;7 条连接在实时阶段超时断开并自动恢复。按 ACK 台账计算的 445 次实时缺口全部可从历史补回,890 条 ACK 全部精确落库;9 条结果未知实际落库,1 条 socket 失败未落库。因此本轮没有持久化丢失,但实时正确性和延迟门禁未通过。
900 人实时阶段三个内部队列的 full 计数均无增量,Kafka lag 为 0。成功处理的 899 条消息中,`permission_check` P95 不超过 10ms、`save` P95 不超过 50ms、`kafka_produce` P95 不超过 25ms、`realtime_fanout` P95 不超过 5ms;抽样覆盖后半段 575468 次 WS 投递,发送队列等待 P95 不超过 10ms、socket write P95 不超过 0.5ms,且没有发送队列满或 text write 失败。
现有 `realtime_fanout` 指标在 `pushForData` 之前结束。`pushForData` 位于 Topic 串行处理路径中,每条群消息遍历全部成员、构造推送收件人并调用 `sendPush`;即使成员全部在线,900 人规模下这段未计时工作仍可让后续发布在 `topic_client` 中排队。队列未达到容量上限并不代表没有排队,这与 ACK 延迟逐步增长到 19 秒、投递延迟增长到 55 秒的现象一致。下一步应先补充 `push_prepare`、`push_dispatch` 和 Topic client queue wait 指标,再将群推送准备移出 Topic 串行热路径或改为异步批处理。
900 人在 240 秒错峰下线后,群主 Pod 的 `topic_server full` 从 0 增至 379,说明 presence 离线扩散问题随群规模明显放大。压测机两个 agent 的峰值 CPU 分别约 39.5% 和 38.5% 单核,峰值 RSS 各约 76MB、FD 各 460,不是本轮瓶颈。
新增产物:
- 300 人精确群:`loadtest/im-pressure/results/s300-group-members-setup-0929/`
- 300 人复测:`loadtest/im-pressure/results/s300m-group-r1-0930/`
- 600 人精确群:`loadtest/im-pressure/results/s600-group-members-setup-0930/`
- 600 人复测:`loadtest/im-pressure/results/s600m-group-r1-0930/`
- 900 人精确群:`loadtest/im-pressure/results/s900-group-members-setup-0930/`
- 900 人复测:`loadtest/im-pressure/results/s900m-group-r1-0930/`
## 13. 容量拐点修复(待发版复测)
针对精确 900 人群暴露的 Topic 串行积压和批量下线 presence 风暴,已完成以下代码改造:
1. `pushForData` 在 Topic goroutine 内只复制成员推送快照,不再逐成员同步查询 Redis,也不再执行跨 Pod 收件人拆分和 user updater 投递。
2. 新增总容量 4096 的有界异步推送队列和 4 个固定分片 worker。同一 `tenant + topic` 固定进入同一分片,保证同一群的推送和未读更新顺序;不同群可并行处理。
3. 离线成员 presence 使用一次 Redis pipeline 批量读取,单条消息的 Redis 网络往返由成员数 N 次降为 1 次。
4. 群离线 presence 遇到 Topic server queue 满时不再直接丢弃。服务端按 `tenant + topic + source user` 合并为最新状态,并在 Topic 队列恢复后重投;待处理集合上限 16384。
5. 新增 Topic client queue wait、push prepare、push dispatch queue、push dispatch duration、push recipient count、deferred presence 当前深度等指标。
新增重点指标:
- `im_topic_queue_wait_duration_seconds`
- `im_message_publish_stage_duration_seconds{stage="push_prepare"}`
- `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_internal_queue_current{queue="topic_presence_pending"}`
- `im_internal_queue_enqueue_total{queue="topic_presence_deferred"}`
发版后先复测精确 600 人群,确认基线无回归;再复测精确 900 人群。900 人验收要求:900/900 ACK、810000/810000 实时投递、0 断连、0 持久化缺失,`topic_client/topic_server/push_dispatch` 无 full,Topic queue wait P95 不超过 500ms,ACK P95 不超过 500ms,投递 P95 不超过 1s。渐进下线结束后 deferred presence 必须回落到 0,且不得出现 `topic_server full`。
## 14. 异步推送发版后精确 600 人回归
Run ID:`s600m-pushfix-r1-0930`。测试使用精确 600 人群 `grpJZjWbOek928`,A/B 各建立 300 条连接;两端均完整运行至 5 分钟落库沉淀、历史回查和 240 秒交错关闭结束,没有提前终止连接。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 600 / 600 |
| 发送尝试 / 收到 ACK | 600 / 523 |
| 发送结果未知 | 77(71 timeout、6 socket) |
| 服务端实际接收并落库 | 593 / 600 |
| ACK P95 | 17522.536ms |
| ACK 台账实时投递 | 308745 / 313800 |
| 实时到达率 | 98.3891% |
| 实时缺口 | 5055,全部可从历史补回 |
| 历史回查 | 593 / 593 |
| 无 ACK 但历史可查 | 70 / 70 |
| MySQL 精确命中 | 593 / 593 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 断连 | 34(21 reset、13 abnormal close) |
| 重启 | 0 |
异步推送改造本身没有形成服务端队列瓶颈。群主 Pod 处理的 600 次发布 Topic queue wait 全部不超过 50ms;593 次异步推送均成功入队,`push_dispatch` 没有 full,队列等待 P95 不超过 0.5ms,dispatch P95 不超过 10ms、最大不超过 25ms,deferred presence 最终为 0。Kafka lag 仍为 0。
本轮 77 条 ACK 未知中,70 条已被对端 WS 观察并精确落库,剩余 7 条 socket 失败未进入服务端。由此可确认本轮失败发生在客户端连接和 ACK 返回链路,不是消息持久化、Kafka 消费、Topic 排队或异步推送丢弃。
测试期间 3 个 IM Pod 持续收到培养服务文件确认请求,并在 `store.Files.LinkAttachments` 中对 `cvp...` 业务保留键调用 `types.GetTopicCat`,约每 5 秒产生一轮 HTTP panic 和完整堆栈。该接口允许业务保留键,不能按 Tinode Topic 强制分类;持续 panic 日志和并发异常请求污染了本轮环境,因此本轮不能作为异步推送改造的有效 600 人基线。
已完成两项修复:
1. 附件关联仅对 `usr...` 用户引用执行用户 Topic 转换,`cvp...` 等业务保留键直接按普通关联键落库,消除确定性 panic。
2. `verify-db` 将双机共同观察到的无 ACK 消息与 ACK ledger 合并去重。本轮修正后的独立 SQL 结果为 `expected=593, found_exact=593`,不再把 70 条已落库的 ACK 超时消息误报为 unexpected。
下一轮必须在上述修复发版后重新执行相同的精确 600 人场景。验收前先确认 3 个 Pod 不再出现 `invalid topic type for name 'cvp...'`;随后要求 600/600 ACK、360000/360000 实时投递、MySQL 600/600 精确命中、0 断连,并保持 Topic、push dispatch、Kafka 和数据库指标无积压。
新增产物:
- `loadtest/im-pressure/results/s600m-pushfix-r1-0930/report.json`
- `loadtest/im-pressure/results/s600m-pushfix-r1-0930/db-verification.json`
- `loadtest/im-pressure/results/s600m-pushfix-r1-0930/server-metrics/`
## 15. Panic 修复后精确 600 人连续复测
部署提交 `06e08a6` 后,3 个 Pod 使用同一镜像摘要,均为 Ready、零重启。连续观测和三轮压测期间不再出现 `invalid topic type for name 'cvp...'` 或对应 HTTP panic,确认培养文件关联修复生效。
为区分代码回归与公网瞬态,使用同一配置和精确 600 人群连续执行 R2、R3、R4。每轮 A/B 各 300 连接,保持到 5 分钟沉淀、历史验证和 240 秒交错关闭全部完成。
| 指标 | R2 | R3 | R4 |
|---|---:|---:|---:|
| ACK | 600 / 600 | 600 / 600 | 600 / 600 |
| 实时投递 | 359400 / 360000 | 360000 / 360000 | 360000 / 360000 |
| 断连 | 1 次 timeout | 0 | 0 |
| 历史回查 | 600 / 600 | 600 / 600 | 600 / 600 |
| MySQL 精确命中 | 600 / 600 | 600 / 600 | 600 / 600 |
| MySQL 缺失 / 错配 / 重复 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 |
| ACK P95 | 314.530ms | 1555.109ms | 6228.311ms |
| 投递 P95 | 500ms 桶内 | 2000ms 桶内 | 10000ms 桶内 |
R2 唯一异常是 `lta0259` 到 `216.236.31.189:443` 的一次客户端读超时。该连接在发送阶段开始后自动恢复,因此该账号错过 600 条实时消息;600 条均可从历史补回,服务端和 MySQL 没有丢失。R3、R4 均达到消息正确性全通过:零断连、零实时缺失、零重复、零乱序、零持久化缺失。
R3、R4 的延迟门禁没有通过,但服务端分段指标未出现同步恶化:
- Topic queue wait P95 不超过 0.5ms。
- 消息保存 P95 不超过 25ms。
- push dispatch P95 不超过 10ms,队列无 full,完成后深度为 0。
- WS send queue wait P95 约 3ms,socket write 超过 99% 不超过 0.5ms。
- deferred presence 当前深度为 0。
- 600 条历史和数据库记录全部精确命中,Kafka 未出现消费积压。
ACK 延迟按 3 个拨号 IP 分组后同步上升,不集中于单一入口。R2 三个 IP 的 ACK P95 为 302–372ms,R3 为 1536–1643ms,R4 为 5999–6559ms,说明延迟发生在服务端 socket write 之后的共享公网、负载均衡或压测机接收链路。
压测结束后的网络基线同样异常:
| 入口 IP | TCP connect 平均 | HTTPS TTFB 平均 | ICMP 丢包 | ICMP RTT |
|---|---:|---:|---:|---:|
| `216.236.31.189` | 179ms | 623ms | 10% | 平均 256ms,最大 511ms |
| `216.236.31.191` | 178ms | 543ms | 10% | 平均 154ms |
| `64.118.158.131` | 178ms | 588ms | 0% | 平均 168ms |
三条静态路由均经 `en1` 和网关 `192.168.1.1` 直连,没有经过 VPN/TUN。当前公网 RTT、丢包和 HTTPS TTFB 已高于或接近 500ms ACK 门禁,TCP 重传会进一步放大 WebSocket 队头等待。因此本组结果可证明精确 600 人下服务端消息正确性和持久化能力通过,但不能证明公网端到端延迟 SLA 通过。
后续在进入精确 900 人测试前,应先满足以下任一条件:
1. 使用与测试集群同地域、低丢包的压测机,并保留当前 500ms/1s 延迟门禁。
2. 取得 Ingress/LB 的 upstream、出站带宽、连接 reset 和重传指标,将公网延迟与服务端处理延迟分开验收。
3. 双机正式压测时分别记录本机到 3 个入口的 RTT、丢包和 HTTPS TTFB;网络基线不合格时只验收正确性,不输出容量延迟结论。
新增产物:
- `loadtest/im-pressure/results/s600m-pushfix-r2-0930/`
- `loadtest/im-pressure/results/s600m-pushfix-r3-0930/`
- `loadtest/im-pressure/results/s600m-pushfix-r4-0930/`
## 16. 异步推送发版后精确 900 人复测
Run ID:`s900m-pushfix-r2-0930`,精确群成员 900 人,A/B 各 450 条 WebSocket 连接。两端均完整运行至 5 分钟沉淀、历史回查和 240 秒渐进关闭结束,没有提前终止。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 收到 ACK | 900 / 590 |
| 发送结果未知 | 310(186 条已被对端 WS 观察,124 条未入服) |
| 服务端实际接收并落库 | 776 / 900 |
| ACK P95 | 17075.685ms |
| ACK 台账实时投递 | 381424 / 531000 |
| ACK 台账实时到达率 | 71.8313% |
| 实时缺口 | 149576,全部可从历史补回 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 776 / 776 |
| MySQL 精确命中 | 776 / 776 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 断连 | 410(322 reset、88 abnormal close) |
| Pod 重启 | 0 |
本轮消息正确性结论很明确:590 条有 ACK,186 条虽然 ACK 超时或 socket 失败,但已被其他 WS 客户端观察到;这 776 条全部能从历史查到,且 MySQL 精确落库 776 条。服务端已接收消息的持久化丢失率为 0,无重复、无字段错配。余下 124 条在连接断开期间未进入服务端,不属于服务端落库后丢失。
服务端在 776 条实际入服消息上没有出现容量积压:
- Topic queue wait P95 不超过 10ms,95.7% 不超过 10ms。
- push dispatch 776/776 成功入队,无 full;队列等待 P95 不超过 5ms,dispatch P95 不超过 25ms。
- WS send queue 等待 P95 不超过 5ms,socket text write P95 不超过 0.5ms;1177345 次成功写入,16 次写入失败均是对端已关闭连接。
- 3 个 Pod 的 Kafka consumer lag 和 oldest backlog age 始终为 0,无 Topic、push dispatch 或 WS send queue full。
- 渐进关闭后 push dispatch 和 WS send queue 深度均为 0,`topic_presence_pending` 回落为 0。
压测期间客户端记录 410 次非预期断连,服务端指标同期新增 412 次 WS timeout,三个 Pod 分别为 135、133、144 次,并非单一 Pod 故障。客户端 reset 样本同样均匀分布在三个公网 IP。压测 agent A/B 峰值 CPU 约为 20.8%/22.0% 单核,峰值 RSS 约为 108/101MiB,FD 均为 460,本机进程没有出现 CPU、内存或 FD 容量耗尽。结合本章前已观测到的高 RTT、ICMP 丢包和 HTTPS TTFB,本轮只能判定“当前端到端公网路径下 900 长连接门禁失败”,具体故障点可能在压测机网络、NAT/ISP、Ingress/LB 或其间 TCP 链路,不能据此判定 IM 服务端在 900 人处理容量上失败。
下一次 900 人容量验收必须改为集群同地域压测机,或先取得 Ingress/LB 的 reset、TCP 重传、出站带宽和 upstream 时延指标。网络基线不合格时,继续从当前机器反复施压只会重复得到链路上限,无法给出服务端容量上限。
新增产物:
- `loadtest/im-pressure/results/s900m-pushfix-r2-0930/report.json`
- `loadtest/im-pressure/results/s900m-pushfix-r2-0930/db-verification.json`
- `loadtest/im-pressure/results/s900m-pushfix-r2-0930/server-metrics/`
## 17. 900 连接心跳容错与压测端解耦(待发版复测)
针对 R2 在约一个心跳超时窗口后出现的 410 次断连,已完成三项改造:
1. WebSocket Ping 周期和 Pong 等待时间从硬编码中拆出,支持 `ws_ping_period_seconds` 和 `ws_pong_wait_seconds` 配置。默认为 15 秒 Ping、90 秒 Pong 容错,不再复用 45 秒业务 idle timeout。
2. 新增 `im_ws_heartbeat_seconds` 和 `im_ws_heartbeat_total{event="pong_received"}`,用于核对每个 Pod 的有效心跳配置和 Pong 接收数;测试环境 ArgoCD patch 显式设置 15/90 秒。
3. 压测客户端为每条连接新增有序数据回调队列。Socket 读循环不再同步执行消息去重、顺序和延迟统计,避免统计锁竞争阻塞 ACK、Ping 和 Pong 读取。
发版后仍使用同一精确 900 人群复测。起压前先确认三个 Pod 的 heartbeat gauge 均为 15/90;验收时对比 Pong 增量、timeout 增量、agent 断连和入服/落库数。若仍然同时批量 timeout,下一步必须取得 Ingress/LB TCP 指标或改用同地域压测机,不再继续放大应用心跳窗口。
## 18. 90 秒心跳发版后精确 900 人复测
Run ID:`s900m-heart-r1-0930`。三个 Pod 发版后均为 Ready、零重启,指标确认有效心跳配置均为 `ping_period=15`、`pong_wait=90`。使用与上一轮相同的精确 900 人群,A/B 各 450 条连接,完整运行至历史回查和渐进关闭结束。
| 指标 | 本轮 | 改造前 R2 |
|---|---:|---:|
| 发送尝试 | 900 | 900 |
| 收到 ACK | 854 | 590 |
| ACK 超时 / socket 错误 | 45 / 0 | 310 条结果未知,其中 124 条未入服 |
| 明确失败 | 1 条 `500 internal error` | 0 |
| 服务端实际接收并落库 | 899 / 900 | 776 / 900 |
| 断连 | 11 | 410 |
| ACK P95 | 11475.912ms | 17075.685ms |
| ACK 台账实时投递 | 765336 / 768600 | 381424 / 531000 |
| ACK 台账实时到达率 | 99.5753% | 71.8313% |
| 重复 / 乱序 | 0 / 0 | 0 / 0 |
| 历史回查 | 899 / 899 | 776 / 776 |
| MySQL 精确命中 | 899 / 899 | 776 / 776 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
心跳容错和压测端读循环解耦有明显效果:断连从 410 次降到 11 次,降幅约 97.3%;未入服数从 124 降到 1。负载期三 Pod 累计成功发送约 14554 次 Ping,已观察到约 14472 次 Pong。11 次断连仍在 90 秒窗口附近发生,客户端全部自动重连。当前心跳仅在 Pong 回调时续期;下一步应将有效的客户端 Ping 和业务入站帧同样视为连接活动,而不是继续扩大 Pong 窗口。
45 条 ACK 超时消息全部被对端 WS 观察,且 899 条实际入服消息全部历史可查、MySQL 精确命中,证明已入服消息仍为零持久化丢失。唯一未入服消息已定位到 `10:07:58` 群主 Pod 的 MySQL `invalid connection`:同时出现群禁言策略查询失败和消息保存失败,客户端收到 `500 internal error`。需要对失效 MySQL 连接进行识别、丢弃并安全重试。
服务端分段指标中,899 条成功消息的 permission check P95 不超过 25ms、save P95 不超过 50ms、Kafka produce P95 不超过 25ms、realtime fanout P95 不超过 5ms、push prepare 和 push queue wait P95 不超过 10ms、push dispatch P95 不超过 25ms,无任何内部队列 full,Kafka lag 为 0。但 Topic queue wait P95 为 5s,最后 124 条在连接 timeout 与 presence 处理同期等待 0.1–10s,使 ACK P95 仍未过门禁。
压测 agent 峰值 CPU 约为 28.8%/22.8% 单核,RSS 约 121MiB,FD 各 460,进程资源未饱和。但异步数据回调队列尚未暴露当前深度和等待时间,因此本轮 98 秒投递 P95 包含网络、socket 读取和压测统计队列三段时间,不能单独作为服务端投递 SLA。下一轮前需增加压测回调队列深度/等待指标,并减少 recorder 在同一 `client_mid` 上的锁竞争。
新增产物:
- `loadtest/im-pressure/results/s900m-heartbeat-r1-0930/report.json`
- `loadtest/im-pressure/results/s900m-heartbeat-r1-0930/db-verification.json`
- `loadtest/im-pressure/results/s900m-heartbeat-r1-0930/server-metrics/`
## 19. 900 人复测后第二轮修复
针对第 18 节剩余的 11 次 timeout、1 条 MySQL `invalid connection` 和压测端回调排队不可观测问题,已完成以下修复:
1. 服务端收到有效客户端 Ping 时立即续期读截止时间并回 Pong;成功读取业务帧时也续期读截止时间。连接活跃判定不再只依赖服务端 Ping 对应的 Pong,新增 `im_ws_heartbeat_total{event="ping_received"}` 观测客户端心跳。
2. MySQL 只读查询遇到 `ErrInvalidConn` 时向 `database/sql` 返回 `ErrBadConn`,使连接池丢弃坏连接并在新连接上重试。个人消息仅在存在 `client_msg_id` 唯一键保护时重试一次;群消息由 `message_id` 和 `client_msg_id` 唯一键保护,整个保存操作可安全重试一次。新增 `im_db_invalid_connection_retry_total{operation,outcome}`。
3. recorder 的群消息接收位图改为原子 CAS。同一 `client_mid` 只在首次创建 observation 时使用 shard 锁,后续数百个收件人不再串行争用同一把写锁。
4. agent 新增回调队列当前深度、处理数、峰值、入队/完成数、累计等待和最大等待指标,并将快照写入 `summary.json` 的 `callback_queue`。生成实时到达率前会等待排队及正在处理的回调全部完成,避免统计尚未落入 recorder 时产生假丢失。
下一轮仍使用精确 900 人群复测。除原有正确性门禁外,重点检查:
- `im_ws_heartbeat_total{event="ping_received"}` 有持续增量,服务端 timeout 和客户端断连进一步下降。
- `im_db_invalid_connection_retry_total` 若出现,结果应为 `discard` 或 `recovered`,客户端不再收到对应 500。
- `callback_queue.current` 和 `callback_queue.processing` 在结算时均为 0;结合 `maximum`、`wait_seconds_total / completed` 和 `wait_seconds_max` 区分压测端排队与服务端投递延迟。
## 20. 第二轮修复后精确 900 人复测
Run ID:`s900m-activity-r1-0930`,部署提交 `36129e3`。复用精确 900 人群 `grpQ5_hPdAz3cE`,A/B 各 450 条 WebSocket 连接。两个 agent 均通过 30 秒满连接屏障,并一直运行至实时投递、5 分钟沉淀、历史回查、MySQL 校验和 120 秒渐进关闭全部完成。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 收到 ACK | 900 / 899 |
| 结果未知 | 1 条 ACK 超时,socket 错误 0 |
| 服务端实际接收并落库 | 900 / 900 |
| 实际实时投递 | 810000 / 810000 |
| 实时到达率 | 100% |
| 重复 / 乱序 | 0 / 0 |
| 压测期非预期断连 | 0 |
| 服务端 WS timeout | 0 |
| 历史回查 | 900 / 900 |
| MySQL 精确命中 | 900 / 900 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| ACK P95 | 4748.423ms |
| 投递 P95 | 10000ms 桶内 |
唯一 ACK 超时为 `s900m-activity-r1-0930-g-b-0121-01`。该消息虽然 20 秒内没有收到 ctrl ACK,但 A/B 均实时观察到,服务端分配 seq `3714`,历史可查且 MySQL 精确命中;因此是 ACK 返回链路超时,不是消息未入服或丢失。报告中的 `unexpected_messages=900` 正是该条没有进入 ACK ledger、但被 900 个连接实际收到的消息。
本轮连接活跃改造生效。三 Pod 均持续记录 `ping_received` 和 `pong_received`,负载阶段没有 WS timeout;两个 agent 全程 450/450 在线且断连为 0。与上一轮 11 次断连相比,客户端 Ping 和业务帧续期后已消除本轮心跳误断。
压测端回调队列指标证明 recorder 不再构成秒级延迟:
| agent | 入队 / 完成 | 峰值等待数 | 最大等待 | 平均等待 | 结算时等待 / 处理中 |
|---|---:|---:|---:|---:|---:|
| A | 405000 / 405000 | 106 | 11.353ms | 约 0.022ms | 0 / 0 |
| B | 405000 / 405000 | 98 | 5.075ms | 约 0.020ms | 0 / 0 |
服务端内部处理也没有出现容量积压:
- Topic queue wait P95 不超过 50ms,全部 900 条不超过 250ms。
- permission check P95 不超过 25ms,消息保存 P95 不超过 50ms,realtime fanout P95 不超过 5ms,push prepare P95 不超过 10ms。
- WS send queue wait P95 不超过 25ms,socket text write P95 不超过 0.5ms。
- push dispatch queue 无 full,Kafka consumer lag 和 oldest backlog age 均为 0。
- 没有 `invalid connection`、消息保存失败、内部队列 full、panic 或 Pod 重启;关闭后 push 队列和 `topic_presence_pending` 均回落为 0。
因此本轮消息正确性和持久化门禁已经通过,但端到端延迟门禁仍未通过。测试结束后的三入口公网 RTT 为 153–189ms,HTTPS TTFB 为 0.50–1.04s;三条路由均经 `en1` 和网关 `192.168.1.1` 直连。服务端从 Topic 排队到 socket write 的 P95 远低于端到端 ACK/投递 P95,压测回调队列最大等待也只有 11ms,剩余延迟主要位于服务端 socket 写入之后的 Ingress/LB、公网 TCP、NAT 或压测机网络路径。后续服务端容量验收应在集群同地域压测机执行,并同时采集 Ingress/LB 出站带宽、TCP 重传、reset 和 upstream 指标。
新增产物:
- `loadtest/im-pressure/results/s900m-activity-r1-0930/report.json`
- `loadtest/im-pressure/results/s900m-activity-r1-0930/db-verification.json`
- `loadtest/im-pressure/results/s900m-activity-r1-0930/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-activity-r1-0930/server-metrics/`
## 21. 精确 900 人混合场景测试
混合场景使用 900 条 WebSocket、135 对单聊用户和 224 个群发送者。单聊双方各发 5 条,共 1350 条;群发送者各发 2 条,共 448 条;总发送尝试 1798 条。每个 agent 理论应观察 201600 次群投递和 1350 次单聊发送方回显/接收方投递,共 202950 次唯一回调。
### 21.1 R1:发现结算窗口过早
Run ID:`s900m-mixed-r1-1001`。
- 1798 条消息全部进入服务端,历史 1798/1798,MySQL 1798/1798,缺失、错配、重复和额外记录均为 0。
- 收到 ACK 1411 条,387 条 ctrl ACK 超时;ACK P95 为 15756.928ms。
- A/B 负载期均为 450 条连接且断连为 0。
- 固定 60 秒 drain 结束时,A 的 summary 记录 202867/202950,误报 83 次实时缺口;summary 生成后指标继续增长至 202950/202950,说明这些帧是公网链路中的晚到帧,并未丢失。
- B 为 202950/202950。回调队列最大等待分别约 98ms/92ms,结算时等待和处理中均为 0。
- 发现 1 次单聊顺序回退:接收方 A-27 在同一 P2P Topic 上先收到 seq 245、后收到 seq 244。
本轮证明只等待“回调队列瞬时为空”不够:帧可能仍位于服务端 socket、Ingress/LB、TCP 或客户端 socket 缓冲区。压测程序已增加 `delivery_quiet_period` 和 `delivery_quiet_timeout`。固定 drain 后,接收计数和回调队列必须连续静默 10 秒才允许生成实时 summary;任意晚到帧都会重置静默窗口,最长等待 3 分钟。
同时修正实时统计口径:无 ACK 但已被任一 WebSocket 观察到的消息会与 ACK ledger 合并后参与全部收件人的实时到达率和“实时缺失但已落库”计算,不再被记为 `unexpected_messages`。该口径从后续轮次开始生效;R1/R2 原始报告仍保留当时输出,以上结论已按全量唯一回调和历史/MySQL 结果校正。
### 21.2 R2:静默结算修复验证
Run ID:`s900m-mixed-r2-1001`。本轮使用修复后的压测程序,静默等待在 A/B 均正常执行并完成后才生成 summary。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1760 |
| ACK 超时 / socket 错误 | 37 / 1 |
| 压测期断连 | 14,全部为 timeout,A/B 各 7 |
| A 唯一实时回调 | 202535 / 202950 |
| B 唯一实时回调 | 202787 / 202950 |
| 全量唯一实时回调 | 405322 / 405900,约 99.8576% |
| ACK 台账实时缺口 | 566,全部已落库 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| ACK P95 | 14317.159ms |
| 投递 P95 | 46622.373ms |
R2 的 A/B 各 7 条连接在负载期超时断开,随后全部自动恢复至 450/450。实时缺口与断连窗口一致,静默窗口结束后接收计数不再增长,因此不是 R1 那种过早结算。所有实时缺口均可由历史补回,MySQL 独立校验证明服务端持久化零丢失。
两个 agent 的回调队列最大等待约 10ms,结算时 current/processing 均为 0,压测 recorder 不是本轮瓶颈。服务端 1350 条 P2P 消息全部处理完成,P2P Topic queue P95 位于 250–500ms,relationship check P95 位于 100–250ms;Kafka lag 为 0,未出现 MySQL invalid connection、内部队列 full 或 Pod 重启。关闭后 push 队列和 deferred presence 均回落为 0。
混合场景在当前公网压测机上未通过实时和延迟门禁,但持久化门禁通过。下一步不应直接扩大到 1200 人,应先在集群同地域压测机重复 900 人混合场景;若同地域仍出现乱序或实时缺口,再分别处理跨 Pod P2P 顺序和 WS 投递路径。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r1-1001/`
- `loadtest/im-pressure/results/s900m-mixed-r2-1001/`
### 21.3 R3:零断连、零实时丢失,但仍有 ctrl ACK 超时
Run ID:`s900m-mixed-r3-1001`。本轮沿用 900 条 WebSocket、135 对单聊用户和 224 个群发送者,共发送 1350 条单聊和 448 条群消息。端到端 P95 延迟按最新口径仅作观测,不参与通过判定。
| 指标 | R3 | R2 |
|---|---:|---:|
| 就绪连接 | 900 / 900 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1751 | 1798 / 1760 |
| ctrl ACK 超时 / socket 错误 | 47 / 0 | 37 / 1 |
| 压测期非预期断连 | 0 | 14 |
| 两端唯一 WS 回调 | 405900 / 405900 | 405322 / 405900 |
| 实时缺口 | 0 | 578 次底层回调,ACK 台账折算缺口 566 |
| 重复 / 乱序 | 0 / 0 | 0 / 0 |
| 历史回查 | 1798 / 1798 | 1798 / 1798 |
| 无 ACK 但已观察消息的历史命中 | 47 / 47 | 38 / 38 |
| MySQL 精确命中 | 1798 / 1798 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
| ACK P95(仅观测) | 14020.142ms | 14317.159ms |
| 投递 P95(仅观测) | 52784.346ms | 46622.373ms |
R3 的消息正确性明显好于 R2:A/B 各收到 202950/202950 个唯一回调,包括每端 201600 次群投递、675 次单聊对端投递和 675 次发送方回显。固定 drain 后又经过连续 10 秒静默窗口,计数未再变化,本轮没有过早结算。
47 条 ctrl ACK 超时中,单聊 26 条、群聊 21 条,A/B 分别为 22/25 条。它们全部被 WS 实时观察,47/47 历史可查,并被 MySQL 独立校验纳入 1798/1798 精确命中,所以不是消息未入服或持久化丢失。但客户端在 20 秒内未收到 ctrl ACK,仍是独立的发送协议问题,不属于已取消的 500ms 延迟门禁。因此本轮应判定为:
- 实时到达、重复、顺序、断连、历史和落库正确性通过。
- 混合场景总验收仍未通过,唯一阻断为 47 条 `command_timeout=20s` 的 ctrl ACK 超时。
- ACK P95 和投递 P95 保留在报告中,不影响上述判定。
服务端负载期没有 Topic/WS 发送队列 full,WS send queue 结算时均为 0,所有 Kafka consumer lag 为 0,三个 Pod 均为 Ready 且零重启。连接渐进关闭后,三 Pod 连接数从 518/373/550 回落至 7/2/7,与起压前 8/3/6 基本一致。Pod 2 增加了 1 次 `Cluster.Route` 和 1 次 `Cluster.UserCacheUpdate` 瞬时 error,均不足 1ms,最近日志无对应错误,且未影响本轮投递与落库。
压测端回调队列在结算时均为 0;A/B 队列峰值为 355/174,最大等待仅为 37.054ms/25.036ms,不是 20 秒 ACK 超时或 52 秒投递 P95 的原因。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r3-1001/server-metrics/`
### 21.4 R3 ctrl ACK 超时原因分析
#### 21.4.1 代码路径
服务端的消息发布顺序为:Topic 队列出队、权限/关系校验、持久化、生成 `NoErrAccepted` ctrl ACK 并调用 `queueOut`,然后才做当前消息的 Topic 广播、跨 Pod 实时 fanout 和离线推送准备。因此,当前消息的大规模 fanout 不会阻塞同一条消息的 ACK 生成。
但所有 ctrl 和 data 最终共用用户的同一个 WebSocket/TCP 有序字节流。ACK 可以在当前消息的 data 之前入队,但无法超过已经写入 socket 或正在 Ingress/LB/公网 TCP 中传输的早先 data 帧。混合场景中发送者同时也是 900 人群的收件人,因此每条发送连接在等待自己 ACK 时,前面可能已有数百条群 data 帧。
#### 21.4.2 服务端分段证据
按起压前与负载结束 Prometheus 快照计算本轮增量:
| 阶段 | 增量样本 | P95 |
|---|---:|---:|
| P2P Topic 队列等待 | 1350 | 100ms 桶内 |
| P2P 关系校验 | 1350 | 100ms 桶内 |
| P2P 保存 | 1350 | 50ms 桶内 |
| 群 Topic 队列等待 | 448 | 25ms 桶内 |
| 群权限校验 | 448 | 25ms 桶内 |
| 群保存 | 448 | 50ms 桶内 |
| Cluster TopicMaster RPC | 2495 | 3至5ms 桶内 |
| Cluster proxy 队列深度 | 7988 | P95=1 |
| WS 发送队列深度 | 817924 | P95=0 |
| WS 发送队列等待 | 817924 | 10ms 桶内 |
| WS text frame 写入 | 817946 | 0.5ms 桶内 |
本轮三 Pod 共成功写出约 81.8 万个文本帧、193.6MB。服务端应用层队列、保存、Cluster RPC 和 socket write 均远低于 20 秒;压测时间窗内无 queue full、write timeout、数据库错误、Pod 重启或 panic。因此没有证据支持“业务处理或服务端应用队列阻塞 20 秒”。
#### 21.4.3 超时分布证据
- 47 条超时全部发生在起压后 20至40 秒,正好是每条连接已经接收约 200至400 条群广播帧的区间。
- 40/47 是同一用户的第 2 条消息,5 条是第 3 条,只有 2 条是第 1 条;超时不是随机散布在整个测试周期。
- 三个入口 IP 分别有 16/16/15 条超时,排除单一 Ingress IP 热点。
- 压测端回调队列最大等待只有 37ms,数据回调已与 socket 读循环解耦;超时不是 recorder 锁竞争或回调队列堵塞。
- 测试后三个入口的当前 ICMP RTT 约为 167至197ms,HTTPS TTFB 约为 0.49至0.61 秒;路由均经 `en1` 直连,未经 VPN/TUN。
综合上述证据,当前根因判断为:**高置信度的 WebSocket/TCP 单流队头阻塞,延迟主要发生在服务端完成 socket write 之后的 Ingress/LB、公网 TCP、NAT 和压测机接收路径。**这个结论可以解释“服务端已快速保存且客户端最终收到全部 data,但部分 ctrl 在 20 秒窗口内未被读到”。
尚未完全证明的环节是:当前压测程序在 ctrl 超时后删除 wait entry,晚到 ctrl 会被静默忽略;服务端 WS 写指标也没有区分 ctrl 和 data。因此现有数据无法逐条证明 47 个 ACK 全部在 20 秒后到达,只能证明对应消息全部已被服务端接受、投递和持久化。
#### 21.4.4 处理建议
1. 压测端增加 late ctrl 台账:ctrl 超时后保留 ID 和发送时间 10 分钟,记录晚到 ACK 数、最终延迟和完全未到数。
2. 服务端将 WS 出站指标按 `ctrl/data/pres/info` 分类,新增从客户端请求时间到 ctrl ACK 入队、实际 socket write 完成的直方图;跨 Pod 时再记录 master-to-proxy ctrl 延迟。
3. 下一轮保持完全相同的 900 人混合负载,将 `command_timeout` 改为 90 秒,但单独统计超过 20 秒的 ACK。若 47 类消息都在 90 秒内到达,且服务端 ctrl write 延迟仍在毫秒级,则队头阻塞结论得到直接验证。
4. 当前业务不对端到端延迟设阻断门禁,因此不建议现在改造独立控制连接或破坏 WebSocket 有序性。应先补齐上述观测,并将“消息已实时观测且历史/MySQL 命中”与“20 秒内收到 ctrl ACK”分开报告。
5. 另有一项与 ACK 超时无关的独立缺陷:负载结束后 Pod 2 记录 `gob: type not registered for interface: []map[string]interface {}`,影响另一个群 Topic 的 Cluster Route。当前 cluster gob 已注册 `[]any` 和 `map[string]any`,但未注册 `[]map[string]any`,应单独补注册和编解码回归测试。
#### 21.4.5 观测改造完成情况与 R4 口径
上述观测缺口已补齐:
- 压测端会统计 `publish_ctrl_observed`、`publish_ctrl_over_20s`、`publish_ctrl_late`、`publish_ctrl_unresolved` 和 `publish_ctrl_p95_ms`,并保留最多 1000 条超过 20 秒或晚到的样本。
- agent Prometheus 新增 `im_load_agent_publish_ctrl_observed_total`、`im_load_agent_publish_ctrl_over_20s_total`、`im_load_agent_publish_ctrl_late_total` 和 `im_load_agent_publish_ctrl_unresolved`。
- 服务端 Prometheus 新增 `im_ctrl_ack_delivery_duration_seconds{stage="enqueue|write"}`,按 Pod 记录从请求进入到 ACK 进入 WS 发送队列、以及完成 socket write 的时间。
- Cluster gob 已注册 `[]map[string]any`,并用真实系统消息结构补了编解码回归测试。
R4 保持 R3 的 900 连接混合负载,仅将 `command_timeout` 从 `20s` 改为 `90s`。验证时必须同时满足:`publish_ctrl_unresolved=0`、消息实时与历史/MySQL 零丢失,并对比服务端 `enqueue`/`write` P95 与客户端 ctrl P95。如服务端两阶段仍为毫秒级,但 `publish_ctrl_over_20s>0`,则可将超过 20 秒的部分定位在 socket write 之后的网络/TCP 接收路径。
## 22. 精确 900 人全量单聊测试
Run ID:`s900m-p2p-r1-1001`。900 条 WebSocket 连接组成 450 对好友,A/B 各承担 450 个用户。每个用户以 1 秒间隔发送 10 条消息,共尝试发送 9000 条。两个 agent 均完整运行至实时静默判定、5 分钟沉淀、历史回查、MySQL 校验和 120 秒渐进关闭结束,没有提前终止。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 9000 / 9000 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 对端实时投递 | 9000 / 9000 |
| 发送方回显 | 9000 / 9000 |
| WS 唯一回调总数 | 18000 / 18000 |
| 实时到达率 / 丢失率 | 100% / 0% |
| 重复 / 乱序 / 非预期消息 | 0 / 0 / 0 |
| 压测期非预期断连 | 0 |
| 历史回查 | 9000 / 9000 |
| MySQL 精确命中 | 9000 / 9000 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| ACK P95 / 最大值 | 854.385ms / 1324.17ms |
| 实时投递 P95 / 最大值 | 1000ms 桶内 / 1324.255ms |
| 服务端 Pod 重启 | 0 |
报告的 `expected_deliveries=9000` 只统计对端投递,不包含发送方自身回显。A/B 各收到 4500 次对端投递和 4500 次本地回显,因此底层唯一 WS 数据回调合计为 18000 次,不是重复消息。
本轮单聊正确性和持久化门禁完整通过:9000 条发送全部收到 ACK,所有收发双方均实时收到应有消息,历史接口与 `im_008_messages` 都精确命中 9000 条。没有发生 ACK 超时、socket 错误、重复、乱序、实时丢失或持久化丢失。
压测端的回调处理没有构成瓶颈:
| agent | 回调入队 / 完成 | 队列峰值 | 最大等待 | 结算时等待 / 处理中 |
|---|---:|---:|---:|---:|
| A | 9000 / 9000 | 7 | 16.317ms | 0 / 0 |
| B | 9000 / 9000 | 9 | 13.004ms | 0 / 0 |
服务端运行状态稳定:
- 三个 Pod 均为 Ready、0 重启,单 Pod 资源配置为 `requests=1 CPU/512Mi`、`limits=2 CPU/1Gi`。
- 负载结束时 WS 连接分布为 527/373/544;压测连接渐进关闭后回落至 1/0/2,剩余为环境常驻连接,未再出现压测 agent FD 不释放。
- 三个 Pod 的 WS send queue 结算时均为 0;Kafka 的消息持久化、群消息持久化、管理命令、配置事件、缓存失效、死信和工作台 consumer lag 均为 0。
- 关闭过程中的少量 `ping closed` 累计指标来自客户端主动渐退,本轮 agent 记录的负载期非预期断连为 0。
按最新验收口径,ACK P95 和投递 P95 仅用于趋势对比,不作为当前公网单机容量测试的阻断条件。重新合并后本轮 `passed=true`:单聊消息正确性、实时到达、顺序性、连接稳定性和落库均通过。ACK P95 854.385ms 和投递 P95 1000ms 仍保留在报告中,但不影响本轮通过结论。
全量单聊已通过,无需为 500ms 延迟门禁单独重复 R2。下一步可进入更高风险的群聊或混合场景复测,仍以零丢失、零重复、零乱序、零断连和全量落库为通过条件。
新增产物:
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/report.json`
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-p2p-r1-1001/server-metrics/`
## 23. 900 人混合 R4 长 ACK 观测复测
Run ID:`s900m-mixed-r4-1001`。负载与 R3 相同:900 条 WebSocket、135 对单聊用户和 224 个群发送者,共发送 1350 条单聊和 448 条群消息。本轮仅将 `command_timeout` 改为 90 秒,并启用 ctrl ACK 晚到台账和服务端 `enqueue/write` 分段指标。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒的 ACK | 118 |
| 90 秒超时 / 晚到 / 最终未到 | 0 / 0 / 0 |
| ACK P95 / 最大值 | 23425.942ms / 68730.192ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时到达率 / 丢失率 | 100% / 0% |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
118 条慢 ACK 的精确延迟范围为 20.317 至 68.731 秒,中位数约 27.062 秒,慢样本 P95 约 39.465 秒。两个 agent 分别有 56 和 62 条超过 20 秒的 ACK,但全部在 90 秒内返回。R4 直接证明同负载下存在“20 秒后才到、但未丢失”的 ACK,强力支持 R3 的 47 条超时属于同类晚 ACK,而非服务端拒绝或消息丢失。
服务端三个 Pod 的 ctrl ACK 指标共有 1801 个自 Pod 启动后的样本,与本轮 1798 条发送只相差 3 条环境流量:
| Pod | enqueue 平均 | write 平均 | enqueue/write P95 |
|---|---:|---:|---:|
| im-server-0 | 49.672ms | 50.059ms | 均在 250ms 桶内 |
| im-server-1 | 51.612ms | 52.176ms | 均在 250ms 桶内 |
| im-server-2 | 48.051ms | 48.358ms | 均在 250ms 桶内 |
ACK 从 WS 队列入队到 socket write 完成只增加约 0.3至0.6ms,而客户端有 118 条超过 20 秒。因此可确认长延迟发生在服务端 socket write 完成之后的 Ingress/LB、公网 TCP/NAT 或压测机接收路径,不在 Topic、持久化、Cluster RPC 或 WS 应用发送队列内。
本轮底层共处理 405900 个匹配的 WS data 回调,A/B 的回调队列峰值为 120/149,最大等待仅 9.603/9.199ms,结算时全部排空。Kafka 各 consumer lag 均为 0,未出现 Topic、Cluster proxy 或 WS send queue full;渐进关闭后只有 4 次预期的 ping closed 写错误。
报告中 `ack_p95_ms=23425.942` 是基于 1798 条 ACK 原始时间戳的精确值。本轮二进制的 ctrl 直方图上界仅到 10 秒,使 `publish_ctrl_p95_ms` 在 `+Inf` 桶中退化为最大值 68731.141ms,该字段本轮不作为 P95 使用。直方图已增加 20、30、45、60、90 和 120 秒桶及回归测试,下轮会输出正确的长延迟分位桶。
服务端日志还发现两次与本轮 Topic 无关的 Cluster Route 失败:`gob: type not registered for interface: types.KVMap`。`types.KVMap` 是命名 map 类型,不会被 `map[string]any` 的 gob 注册覆盖。已补充 `types.KVMap` 和 `[]types.KVMap` 注册,并扩展 Cluster gob 回归测试;该修复需要再发版。
本轮 `report.json` 最终 `passed=true`。混合场景的消息正确性、实时到达、顺序、连接稳定性、历史回查和 MySQL 落库均通过;长 ACK 已被定位为 socket write 之后的端到端队头阻塞,不再阻断当前容量验收。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r4-1001/im-server-{0,1,2}.log`
## 24. 900 人混合 R5 顺序复测
Run ID:`s900m-mixed-r5-1001`。本轮在 R4 相同的 900 条 WebSocket、135 对单聊用户和 224 个群发送者负载下,验证长延迟直方图、Cluster gob 类型修复及消息顺序。
| 指标 | 结果 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 90 秒命令窗口内 ACK | 1798 / 1797 |
| 晚到 2xx / 最终接受 | 1 / 1798 |
| 明确拒绝 / 最终未知 | 0 / 0 |
| 超过 20 秒的 ACK | 375 |
| ctrl ACK P95 桶 / 精确 ACK P95 | 45s / 41746.854ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 / 丢失 | 404550 / 404550,0 丢失 |
| 重复 / 乱序 / 断连 | 0 / 1 / 0 |
| 历史回查 | 1798 / 1798 |
| 无及时 ACK 消息历史命中 | 1 / 1 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
唯一超时消息 `s900m-mixed-r5-1001-g-a-0136-02` 在 95.028 秒收到 202,因此属于晚到成功,不是拒绝或丢失。压测报告已区分 `publish_accepted`(命令等待窗口内)与 `publish_final_accepted`(包含晚到 2xx);用新口径重算后发送成功率为 100%,报告只因真实乱序而失败。长延迟直方图的 P95 正确落在 45 秒桶,不再退化为最大值。
本轮唯一乱序发生在 A 机 97 号连接的同一单聊 Topic:客户端先收到 seq=322,后收到 seq=321。两条消息由会话两端在几乎同一时刻发送,服务端均实时投递并精确落库,所以问题不在 MySQL、Kafka 或历史回查,而在 Topic master 到 proxy Pod 的实时转发顺序。
代码检查确认,同一个 multiplex session 每次入队都会调度 `clusterWriteLoop`;此前没有排他状态,多个 worker 可以同时消费同一个 `send` channel。随后使用 fire-and-forget RPC 时,先取出的 seq=321 与后取出的 seq=322 会在远端并发执行,导致 seq=322 先进入 proxy Topic。该竞态与本轮现象完全一致。
修复内容:
1. multiplex session 增加原子调度所有权,同一 session 同时只允许一个 Cluster 写任务排空;释放所有权时再次检查队列,覆盖入队与退出之间的竞态。
2. Master 到 Proxy 的同一 Topic 响应改为逐条等待 RPC 完成,保证远端处理完 seq=N 后才发送 seq=N+1;不同 Topic 仍由 worker pool 并行处理。
3. 新增本地 RPC 回归测试,故意延迟 seq=1 的处理并重复调度同一 session,验证接收顺序始终为 1、2;该测试在 `go test -race` 下通过。
4. 压测工具新增晚到 2xx 最终接受计数,已解决“最终已 202 但报告仍算发送失败”的重复判错。
服务端三个 Pod 共记录 1798 条 ctrl ACK,分布为 574/611/613;enqueue/write 平均约 60至66ms,P95 均不超过 250ms。未出现 Topic、Cluster proxy、WS send queue full、Kafka 错误、WS text write 错误或 Pod 重启。A/B 回调队列峰值为 253/290,最大等待 10.703/5.929ms,结算时均已排空。完整 Pod 日志中未再出现 `types.KVMap` 或其他 gob 注册错误,证明 R4 后的 Cluster gob 修复已生效。
本轮 MySQL 校验覆盖 `im_008_group_messages_202610` 与 `im_008_messages`,1798 条业务消息全部精确命中。结论是持久化、实时到达、长 ACK 统计和 gob 修复均通过,当前唯一阻断项为已完成代码修复但尚待发版复验的跨 Pod 顺序竞态。
下一步发版后执行完全相同的 900 人混合 R6。R6 通过条件为:最终接受 1798/1798、实时投递 404550/404550、重复/乱序/断连均为 0、历史和 MySQL 均为 1798/1798;ACK 延迟继续观测但不作为阻断门禁。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/im-server-{0,1,2}.log`
## 25. 900 人混合 R6/R7 顺序修复发版复验
### 25.1 R6:顺序修复通过,公网瞬时断连导致整轮失败
Run ID:`s900m-mixed-r6-1001`。本轮首次验证同步 Master-to-Proxy 顺序投递版本,跨 Pod 乱序已从 R5 的 1 次降为 0,但压测机在 15:42:13 至 15:42:18 UTC 同时对两个入口 IP 发生 TCP 写超时。
| 指标 | R6 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1785 |
| socket 结果未知 | 13 |
| 压测期断连 | 11,均为 write timeout |
| 实时投递 | 401361 / 404542 |
| 实时缺口 | 3181,全部历史可查 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 1790 / 1790 |
| MySQL 精确命中 | 1790 / 1790 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
11 次断连分布在 `216.236.31.189` 和 `216.236.31.191` 两个入口,并集中于约 5 秒窗口;三个服务 Pod 均 Ready、零重启,负载期无 Topic/Cluster/WS 队列满、Kafka 错误或 gob 错误。13 条 socket 结果未知中有 5 条被对端观察并精确落库,另外 8 条未落库且未形成额外数据,符合请求在进入服务端前失败的特征。
R6 的顺序修复目标已通过,但整轮因断连、发送未知和实时缺口判定失败。三条目标 IP 路由复查均为 `en1` 直连,未经过 VPN/TUN。为排除一次性公网抖动,随即执行相同参数的 R7。
### 25.2 R7:消息业务门禁全部通过
Run ID:`s900m-mixed-r7-1001`。运行前对三个入口 IP 的 HTTPS 预检均返回 200,TLS 总耗时约 1.0 至 1.1 秒。
| 指标 | R7 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 最终接受 | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒 / 晚到 / 最终未到 ACK | 74 / 0 / 0 |
| 精确 ACK P95 | 16795.372ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 | 404550 / 404550 |
| WS 匹配回调总数(含单聊回显) | 405900 / 405900 |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
R7 的 `report.json` 为 `passed=true`。R5 暴露的同 Topic 跨 Pod 乱序在 R6 和 R7 连续两轮均为 0,证明单 session 排他 drain 与同步 RPC 顺序修复有效。压测端 A/B 回调队列峰值为 156/150,最大等待 13.281/25.038ms,结算时均为 0。
按 R7 起压前和负载结束快照计算增量:服务端新增 1798 条 ctrl ACK,enqueue/write 平均约 82.43/82.89ms,P95 均在 500ms 桶内;`Cluster.TopicProxy` 新增 7943 次调用,平均约 2.27ms、P95 在 10ms 桶内。负载期无新增 RPC error、内部队列失败、Kafka lag、queue full、gob 错误或 Pod 重启,说明同步 RPC 没有形成负载期瓶颈。关闭后服务端 active WS 回落为 0,压测进程和 FD 均完全释放。
### 25.3 关闭阶段 multiplex 队列竞态
R6 和 R7 均在最后一批连接关闭后记录 1 次 `s.queueOut: session's send queue full 8:grpQ5_hPdAz3cE-im-server-0`。该事件发生在业务发送、实时静默、历史回查和 summary 生成之后,没有影响上述消息结果,但违反“渐进关闭阶段也不得队列满”的严格运维门禁。
根因是 proxy stop/detach 后 multiplex worker 已退出并从 SessionStore 删除,而 Topic 的异步 unsubscribe 尚未完成,短时间内仍可能通过旧指针调用 `queueOut`。旧 session 未标记 terminating,消息进入无人消费的 send channel 并最终填满。修复已在本地完成:Cluster session 清理开始时先原子标记 terminating,并排空残留 channel;后续对旧指针的写入会被直接丢弃,不再进入失去 drain owner 的队列。新增关闭竞态测试和顺序测试均在普通模式连续 20 次、race 模式连续 10 次通过。
因此当前结论分为两层:
- 900 人混合消息业务门禁已经通过,跨 Pod 顺序修复有效。
- 完整运维门禁还需发布关闭竞态补丁后做一次 R8,重点验证业务指标继续全绿且渐进关闭阶段 `queue full=0`。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r6-1001/`
- `loadtest/im-pressure/results/s900m-mixed-r7-1001/`
## 26. 900 人混合 R8 关闭竞态补丁复验
Run ID:`s900m-mixed-r8-1002`。本轮使用与 R7 完全相同的 900 条 WebSocket、135 对单聊用户和 224 个群发送者负载,重点验证关闭竞态补丁发布后,业务负载期和 120 秒渐进关闭全过程不再出现 `send queue full`。
| 指标 | R8 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 计划发送 / 实际尝试 | 1798 / 1798 |
| 最终接受 / socket 结果未知 | 1796 / 2 |
| 明确拒绝 / ACK 超过 20 秒 | 0 / 0 |
| 精确 ACK P95 / 最大值 | 1041.578ms / 2055.915ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 已接受群消息应投 / 实投 | 401400 / 400954 |
| 已接受消息实时总投递 | 402750 / 402304 |
| 实时缺口 | 446,全部可持久化回查 |
| 重复 / 乱序 | 0 / 0 |
| 压测期非预期断连 | 1,公网读超时后自动重连 |
| 历史回查 | 1796 / 1796 |
| MySQL 精确命中 | 1796 / 1796 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 负载期 / 关闭期 queue full | 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
### 26.1 严格业务门禁结论
本轮 `report.json` 为 `passed=false`。失败由 A 机第 231 个连接的一次公网读超时引起:
```text
read tcp 192.168.1.135:57717->216.236.31.191:443: read: operation timed out
```
断连发生于 `2026-10-02T06:31:53.391465Z`,客户端约 1.5 秒后报告连接恢复。该用户的两条群发送 `s900m-mixed-r8-1002-g-a-0231-01/02` 分别得到 `websocket closed` 和 `websocket connection unavailable`,未进入服务端,MySQL 中也没有对应额外行。因此实际被服务端接受的业务消息为 1796 条,不能把这两条计为服务端持久化丢失。
同一连接在群消息阶段漏收 446 条已接受群消息,形成 446 次实时投递缺口;这些消息全部可通过历史回查获得。其余连接的群投递、全部 1350 次单聊对端投递、重复和顺序检查均正确。按严格验收标准,本轮仍因 `disconnects=1`、`publish_outcome_unknown=2` 和实时缺口判定失败,不能替代 R7 的业务全绿结论。
服务端三个 Pod 在超时窗口均保持 Ready、零重启、Kafka lag 为 0,也没有 Topic、Cluster、WS 队列满、RPC error、gob 或持久化错误。证据更符合压测机到单个公网入口的瞬时网络读超时,不支持服务端容量拐点或队列阻塞判断。
### 26.2 关闭竞态补丁结论
关闭竞态补丁的专项验收通过。两个 agent 均完成实时静默、5 分钟沉淀、历史回查、summary 生成和 120 秒渐进关闭,并以退出码 0 结束;没有提前停止进程。从负载开始到全部压测连接关闭后的完整 Pod 日志中:
- `s.queueOut: session's send queue full` 为 0;
- Cluster P2M `queue_full` 增量为 0;
- WS send queue `queue_full` 增量为 0,三个 Pod 结算队列深度均为 0;
- 未出现旧 multiplex session 无消费者却继续积压消息的现象;
- 三个 Pod 均保持 Ready、0 重启,Cluster peer 均为 2/2。
这证明“清理 Cluster session 前先标记 terminating 并 purge channel”的修复已经消除 R6/R7 关闭末尾出现的孤儿发送队列积压。关闭期日志仅有 1 次客户端渐退附近的 `close 1006 unexpected EOF`,没有引发队列堆积或 Pod 状态异常。
### 26.3 服务端与压测端观测
按起压前和负载结束快照计算增量:
| 指标 | 结果 |
|---|---:|
| `Cluster.TopicProxy` | 8227 次,平均 2.285ms,P95 在 10ms 桶内 |
| ctrl ACK enqueue | 1798 次,平均 62.11ms,P95 在 250ms 桶内 |
| ctrl ACK write | 1798 次,平均 62.43ms,P95 在 250ms 桶内 |
| Cluster P2M queue full | 0 |
| WS send queue full | 0 |
| Kafka consumer lag | 全部为 0 |
A/B 回调队列峰值为 63/47,最大等待为 9.865/10.031ms,结算时等待和处理中均为 0。客户端回调处理没有形成瓶颈。MySQL 校验覆盖 `im_008_group_messages_202610` 与 `im_008_messages`,1796 条服务端已接受消息全部精确命中。
本轮结论应分开使用:**关闭竞态修复已通过专项复验;R8 整体业务门禁因一次公网断连失败。**如需获得“修复版本下业务与关闭运维门禁同时全绿”的单轮证据,应保持参数不变再执行 R9;无需因本轮结果继续修改服务端队列代码。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r8-1002/im-server-{0,1,2}.log`
## 27. 900 人混合 R9 全量复验
Run ID:`s900m-mixed-r9-1002`。本轮保持 R8 的配置、账号、消息量和验收口径不变。起压前三个公网入口均返回 200,HTTPS 总耗时约 0.48 至 0.56 秒;三个 Pod 使用相同镜像,均为 Ready、0 重启。
| 指标 | R9 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 最终接受 | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒的 ACK | 10 |
| 精确 ACK P95 | 11417.014ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 | 404550 / 404550 |
| WS 匹配回调总数(含单聊回显) | 405900 / 405900 |
| 实时到达率 / 丢失率 | 100% / 0% |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| 负载期 queue full | 0 |
| 渐进关闭期 queue full | 1 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
### 27.1 业务门禁通过
本轮 `report.json` 为 `passed=true`。1798 条发送全部得到最终 ACK,404550 次对端投递全部实时到达;没有发送未知、实时丢失、重复、乱序或非预期断连。A/B 各收到 202950 次匹配回调,其中包含 675 次单聊发送方回显,因此底层回调总数为 405900。
历史接口和 MySQL 均精确命中 1798/1798。数据库校验覆盖 `im_008_group_messages_202610` 与 `im_008_messages`,缺失、错配、重复和额外行均为 0。Kafka consumer lag 全部为 0,三个 Pod 全程 Ready、0 重启。
A/B 回调队列峰值为 218/210,结算时均完全排空。按起压前和负载结束快照计算,服务端 `Cluster.TopicProxy` 新增 7857 次调用,平均 1.752ms、P95 在 5ms 桶内;ctrl ACK enqueue/write 各 1798 次,平均 72.513/72.812ms,P95 均在 500ms 桶内。负载期没有 Topic、Cluster、WS 队列满、RPC 失败、gob 或持久化错误。
因此 R9 已取得修复版本下完整的消息业务全绿证据,并再次验证跨 Pod 顺序修复有效。10 条超过 20 秒的 ACK 均在 90 秒命令窗口内返回,继续按既定口径只做趋势观测。
### 27.2 关闭期仍有一个低概率队列满
在最后一批连接退出时,Pod `im-server-1` 于 `2026-10-02T07:23:16Z` 记录:
```text
s.queueOut: session's send queue full 8:grpQ5_hPdAz3cE-im-server-2
topic[grpQ5_hPdAz3cE]: connection stuck, detaching - 8:grpQ5_hPdAz3cE-im-server-2
cluster: session proxy closed 8:grpQ5_hPdAz3cE-im-server-2
```
该事件发生在 summary 已生成、1798 条历史核验完成之后,没有影响消息正确性和持久化。结算时三个 Pod 的 WS send queue 与 Cluster P2M queue 深度均为 0,Kafka lag 为 0。
R9 证据修正了 R6/R7 时的部分根因判断。此次队列满发生时,`grp...-im-server-2` multiplex session 尚未终止;日志顺序是先队列满、Topic 判定连接卡住并 detach,最后 session 才关闭。因此 R8 发布的 `terminating + purgeChannels` 补丁只能防止“已进入终止流程的旧 session 被继续写入”,不能处理仍存活 multiplex session 在大量连接关闭时的瞬时响应突发。
当前 multiplex `send` channel 容量为 `sendQueueLimit + 32 = 160`。Cluster 写任务采用排他调度和同步 Master-to-Proxy RPC;正常负载期吞吐稳定,但关闭阶段多个 unsubscribe/presence 响应可在 drain worker 获得调度前快速填满 160 个槽位。R8 未复现、R9 复现 1 次,符合低概率调度竞态,而不是持续消费能力不足:R9 关闭阶段 `Cluster.TopicProxy` 仍完成 3195 次调用,平均约 0.9ms、P95 在 3ms 桶内。
后续修复不应只继续增加 terminating 判断。建议为 MULTIPLEX 出站队列增加一个很短的有界背压重试:首次非阻塞入队失败时确保 drain 已调度,并等待最多约 250ms 获取队列槽位;超时后才维持现有的 `queue full -> detach` 保护。这样可以吸收调度抖动和关闭突发,同时避免永久阻塞 Topic goroutine。需要增加“队列已满但 worker 很快释放槽位”与“worker 永久不消费时按时失败”两类回归测试,发版后再执行 R10 专项复验。
上述修复已在 R9 后完成:MULTIPLEX 出站队列首次满时会确保 Cluster drain 已调度,并在 250ms 有界窗口内等待一个槽位;只有持续无消费超过窗口才沿用原来的失败和 detach 保护。新增的 `clusterQueueMu` 将 terminating 检查与入队、以及终止标记与 channel 清理串行化,防止等待期间 session 恰好终止后消息重新进入已清空队列。普通 WebSocket、gRPC 和长轮询队列仍保持原有非阻塞语义,不受此次改动影响。
回归测试覆盖短暂队列满后恢复、持续不消费按时失败、等待期间 session 终止不残留消息,以及原有跨 Pod 顺序与终止拒绝写入场景。针对性测试普通模式连续 20 次、race 模式连续 10 次通过,`go test ./server ./loadtest/im-pressure` 全部通过。该修复尚待发版并执行 R10 复验。
本轮最终结论:**消息业务门禁通过;完整运维门禁仍因关闭期 1 次 multiplex queue full 未通过。**
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/report.json`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/db-verification.json`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r9-1002/im-server-{0,1,2}.log`
+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 存在 | | `verify_bucket` | 启动时是否联网校验 Bucket 存在 |
| `use_sys_config` | 为 `true` 时从 `hotgo.hg_sys_config` 读取 OSS 配置 | | `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 配置。 当前 `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 INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_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'); WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_endpoint');
INSERT INTO hg_sys_config INSERT INTO hg_sys_config
@@ -60,9 +60,35 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 | | 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 | `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 | | 文件 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、普通日志、埋点或崩溃报告。 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: WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
```json ```json
@@ -128,6 +154,12 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。
资源不存在类错误统一语义(2026-09-29 修订):
- 好友申请 `accept/reject` 在申请不存在时统一返回 `404/20004 RESOURCE_NOT_FOUND`,两个入口语义一致。
- 群类接口(`DELETE /v1/im/groups/{topic}`、`POST /v1/im/groups/{topic}/leave`、`POST /v1/im/groups/{topic}/qrcodes`、`PUT /v1/im/groups/{topic}/members/me/profile`、`GET/PATCH /v1/im/groups/{topic}/settings` 等经群成员校验的路径)在群不存在时返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散时返回 `410/30004 GROUP_DISSOLVED`;群存在但当前用户非成员仍是 `403/30005 PERMISSION_DENIED`。
- 服务端对不存在的资源不再返回 5xx 或网关错误。
### 0.5 WebSocket 建连、登录和恢复顺序 ### 0.5 WebSocket 建连、登录和恢复顺序
#### 0.5.1 现有服务端可直接执行的冷启动流程 #### 0.5.1 现有服务端可直接执行的冷启动流程
@@ -210,8 +242,22 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} {"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}
``` ```
**向上翻页(加载更早历史)最终参数约定(后端已确认,2026-09-29)**:复用同一 `get data`,最终参数为 `before`(排他上界,返回 `seq < before`),**没有 REST cursor 语义**。聊天页滚动到顶部时发 `data:{"before":<当前已加载最小 seq>,"limit":50}`(`since` 可省略,默认 0)。服务端返回该锚点之前**最接近 `before` 的 `limit` 条**,`{data}` 按 `seq` 从大到小下发(首条即锚点前最新一条);收到 `ctrl 204 no content`(`params.what=="data"`)表示已到会话顶部。`data` 参数全集:`since`(含下界)、`before`(排他上界)、`limit`、`ranges`。P2P 与群聊(含热缓存 fallback)语义一致。REST `GET /v1/im/conversations/{topic}/messages` 当前为空占位(见 4.21.1),不得用于历史分页。
订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 订阅成功后才允许 `{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 #### 0.5.2 Long Polling 兼容流程:先取得的是 sid
如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。
@@ -284,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}}}` | | 租户握手 `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"}}}` | | 账号密码登录 | `{"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"}}}` | | 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-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","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-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-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"}}}` | | 租户不匹配 | `{"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 启动订阅、会话列表和增量恢复 ##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 | | 场景 | 客户端发送 | 服务端返回示例 |
@@ -305,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-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-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-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}}}` | | 文件消息 | `{"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}}}` |
@@ -316,7 +365,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
##### 0.5.4.3.1 会话内系统提示和可点击动作消息 ##### 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、脚本或非白名单路由。 客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。
@@ -357,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-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}}}` | | 清空当前用户视图 | `{"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):** **单聊已读/已收回执协议约定(服务端已验证,2026-09-29):**
1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。 1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。
@@ -406,7 +457,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"event": "group.member.joined", "event": "group.member.joined",
"payload": { "payload": {
"topic": "grp_xxx", "topic": "grp_xxx",
"user_id": "usr_xxx" "user_id": "usr_xxx",
"member_sync": "single",
"visibility": "all_members"
}, },
"ts": "2026-08-25T10:00:00Z" "ts": "2026-08-25T10:00:00Z"
} }
@@ -423,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"}}}` | 刷新好友列表和会话头显示名 | | 修改好友备注 | 操作人本人 | `{"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.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":"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_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"}}}` | 刷新安全验证页面;当前会写系统通知 | | 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
@@ -461,16 +517,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
客户端侧要求: 客户端侧要求:
- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。 - App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。
- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。 - 当前测试环境使用 `registration_id` 模式,服务端配置 `jpush_use_alias=0`,不依赖客户端 alias;alias 绑定可保留但不是投递依据。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。 - 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 渲染最终状态。 - 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。
服务端侧要求: 服务端侧要求:
- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。 - `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 任务。 - 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。
- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。 - 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。
- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。 - Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。
- iOS 测试环境固定使用 `options.apns_production=false`;客户端包也必须是开发 APNs 环境。TestFlight/App Store 生产包需要后端与客户端同时切到生产 APNs。
极光 Push extras 统一格式: 极光 Push extras 统一格式:
@@ -489,7 +549,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 | | `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 | | `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` | 打开通话记录或对应会话。 | | `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | | `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | | `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
@@ -499,22 +559,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json ```json
{ {
"platform": "all", "platform": "ios",
"audience": { "audience": {
"alias": ["usr_target"] "registration_id": ["161a3797c95ee4a8bd3"]
}, },
"notification": { "notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": { "ios": {
"alert": { "alert": {
"title": "MeetingIM", "title": "MeetingIM",
@@ -542,25 +591,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
```json ```json
{ {
"platform": "all", "platform": "ios",
"audience": { "audience": {
"alias": ["usr_callee"] "registration_id": ["161a3797c95ee4a8bd3"]
}, },
"notification": { "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": { "ios": {
"alert": { "alert": {
"title": "语音通话", "title": "语音通话",
@@ -568,6 +603,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
}, },
"sound": "default", "sound": "default",
"badge": "+1", "badge": "+1",
"category": "im_call",
"content-available": 1,
"extras": { "extras": {
"type": "call_invite", "type": "call_invite",
"tenant_code": "default", "tenant_code": "default",
@@ -575,18 +612,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"seq": "30", "seq": "30",
"call_id": "call_01K...", "call_id": "call_01K...",
"call_type": "audio", "call_type": "audio",
"caller_id": "usr_caller" "caller_id": "usr_caller",
"caller_display": "张三",
"expires_at": "2026-09-18T10:00:30Z"
} }
} }
}, },
"options": { "options": {
"time_to_live": 60, "time_to_live": 30,
"apns_production": false, "apns_production": false,
"classification": 1 "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 Long Polling 与文件基础示例
##### 0.5.5.1 WebSocket 与文件基础接口 ##### 0.5.5.1 WebSocket 与文件基础接口
@@ -595,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}}}` | | `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 `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/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"}}}` | | `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-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | | `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 与产品消息 ### 0.6 Topic 与产品消息
@@ -625,6 +673,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 `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` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。 消息类型首版固定为:`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 幂等、重试和安全 ### 0.7 HTTP 幂等、重试和安全
@@ -657,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`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。 - 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `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)。
成功响应示例: 成功响应示例:
@@ -667,7 +717,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
"data": { "data": {
"tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}},
"endpoints": { "endpoints": {
"websocket": "wss://im.example.com/v0/channels", "websocket": "wss://im.example.com/v0/channels?apikey=AQ...",
"api": "https://im.example.com/v1/im", "api": "https://im.example.com/v1/im",
"file": "https://im.example.com/v0/file" "file": "https://im.example.com/v0/file"
}, },
@@ -832,20 +882,21 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/注册页面.png" alt="注册页面" width="360"> <img src="images/注册页面.png" alt="注册页面" width="360">
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** **状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 - 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `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` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。 - 注册类型规则:注册页必须提交 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-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 - 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 - 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 - 邀请码主数据位于 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`;生产接入短信供应商后不得继续使用固定验证码。 - 手机号方式:前端只接受 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。 - 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 - 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `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。 - 提交注册:`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 - 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 - 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 - 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
@@ -853,19 +904,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
用户名方式: 用户名方式:
```json ```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 ```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 ```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`。 - 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 - 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
@@ -901,11 +952,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 - 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`;注销账号使用 `{"scene":"delete_account","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 - 发码/校验:服务端返回 `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` 继续倒计时,不要当成接口不可达或重新登录。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 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`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 - 小时限流:超过一小时创建次数上限时同样返回 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 落普通存储。 - 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
### 1.8 实名认证.png ### 1.8 实名认证.png
@@ -927,7 +978,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 - 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。
- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 - 前端只读展示,不缓存原始证件信息,不提供直接修改接口。
- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 - 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,Body `{"reason":"...","attachments":[]}`(`reason` 必填、≤500 字;`attachments` 最多 9 个 file_id),Bearer 即可,当前无需二次身份验证;不复用本页保存按钮。
### 1.10 实名认证(安卓).png ### 1.10 实名认证(安卓).png
@@ -965,18 +1016,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `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/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-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/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` 注册发码 | `{"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":"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/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/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 /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 /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` 待实现 | `{}` | `{"status":"ok"}` | | `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 | `{"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}` | | `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. 消息首页、全局导航与系统通知 ## 2. 消息首页、全局导航与系统通知
@@ -1030,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"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 - `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 只下发必要脱敏值。 - 新设备登录事件内容为 `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 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例 ### 2.6 接口示例
@@ -1050,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/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","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"}` | | `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. 单聊会话、会话建立与消息状态 ## 3. 单聊会话、会话建立与消息状态
@@ -1109,11 +1162,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/双方沟通状态.png" alt="双方沟通状态" width="360"> <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` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 - `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 回收,客户端不得再引用。 - 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。
- 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。 - 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。
@@ -1358,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。 - `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 ### 4.20 转发—合并转发—多选状态.png
@@ -1368,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=` 分页详情。 - 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。
- `items[]` 和 `preview[]` 保留 `from` 原发送人 ID,并新增 `from_user` 用户摘要:`{"user_id":"usr...","display_name":"张三","avatar_file_id":"file_avatar"}`。头像为空时可缺省;若资料查询失败,服务端至少返回 `user_id/display_name` 兜底。
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 - 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 - 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
@@ -1389,7 +1444,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | | `POST /v1/im/conversations/p2p` | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | | `GET /v1/im/conversations/{topic}/settings` | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | | `PATCH /v1/im/conversations/{topic}/settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | | `GET /v1/im/conversations/{topic}/messages` | **服务端当前为空占位**:GET 恒返回空列表,勿用 `?since=&limit=` 调它;历史消息(首屏与向上翻页)一律走 WS `get data` 的 `since`/`before`/`limit`(向上翻页见 0.5.1 约定) | `{"topic":"p2p...","items":[],"next_cursor":"","has_more":false}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` | | `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
@@ -1563,9 +1618,32 @@ Body:
#### 4.21.3 消息变更同步水位 #### 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` 会带: 会话列表同步时,`me.meta.sub[].private` 会带:
@@ -1633,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` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 - 组织成员:`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"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 - 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
@@ -1647,6 +1725,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 申请不存在时 `accept/reject` 统一返回 `404/20004 RESOURCE_NOT_FOUND`;通用语义见 0.4。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 - 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png ### 5.3 添加好友.png
@@ -1695,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` 做权限校验。 - 查看该用户二维码调用 `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":"..."}` 并由服务端验证好友关系。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -1764,9 +1843,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/黑名单提示.png" alt="黑名单提示" width="360"> <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`。 - 确认调用 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 消息缓存并保留会话关系。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1792,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"}` | | `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=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/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"}` | | `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}` | | `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":""}` | | `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":""}` |
@@ -1830,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`。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
@@ -1850,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`。 - 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。
- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 - 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。
@@ -1901,6 +1981,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 - `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。
- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 - 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。
- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 - 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.11 群资料.png ### 6.11 群资料.png
@@ -1930,7 +2011,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 - `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 - 服务端校验成员资格、长度和敏感词,返回 `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 群成员列表拉取与缓存策略 ### 6.13.1 群成员列表拉取与缓存策略
@@ -1997,6 +2080,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增。** **状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 - `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群主/管理员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
@@ -2009,6 +2093,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 - 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。
- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 - 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。
- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 - 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。
- 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;已解散重复解散返回 `410/30004 GROUP_DISSOLVED`;非群主返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。
### 6.18 设置群管理员.png ### 6.18 设置群管理员.png
@@ -2113,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":"..."}` 建立群文件记录。 - 先用 `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`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 - 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例 ### 6.29 接口示例
@@ -2136,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}}` | | `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}}` | | `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}}` | | `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}}` | | `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"}` | | `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"}` | | `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"}` | | `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}}` | | `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}/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/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/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}` | | `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}/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}` | | `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}}` | | `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}}` |
@@ -2254,10 +2341,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/文件-下载.png" alt="文件-下载" width="360"> <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=`。 - 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 服务端按文件 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` 显示失效。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
### 7.10 文件-下载中.png ### 7.10 文件-下载中.png
@@ -2346,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"}` | | `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}` | | `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/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}` | | `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":"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"}],"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","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":""}` | | `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"}` | | `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"}` |
@@ -2369,11 +2458,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
<img src="images/对方状态.png" alt="对方状态" width="360"> <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。 - 来电是 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"`。 - 来电端开始响铃后发送 `{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 ### 8.3 语音通话-对方待接受状态.png
@@ -2495,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`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。 - `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 - 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
@@ -2543,6 +2633,16 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。 - 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.4.1 会议笔记
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20` 返回可查看会议笔记的会议列表、`note_status/file_count/permissions`。
- 详情:`GET /v1/im/meetings/{meeting_id}/notes` 返回会议信息和 `files[]`。
- 上传并登记:调用通用文件上传 `POST /v0/file/u/`,multipart 包含 `file=@...`、`purpose=meeting_note`、`meeting_id=<meeting_id>`、`id=<本次上传幂等ID>`,请求头按文件上传鉴权携带 `X-MeetingIM-APIKey`、`X-MeetingIM-Auth`。服务端上传成功后会立即登记会议笔记文件,响应 `ctrl.params` 包含 `url,file_id,purpose,meeting_id,note_file_id,note_file_status,version`。
- 兼容绑定:若客户端先完成普通文件上传,也可以调用 `POST /v1/im/meetings/{meeting_id}/notes/files`,Body `{"file_id":"..."}`,并携带 `Idempotency-Key` 完成登记。
- 替换/删除:`PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}`、`DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}?version=n`。只有记录人/发起人按权限可上传、替换和删除。
### 9.5 日报.png ### 9.5 日报.png
<img src="images/日报.png" alt="日报" width="360"> <img src="images/日报.png" alt="日报" width="360">
@@ -2619,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`。 - 页面配置:`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`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 - 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。
### 9.13 总经理信箱-文案提示.png ### 9.13 总经理信箱-文案提示.png
@@ -2708,7 +2808,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。** **状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。 - 文件项返回 `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`,退出登录或切换企业时清理受控缓存。 - 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。 - `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
@@ -2718,7 +2818,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:复用统一下载任务;会议笔记 ACL 新增。** **状态:复用统一下载任务;会议笔记 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_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。 - 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。 - 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
@@ -2751,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"}}` | | `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"}` | | `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"}` | | `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 字段与状态约定 ### 9.14.10 字段与状态约定
@@ -2830,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}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | | `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=` | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","note_status":"available","file_count":1,"permissions":{"can_upload":true,"can_download":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` | Path meeting ID | `{"meeting_id":"mtg_1","note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","file_name":"会议纪要.pdf","mime_type":"application/pdf","permissions":{"can_download":true}}]}` |
| `POST /v0/file/u/` 会议笔记上传并登记 | Multipart `purpose=meeting_note&meeting_id=mtg_1&id=upload_1&file=@note.pdf` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file_1.pdf","file_id":"file_1","purpose":"meeting_note","meeting_id":"mtg_1","note_file_id":"mnote_1","note_file_status":"active","version":1}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","status":"active","version":1}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | | `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | | `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | | `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
@@ -2867,8 +2971,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `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`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 更新统一使用 `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;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png ### 10.3 设置昵称.png
@@ -2879,7 +2984,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 - `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 - 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突(`409/20009`)先拉最新资料再由用户确认。
### 10.4 出生日期.png ### 10.4 出生日期.png
@@ -2915,7 +3020,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:菜单纯客户端;数据接口部分新增。** **状态:菜单纯客户端;数据接口部分新增。**
- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。
- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 - 退出:`POST /v1/im/auth/logout` 将当前登录 Token 的 SHA-256 摘要写入 Redis 撤销集合,TTL 与 Token 剩余有效期一致,不影响同账号其他设备。成功后关闭 WS、清安全存储和本租户用户缓存;请求须带 `Authorization: Bearer <token>` 和 `Idempotency-Key`。
- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。
### 10.8 设置(安卓).png ### 10.8 设置(安卓).png
@@ -2942,8 +3047,13 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
**状态:新增 App 配置接口。** **状态:新增 App 配置接口。**
- `GET /v1/im/app/releases/latest?platform=ios|android|web&current_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。
- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。
- `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 使用允许域名。 - 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
### 10.11 发现新版本.png ### 10.11 发现新版本.png
@@ -3067,8 +3177,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `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` | 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}}}` | | `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":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `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}}` | | `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"}` | | `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"}` | | `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"}` |
@@ -3083,9 +3193,29 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}
| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/security/realname/appeals` | `{"reason":"证件变更需要更正实名信息","attachments":["file_1"]}` | `{"appeal_id":"rna_1","reason":"...","attachments":["file_1"],"status":"pending"}`;当前无需二次身份验证(如后续启用,将复用 1.7 的 action_token 机制并在此处更新) |
| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | | `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` |
| `POST /v1/im/me/password` | `{"old_password":"OldPass123","new_password":"NewPass123"}` | `{"status":"ok"}`;旧密码错误返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`;新密码不满足策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}` |
| `POST /v1/im/security/challenges` 注销账号发码 | Header `Idempotency-Key`;Body `{"scene":"delete_account","channel":"sms"}` | `{"challenge_id":"sec_chg_9","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges/{id}/verify` 注销账号验证 | `{"code":"123456"}` | `{"action_token":"del_token","action":"delete_account","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/me/deletion` | Header `X-Action-Token: del_token`;Body `{}` | `{"status":"ok"}` |
#### 10.22.2 账号安全:修改密码与注销账号
**修改密码 `POST /v1/im/me/password`(已实现):**
- 已登录(Bearer)用户提交 `old_password`、`new_password`;两者必填,缺一返回 `400 MALFORMED_REQUEST`。
- 服务端先用旧密码走 `basic` 认证校验,失败返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`,不泄露账号状态。
- 新密码不满足密码策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}`。
- 成功后服务端递增 `auth_version` 并撤销该用户全部在线 Session(广播 `password_changed`);包括当前请求在内的所有旧 Token 全部失效,客户端收到成功响应后必须清理本租户凭证并跳转登录页。
**注销账号 `POST /v1/im/me/deletion`(已实现):**
- 两步挑战复用 1.7 敏感操作基建:`POST /v1/im/security/challenges`(Body `{"scene":"delete_account","channel":"sms"}`,发码到账号绑定手机号)→ `POST /v1/im/security/challenges/{id}/verify`(返回一次性 `action_token`)→ `POST /v1/im/me/deletion`(Header `X-Action-Token` 携带)。
- 服务端配置 `permanent_accounts=true` 时拒绝注销:HTTP `403` `{"code":30040,"reason":"FORBIDDEN","message":"account deletion is disabled"}`。
- 执行成功为**软删除**:撤销全部认证记录(含登录密码)、踢出所有在线 Session(`reason=user_deleted`)、退出并停用其拥有的群与 P2P 会话、向关注该用户的好友下发 `gone` 在线状态;账号不再可登录。客户端收到成功响应后清理本地数据并返回企业入口。
- 挑战阶段限流与错误码同 1.7(`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`);action token 缺失/无效返回 HTTP `403` `{"code":30042,"reason":"ACTION_TOKEN_INVALID"}`。
## 11. 反诈安全、举报与意见反馈 ## 11. 反诈安全、举报与意见反馈
+20 -10
View File
@@ -24,6 +24,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/jmoiron/sqlx v1.4.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/mozillazg/go-pinyin v0.20.0
github.com/nyaruka/phonenumbers v1.8.0 github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
@@ -34,13 +35,13 @@ require (
github.com/tinode/jsonco v1.0.0 github.com/tinode/jsonco v1.0.0
github.com/tinode/snowflake v1.0.0 github.com/tinode/snowflake v1.0.0
go.mongodb.org/mongo-driver v1.17.9 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/oauth2 v0.36.0
golang.org/x/sync v0.21.0 golang.org/x/sync v0.23.0
golang.org/x/text v0.38.0 golang.org/x/text v0.42.0
google.golang.org/api v0.287.0 google.golang.org/api v0.287.0
google.golang.org/grpc v1.82.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 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/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // 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/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/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/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/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/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // 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 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 v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect golang.org/x/net v0.60.0 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
) )
require ( require (
@@ -129,18 +139,18 @@ require (
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/montanaflynn/stats v0.9.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.21.1 // 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/twilio/twilio-go v1.30.9
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // 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 golang.org/x/time v0.15.0 // indirect
google.golang.org/appengine v1.6.8 // indirect google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // 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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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.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.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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/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.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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/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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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.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 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 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 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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.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 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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= 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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= 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.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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 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/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 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.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.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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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.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.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.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.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.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 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE=
github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8= 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 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=
github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ= 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.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= 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.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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/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 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 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.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 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 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.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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/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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 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.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 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.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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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.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.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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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.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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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.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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/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 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU= 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/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/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.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.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 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E= 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= 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.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/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.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= 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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+1
View File
@@ -1,5 +1,6 @@
/im-pressure /im-pressure
/config.json /config.json
/config.single-machine.json
/config.local*.json /config.local*.json
/.env.test /.env.test
/manifest*.json /manifest*.json
+12 -4
View File
@@ -2,6 +2,9 @@
该工具按配置指定的企业租户运行:两台压测机各承载 1000 个 WebSocket 用户,共 2000 个用户、1000 对好友和一个 2000 人群。当前测试环境使用 `allrich`,本轮实时参数见 [MACHINE-B.md](./MACHINE-B.md) 和 [allrich 双机压测执行记录](../../docs/test/IM-allrich双机压测执行记录-20260927.md)。 该工具按配置指定的企业租户运行:两台压测机各承载 1000 个 WebSocket 用户,共 2000 个用户、1000 对好友和一个 2000 人群。当前测试环境使用 `allrich`,本轮实时参数见 [MACHINE-B.md](./MACHINE-B.md) 和 [allrich 双机压测执行记录](../../docs/test/IM-allrich双机压测执行记录-20260927.md)。
只使用一台物理压测机时,请使用 [单机 60% 容量压测方案](../../docs/test/IM-单机60%容量压力测试方案.md) 和 [config.single-machine.example.json](./config.single-machine.example.json)。单机依然运行 A/B 两个逻辑 agent,用于保留好友对、跨逻辑分片投递和合并报告的完整契约。
首轮实测数据与瓶颈分析见 [单机 60% 容量压测结果](../../docs/test/IM-单机60%容量压力测试结果-20260929.md)。
机器 B 上的 AI 或操作人请直接阅读 [MACHINE-B.md](./MACHINE-B.md)。该文件不重复讲方案,只给出机器 B 从核验到启动、观察和回传结果的完整命令。 机器 B 上的 AI 或操作人请直接阅读 [MACHINE-B.md](./MACHINE-B.md)。该文件不重复讲方案,只给出机器 B 从核验到启动、观察和回传结果的完整命令。
## 两台机器的固定分工 ## 两台机器的固定分工
@@ -43,7 +46,7 @@
- 两台机器必须使用 NTP/chrony 同步时间。报告会分别输出同机时钟延迟和跨机器单向延迟;跨机器出现负延迟样本时,后者仅用于诊断,不参与门禁。 - 两台机器必须使用 NTP/chrony 同步时间。报告会分别输出同机时钟延迟和跨机器单向延迟;跨机器出现负延迟样本时,后者仅用于诊断,不参与门禁。
- agent 默认每 20 秒发送 WebSocket Ping,覆盖等待另一台机器、发压、drain 和历史核验阶段;`ws_ping_interval` 必须小于公网入口的空闲连接超时。 - agent 默认每 20 秒发送 WebSocket Ping,覆盖等待另一台机器、发压、drain 和历史核验阶段;`ws_ping_interval` 必须小于公网入口的空闲连接超时。
- 正式开始前 agent 会持续检查实际存活连接;连接断开后自动重新登录并订阅 P2P、群 Topic。初次连接不齐或等待另一台机器期间掉线不会退出。 - 正式开始前 agent 会持续检查实际存活连接;连接断开后自动重新登录并订阅 P2P、群 Topic。初次连接不齐或等待另一台机器期间掉线不会退出。
- coordinator 使用两阶段屏障:两边实时连接数都达到配置值后进入验证倒计时;倒计时内任一方掉线就撤销屏障。两边再次确认满连接后才提交最终开始时间。 - coordinator 使用三阶段屏障:两边实时连接数都达到配置值后进入验证倒计时;倒计时内任一方掉线就撤销屏障;提交最终开始时间后,两边必须在该时间前后 3 秒内再次到达起跑屏障,且双方都到达后才允许发送。任一方迟到会让两边共同终止本轮,避免生成错时压测结果。
- 压测机如保持 OpenVPN 等全局 TUN,仅在另一套代理中设置 `DIRECT` 无法绕过它。起压前必须确认 `http_base` 和 WS 域名的每个当前 IP 都走物理网卡;需保留 VPN 访问内网时,可为这些 IP 配置经局域网关的 `/32` 主机路由。VPN 重连、DNS 变更或机器重启后需重新核验。 - 压测机如保持 OpenVPN 等全局 TUN,仅在另一套代理中设置 `DIRECT` 无法绕过它。起压前必须确认 `http_base` 和 WS 域名的每个当前 IP 都走物理网卡;需保留 VPN 访问内网时,可为这些 IP 配置经局域网关的 `/32` 主机路由。VPN 重连、DNS 变更或机器重启后需重新核验。
## 统计口径 ## 统计口径
@@ -194,8 +197,13 @@ mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
两个 agent 都建立 1000 条连接后,协调器下发统一的 UTC `start_at`。场景可替换为: 两个 agent 都建立 1000 条连接后,协调器下发统一的 UTC `start_at`。场景可替换为:
- `p2p`:1000 对用户两侧同时发送,默认每人 10 条、间隔 1 秒。 - `p2p`:1000 对用户两侧同时发送,默认每人 10 条、间隔 1 秒。
- `group`:每台机器 500 个发送者,每人 2 条;全局每 100ms 一条,共 2000 条。 - `group`:默认每个 agent 有 `group_senders_per_machine` 个发送者,每人 `group_rounds` 条;两机合并后按 `group_publish_interval` 控制全局发送间隔。双机默认 500+500 发送者、100ms 一条,共 2000 条;单机 60% 默认 300+300 发送者。
- `mixed`:300 对私聊用户每人 5 条,同时每台 250 个群发送者各发 2 条。 - `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:回收并合并报告 ## 机器 A:回收并合并报告
@@ -215,7 +223,7 @@ scp "${MACHINE_B_SSH}:${MACHINE_B_REPO}/loadtest/im-pressure/results/${RUN_ID}/a
-output "loadtest/im-pressure/results/${RUN_ID}/report.json" -output "loadtest/im-pressure/results/${RUN_ID}/report.json"
``` ```
`report.json` 给出 ACK、实时投递、重复、乱序、断连和历史持久化核验结果。当前正确性门禁要求上述错误计数全部为 0。 `report.json` 给出 ACK、实时投递、重复、乱序、断连和历史持久化核验结果。断连会额外输出 `disconnect_reasons` 分类计数和最多 100 条 `disconnect_samples` 原始错误样本。当前正确性门禁要求上述错误计数全部为 0。
每个 agent 在 `:9105/metrics` 暴露连接、发送、接收、重复、乱序、断连以及 Go/process 指标;端口冲突时可通过 `-metrics-listen` 修改,传空字符串可关闭。 每个 agent 在 `:9105/metrics` 暴露连接、发送、接收、重复、乱序、断连以及 Go/process 指标;端口冲突时可通过 `-metrics-listen` 修改,传空字符串可关闭。
+353 -46
View File
@@ -26,7 +26,9 @@ type agentRun struct {
clients map[int]*WSClient clients map[int]*WSClient
clientsMu sync.RWMutex clientsMu sync.RWMutex
recorder *deliveryRecorder recorder *deliveryRecorder
callbackQueue *callbackQueueMetrics
ack *DurationHistogram ack *DurationHistogram
ctrlLatency *DurationHistogram
attempted atomic.Int64 attempted atomic.Int64
ready atomic.Int64 ready atomic.Int64
publish403 atomic.Int64 publish403 atomic.Int64
@@ -35,14 +37,19 @@ type agentRun struct {
publishTimeout atomic.Int64 publishTimeout atomic.Int64
publishSocket atomic.Int64 publishSocket atomic.Int64
publishOther atomic.Int64 publishOther atomic.Int64
publishCtrlObserved atomic.Int64
publishCtrlOver20 atomic.Int64
publishLateCtrl atomic.Int64
publishLateAccepted atomic.Int64
mu sync.Mutex mu sync.Mutex
accepted []AcceptedMessage accepted []AcceptedMessage
errors []string errors []string
ctrlSamples []CtrlObservationSample
} }
func validScenario(scenario string) bool { 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 { func preflightP2P(ctx context.Context, cfg *Config, manifest *Manifest, local []AccountRecord, apiKey string) error {
@@ -115,9 +122,7 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
if len(accounts) != cfg.UsersPerMachine { if len(accounts) != cfg.UsersPerMachine {
return fmt.Errorf("machine %s accounts=%d expected=%d", machine, len(accounts), cfg.UsersPerMachine) return fmt.Errorf("machine %s accounts=%d expected=%d", machine, len(accounts), cfg.UsersPerMachine)
} }
for i := range accounts { assignAgentEndpoints(cfg, machine, accounts)
accounts[i].Endpoint = cfg.Endpoints[machine][(accounts[i].Index-1)%len(cfg.Endpoints[machine])]
}
if !cfg.SkipP2PPreflight { if !cfg.SkipP2PPreflight {
if err = preflightP2P(ctx, cfg, manifest, accounts, apiKey); err != nil { if err = preflightP2P(ctx, cfg, manifest, accounts, apiKey); err != nil {
return err return err
@@ -125,7 +130,9 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
} }
coord := newCoordinatorClient(coordinatorURL) coord := newCoordinatorClient(coordinatorURL)
placeholderRunID := "pending" placeholderRunID := "pending"
run := &agentRun{cfg: cfg, machine: machine, clients: make(map[int]*WSClient), ack: NewDurationHistogram()} run := &agentRun{cfg: cfg, machine: machine, clients: make(map[int]*WSClient), ack: NewDurationHistogram(),
ctrlLatency: NewDurationHistogram(),
callbackQueue: &callbackQueueMetrics{}}
run.recorder = newDeliveryRecorder(placeholderRunID, machine, len(accounts)) run.recorder = newDeliveryRecorder(placeholderRunID, machine, len(accounts))
stopMetrics, err := startAgentMetrics(metricsListen, run) stopMetrics, err := startAgentMetrics(metricsListen, run)
if err != nil { if err != nil {
@@ -154,31 +161,34 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
defer close(maintenanceDone) defer close(maintenanceDone)
run.maintainConnections(maintenanceCtx, accounts, manifest.GroupTopic, apiKey, password) run.maintainConnections(maintenanceCtx, accounts, manifest.GroupTopic, apiKey, password)
}() }()
var stopMaintenanceOnce sync.Once
stopConnectionMaintenance := func() {
stopMaintenanceOnce.Do(func() {
stopMaintenance()
<-maintenanceDone
})
}
defer stopConnectionMaintenance()
start, err := coord.ready(ctx, func() AgentReady { start, err := coord.ready(ctx, func() AgentReady {
return AgentReady{ return AgentReady{
Machine: machine, ReadyConnections: run.aliveConnections(), ExpectedConnections: len(accounts), Machine: machine, ReadyConnections: run.aliveConnections(), ExpectedConnections: len(accounts),
} }
}) })
if err != nil { if err != nil {
stopMaintenance()
<-maintenanceDone
return err return err
} }
if !runIDPattern.MatchString(start.RunID) || !validScenario(start.Scenario) { if !runIDPattern.MatchString(start.RunID) || !validScenario(start.Scenario) {
stopMaintenance()
<-maintenanceDone
return fmt.Errorf("invalid coordinator start: %+v", start) return fmt.Errorf("invalid coordinator start: %+v", start)
} }
run.runID = start.RunID run.runID = start.RunID
run.scenario = start.Scenario run.scenario = start.Scenario
run.recorder.setRunID(start.RunID) run.recorder.setRunID(start.RunID)
if err = waitUntil(ctx, start.StartAt); err != nil { if err = waitUntil(ctx, start.StartAt); err != nil {
stopMaintenance()
<-maintenanceDone
return err return err
} }
stopMaintenance() if err = coord.started(ctx, machine, start.StartAt); err != nil {
<-maintenanceDone return fmt.Errorf("coordinated start rejected: %w", err)
}
if alive := run.aliveConnections(); alive != len(accounts) { if alive := run.aliveConnections(); alive != len(accounts) {
return fmt.Errorf("connections alive at scheduled start=%d expected=%d", alive, len(accounts)) return fmt.Errorf("connections alive at scheduled start=%d expected=%d", alive, len(accounts))
} }
@@ -206,6 +216,19 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
if err = waitContext(ctx, drain); err != nil { if err = waitContext(ctx, drain); err != nil {
return err return err
} }
if err = waitForCallbackQueue(ctx, run.callbackQueue, 30*time.Second); err != nil {
return err
}
quietPeriod, _ := configDuration(cfg.DeliveryQuiet)
quietTimeout, _ := configDuration(cfg.DeliveryTimeout)
log.Printf("agent=%s waiting for %s of delivery quiescence", machine, quietPeriod)
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) observedLocal := run.recorder.unackedObservedMessages(ledger, start.Scenario)
if err = coord.submitObserved(ctx, machine, observedLocal); err != nil { if err = coord.submitObserved(ctx, machine, observedLocal); err != nil {
return err return err
@@ -223,11 +246,18 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
PublishRejected: run.explicitRejected(), PublishOutcomeUnknown: run.outcomeUnknown(), PublishRejected: run.explicitRejected(), PublishOutcomeUnknown: run.outcomeUnknown(),
PublishErrors: run.publishErrorCounts(), PublishErrors: run.publishErrorCounts(),
AckLatency: run.ack.Snapshot(), AcceptedLocal: local, AckLatency: run.ack.Snapshot(), AcceptedLocal: local,
CallbackQueue: run.callbackQueue.snapshot(),
} }
run.mu.Lock() run.mu.Lock()
base.Errors = append(base.Errors, run.errors...) base.Errors = append(base.Errors, run.errors...)
run.mu.Unlock() run.mu.Unlock()
summary := run.recorder.summarize(base, ledger) 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.
summary := run.recorder.summarize(base, verificationLedger)
historySettle, _ := configDuration(cfg.HistorySettle) historySettle, _ := configDuration(cfg.HistorySettle)
log.Printf("agent=%s waiting %s before history verification", machine, historySettle) log.Printf("agent=%s waiting %s before history verification", machine, historySettle)
if err = waitContext(ctx, historySettle); err != nil { if err = waitContext(ctx, historySettle); err != nil {
@@ -241,13 +271,16 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
if err != nil { if err != nil {
return err return err
} }
// Both agents have completed history verification. Connection maintenance can
// now stop without making the other agent verify through stale sockets.
stopConnectionMaintenance()
summary.HistoryChecked = int64(history.checked) summary.HistoryChecked = int64(history.checked)
summary.HistoryFound = int64(history.found) summary.HistoryFound = int64(history.found)
summary.HistoryUnackedChecked = int64(history.unackedChecked) summary.HistoryUnackedChecked = int64(history.unackedChecked)
summary.HistoryUnackedFound = int64(history.unackedFound) summary.HistoryUnackedFound = int64(history.unackedFound)
persistenceMissing := history.missing persistenceMissing := history.missing
summary.PersistenceLost = int64(len(persistenceMissing)) summary.PersistenceLost = int64(len(persistenceMissing))
summary.RealtimeLostStored = run.recorder.countRealtimeLostStored(ledger, storedMessageIDs) summary.RealtimeLostStored = run.recorder.countRealtimeLostStored(verificationLedger, storedMessageIDs)
if len(persistenceMissing) > 10000 { if len(persistenceMissing) > 10000 {
summary.PersistenceMissing = append([]string(nil), persistenceMissing[:10000]...) summary.PersistenceMissing = append([]string(nil), persistenceMissing[:10000]...)
summary.Errors = append(summary.Errors, "persistence missing details truncated at 10000") summary.Errors = append(summary.Errors, "persistence missing details truncated at 10000")
@@ -255,6 +288,7 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
summary.PersistenceMissing = persistenceMissing summary.PersistenceMissing = persistenceMissing
} }
summary.FinishedAt = time.Now().UTC() summary.FinishedAt = time.Now().UTC()
run.applyCtrlObservations(&summary)
if err = writeAgentSummary(output, summary); err != nil { if err = writeAgentSummary(output, summary); err != nil {
return err return err
} }
@@ -267,6 +301,68 @@ func runAgent(ctx context.Context, cfg *Config, manifest *Manifest, machine, coo
return nil return nil
} }
func waitForCallbackQueue(ctx context.Context, queue *callbackQueueMetrics, timeout time.Duration) error {
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for !queue.isDrained() {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("data callback queue did not drain within %s: current=%d processing=%d",
timeout, queue.current.Load(), queue.processing.Load())
case <-ticker.C:
}
}
return nil
}
func waitForDeliveryQuiescence(ctx context.Context, recorder *deliveryRecorder, queue *callbackQueueMetrics,
quietPeriod, timeout time.Duration) error {
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
lastReceived := recorder.received.Load()
stableSince := time.Now()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("deliveries did not become quiescent within %s: received=%d queued=%d processing=%d",
timeout, recorder.received.Load(), queue.current.Load(), queue.processing.Load())
case now := <-ticker.C:
received := recorder.received.Load()
if received != lastReceived || !queue.isDrained() {
lastReceived = received
stableSince = now
continue
}
if now.Sub(stableSince) >= quietPeriod {
return nil
}
}
}
}
func assignAgentEndpoints(cfg *Config, machine string, accounts []AccountRecord) {
endpoints := cfg.Endpoints[machine]
dialIPs := cfg.DialIPs[machine]
machineOffset := 0
if machine == "b" {
machineOffset = 1
}
for i := range accounts {
accounts[i].Endpoint = endpoints[(accounts[i].Index-1)%len(endpoints)]
if len(dialIPs) > 0 {
slot := (accounts[i].Index-1)*2 + machineOffset
accounts[i].DialIP = strings.TrimSpace(dialIPs[slot%len(dialIPs)])
}
}
}
type historyQuery struct { type historyQuery struct {
client *WSClient client *WSClient
topic string topic string
@@ -458,8 +554,18 @@ func newHistoryQuery(client *WSClient, topic string, messages []AcceptedMessage,
for _, message := range messages { for _, message := range messages {
expected = append(expected, message.ClientMid) expected = append(expected, message.ClientMid)
} }
return historyQuery{client: client, topic: topic, since: messages[0].Seq, since := messages[0].Seq
before: messages[len(messages)-1].Seq + 1, limit: limit, expected: expected} before := messages[len(messages)-1].Seq + 1
// P2P topics interleave both senders. The requested range must leave room
// for non-target messages between the expected sequence numbers.
if span := before - since; span > int64(limit) {
limit = int(span)
}
if limit > 200 {
limit = 200
}
return historyQuery{client: client, topic: topic, since: since,
before: before, limit: limit, expected: expected}
} }
func (r *agentRun) waitHistoryPage(ctx context.Context, expected []string, timeout time.Duration) { func (r *agentRun) waitHistoryPage(ctx context.Context, expected []string, timeout time.Duration) {
@@ -581,39 +687,97 @@ func (r *agentRun) connect(ctx context.Context, accounts []AccountRecord, groupT
} }
func (r *agentRun) maintainConnections(ctx context.Context, accounts []AccountRecord, groupTopic, apiKey, password string) { 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)) byIndex := make(map[int]AccountRecord, len(accounts))
for _, account := range accounts { for _, account := range accounts {
byIndex[account.Index] = account 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) ticker := time.NewTicker(time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
jobs := make(chan AccountRecord)
var wg sync.WaitGroup var wg sync.WaitGroup
for index, account := range byIndex { workers := reconnectWorkers
current := r.client(index) if workers > len(accounts) {
if current != nil && current.alive() { workers = len(accounts)
}
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for account := range jobs {
index := account.Index
previous := r.client(index)
if previous != nil && previous.alive() {
continue continue
} }
wg.Add(1)
go func(index int, account AccountRecord, previous *WSClient) {
defer wg.Done()
if previous != nil { if previous != nil {
previous.Close() previous.Close()
} }
client, err := r.connectAccountWithRetry(ctx, account, groupTopic, apiKey, password) client, err := r.connectAccountWithRetry(ctx, account, groupTopic, apiKey, password)
if err != nil { if err != nil {
if ctx.Err() == nil { if ctx.Err() == nil {
log.Printf("account=%s pre-start reconnect failed: %v", account.Username, err) 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)
}
}()
}
indexes := make([]int, 0, len(byIndex))
for index := range byIndex {
indexes = append(indexes, index)
}
sort.Ints(indexes)
for _, index := range indexes {
account := byIndex[index]
current := r.client(index)
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():
close(jobs)
wg.Wait()
return return
} }
r.setClient(index, client)
log.Printf("account=%s pre-start connection restored", account.Username)
}(index, account, current)
} }
close(jobs)
wg.Wait() wg.Wait()
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -623,10 +787,30 @@ 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) { func (r *agentRun) connectAccountWithRetry(ctx context.Context, account AccountRecord, groupTopic, apiKey, password string) (*WSClient, error) {
var lastErr error var lastErr error
for attempt := 1; attempt <= 6; attempt++ { for attempt := 1; attempt <= 6; attempt++ {
client, err := connectWSClient(ctx, r.cfg, account, apiKey, password, r.recorder.onData, r.recorder.onDisconnect) client, err := connectWSClient(ctx, r.cfg, account, apiKey, password, r.recorder.onData, r.recorder.onDisconnect,
r.observeCtrl, r.callbackQueue)
if err == nil { if err == nil {
if err = client.Subscribe(ctx, account.P2PTopic); err == nil { if err = client.Subscribe(ctx, account.P2PTopic); err == nil {
err = client.Subscribe(ctx, groupTopic) err = client.Subscribe(ctx, groupTopic)
@@ -659,13 +843,37 @@ func (r *agentRun) execute(ctx context.Context, start time.Time, groupTopic stri
case "p2p": case "p2p":
return r.scheduleP2P(ctx, start, r.cfg.UsersPerMachine, r.cfg.P2PMessages, mustDuration(r.cfg.P2PInterval)) return r.scheduleP2P(ctx, start, r.cfg.UsersPerMachine, r.cfg.P2PMessages, mustDuration(r.cfg.P2PInterval))
case "group": case "group":
return r.scheduleGroup(ctx, start, groupTopic, 1, 500, 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": case "mixed":
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 {
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)
}
var wg sync.WaitGroup var wg sync.WaitGroup
errCh := make(chan error, 2) errCh := make(chan error, 2)
wg.Add(2) wg.Add(2)
go func() { defer wg.Done(); errCh <- r.scheduleP2P(ctx, start, 300, 5, 20*time.Second) }() 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, 301, 550, 2) }() go func() {
defer wg.Done()
errCh <- r.scheduleGroup(ctx, start, groupTopic, p2pUsers+1, p2pUsers+groupSenders, r.cfg.GroupRounds, mustDuration(r.cfg.GroupInterval))
}()
wg.Wait() wg.Wait()
close(errCh) close(errCh)
for err := range errCh { for err := range errCh {
@@ -674,20 +882,32 @@ func (r *agentRun) execute(ctx context.Context, start time.Time, groupTopic stri
} }
} }
return nil return nil
case "idle":
return waitContext(ctx, mustDuration(r.cfg.IdleDuration))
default: default:
return fmt.Errorf("unknown scenario %s", r.scenario) return fmt.Errorf("unknown scenario %s", r.scenario)
} }
} }
func scaledUsers(total, numerator, denominator int) int {
count := total * numerator / denominator
if count < 1 {
return 1
}
if count > total {
return total
}
return count
}
func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, messages int, interval time.Duration) error { func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, messages int, interval time.Duration) error {
var wg sync.WaitGroup var wg sync.WaitGroup
for index := 1; index <= users; index++ { for index := 1; index <= users; index++ {
client := r.client(index) if r.client(index) == nil {
if client == nil {
return fmt.Errorf("missing client %d", index) return fmt.Errorf("missing client %d", index)
} }
wg.Add(1) wg.Add(1)
go func(index int, client *WSClient) { go func(index int) {
defer wg.Done() defer wg.Done()
jitter := time.Duration((index*37)%200) * time.Millisecond jitter := time.Duration((index*37)%200) * time.Millisecond
for messageIndex := 1; messageIndex <= messages; messageIndex++ { for messageIndex := 1; messageIndex <= messages; messageIndex++ {
@@ -696,6 +916,11 @@ func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, mess
return return
} }
mid := fmt.Sprintf("%s-p-%s-%04d-%02d", r.runID, r.machine, index, messageIndex) mid := fmt.Sprintf("%s-p-%s-%04d-%02d", r.runID, r.machine, index, messageIndex)
client := r.client(index)
if client == nil || !client.alive() {
r.recordUnavailablePublish(mid)
continue
}
accepted, err := r.publish(ctx, client, client.account.P2PTopic, mid, messageIndex) accepted, err := r.publish(ctx, client, client.account.P2PTopic, mid, messageIndex)
if err == nil { if err == nil {
accepted.PeerIndex = index accepted.PeerIndex = index
@@ -707,46 +932,64 @@ func (r *agentRun) scheduleP2P(ctx context.Context, start time.Time, users, mess
r.addAccepted(accepted) r.addAccepted(accepted)
} }
} }
}(index, client) }(index)
} }
wg.Wait() wg.Wait()
return ctx.Err() 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 { if first < 1 || last > r.clientCount() || first > last {
return fmt.Errorf("invalid group publisher range %d-%d", first, last) return fmt.Errorf("invalid group publisher range %d-%d", first, last)
} }
count := last - first + 1 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) offset := time.Duration(0)
if r.machine == "b" { if r.machine == "b" {
offset = 100 * time.Millisecond offset = globalInterval
} }
var wg sync.WaitGroup var wg sync.WaitGroup
for index := first; index <= last; index++ { for index := first; index <= last; index++ {
client := r.client(index)
position := index - first position := index - first
wg.Add(1) wg.Add(1)
go func(index, position int, client *WSClient) { go func(index, position int) {
defer wg.Done() defer wg.Done()
for round := 1; round <= rounds; round++ { 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 { if waitUntil(ctx, target) != nil {
return return
} }
mid := fmt.Sprintf("%s-g-%s-%04d-%02d", r.runID, r.machine, index, round) mid := fmt.Sprintf("%s-g-%s-%04d-%02d", r.runID, r.machine, index, round)
client := r.client(index)
if client == nil || !client.alive() {
r.recordUnavailablePublish(mid)
continue
}
accepted, err := r.publish(ctx, client, topic, mid, round) accepted, err := r.publish(ctx, client, topic, mid, round)
if err == nil { if err == nil {
r.addAccepted(accepted) r.addAccepted(accepted)
} }
} }
}(index, position, client) }(index, position)
} }
wg.Wait() wg.Wait()
return ctx.Err() return ctx.Err()
} }
func (r *agentRun) recordUnavailablePublish(mid string) {
r.attempted.Add(1)
r.publishSocket.Add(1)
r.mu.Lock()
if len(r.errors) < 1000 {
r.errors = append(r.errors, fmt.Sprintf("publish %s: websocket connection unavailable", mid))
}
r.mu.Unlock()
}
func (r *agentRun) publish(ctx context.Context, client *WSClient, topic, mid string, messageIndex int) (AcceptedMessage, error) { func (r *agentRun) publish(ctx context.Context, client *WSClient, topic, mid string, messageIndex int) (AcceptedMessage, error) {
r.attempted.Add(1) r.attempted.Add(1)
accepted, err := client.Publish(ctx, topic, mid, r.runID, r.scenario, messageIndex) accepted, err := client.Publish(ctx, topic, mid, r.runID, r.scenario, messageIndex)
@@ -794,7 +1037,57 @@ func (r *agentRun) explicitRejected() int64 {
} }
func (r *agentRun) outcomeUnknown() int64 { func (r *agentRun) outcomeUnknown() int64 {
return r.publishTimeout.Load() + r.publishSocket.Load() return r.unresolvedCtrl() + r.publishSocket.Load()
}
func (r *agentRun) unresolvedCtrl() int64 {
unresolved := r.publishTimeout.Load() - r.publishLateCtrl.Load()
if unresolved < 0 {
return 0
}
return unresolved
}
func (r *agentRun) observeCtrl(account AccountRecord, observation ctrlObservation) {
if observation.Kind != "pub" {
return
}
r.publishCtrlObserved.Add(1)
r.ctrlLatency.Observe(observation.Latency)
if observation.Latency > 20*time.Second {
r.publishCtrlOver20.Add(1)
}
if observation.Late {
r.publishLateCtrl.Add(1)
if observation.Code >= 200 && observation.Code < 300 {
r.publishLateAccepted.Add(1)
}
}
if observation.Late || observation.Latency > 20*time.Second {
r.mu.Lock()
if len(r.ctrlSamples) < 1000 {
r.ctrlSamples = append(r.ctrlSamples, CtrlObservationSample{
Machine: account.Machine, Index: account.Index, ID: observation.ID, Code: observation.Code,
SentAt: observation.SentAt, ReceivedAt: observation.ReceivedAt,
LatencyMS: float64(observation.Latency) / float64(time.Millisecond), Late: observation.Late,
})
}
r.mu.Unlock()
}
}
func (r *agentRun) applyCtrlObservations(summary *AgentSummary) {
summary.CtrlTrackingEnabled = true
summary.PublishCtrlObserved = r.publishCtrlObserved.Load()
summary.PublishCtrlOver20 = r.publishCtrlOver20.Load()
summary.PublishLateCtrl = r.publishLateCtrl.Load()
summary.PublishLateAccepted = r.publishLateAccepted.Load()
summary.PublishCtrlUnresolved = r.unresolvedCtrl()
summary.PublishOutcomeUnknown = r.outcomeUnknown()
summary.PublishCtrlLatency = r.ctrlLatency.Snapshot()
r.mu.Lock()
summary.CtrlObservationSamples = append([]CtrlObservationSample(nil), r.ctrlSamples...)
r.mu.Unlock()
} }
func (r *agentRun) addAccepted(message AcceptedMessage) { func (r *agentRun) addAccepted(message AcceptedMessage) {
@@ -858,9 +1151,10 @@ func (r *agentRun) drainDuration() (time.Duration, error) {
func (r *agentRun) close() { func (r *agentRun) close() {
clients := r.clientsSnapshot() clients := r.clientsSnapshot()
var interval time.Duration sort.Slice(clients, func(i, j int) bool { return clients[i].account.Index < clients[j].account.Index })
if r.cfg != nil && r.cfg.CloseRamp != "" && len(clients) > 1 { interval, initialDelay := closeSchedule(r.cfg, r.machine, len(clients))
interval = mustDuration(r.cfg.CloseRamp) / time.Duration(len(clients)-1) if initialDelay > 0 {
time.Sleep(initialDelay)
} }
for index, client := range clients { for index, client := range clients {
client.Close() client.Close()
@@ -870,6 +1164,19 @@ func (r *agentRun) close() {
} }
} }
func closeSchedule(cfg *Config, machine string, clients int) (interval, initialDelay time.Duration) {
if cfg == nil || cfg.CloseRamp == "" || clients <= 1 {
return 0, 0
}
// The two logical agents share one global ramp. Interleaving their slots avoids
// doubling the disconnect rate when both agents begin cleanup at the same time.
interval = 2 * mustDuration(cfg.CloseRamp) / time.Duration(clients-1)
if machine == "b" {
initialDelay = interval / 2
}
return interval, initialDelay
}
func waitUntil(ctx context.Context, target time.Time) error { func waitUntil(ctx context.Context, target time.Time) error {
delay := time.Until(target) delay := time.Until(target)
if delay <= 0 { if delay <= 0 {
@@ -2,7 +2,9 @@ package main
import ( import (
"errors" "errors"
"fmt"
"testing" "testing"
"time"
) )
func TestHistoryQueriesForMessagesGroupsRangesByTopic(t *testing.T) { func TestHistoryQueriesForMessagesGroupsRangesByTopic(t *testing.T) {
@@ -51,6 +53,67 @@ func TestClassifyPublishErrorTreatsClosedConnectionAsUnknownSocketError(t *testi
} }
} }
func TestScenarioUsersScaleWithConfiguredCapacity(t *testing.T) {
tests := []struct {
users int
group int
mixedP2P int
mixedGroup int
}{
{users: 1000, group: 500, mixedP2P: 300, mixedGroup: 250},
{users: 600, group: 300, mixedP2P: 180, mixedGroup: 150},
}
for _, tc := range tests {
if got := scaledUsers(tc.users, 1, 2); got != tc.group {
t.Fatalf("users=%d group=%d want=%d", tc.users, got, tc.group)
}
if got := scaledUsers(tc.users, 3, 10); got != tc.mixedP2P {
t.Fatalf("users=%d mixed p2p=%d want=%d", tc.users, got, tc.mixedP2P)
}
if got := scaledUsers(tc.users, 1, 4); got != tc.mixedGroup {
t.Fatalf("users=%d mixed group=%d want=%d", tc.users, got, tc.mixedGroup)
}
}
}
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 {
messages[i] = AcceptedMessage{ClientMid: fmt.Sprintf("mid-%d", i), Seq: int64(100 + i*2)}
}
query := newHistoryQuery(nil, "p2p-one", messages, 10)
if query.since != 100 || query.before != 119 || query.limit != 19 {
t.Fatalf("history query range=%d-%d limit=%d want=100-119/19", query.since, query.before, query.limit)
}
}
func TestMergeMessageLedgersPrefersPeerObservationAndAck(t *testing.T) { func TestMergeMessageLedgersPrefersPeerObservationAndAck(t *testing.T) {
senderEcho := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "a", Seq: 10} senderEcho := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "a", Seq: 10}
peerDelivery := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "b", PeerIndex: 7, Seq: 10} peerDelivery := AcceptedMessage{ClientMid: "mid-1", SenderMachine: "a", PeerMachine: "b", PeerIndex: 7, Seq: 10}
@@ -65,3 +128,15 @@ func TestMergeMessageLedgersPrefersPeerObservationAndAck(t *testing.T) {
t.Fatalf("ACK ledger was not authoritative: %+v", merged) t.Fatalf("ACK ledger was not authoritative: %+v", merged)
} }
} }
func TestCloseScheduleInterleavesLogicalAgents(t *testing.T) {
cfg := &Config{CloseRamp: "60s"}
intervalA, delayA := closeSchedule(cfg, "a", 601)
intervalB, delayB := closeSchedule(cfg, "b", 601)
if intervalA != 200*time.Millisecond || intervalB != intervalA {
t.Fatalf("intervals: a=%s b=%s, want 200ms", intervalA, intervalB)
}
if delayA != 0 || delayB != 100*time.Millisecond {
t.Fatalf("delays: a=%s b=%s, want 0/100ms", delayA, delayB)
}
}
+25
View File
@@ -46,6 +46,10 @@ func startAgentMetrics(address string, run *agentRun) (func(), error) {
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_502_total", Help: "Publish commands rejected with 502.", ConstLabels: labels}, func() float64 { return float64(run.publish502.Load()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_502_total", Help: "Publish commands rejected with 502.", ConstLabels: labels}, func() float64 { return float64(run.publish502.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_503_total", Help: "Publish commands rejected with 503.", ConstLabels: labels}, func() float64 { return float64(run.publish503.Load()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_503_total", Help: "Publish commands rejected with 503.", ConstLabels: labels}, func() float64 { return float64(run.publish503.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_timeout_total", Help: "Publish commands with control-response timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishTimeout.Load()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_timeout_total", Help: "Publish commands with control-response timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishTimeout.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_observed_total", Help: "Publish control responses observed, including responses received after command timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishCtrlObserved.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_over_20s_total", Help: "Publish control responses taking longer than 20 seconds.", ConstLabels: labels}, func() float64 { return float64(run.publishCtrlOver20.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_ctrl_late_total", Help: "Publish control responses received after command timeout.", ConstLabels: labels}, func() float64 { return float64(run.publishLateCtrl.Load()) }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_publish_ctrl_unresolved", Help: "Publish control responses still unresolved after accounting for late responses.", ConstLabels: labels}, func() float64 { return float64(run.unresolvedCtrl()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_socket_error_total", Help: "Publish commands with socket errors.", ConstLabels: labels}, func() float64 { return float64(run.publishSocket.Load()) }), prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_publish_socket_error_total", Help: "Publish commands with socket errors.", ConstLabels: labels}, func() float64 { return float64(run.publishSocket.Load()) }),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_received_total", Help: "Unique matching data deliveries received.", ConstLabels: labels}, func() float64 { prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_received_total", Help: "Unique matching data deliveries received.", ConstLabels: labels}, func() float64 {
received, _, _, _ := run.recorder.metricCounts() received, _, _, _ := run.recorder.metricCounts()
@@ -63,6 +67,27 @@ func startAgentMetrics(address string, run *agentRun) (func(), error) {
_, _, _, disconnects := run.recorder.metricCounts() _, _, _, disconnects := run.recorder.metricCounts()
return float64(disconnects) return float64(disconnects)
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_queue_current", Help: "Data callbacks currently waiting to start.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.current.Load())
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_queue_max", Help: "Peak data callbacks waiting to start.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.maximum.Load())
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_processing", Help: "Data callbacks currently processing.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.processing.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_callback_queue_enqueued_total", Help: "Data callbacks enqueued.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.enqueued.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_callback_queue_completed_total", Help: "Data callbacks which completed processing.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.completed.Load())
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{Name: "im_load_agent_callback_queue_wait_seconds_total", Help: "Cumulative time spent waiting to start data callbacks.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.waitNanos.Load()) / float64(time.Second)
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "im_load_agent_callback_queue_wait_seconds_max", Help: "Maximum time spent waiting to start a data callback.", ConstLabels: labels}, func() float64 {
return float64(run.callbackQueue.maxWait.Load()) / float64(time.Second)
}),
) )
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"sync/atomic"
"time"
)
type callbackQueueMetrics struct {
current atomic.Int64
processing atomic.Int64
maximum atomic.Int64
enqueued atomic.Int64
completed atomic.Int64
waitNanos atomic.Int64
maxWait atomic.Int64
}
func (m *callbackQueueMetrics) enqueue() {
if m == nil {
return
}
current := m.current.Add(1)
m.enqueued.Add(1)
updateAtomicMax(&m.maximum, current)
}
func (m *callbackQueueMetrics) startCallback(queuedAt time.Time) {
if m == nil {
return
}
wait := time.Since(queuedAt).Nanoseconds()
if wait < 0 {
wait = 0
}
m.current.Add(-1)
m.processing.Add(1)
m.waitNanos.Add(wait)
updateAtomicMax(&m.maxWait, wait)
}
func (m *callbackQueueMetrics) finishCallback() {
if m == nil {
return
}
m.processing.Add(-1)
m.completed.Add(1)
}
func (m *callbackQueueMetrics) snapshot() CallbackQueueSnapshot {
if m == nil {
return CallbackQueueSnapshot{}
}
return CallbackQueueSnapshot{
Current: m.current.Load(), Processing: m.processing.Load(), Maximum: m.maximum.Load(), Enqueued: m.enqueued.Load(),
Completed: m.completed.Load(), WaitSecondsTotal: float64(m.waitNanos.Load()) / float64(time.Second),
WaitSecondsMax: float64(m.maxWait.Load()) / float64(time.Second),
}
}
func (m *callbackQueueMetrics) isDrained() bool {
return m == nil || (m.current.Load() == 0 && m.processing.Load() == 0)
}
func updateAtomicMax(target *atomic.Int64, value int64) {
for current := target.Load(); value > current; current = target.Load() {
if target.CompareAndSwap(current, value) {
return
}
}
}
@@ -0,0 +1,64 @@
package main
import (
"context"
"testing"
"time"
)
func TestCallbackQueueMetricsTrackWaitingAndProcessing(t *testing.T) {
metrics := &callbackQueueMetrics{}
metrics.enqueue()
metrics.enqueue()
metrics.startCallback(time.Now().Add(-10 * time.Millisecond))
snapshot := metrics.snapshot()
if snapshot.Current != 1 || snapshot.Processing != 1 || snapshot.Maximum != 2 || snapshot.Enqueued != 2 {
t.Fatalf("unexpected active queue snapshot: %+v", snapshot)
}
if snapshot.WaitSecondsTotal <= 0 || snapshot.WaitSecondsMax <= 0 || metrics.isDrained() {
t.Fatalf("wait or drain state not tracked: %+v", snapshot)
}
metrics.finishCallback()
metrics.startCallback(time.Now())
metrics.finishCallback()
if snapshot = metrics.snapshot(); snapshot.Current != 0 || snapshot.Processing != 0 || snapshot.Completed != 2 || !metrics.isDrained() {
t.Fatalf("unexpected drained queue snapshot: %+v", snapshot)
}
}
func TestWaitForCallbackQueueWaitsForProcessingCallback(t *testing.T) {
metrics := &callbackQueueMetrics{}
metrics.enqueue()
metrics.startCallback(time.Now())
go func() {
time.Sleep(20 * time.Millisecond)
metrics.finishCallback()
}()
started := time.Now()
if err := waitForCallbackQueue(context.Background(), metrics, time.Second); err != nil {
t.Fatal(err)
}
if time.Since(started) < 15*time.Millisecond {
t.Fatal("wait returned before the callback finished")
}
}
func TestWaitForDeliveryQuiescenceResetsForLateDelivery(t *testing.T) {
recorder := newDeliveryRecorder("run", "a", 1)
queue := &callbackQueueMetrics{}
go func() {
time.Sleep(20 * time.Millisecond)
recorder.received.Add(1)
}()
started := time.Now()
if err := waitForDeliveryQuiescence(context.Background(), recorder, queue, 150*time.Millisecond, time.Second); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(started); elapsed < 250*time.Millisecond {
t.Fatalf("quiet period was not reset after late delivery: elapsed=%s", elapsed)
}
}
+12 -1
View File
@@ -13,6 +13,9 @@
"insecure_tls": false, "insecure_tls": false,
"prepare_workers": 20, "prepare_workers": 20,
"connect_ramp": "20s", "connect_ramp": "20s",
"reconnect_workers": 8,
"reconnect_min_wait": "2s",
"reconnect_max_wait": "30s",
"command_timeout": "20s", "command_timeout": "20s",
"ws_ping_interval": "20s", "ws_ping_interval": "20s",
"preflight_workers": 32, "preflight_workers": 32,
@@ -26,6 +29,14 @@
"close_ramp": "10s", "close_ramp": "10s",
"p2p_messages_per_user": 10, "p2p_messages_per_user": 10,
"p2p_interval": "1s", "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_p2p": "30s",
"drain_group": "60s" "drain_group": "60s",
"delivery_quiet_period": "10s",
"delivery_quiet_timeout": "3m"
} }
@@ -0,0 +1,46 @@
{
"tenant": "006",
"http_base": "https://im-api.example.com",
"api_key_env": "IM_API_KEY",
"password_env": "IM_LOAD_PASSWORD",
"verification_code_env": "IM_VERIFICATION_CODE",
"registration_phone_prefix": "",
"users_per_machine": 600,
"endpoints": {
"a": ["wss://im-api.example.com/v0/channels"],
"b": ["wss://im-api.example.com/v0/channels"]
},
"dial_ips": {
"a": [],
"b": []
},
"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,
"skip_p2p_preflight": false,
"history_workers": 12,
"history_page_size": 10,
"history_query_interval": "500ms",
"history_page_wait": "3s",
"history_settle": "5m",
"history_timeout": "3m",
"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",
"delivery_quiet_timeout": "3m"
}
+86 -3
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -29,6 +30,20 @@ type AgentReady struct {
Armed bool `json:"armed"` Armed bool `json:"armed"`
} }
type AgentStarted struct {
Machine string `json:"machine"`
ScheduledAt time.Time `json:"scheduled_at"`
}
type StartGate struct {
Proceed bool `json:"proceed"`
Error string `json:"error,omitempty"`
ArrivedAgents int `json:"arrived_agents"`
ExpectedAgents int `json:"expected_agents"`
}
const maxAgentStartSkew = 3 * time.Second
type coordinatorState struct { type coordinatorState struct {
mu sync.Mutex mu sync.Mutex
start StartInfo start StartInfo
@@ -37,6 +52,8 @@ type coordinatorState struct {
expectedAgents int expectedAgents int
ready map[string]AgentReady ready map[string]AgentReady
armed map[string]bool armed map[string]bool
started map[string]time.Time
startError string
ledgers map[string][]AcceptedMessage ledgers map[string][]AcceptedMessage
observed map[string][]AcceptedMessage observed map[string][]AcceptedMessage
history map[string][]string history map[string][]string
@@ -60,11 +77,13 @@ func runCoordinator(ctx context.Context, listen, runID, scenario, output string,
expectedAgents: expectedAgents, expectedAgents: expectedAgents,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage), ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
observed: make(map[string][]AcceptedMessage), history: make(map[string][]string), observed: make(map[string][]AcceptedMessage), history: make(map[string][]string),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}), armed: make(map[string]bool), started: make(map[string]time.Time),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady) mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart) mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/started", state.handleStarted)
mux.HandleFunc("/v1/ledger", state.handleLedger) mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved) mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory) mux.HandleFunc("/v1/history", state.handleHistory)
@@ -210,11 +229,15 @@ func (s *coordinatorState) handleReady(w http.ResponseWriter, r *http.Request) {
s.start.StartAt = time.Time{} s.start.StartAt = time.Time{}
s.start.Committed = false s.start.Committed = false
clear(s.armed) clear(s.armed)
clear(s.started)
s.startError = ""
} }
if allStable && s.start.StartAt.IsZero() { if allStable && s.start.StartAt.IsZero() {
s.start.StartAt = now.Add(s.startDelay) s.start.StartAt = now.Add(s.startDelay)
s.start.Committed = false s.start.Committed = false
clear(s.armed) clear(s.armed)
clear(s.started)
s.startError = ""
log.Printf("all agents ready; validation_at=%s", s.start.StartAt.Format(time.RFC3339Nano)) log.Printf("all agents ready; validation_at=%s", s.start.StartAt.Format(time.RFC3339Nano))
} }
if allStable && !s.start.Committed && ready.Armed && !now.Before(s.start.StartAt) { if allStable && !s.start.Committed && ready.Armed && !now.Before(s.start.StartAt) {
@@ -234,6 +257,43 @@ func (s *coordinatorState) handleReady(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, start) writeJSON(w, http.StatusOK, start)
} }
func (s *coordinatorState) handleStarted(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var arrival AgentStarted
if !decodeLimitedJSON(w, r, &arrival) || (arrival.Machine != "a" && arrival.Machine != "b") || arrival.ScheduledAt.IsZero() {
http.Error(w, "invalid start arrival", http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.start.Committed || !arrival.ScheduledAt.Equal(s.start.StartAt) {
writeJSON(w, http.StatusOK, StartGate{Error: "start schedule changed", ExpectedAgents: s.expectedAgents})
return
}
if s.started == nil {
s.started = make(map[string]time.Time)
}
if _, exists := s.started[arrival.Machine]; !exists {
now := time.Now().UTC()
s.started[arrival.Machine] = now
skew := now.Sub(s.start.StartAt)
if skew < -maxAgentStartSkew || skew > maxAgentStartSkew {
s.startError = fmt.Sprintf("agent %s reached start barrier %s from schedule (limit %s)",
arrival.Machine, skew.Round(time.Millisecond), maxAgentStartSkew)
log.Printf("start barrier rejected: %s", s.startError)
}
}
gate := StartGate{
Proceed: len(s.started) == s.expectedAgents && s.startError == "", Error: s.startError,
ArrivedAgents: len(s.started), ExpectedAgents: s.expectedAgents,
}
writeJSON(w, http.StatusOK, gate)
}
func (s *coordinatorState) handleStart(w http.ResponseWriter, r *http.Request) { func (s *coordinatorState) handleStart(w http.ResponseWriter, r *http.Request) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -311,7 +371,8 @@ func (s *coordinatorState) handleStatus(w http.ResponseWriter, _ *http.Request)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"start": s.start, "ready": s.ready, "armed": s.armed, "start": s.start, "ready": s.ready, "armed": s.armed, "started": s.started,
"start_error": s.startError,
"ledger_agents": len(s.ledgers), "observed_agents": len(s.observed), "ledger_agents": len(s.ledgers), "observed_agents": len(s.observed),
"history_agents": len(s.history), "summary_agents": len(s.summaries), "history_agents": len(s.history), "summary_agents": len(s.summaries),
}) })
@@ -322,12 +383,15 @@ func (s *coordinatorState) writeOutput(path string) error {
data := struct { data := struct {
Start StartInfo `json:"start"` Start StartInfo `json:"start"`
Ready map[string]AgentReady `json:"ready"` Ready map[string]AgentReady `json:"ready"`
Started map[string]time.Time `json:"started"`
StartError string `json:"start_error,omitempty"`
Ledgers map[string][]AcceptedMessage `json:"ledgers"` Ledgers map[string][]AcceptedMessage `json:"ledgers"`
Observed map[string][]AcceptedMessage `json:"observed"` Observed map[string][]AcceptedMessage `json:"observed"`
History map[string][]string `json:"history"` History map[string][]string `json:"history"`
Summaries map[string]AgentSummary `json:"summaries"` Summaries map[string]AgentSummary `json:"summaries"`
}{ }{
Start: s.start, Ready: s.ready, Ledgers: s.ledgers, Observed: s.observed, Start: s.start, Ready: s.ready, Started: s.started, StartError: s.startError,
Ledgers: s.ledgers, Observed: s.observed,
History: s.history, Summaries: s.summaries, History: s.history, Summaries: s.summaries,
} }
s.mu.Unlock() s.mu.Unlock()
@@ -403,6 +467,25 @@ func (c *coordinatorClient) submitLedger(ctx context.Context, machine string, me
return err return err
} }
func (c *coordinatorClient) started(ctx context.Context, machine string, scheduledAt time.Time) error {
arrival := AgentStarted{Machine: machine, ScheduledAt: scheduledAt}
for {
var gate StartGate
if _, err := c.request(ctx, http.MethodPost, "/v1/started", arrival, &gate); err != nil {
return err
}
if gate.Error != "" {
return errors.New(gate.Error)
}
if gate.Proceed {
return nil
}
if err := waitContext(ctx, 100*time.Millisecond); err != nil {
return err
}
}
}
func (c *coordinatorClient) ledger(ctx context.Context) ([]AcceptedMessage, error) { func (c *coordinatorClient) ledger(ctx context.Context) ([]AcceptedMessage, error) {
for { for {
var messages []AcceptedMessage var messages []AcceptedMessage
+50
View File
@@ -15,6 +15,7 @@ func TestCoordinatorResetsBarrierWhenConnectionCountDrops(t *testing.T) {
start: StartInfo{RunID: "run-reset", Scenario: "group"}, startDelay: time.Minute, start: StartInfo{RunID: "run-reset", Scenario: "group"}, startDelay: time.Minute,
commitDelay: time.Second, expectedAgents: 2, ready: make(map[string]AgentReady), commitDelay: time.Second, expectedAgents: 2, ready: make(map[string]AgentReady),
armed: make(map[string]bool), ledgers: make(map[string][]AcceptedMessage), armed: make(map[string]bool), ledgers: make(map[string][]AcceptedMessage),
started: make(map[string]time.Time),
summaries: make(map[string]AgentSummary), complete: make(chan struct{}), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
} }
postReady := func(ready AgentReady) StartInfo { postReady := func(ready AgentReady) StartInfo {
@@ -52,6 +53,7 @@ func TestCoordinatorClientWaitsForBothAgentsAndMergesLedger(t *testing.T) {
commitDelay: 50 * time.Millisecond, expectedAgents: 2, commitDelay: 50 * time.Millisecond, expectedAgents: 2,
ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage), ready: make(map[string]AgentReady), ledgers: make(map[string][]AcceptedMessage),
armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}), armed: make(map[string]bool), summaries: make(map[string]AgentSummary), complete: make(chan struct{}),
started: make(map[string]time.Time),
} }
server := httptest.NewServer(testCoordinatorMux(state)) server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close() defer server.Close()
@@ -116,6 +118,7 @@ func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/ready", state.handleReady) mux.HandleFunc("/v1/ready", state.handleReady)
mux.HandleFunc("/v1/start", state.handleStart) mux.HandleFunc("/v1/start", state.handleStart)
mux.HandleFunc("/v1/started", state.handleStarted)
mux.HandleFunc("/v1/ledger", state.handleLedger) mux.HandleFunc("/v1/ledger", state.handleLedger)
mux.HandleFunc("/v1/observed", state.handleObserved) mux.HandleFunc("/v1/observed", state.handleObserved)
mux.HandleFunc("/v1/history", state.handleHistory) mux.HandleFunc("/v1/history", state.handleHistory)
@@ -123,3 +126,50 @@ func testCoordinatorMux(state *coordinatorState) *http.ServeMux {
mux.HandleFunc("/v1/status", state.handleStatus) mux.HandleFunc("/v1/status", state.handleStatus)
return mux return mux
} }
func TestCoordinatorRejectsLateAgentForBothMachines(t *testing.T) {
startAt := time.Now().Add(-maxAgentStartSkew - time.Second).UTC()
state := &coordinatorState{
start: StartInfo{RunID: "run-late", Scenario: "group", StartAt: startAt, Committed: true},
expectedAgents: 2, started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
if err := client.started(context.Background(), "b", startAt); err == nil {
t.Fatal("late agent must be rejected")
}
if err := client.started(context.Background(), "a", startAt); err == nil {
t.Fatal("the other agent must observe the same rejected run")
}
}
func TestCoordinatorReleasesAgentsOnlyAfterBothReachStart(t *testing.T) {
startAt := time.Now().UTC()
state := &coordinatorState{
start: StartInfo{RunID: "run-synced", Scenario: "group", StartAt: startAt, Committed: true},
expectedAgents: 2, started: make(map[string]time.Time),
}
server := httptest.NewServer(testCoordinatorMux(state))
defer server.Close()
client := newCoordinatorClient(server.URL)
aDone := make(chan error, 1)
go func() { aDone <- client.started(context.Background(), "a", startAt) }()
time.Sleep(50 * time.Millisecond)
select {
case err := <-aDone:
t.Fatalf("machine A passed before B arrived: %v", err)
default:
}
if err := client.started(context.Background(), "b", startAt); err != nil {
t.Fatalf("machine B start: %v", err)
}
select {
case err := <-aDone:
if err != nil {
t.Fatalf("machine A start: %v", err)
}
case <-time.After(time.Second):
t.Fatal("machine A was not released")
}
}
+23 -8
View File
@@ -25,6 +25,7 @@ type dbVerifyResult struct {
Unexpected int `json:"unexpected_rows"` Unexpected int `json:"unexpected_rows"`
Tables []string `json:"tables"` Tables []string `json:"tables"`
MissingMIDs []string `json:"missing_client_mids,omitempty"` MissingMIDs []string `json:"missing_client_mids,omitempty"`
UnexpectedMIDs []string `json:"unexpected_client_mids,omitempty"`
} }
type persistedIdentity struct { type persistedIdentity struct {
@@ -42,6 +43,7 @@ func runDBVerify(ctx context.Context, coordinatorPath, output, tenantCode, dsnEn
var coordinator struct { var coordinator struct {
Start StartInfo `json:"start"` Start StartInfo `json:"start"`
Ledgers map[string][]AcceptedMessage `json:"ledgers"` Ledgers map[string][]AcceptedMessage `json:"ledgers"`
Observed map[string][]AcceptedMessage `json:"observed"`
} }
if err = json.Unmarshal(raw, &coordinator); err != nil { if err = json.Unmarshal(raw, &coordinator); err != nil {
return fmt.Errorf("decode coordinator: %w", err) return fmt.Errorf("decode coordinator: %w", err)
@@ -76,19 +78,13 @@ func runDBVerify(ctx context.Context, coordinatorPath, output, tenantCode, dsnEn
return err return err
} }
expected := make(map[string]AcceptedMessage) expected := expectedDBMessages(coordinator.Start.RunID, coordinator.Ledgers, coordinator.Observed)
months := make(map[string]struct{}) months := make(map[string]struct{})
for _, ledger := range coordinator.Ledgers { for _, message := range expected {
for _, message := range ledger {
if message.RunID != coordinator.Start.RunID {
continue
}
expected[message.ClientMid] = message
if message.Kind == "group" { if message.Kind == "group" {
months[message.SentAt.UTC().Format("200601")] = struct{}{} months[message.SentAt.UTC().Format("200601")] = struct{}{}
} }
} }
}
tables := []string{fmt.Sprintf("im_%03d_messages", tenantID)} tables := []string{fmt.Sprintf("im_%03d_messages", tenantID)}
for month := range months { for month := range months {
tables = append(tables, fmt.Sprintf("im_%03d_group_messages_%s", tenantID, month)) tables = append(tables, fmt.Sprintf("im_%03d_group_messages_%s", tenantID, month))
@@ -145,9 +141,11 @@ func runDBVerify(ctx context.Context, coordinatorPath, output, tenantCode, dsnEn
for mid := range actual { for mid := range actual {
if _, ok := expected[mid]; !ok { if _, ok := expected[mid]; !ok {
result.Unexpected++ result.Unexpected++
result.UnexpectedMIDs = append(result.UnexpectedMIDs, mid)
} }
} }
sort.Strings(result.MissingMIDs) sort.Strings(result.MissingMIDs)
sort.Strings(result.UnexpectedMIDs)
resultRaw, _ := json.MarshalIndent(result, "", " ") resultRaw, _ := json.MarshalIndent(result, "", " ")
if output == "" || output == "-" { if output == "" || output == "-" {
if _, err = fmt.Fprintln(os.Stdout, string(resultRaw)); err != nil { if _, err = fmt.Fprintln(os.Stdout, string(resultRaw)); err != nil {
@@ -164,3 +162,20 @@ func runDBVerify(ctx context.Context, coordinatorPath, output, tenantCode, dsnEn
} }
return nil return nil
} }
func expectedDBMessages(runID string, ledgers, observed map[string][]AcceptedMessage) map[string]AcceptedMessage {
all := make([]AcceptedMessage, 0)
for _, messages := range observed {
all = append(all, messages...)
}
for _, messages := range ledgers {
all = append(all, messages...)
}
expected := make(map[string]AcceptedMessage, len(all))
for _, message := range mergeMessageLedgers(nil, all) {
if message.RunID == runID && message.ClientMid != "" {
expected[message.ClientMid] = message
}
}
return expected
}
+24
View File
@@ -0,0 +1,24 @@
package main
import "testing"
func TestExpectedDBMessagesIncludesObservedWithoutAck(t *testing.T) {
ledgers := map[string][]AcceptedMessage{
"a": {{RunID: "run-1", ClientMid: "acked", Topic: "grp-one", Seq: 1}},
}
observed := map[string][]AcceptedMessage{
"a": {
{RunID: "run-1", ClientMid: "unacked", Topic: "grp-one", Seq: 2},
{RunID: "other", ClientMid: "foreign", Topic: "grp-one", Seq: 3},
},
"b": {{RunID: "run-1", ClientMid: "unacked", Topic: "grp-one", Seq: 2}},
}
expected := expectedDBMessages("run-1", ledgers, observed)
if len(expected) != 2 {
t.Fatalf("expected messages=%d want=2: %+v", len(expected), expected)
}
if expected["acked"].Seq != 1 || expected["unacked"].Seq != 2 {
t.Fatalf("unexpected merged messages: %+v", expected)
}
}
+19 -20
View File
@@ -2,7 +2,7 @@ package main
import ( import (
"sort" "sort"
"sync" "sync/atomic"
"time" "time"
) )
@@ -10,14 +10,14 @@ var latencyBounds = []time.Duration{
time.Millisecond, 3 * time.Millisecond, 5 * time.Millisecond, 10 * time.Millisecond, time.Millisecond, 3 * time.Millisecond, 5 * time.Millisecond, 10 * time.Millisecond,
25 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 25 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond,
500 * time.Millisecond, time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 500 * time.Millisecond, time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second,
20 * time.Second, 30 * time.Second, 45 * time.Second, 60 * time.Second, 90 * time.Second, 120 * time.Second,
} }
type DurationHistogram struct { type DurationHistogram struct {
mu sync.Mutex count atomic.Int64
count int64 sum atomic.Int64
sum time.Duration max atomic.Int64
max time.Duration bucket []atomic.Int64
bucket []int64
} }
type HistogramSnapshot struct { type HistogramSnapshot struct {
@@ -28,37 +28,36 @@ type HistogramSnapshot struct {
} }
func NewDurationHistogram() *DurationHistogram { func NewDurationHistogram() *DurationHistogram {
return &DurationHistogram{bucket: make([]int64, len(latencyBounds)+1)} return &DurationHistogram{bucket: make([]atomic.Int64, len(latencyBounds)+1)}
} }
func (h *DurationHistogram) Observe(value time.Duration) { func (h *DurationHistogram) Observe(value time.Duration) {
if value < 0 { if value < 0 {
return return
} }
h.mu.Lock() nanos := int64(value)
defer h.mu.Unlock() h.count.Add(1)
h.count++ h.sum.Add(nanos)
h.sum += value for previous := h.max.Load(); nanos > previous; previous = h.max.Load() {
if value > h.max { if h.max.CompareAndSwap(previous, nanos) {
h.max = value break
}
} }
idx := sort.Search(len(latencyBounds), func(i int) bool { return value <= latencyBounds[i] }) idx := sort.Search(len(latencyBounds), func(i int) bool { return value <= latencyBounds[i] })
h.bucket[idx]++ h.bucket[idx].Add(1)
} }
func (h *DurationHistogram) Snapshot() HistogramSnapshot { func (h *DurationHistogram) Snapshot() HistogramSnapshot {
h.mu.Lock()
defer h.mu.Unlock()
buckets := make(map[string]int64, len(h.bucket)) buckets := make(map[string]int64, len(h.bucket))
for i, count := range h.bucket { for i := range h.bucket {
label := "+Inf" label := "+Inf"
if i < len(latencyBounds) { if i < len(latencyBounds) {
label = latencyBounds[i].String() label = latencyBounds[i].String()
} }
buckets[label] = count buckets[label] = h.bucket[i].Load()
} }
return HistogramSnapshot{ return HistogramSnapshot{
Count: h.count, SumMS: float64(h.sum) / float64(time.Millisecond), Count: h.count.Load(), SumMS: float64(h.sum.Load()) / float64(time.Millisecond),
MaxMS: float64(h.max) / float64(time.Millisecond), Buckets: buckets, MaxMS: float64(h.max.Load()) / float64(time.Millisecond), Buckets: buckets,
} }
} }
+8 -3
View File
@@ -269,6 +269,11 @@ type groupMembersResponse struct {
} }
func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord, topic string) (int, error) { func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord, topic string) (int, error) {
members, err := a.groupMemberIDs(ctx, account, topic)
return len(members), err
}
func (a *apiClient) groupMemberIDs(ctx context.Context, account AccountRecord, topic string) (map[string]bool, error) {
cursor := "" cursor := ""
seen := make(map[string]bool) seen := make(map[string]bool)
for { for {
@@ -278,7 +283,7 @@ func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord
} }
var page groupMembersResponse var page groupMembersResponse
if err := a.do(ctx, http.MethodGet, path, account.Token, "", nil, &page); err != nil { if err := a.do(ctx, http.MethodGet, path, account.Token, "", nil, &page); err != nil {
return 0, err return nil, err
} }
for _, item := range page.Items { for _, item := range page.Items {
seen[item.UserID] = true seen[item.UserID] = true
@@ -287,11 +292,11 @@ func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord
break break
} }
if page.NextCursor == cursor { if page.NextCursor == cursor {
return 0, fmt.Errorf("group member cursor did not advance: %s", cursor) return nil, fmt.Errorf("group member cursor did not advance: %s", cursor)
} }
cursor = page.NextCursor cursor = page.NextCursor
} }
return len(seen), nil return seen, nil
} }
func queryInt(value string, fallback int) int { func queryInt(value string, fallback int) int {
+1 -1
View File
@@ -101,7 +101,7 @@ func coordinatorCommand(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("coordinator", flag.ContinueOnError) fs := flag.NewFlagSet("coordinator", flag.ContinueOnError)
listen := fs.String("listen", ":8090", "coordinator listen address") listen := fs.String("listen", ":8090", "coordinator listen address")
runID := fs.String("run-id", "", "unique run ID, max 24 characters") 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") output := fs.String("output", "loadtest/im-pressure/results/coordinator.json", "coordinator result path")
expected := fs.Int("expected-agents", 2, "number of agents") expected := fs.Int("expected-agents", 2, "number of agents")
delay := fs.Duration("start-delay", 30*time.Second, "delay after all agents are ready") delay := fs.Duration("start-delay", 30*time.Second, "delay after all agents are ready")
+57 -12
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"time" "time"
) )
@@ -18,10 +19,18 @@ type CombinedReport struct {
ReadyConnections int `json:"ready_connections"` ReadyConnections int `json:"ready_connections"`
PublishAttempted int64 `json:"publish_attempted"` PublishAttempted int64 `json:"publish_attempted"`
PublishAccepted int64 `json:"publish_accepted"` PublishAccepted int64 `json:"publish_accepted"`
PublishLateAccepted int64 `json:"publish_late_accepted"`
PublishFinalAccepted int64 `json:"publish_final_accepted"`
PublishRejected int64 `json:"publish_rejected"` PublishRejected int64 `json:"publish_rejected"`
PublishExplicitReject int64 `json:"publish_explicitly_rejected"` PublishExplicitReject int64 `json:"publish_explicitly_rejected"`
PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"` PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"` PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
PublishCtrlObserved int64 `json:"publish_ctrl_observed"`
PublishCtrlOver20 int64 `json:"publish_ctrl_over_20s"`
PublishLateCtrl int64 `json:"publish_ctrl_late"`
PublishCtrlUnresolved int64 `json:"publish_ctrl_unresolved"`
PublishCtrlP95MS float64 `json:"publish_ctrl_p95_ms"`
CtrlObservationSamples []CtrlObservationSample `json:"ctrl_observation_samples,omitempty"`
PublishSuccessRate float64 `json:"publish_success_rate"` PublishSuccessRate float64 `json:"publish_success_rate"`
AckP95MS float64 `json:"ack_p95_ms"` AckP95MS float64 `json:"ack_p95_ms"`
DeliveryP95MS float64 `json:"delivery_p95_ms"` DeliveryP95MS float64 `json:"delivery_p95_ms"`
@@ -38,6 +47,8 @@ type CombinedReport struct {
OutOfOrder int64 `json:"out_of_order"` OutOfOrder int64 `json:"out_of_order"`
OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"` OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"`
Disconnects int64 `json:"disconnects"` Disconnects int64 `json:"disconnects"`
DisconnectReasons map[string]int64 `json:"disconnect_reasons,omitempty"`
DisconnectSamples []DisconnectSample `json:"disconnect_samples,omitempty"`
UnexpectedMessages int64 `json:"unexpected_messages"` UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"` HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"` HistoryFound int64 `json:"history_found"`
@@ -62,7 +73,7 @@ func runMerge(aPath, bPath, output string) error {
return fmt.Errorf("agent summaries do not belong to the same run") return fmt.Errorf("agent summaries do not belong to the same run")
} }
report := CombinedReport{RunID: a.RunID, Scenario: a.Scenario, GeneratedAt: time.Now().UTC(), Agents: []AgentSummary{a, b}, Passed: true, report := CombinedReport{RunID: a.RunID, Scenario: a.Scenario, GeneratedAt: time.Now().UTC(), Agents: []AgentSummary{a, b}, Passed: true,
PublishErrors: make(map[string]int64)} PublishErrors: make(map[string]int64), DisconnectReasons: make(map[string]int64)}
for _, summary := range report.Agents { for _, summary := range report.Agents {
expectedConnections := summary.ExpectedConnections expectedConnections := summary.ExpectedConnections
if expectedConnections == 0 { if expectedConnections == 0 {
@@ -72,16 +83,39 @@ func runMerge(aPath, bPath, output string) error {
report.ReadyConnections += summary.ReadyConnections report.ReadyConnections += summary.ReadyConnections
report.PublishAttempted += summary.PublishAttempted report.PublishAttempted += summary.PublishAttempted
report.PublishAccepted += summary.PublishAccepted report.PublishAccepted += summary.PublishAccepted
lateAccepted := summary.PublishLateAccepted
// Backfill summaries generated before publish_ctrl_late_accepted was added.
if summary.CtrlTrackingEnabled && lateAccepted == 0 {
for _, sample := range summary.CtrlObservationSamples {
if sample.Late && sample.Code >= 200 && sample.Code < 300 {
lateAccepted++
}
}
}
report.PublishLateAccepted += lateAccepted
report.PublishFinalAccepted += summary.PublishAccepted + lateAccepted
for category, count := range summary.PublishErrors { for category, count := range summary.PublishErrors {
report.PublishErrors[category] += count report.PublishErrors[category] += count
} }
unknown := summary.PublishOutcomeUnknown unknown := summary.PublishOutcomeUnknown
if unknown == 0 { if summary.CtrlTrackingEnabled {
unknown = summary.PublishCtrlUnresolved + summary.PublishErrors["socket"]
} else if unknown == 0 {
unknown = summary.PublishErrors["ctrl_timeout"] + summary.PublishErrors["socket"] unknown = summary.PublishErrors["ctrl_timeout"] + summary.PublishErrors["socket"]
} }
explicit := summary.PublishErrors["403"] + summary.PublishErrors["502"] + explicit := summary.PublishErrors["403"] + summary.PublishErrors["502"] +
summary.PublishErrors["503"] + summary.PublishErrors["other"] summary.PublishErrors["503"] + summary.PublishErrors["other"]
report.PublishOutcomeUnknown += unknown report.PublishOutcomeUnknown += unknown
report.PublishCtrlObserved += summary.PublishCtrlObserved
report.PublishCtrlOver20 += summary.PublishCtrlOver20
report.PublishLateCtrl += summary.PublishLateCtrl
report.PublishCtrlUnresolved += summary.PublishCtrlUnresolved
if remaining := 1000 - len(report.CtrlObservationSamples); remaining > 0 {
if remaining > len(summary.CtrlObservationSamples) {
remaining = len(summary.CtrlObservationSamples)
}
report.CtrlObservationSamples = append(report.CtrlObservationSamples, summary.CtrlObservationSamples[:remaining]...)
}
report.PublishExplicitReject += explicit report.PublishExplicitReject += explicit
report.PublishRejected += explicit report.PublishRejected += explicit
report.ExpectedDeliveries += summary.ExpectedPeer + summary.ExpectedGroup report.ExpectedDeliveries += summary.ExpectedPeer + summary.ExpectedGroup
@@ -92,6 +126,15 @@ func runMerge(aPath, bPath, output string) error {
report.OutOfOrder += summary.OutOfOrder report.OutOfOrder += summary.OutOfOrder
report.OrderingViolations = append(report.OrderingViolations, summary.OrderingViolations...) report.OrderingViolations = append(report.OrderingViolations, summary.OrderingViolations...)
report.Disconnects += summary.Disconnects report.Disconnects += summary.Disconnects
for category, count := range summary.DisconnectReasons {
report.DisconnectReasons[category] += count
}
if remaining := 100 - len(report.DisconnectSamples); remaining > 0 {
if remaining > len(summary.DisconnectSamples) {
remaining = len(summary.DisconnectSamples)
}
report.DisconnectSamples = append(report.DisconnectSamples, summary.DisconnectSamples[:remaining]...)
}
report.UnexpectedMessages += summary.UnexpectedMessages report.UnexpectedMessages += summary.UnexpectedMessages
report.HistoryChecked += summary.HistoryChecked report.HistoryChecked += summary.HistoryChecked
report.HistoryFound += summary.HistoryFound report.HistoryFound += summary.HistoryFound
@@ -100,14 +143,23 @@ func runMerge(aPath, bPath, output string) error {
report.PersistenceLost += summary.PersistenceLost report.PersistenceLost += summary.PersistenceLost
report.CrossClockSkewSamples += summary.CrossClockSkewSamples report.CrossClockSkewSamples += summary.CrossClockSkewSamples
report.Missing = append(report.Missing, summary.Missing...) report.Missing = append(report.Missing, summary.Missing...)
if len(summary.Errors) > 0 { actionableErrors := len(summary.Errors)
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("agent %s errors=%d", summary.Machine, len(summary.Errors))) lateTimeouts := int(lateAccepted)
for _, message := range summary.Errors {
if lateTimeouts > 0 && strings.Contains(message, "ctrl timeout") {
actionableErrors--
lateTimeouts--
}
}
if actionableErrors > 0 {
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("agent %s unresolved errors=%d", summary.Machine, actionableErrors))
} }
} }
if report.PublishAttempted > 0 { if report.PublishAttempted > 0 {
report.PublishSuccessRate = float64(report.PublishAccepted) / float64(report.PublishAttempted) report.PublishSuccessRate = float64(report.PublishFinalAccepted) / float64(report.PublishAttempted)
} }
report.AckP95MS = mergedAckP95(report.Agents) report.AckP95MS = mergedAckP95(report.Agents)
report.PublishCtrlP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.PublishCtrlLatency })
report.DeliveryLocalP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyLocal }) report.DeliveryLocalP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyLocal })
report.DeliveryCrossP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyCross }) report.DeliveryCrossP95MS = mergedHistogramP95(report.Agents, func(summary AgentSummary) HistogramSnapshot { return summary.DeliveryLatencyCross })
// delivery_p95_ms remains the compatibility field, but now uses only same-clock // delivery_p95_ms remains the compatibility field, but now uses only same-clock
@@ -136,13 +188,6 @@ func runMerge(aPath, bPath, output string) error {
report.FailureReasons = append(report.FailureReasons, report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("deliveries observed without ACK ledger=%d", report.UnexpectedMessages)) fmt.Sprintf("deliveries observed without ACK ledger=%d", report.UnexpectedMessages))
} }
if report.AckP95MS > 500 {
report.FailureReasons = append(report.FailureReasons, fmt.Sprintf("ack p95=%.3fms expected<=500ms", report.AckP95MS))
}
if report.DeliveryP95MS > 1000 {
report.FailureReasons = append(report.FailureReasons,
fmt.Sprintf("delivery p95=%.0fms expected<=1000ms", report.DeliveryP95MS))
}
if report.PublishRejected > 0 || report.RealtimeLost > 0 || report.PersistenceLost > 0 || report.Duplicates > 0 || report.OutOfOrder > 0 || report.Disconnects > 0 || report.UnexpectedMessages > 0 { if report.PublishRejected > 0 || report.RealtimeLost > 0 || report.PersistenceLost > 0 || report.Duplicates > 0 || report.OutOfOrder > 0 || report.Disconnects > 0 || report.UnexpectedMessages > 0 {
report.FailureReasons = append(report.FailureReasons, "message correctness gate failed") report.FailureReasons = append(report.FailureReasons, "message correctness gate failed")
} }
+73 -4
View File
@@ -13,12 +13,18 @@ func TestRunMergeComputesArrivalAndPersistenceGate(t *testing.T) {
a := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "a", ReadyConnections: 1000, a := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "a", ReadyConnections: 1000,
ExpectedConnections: 1000, ExpectedConnections: 1000,
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 9, UnexpectedMessages: 42, PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 9, UnexpectedMessages: 42,
CtrlTrackingEnabled: true, PublishCtrlObserved: 9, PublishCtrlOver20: 2, PublishLateCtrl: 2,
PublishCtrlUnresolved: 1,
PublishErrors: map[string]int64{"ctrl_timeout": 3, "socket": 2, "503": 1}, PublishErrors: map[string]int64{"ctrl_timeout": 3, "socket": 2, "503": 1},
RealtimeLost: 1, HistoryChecked: 10, HistoryFound: 10} RealtimeLost: 1, HistoryChecked: 10, HistoryFound: 10, Disconnects: 1,
DisconnectReasons: map[string]int64{"timeout": 1},
DisconnectSamples: []DisconnectSample{{ReceiverMachine: "a", ReceiverIndex: 3, Category: "timeout"}}}
b := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "b", ReadyConnections: 1000, b := AgentSummary{RunID: "run1", Scenario: "p2p", Machine: "b", ReadyConnections: 1000,
ExpectedConnections: 1000, ExpectedConnections: 1000,
PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 10, PublishAttempted: 10, PublishAccepted: 10, ExpectedPeer: 10, ReceivedPeer: 10,
HistoryChecked: 10, HistoryFound: 9, PersistenceLost: 1, OutOfOrder: 1, HistoryChecked: 10, HistoryFound: 9, PersistenceLost: 1, OutOfOrder: 1,
CtrlTrackingEnabled: true, PublishCtrlObserved: 10,
Disconnects: 2, DisconnectReasons: map[string]int64{"timeout": 1, "connection_reset": 1},
OrderingViolations: []OrderingViolation{{ReceiverMachine: "b", ReceiverIndex: 7, PreviousSeq: 12, CurrentSeq: 11}}} OrderingViolations: []OrderingViolation{{ReceiverMachine: "b", ReceiverIndex: 7, PreviousSeq: 12, CurrentSeq: 11}}}
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json") aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, a) writeSummaryForTest(t, aPath, a)
@@ -40,13 +46,22 @@ func TestRunMergeComputesArrivalAndPersistenceGate(t *testing.T) {
if report.Passed || report.PersistenceLost != 1 { if report.Passed || report.PersistenceLost != 1 {
t.Fatalf("correctness gate did not fail: %+v", report) t.Fatalf("correctness gate did not fail: %+v", report)
} }
if report.UnexpectedMessages != 42 || report.PublishOutcomeUnknown != 5 || if report.UnexpectedMessages != 42 || report.PublishOutcomeUnknown != 3 ||
report.PublishExplicitReject != 1 || report.PublishRejected != 1 { report.PublishExplicitReject != 1 || report.PublishRejected != 1 {
t.Fatalf("ambiguous publish outcomes were not merged: %+v", report) t.Fatalf("ambiguous publish outcomes were not merged: %+v", report)
} }
if report.PublishCtrlObserved != 19 || report.PublishCtrlOver20 != 2 || report.PublishLateCtrl != 2 ||
report.PublishCtrlUnresolved != 1 {
t.Fatalf("ctrl observations were not merged: %+v", report)
}
if len(report.OrderingViolations) != 1 || report.OrderingViolations[0].ReceiverIndex != 7 { if len(report.OrderingViolations) != 1 || report.OrderingViolations[0].ReceiverIndex != 7 {
t.Fatalf("ordering violations were not merged: %+v", report.OrderingViolations) t.Fatalf("ordering violations were not merged: %+v", report.OrderingViolations)
} }
if report.Disconnects != 3 || report.DisconnectReasons["timeout"] != 2 ||
report.DisconnectReasons["connection_reset"] != 1 || len(report.DisconnectSamples) != 1 {
t.Fatalf("disconnect diagnostics were not merged: count=%d reasons=%v samples=%+v",
report.Disconnects, report.DisconnectReasons, report.DisconnectSamples)
}
} }
func TestMergedHistogramP95(t *testing.T) { func TestMergedHistogramP95(t *testing.T) {
@@ -59,6 +74,23 @@ func TestMergedHistogramP95(t *testing.T) {
} }
} }
func TestMergedHistogramP95DoesNotUseMaximumForLongTail(t *testing.T) {
histogram := NewDurationHistogram()
for i := 0; i < 94; i++ {
histogram.Observe(5 * time.Second)
}
for i := 0; i < 5; i++ {
histogram.Observe(25 * time.Second)
}
histogram.Observe(70 * time.Second)
agents := []AgentSummary{{PublishCtrlLatency: histogram.Snapshot()}}
if got := mergedHistogramP95(agents, func(summary AgentSummary) HistogramSnapshot {
return summary.PublishCtrlLatency
}); got != 30000 {
t.Fatalf("p95=%v want=30000", got)
}
}
func TestMergedAckP95UsesExactAcceptedTimestamps(t *testing.T) { func TestMergedAckP95UsesExactAcceptedTimestamps(t *testing.T) {
base := time.Date(2026, 9, 27, 8, 0, 0, 0, time.UTC) base := time.Date(2026, 9, 27, 8, 0, 0, 0, time.UTC)
agents := []AgentSummary{{ agents := []AgentSummary{{
@@ -85,7 +117,7 @@ func TestMergedAckP95FallsBackWhenRawSamplesAreIncomplete(t *testing.T) {
} }
} }
func TestRunMergeUsesSameClockDeliveryLatencyForGate(t *testing.T) { func TestRunMergeReportsSameClockDeliveryLatency(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
local := HistogramSnapshot{Count: 10, MaxMS: 200, Buckets: map[string]int64{"200ms": 10}} local := HistogramSnapshot{Count: 10, MaxMS: 200, Buckets: map[string]int64{"200ms": 10}}
cross := HistogramSnapshot{Count: 10, MaxMS: 5000, Buckets: map[string]int64{"5s": 10}} cross := HistogramSnapshot{Count: 10, MaxMS: 5000, Buckets: map[string]int64{"5s": 10}}
@@ -112,7 +144,44 @@ func TestRunMergeUsesSameClockDeliveryLatencyForGate(t *testing.T) {
t.Fatalf("unexpected delivery percentiles: %+v", report) t.Fatalf("unexpected delivery percentiles: %+v", report)
} }
if report.CrossClockSkewSamples != 6 || !report.Passed { if report.CrossClockSkewSamples != 6 || !report.Passed {
t.Fatalf("cross-machine clock skew must not fail the same-clock latency gate: %+v", report) t.Fatalf("cross-machine clock skew must not fail correctness acceptance: %+v", report)
}
}
func TestRunMergeTreatsLateSuccessAsFinalAcceptance(t *testing.T) {
dir := t.TempDir()
late := AgentSummary{
RunID: "run1", Scenario: "group", Machine: "a", ExpectedConnections: 1, ReadyConnections: 1,
PublishAttempted: 1, PublishAccepted: 0, CtrlTrackingEnabled: true,
PublishCtrlObserved: 1, PublishLateCtrl: 1, PublishCtrlUnresolved: 0,
PublishErrors: map[string]int64{"ctrl_timeout": 1},
CtrlObservationSamples: []CtrlObservationSample{{Code: 202, Late: true}},
Errors: []string{"publish message-1: ctrl timeout id=pub-a-1"},
}
timely := AgentSummary{
RunID: "run1", Scenario: "group", Machine: "b", ExpectedConnections: 1, ReadyConnections: 1,
PublishAttempted: 1, PublishAccepted: 1, CtrlTrackingEnabled: true,
PublishCtrlObserved: 1, PublishErrors: map[string]int64{},
}
aPath, bPath, reportPath := filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json"), filepath.Join(dir, "report.json")
writeSummaryForTest(t, aPath, late)
writeSummaryForTest(t, bPath, timely)
if err := runMerge(aPath, bPath, reportPath); err != nil {
t.Fatal(err)
}
var report CombinedReport
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.PublishAccepted != 1 || report.PublishLateAccepted != 1 || report.PublishFinalAccepted != 2 || report.PublishSuccessRate != 1 {
t.Fatalf("late acceptance was not included in final publish outcome: %+v", report)
}
if !report.Passed || len(report.FailureReasons) != 0 {
t.Fatalf("resolved late ACK must not fail the report: %+v", report.FailureReasons)
} }
} }
+118
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"sort" "sort"
"strings" "strings"
@@ -21,15 +22,27 @@ type Config struct {
PasswordEnv string `json:"password_env"` PasswordEnv string `json:"password_env"`
UsersPerMachine int `json:"users_per_machine"` UsersPerMachine int `json:"users_per_machine"`
Endpoints map[string][]string `json:"endpoints"` Endpoints map[string][]string `json:"endpoints"`
DialIPs map[string][]string `json:"dial_ips,omitempty"`
InsecureTLS bool `json:"insecure_tls"` InsecureTLS bool `json:"insecure_tls"`
PrepareWorkers int `json:"prepare_workers"` PrepareWorkers int `json:"prepare_workers"`
ConnectRamp string `json:"connect_ramp"` 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"` CommandTimeout string `json:"command_timeout"`
WSPingInterval string `json:"ws_ping_interval"` WSPingInterval string `json:"ws_ping_interval"`
P2PMessages int `json:"p2p_messages_per_user"` P2PMessages int `json:"p2p_messages_per_user"`
P2PInterval string `json:"p2p_interval"` 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"` DrainP2P string `json:"drain_p2p"`
DrainGroup string `json:"drain_group"` DrainGroup string `json:"drain_group"`
DeliveryQuiet string `json:"delivery_quiet_period"`
DeliveryTimeout string `json:"delivery_quiet_timeout"`
PhonePrefix string `json:"registration_phone_prefix"` PhonePrefix string `json:"registration_phone_prefix"`
VerifyCodeEnv string `json:"verification_code_env"` VerifyCodeEnv string `json:"verification_code_env"`
HistoryWorkers int `json:"history_workers"` HistoryWorkers int `json:"history_workers"`
@@ -64,6 +77,29 @@ func loadConfig(path string) (*Config, error) {
if cfg.ConnectRamp == "" { if cfg.ConnectRamp == "" {
cfg.ConnectRamp = "20s" 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 == "" { if cfg.CommandTimeout == "" {
cfg.CommandTimeout = "20s" cfg.CommandTimeout = "20s"
} }
@@ -79,12 +115,48 @@ func loadConfig(path string) (*Config, error) {
if cfg.P2PInterval == "" { if cfg.P2PInterval == "" {
cfg.P2PInterval = "1s" 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 == "" { if cfg.DrainP2P == "" {
cfg.DrainP2P = "30s" cfg.DrainP2P = "30s"
} }
if cfg.DrainGroup == "" { if cfg.DrainGroup == "" {
cfg.DrainGroup = "60s" cfg.DrainGroup = "60s"
} }
if cfg.DeliveryQuiet == "" {
cfg.DeliveryQuiet = "10s"
}
if _, err = configDuration(cfg.DeliveryQuiet); err != nil {
return nil, fmt.Errorf("delivery_quiet_period: %w", err)
}
if cfg.DeliveryTimeout == "" {
cfg.DeliveryTimeout = "3m"
}
if _, err = configDuration(cfg.DeliveryTimeout); err != nil {
return nil, fmt.Errorf("delivery_quiet_timeout: %w", err)
}
if cfg.HistoryWorkers <= 0 { if cfg.HistoryWorkers <= 0 {
cfg.HistoryWorkers = 12 cfg.HistoryWorkers = 12
} }
@@ -140,6 +212,11 @@ func loadConfig(path string) (*Config, error) {
if len(cfg.Endpoints[machine]) == 0 { if len(cfg.Endpoints[machine]) == 0 {
return nil, fmt.Errorf("at least one endpoint is required for machine %s", machine) return nil, fmt.Errorf("at least one endpoint is required for machine %s", machine)
} }
for _, ip := range cfg.DialIPs[machine] {
if net.ParseIP(strings.TrimSpace(ip)) == nil {
return nil, fmt.Errorf("invalid dial IP %q for machine %s", ip, machine)
}
}
} }
if cfg.PhonePrefix != "" { if cfg.PhonePrefix != "" {
if len(cfg.PhonePrefix) != 7 || strings.Trim(cfg.PhonePrefix, "0123456789") != "" { if len(cfg.PhonePrefix) != 7 || strings.Trim(cfg.PhonePrefix, "0123456789") != "" {
@@ -214,6 +291,7 @@ type AccountRecord struct {
TokenExpires time.Time `json:"token_expires,omitempty"` TokenExpires time.Time `json:"token_expires,omitempty"`
P2PTopic string `json:"p2p_topic,omitempty"` P2PTopic string `json:"p2p_topic,omitempty"`
Endpoint string `json:"endpoint,omitempty"` Endpoint string `json:"endpoint,omitempty"`
DialIP string `json:"-"`
} }
func newManifest(cfg *Config) *Manifest { func newManifest(cfg *Config) *Manifest {
@@ -398,6 +476,14 @@ type AgentSummary struct {
PublishRejected int64 `json:"publish_rejected"` PublishRejected int64 `json:"publish_rejected"`
PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"` PublishOutcomeUnknown int64 `json:"publish_outcome_unknown"`
PublishErrors map[string]int64 `json:"publish_errors,omitempty"` PublishErrors map[string]int64 `json:"publish_errors,omitempty"`
CtrlTrackingEnabled bool `json:"ctrl_tracking_enabled"`
PublishCtrlObserved int64 `json:"publish_ctrl_observed"`
PublishCtrlOver20 int64 `json:"publish_ctrl_over_20s"`
PublishLateCtrl int64 `json:"publish_ctrl_late"`
PublishLateAccepted int64 `json:"publish_ctrl_late_accepted"`
PublishCtrlUnresolved int64 `json:"publish_ctrl_unresolved"`
PublishCtrlLatency HistogramSnapshot `json:"publish_ctrl_latency"`
CtrlObservationSamples []CtrlObservationSample `json:"ctrl_observation_samples,omitempty"`
ExpectedPeer int64 `json:"expected_peer"` ExpectedPeer int64 `json:"expected_peer"`
ReceivedPeer int64 `json:"received_peer"` ReceivedPeer int64 `json:"received_peer"`
ExpectedEcho int64 `json:"expected_echo"` ExpectedEcho int64 `json:"expected_echo"`
@@ -410,6 +496,8 @@ type AgentSummary struct {
OutOfOrder int64 `json:"out_of_order"` OutOfOrder int64 `json:"out_of_order"`
OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"` OrderingViolations []OrderingViolation `json:"ordering_violations,omitempty"`
Disconnects int64 `json:"disconnects"` Disconnects int64 `json:"disconnects"`
DisconnectReasons map[string]int64 `json:"disconnect_reasons,omitempty"`
DisconnectSamples []DisconnectSample `json:"disconnect_samples,omitempty"`
UnexpectedMessages int64 `json:"unexpected_messages"` UnexpectedMessages int64 `json:"unexpected_messages"`
HistoryChecked int64 `json:"history_checked"` HistoryChecked int64 `json:"history_checked"`
HistoryFound int64 `json:"history_found"` HistoryFound int64 `json:"history_found"`
@@ -421,12 +509,42 @@ type AgentSummary struct {
DeliveryLatencyLocal HistogramSnapshot `json:"delivery_latency_local"` DeliveryLatencyLocal HistogramSnapshot `json:"delivery_latency_local"`
DeliveryLatencyCross HistogramSnapshot `json:"delivery_latency_cross_machine"` DeliveryLatencyCross HistogramSnapshot `json:"delivery_latency_cross_machine"`
CrossClockSkewSamples int64 `json:"cross_machine_negative_latency_samples"` CrossClockSkewSamples int64 `json:"cross_machine_negative_latency_samples"`
CallbackQueue CallbackQueueSnapshot `json:"callback_queue"`
Errors []string `json:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
Missing []MissingDelivery `json:"missing,omitempty"` Missing []MissingDelivery `json:"missing,omitempty"`
PersistenceMissing []string `json:"persistence_missing,omitempty"` PersistenceMissing []string `json:"persistence_missing,omitempty"`
AcceptedLocal []AcceptedMessage `json:"accepted_local,omitempty"` AcceptedLocal []AcceptedMessage `json:"accepted_local,omitempty"`
} }
type CtrlObservationSample struct {
Machine string `json:"machine"`
Index int `json:"index"`
ID string `json:"id"`
Code int `json:"code"`
SentAt time.Time `json:"sent_at"`
ReceivedAt time.Time `json:"received_at"`
LatencyMS float64 `json:"latency_ms"`
Late bool `json:"late"`
}
type CallbackQueueSnapshot struct {
Current int64 `json:"current"`
Processing int64 `json:"processing"`
Maximum int64 `json:"maximum"`
Enqueued int64 `json:"enqueued"`
Completed int64 `json:"completed"`
WaitSecondsTotal float64 `json:"wait_seconds_total"`
WaitSecondsMax float64 `json:"wait_seconds_max"`
}
type DisconnectSample struct {
ReceiverMachine string `json:"receiver_machine"`
ReceiverIndex int `json:"receiver_index"`
Category string `json:"category"`
Error string `json:"error"`
At time.Time `json:"at"`
}
type OrderingViolation struct { type OrderingViolation struct {
ReceiverMachine string `json:"receiver_machine"` ReceiverMachine string `json:"receiver_machine"`
ReceiverIndex int `json:"receiver_index"` ReceiverIndex int `json:"receiver_index"`
+41
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"testing" "testing"
) )
@@ -24,6 +25,46 @@ func TestLoadConfigAppliesBoundedHistoryDefaults(t *testing.T) {
} }
} }
func TestAssignAgentEndpointsDistributesDialIPsAcrossMachines(t *testing.T) {
cfg := &Config{
Endpoints: map[string][]string{"a": {"wss://api.example.test/ws"}, "b": {"wss://api.example.test/ws"}},
DialIPs: map[string][]string{"a": {"192.0.2.1", "192.0.2.2", "192.0.2.3"}, "b": {"192.0.2.1", "192.0.2.2", "192.0.2.3"}},
}
a := []AccountRecord{{Index: 1}, {Index: 2}, {Index: 3}}
b := []AccountRecord{{Index: 1}, {Index: 2}, {Index: 3}}
assignAgentEndpoints(cfg, "a", a)
assignAgentEndpoints(cfg, "b", b)
got := []string{a[0].DialIP, b[0].DialIP, a[1].DialIP, b[1].DialIP, a[2].DialIP, b[2].DialIP}
want := []string{"192.0.2.1", "192.0.2.2", "192.0.2.3", "192.0.2.1", "192.0.2.2", "192.0.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected dial IP distribution: got=%v want=%v", got, want)
}
}
func TestLoadConfigRejectsInvalidDialIP(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
raw := []byte(`{"http_base":"https://example.test","endpoints":{"a":["wss://a"],"b":["wss://b"]},"dial_ips":{"a":["not-an-ip"]}}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("invalid dial IP was accepted")
}
}
func TestSingleMachineExampleUsesSixtyPercentCapacity(t *testing.T) {
cfg, err := loadConfig("config.single-machine.example.json")
if err != nil {
t.Fatal(err)
}
if cfg.UsersPerMachine != 600 || cfg.CloseRamp != "60s" {
t.Fatalf("unexpected single-machine capacity: users=%d close_ramp=%s", cfg.UsersPerMachine, cfg.CloseRamp)
}
if len(cfg.Endpoints["a"]) != 1 || len(cfg.Endpoints["b"]) != 1 {
t.Fatalf("single-machine endpoints must define both logical agents: %+v", cfg.Endpoints)
}
}
func TestNewManifestDistributesUsersAndEndpoints(t *testing.T) { func TestNewManifestDistributesUsersAndEndpoints(t *testing.T) {
cfg := &Config{Tenant: "006", UsersPerMachine: 3, Endpoints: map[string][]string{ cfg := &Config{Tenant: "006", UsersPerMachine: 3, Endpoints: map[string][]string{
"a": {"ws://a1", "ws://a2"}, "b": {"ws://b1"}, "a": {"ws://a1", "ws://a2"}, "b": {"ws://b1"},
+62 -16
View File
@@ -49,7 +49,7 @@ func runPrepare(ctx context.Context, cfg *Config, manifestPath string) error {
if err = saveManifest(manifestPath, manifest); err != nil { if err = saveManifest(manifestPath, manifest); err != nil {
return err return err
} }
if err = prepareGroup(ctx, cfg, manifest, api); err != nil { if err = prepareGroup(ctx, cfg, manifest, api, manifestPath); err != nil {
return err return err
} }
if err = saveManifest(manifestPath, manifest); err != nil { if err = saveManifest(manifestPath, manifest); err != nil {
@@ -184,7 +184,7 @@ func prepareFriendships(ctx context.Context, cfg *Config, manifest *Manifest, ap
return err return err
} }
func prepareGroup(ctx context.Context, cfg *Config, manifest *Manifest, api *apiClient) error { func prepareGroup(ctx context.Context, cfg *Config, manifest *Manifest, api *apiClient, manifestPath string) error {
if err := parallelAccounts(ctx, manifest.Accounts, cfg.PrepareWorkers, func(account AccountRecord) error { if err := parallelAccounts(ctx, manifest.Accounts, cfg.PrepareWorkers, func(account AccountRecord) error {
return retryPrepareOperation(ctx, "privacy-"+account.Username, func() error { return retryPrepareOperation(ctx, "privacy-"+account.Username, func() error {
return api.disableGroupInviteVerification(ctx, account) return api.disableGroupInviteVerification(ctx, account)
@@ -200,6 +200,9 @@ func prepareGroup(ctx context.Context, cfg *Config, manifest *Manifest, api *api
return err return err
} }
manifest.GroupTopic = group.Topic manifest.GroupTopic = group.Topic
if err = saveManifest(manifestPath, manifest); err != nil {
return fmt.Errorf("checkpoint created group: %w", err)
}
log.Printf("created load-test group topic_hash=%s", topicHash(group.Topic)) log.Printf("created load-test group topic_hash=%s", topicHash(group.Topic))
} }
memberIDs := make([]string, 0, len(manifest.Accounts)-1) memberIDs := make([]string, 0, len(manifest.Accounts)-1)
@@ -214,20 +217,9 @@ func prepareGroup(ctx context.Context, cfg *Config, manifest *Manifest, api *api
if end > len(memberIDs) { if end > len(memberIDs) {
end = len(memberIDs) end = len(memberIDs)
} }
var response *groupInvitationResponse if err := ensureGroupMembers(ctx, api, owner, manifest.GroupTopic, batch, memberIDs[offset:end]); err != nil {
err := retryPrepareOperation(ctx, fmt.Sprintf("group-invite-%03d", batch), func() error {
var inviteErr error
response, inviteErr = api.inviteGroup(ctx, owner, manifest.GroupTopic, batch, memberIDs[offset:end])
return inviteErr
})
if err != nil {
return fmt.Errorf("invite group batch %d: %w", batch, err) return fmt.Errorf("invite group batch %d: %w", batch, err)
} }
for _, item := range response.Items {
if item.Status != "joined" && item.Status != "already_member" {
return fmt.Errorf("invite user %s status=%s reason=%s", item.UserID, item.Status, item.Reason)
}
}
} }
count, err := api.countGroupMembers(ctx, owner, manifest.GroupTopic) count, err := api.countGroupMembers(ctx, owner, manifest.GroupTopic)
if err != nil { if err != nil {
@@ -240,9 +232,60 @@ func prepareGroup(ctx context.Context, cfg *Config, manifest *Manifest, api *api
return nil return nil
} }
func retryPrepareOperation(ctx context.Context, label string, operation func() error) error { func ensureGroupMembers(ctx context.Context, api *apiClient, owner AccountRecord, topic string, batch int, desired []string) error {
var lastErr error var lastErr error
for attempt := 1; attempt <= 6; attempt++ { for attempt := 1; attempt <= 12; attempt++ {
members, err := api.groupMemberIDs(ctx, owner, topic)
if err != nil {
lastErr = fmt.Errorf("list current members: %w", err)
} else {
missing := make([]string, 0, len(desired))
for _, userID := range desired {
if !members[userID] {
missing = append(missing, userID)
}
}
if len(missing) == 0 {
return nil
}
response, inviteErr := api.inviteGroup(ctx, owner, topic, batch, missing)
if inviteErr != nil {
lastErr = inviteErr
} else {
lastErr = nil
for _, item := range response.Items {
if item.Status != "joined" && item.Status != "already_member" {
lastErr = fmt.Errorf("invite user %s status=%s reason=%s", item.UserID, item.Status, item.Reason)
break
}
}
if lastErr == nil {
continue
}
}
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt == 12 {
break
}
delay := time.Duration(attempt*5) * time.Second
log.Printf("operation=group-invite-%03d attempt=%d failed; retry_in=%s: %v", batch, attempt, delay, lastErr)
if err := waitContext(ctx, delay); err != nil {
return err
}
}
return fmt.Errorf("missing members not recovered after retries: %w", lastErr)
}
func retryPrepareOperation(ctx context.Context, label string, operation func() error) error {
return retryOperation(ctx, label, 6, operation)
}
func retryOperation(ctx context.Context, label string, attempts int, operation func() error) error {
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if err := operation(); err == nil { if err := operation(); err == nil {
return nil return nil
} else { } else {
@@ -251,6 +294,9 @@ func retryPrepareOperation(ctx context.Context, label string, operation func() e
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }
if attempt == attempts {
break
}
delay := time.Duration(attempt*5) * time.Second delay := time.Duration(attempt*5) * time.Second
log.Printf("operation=%s attempt=%d failed; retry_in=%s: %v", label, attempt, delay, lastErr) log.Printf("operation=%s attempt=%d failed; retry_in=%s: %v", label, attempt, delay, lastErr)
if err := waitContext(ctx, delay); err != nil { if err := waitContext(ctx, delay); err != nil {
+237 -63
View File
@@ -2,8 +2,11 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"math/bits" "math/bits"
"net"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -11,7 +14,19 @@ import (
"time" "time"
) )
const observationShardCount = 64
type observation struct { type observation struct {
topic string
seq int64
senderMachine string
senderIndex int
scenario string
sentAt int64
bits []atomic.Uint64
}
type observationView struct {
topic string topic string
seq int64 seq int64
senderMachine string senderMachine string
@@ -21,6 +36,17 @@ type observation struct {
bits []uint64 bits []uint64
} }
type observationShard struct {
mu sync.RWMutex
observed map[string]*observation
history map[string]bool
}
type sequenceState struct {
mu sync.Mutex
last map[string]int64
}
type deliveryRecorder struct { type deliveryRecorder struct {
machine string machine string
localUsers int localUsers int
@@ -32,26 +58,35 @@ type deliveryRecorder struct {
runID atomic.Value runID atomic.Value
historyMode atomic.Bool historyMode atomic.Bool
mu sync.Mutex shards []observationShard
observed map[string]*observation sequences []sequenceState
lastSeq []map[string]int64 duplicates atomic.Int64
duplicates int64 outOfOrder atomic.Int64
outOfOrder int64 malformed atomic.Int64
crossClockSkewSamples atomic.Int64
orderingMu sync.Mutex
ordering []OrderingViolation ordering []OrderingViolation
malformed int64
crossClockSkewSamples int64 disconnectMu sync.Mutex
history map[string]bool disconnectReasons map[string]int64
disconnectSamples []DisconnectSample
} }
func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorder { func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorder {
lastSeq := make([]map[string]int64, localUsers) shards := make([]observationShard, observationShardCount)
for i := range lastSeq { for i := range shards {
lastSeq[i] = make(map[string]int64) shards[i].observed = make(map[string]*observation)
shards[i].history = make(map[string]bool)
}
sequences := make([]sequenceState, localUsers)
for i := range sequences {
sequences[i].last = make(map[string]int64)
} }
recorder := &deliveryRecorder{ recorder := &deliveryRecorder{
machine: machine, localUsers: localUsers, latency: NewDurationHistogram(), machine: machine, localUsers: localUsers, latency: NewDurationHistogram(),
localLatency: NewDurationHistogram(), crossLatency: NewDurationHistogram(), localLatency: NewDurationHistogram(), crossLatency: NewDurationHistogram(),
observed: make(map[string]*observation), lastSeq: lastSeq, history: make(map[string]bool), shards: shards, sequences: sequences, disconnectReasons: make(map[string]int64),
} }
recorder.runID.Store(runID) recorder.runID.Store(runID)
return recorder return recorder
@@ -59,7 +94,13 @@ func newDeliveryRecorder(runID, machine string, localUsers int) *deliveryRecorde
func (r *deliveryRecorder) setRunID(runID string) { r.runID.Store(runID) } func (r *deliveryRecorder) setRunID(runID string) { r.runID.Store(runID) }
func (r *deliveryRecorder) resetDisconnects() { r.disconnects.Store(0) } func (r *deliveryRecorder) resetDisconnects() {
r.disconnectMu.Lock()
r.disconnects.Store(0)
r.disconnectReasons = make(map[string]int64)
r.disconnectSamples = nil
r.disconnectMu.Unlock()
}
func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) { func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
runID, _ := r.runID.Load().(string) runID, _ := r.runID.Load().(string)
@@ -75,40 +116,53 @@ func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
SentAt int64 `json:"sent_at_unix_nano"` SentAt int64 `json:"sent_at_unix_nano"`
} }
if json.Unmarshal(data.Content, &content) != nil || content.RunID != runID { if json.Unmarshal(data.Content, &content) != nil || content.RunID != runID {
r.mu.Lock() r.malformed.Add(1)
r.malformed++
r.mu.Unlock()
return return
} }
if r.historyMode.Load() { if r.historyMode.Load() {
r.mu.Lock() shard := r.shard(mid)
r.history[mid] = true shard.mu.Lock()
r.mu.Unlock() shard.history[mid] = true
shard.mu.Unlock()
return return
} }
index := account.Index - 1 index := account.Index - 1
if index < 0 || index >= r.localUsers { if index < 0 || index >= r.localUsers {
return return
} }
r.mu.Lock() shard := r.shard(mid)
defer r.mu.Unlock() shard.mu.RLock()
obs := r.observed[mid] obs := shard.observed[mid]
shard.mu.RUnlock()
if obs == nil {
shard.mu.Lock()
obs = shard.observed[mid]
if obs == nil { if obs == nil {
obs = &observation{topic: data.Topic, seq: data.Seq, senderMachine: content.SenderMachine, obs = &observation{topic: data.Topic, seq: data.Seq, senderMachine: content.SenderMachine,
senderIndex: content.SenderIndex, scenario: content.Scenario, sentAt: content.SentAt, senderIndex: content.SenderIndex, scenario: content.Scenario, sentAt: content.SentAt,
bits: make([]uint64, (r.localUsers+63)/64)} bits: make([]atomic.Uint64, (r.localUsers+63)/64)}
r.observed[mid] = obs shard.observed[mid] = obs
}
shard.mu.Unlock()
} }
word, bit := index/64, uint(index%64) word, bit := index/64, uint(index%64)
mask := uint64(1) << bit mask := uint64(1) << bit
if obs.bits[word]&mask != 0 { if !markObservationBit(obs.bits, word, mask) {
r.duplicates++ r.duplicates.Add(1)
return return
} }
obs.bits[word] |= mask
r.received.Add(1) r.received.Add(1)
if previous := r.lastSeq[index][data.Topic]; previous > 0 && data.Seq < previous { sequence := &r.sequences[index]
r.outOfOrder++ sequence.mu.Lock()
previous := sequence.last[data.Topic]
violation := previous > 0 && data.Seq < previous
if data.Seq > previous {
sequence.last[data.Topic] = data.Seq
}
sequence.mu.Unlock()
if violation {
r.outOfOrder.Add(1)
r.orderingMu.Lock()
if len(r.ordering) < 100 { if len(r.ordering) < 100 {
r.ordering = append(r.ordering, OrderingViolation{ r.ordering = append(r.ordering, OrderingViolation{
ReceiverMachine: r.machine, ReceiverMachine: r.machine,
@@ -119,9 +173,7 @@ func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
ClientMid: mid, ClientMid: mid,
}) })
} }
} r.orderingMu.Unlock()
if data.Seq > r.lastSeq[index][data.Topic] {
r.lastSeq[index][data.Topic] = data.Seq
} }
if content.SentAt > 0 { if content.SentAt > 0 {
delay := time.Since(time.Unix(0, content.SentAt)) delay := time.Since(time.Unix(0, content.SentAt))
@@ -133,7 +185,7 @@ func (r *deliveryRecorder) onData(account AccountRecord, data dataPacket) {
if senderMachine == r.machine { if senderMachine == r.machine {
r.localLatency.Observe(delay) r.localLatency.Observe(delay)
} else if delay < 0 { } else if delay < 0 {
r.crossClockSkewSamples++ r.crossClockSkewSamples.Add(1)
} else { } else {
r.crossLatency.Observe(delay) r.crossLatency.Observe(delay)
} }
@@ -150,26 +202,22 @@ func senderMachineFromClientMid(runID, mid string) string {
} }
func (r *deliveryRecorder) metricCounts() (received, duplicates, outOfOrder, disconnects int64) { func (r *deliveryRecorder) metricCounts() (received, duplicates, outOfOrder, disconnects int64) {
r.mu.Lock() return r.received.Load(), r.duplicates.Load(), r.outOfOrder.Load(), r.disconnects.Load()
duplicates = r.duplicates
outOfOrder = r.outOfOrder
r.mu.Unlock()
return r.received.Load(), duplicates, outOfOrder, r.disconnects.Load()
} }
func (r *deliveryRecorder) beginHistory() { r.historyMode.Store(true) } func (r *deliveryRecorder) beginHistory() { r.historyMode.Store(true) }
func (r *deliveryRecorder) historyFound(mid string) bool { func (r *deliveryRecorder) historyFound(mid string) bool {
r.mu.Lock() shard := r.shard(mid)
defer r.mu.Unlock() shard.mu.RLock()
return r.history[mid] found := shard.history[mid]
shard.mu.RUnlock()
return found
} }
func (r *deliveryRecorder) historyFoundAll(messageIDs []string) bool { func (r *deliveryRecorder) historyFoundAll(messageIDs []string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, mid := range messageIDs { for _, mid := range messageIDs {
if !r.history[mid] { if !r.historyFound(mid) {
return false return false
} }
} }
@@ -182,10 +230,11 @@ func (r *deliveryRecorder) unackedObservedMessages(ledger []AcceptedMessage, sce
accepted[message.ClientMid] = true accepted[message.ClientMid] = true
} }
runID, _ := r.runID.Load().(string) runID, _ := r.runID.Load().(string)
r.mu.Lock()
defer r.mu.Unlock()
messages := make([]AcceptedMessage, 0) messages := make([]AcceptedMessage, 0)
for mid, obs := range r.observed { for i := range r.shards {
shard := &r.shards[i]
shard.mu.RLock()
for mid, obs := range shard.observed {
if accepted[mid] || obs.seq <= 0 || obs.topic == "" { if accepted[mid] || obs.seq <= 0 || obs.topic == "" {
continue continue
} }
@@ -199,12 +248,14 @@ func (r *deliveryRecorder) unackedObservedMessages(ledger []AcceptedMessage, sce
} }
message := AcceptedMessage{RunID: runID, Scenario: observedScenario, Kind: kind, message := AcceptedMessage{RunID: runID, Scenario: observedScenario, Kind: kind,
ClientMid: mid, Topic: obs.topic, Seq: obs.seq, SenderMachine: obs.senderMachine, ClientMid: mid, Topic: obs.topic, Seq: obs.seq, SenderMachine: obs.senderMachine,
SenderIndex: obs.senderIndex, PeerMachine: r.machine, PeerIndex: firstObservedIndex(obs.bits)} SenderIndex: obs.senderIndex, PeerMachine: r.machine, PeerIndex: firstObservedAtomicIndex(obs.bits)}
if obs.sentAt > 0 { if obs.sentAt > 0 {
message.SentAt = time.Unix(0, obs.sentAt).UTC() message.SentAt = time.Unix(0, obs.sentAt).UTC()
} }
messages = append(messages, message) messages = append(messages, message)
} }
shard.mu.RUnlock()
}
sort.Slice(messages, func(i, j int) bool { return messages[i].ClientMid < messages[j].ClientMid }) sort.Slice(messages, func(i, j int) bool { return messages[i].ClientMid < messages[j].ClientMid })
return messages return messages
} }
@@ -218,34 +269,109 @@ func firstObservedIndex(words []uint64) int {
return 0 return 0
} }
func (r *deliveryRecorder) onDisconnect(_ AccountRecord, _ error) { func firstObservedAtomicIndex(words []atomic.Uint64) int {
for wordIndex := range words {
if word := words[wordIndex].Load(); word != 0 {
return wordIndex*64 + bits.TrailingZeros64(word) + 1
}
}
return 0
}
func markObservationBit(words []atomic.Uint64, word int, mask uint64) bool {
for {
current := words[word].Load()
if current&mask != 0 {
return false
}
if words[word].CompareAndSwap(current, current|mask) {
return true
}
}
}
func (r *deliveryRecorder) onDisconnect(account AccountRecord, err error) {
category := disconnectCategory(err)
r.disconnectMu.Lock()
r.disconnects.Add(1) r.disconnects.Add(1)
r.disconnectReasons[category]++
if len(r.disconnectSamples) < 100 {
message := ""
if err != nil {
message = err.Error()
if len(message) > 300 {
message = message[:300]
}
}
r.disconnectSamples = append(r.disconnectSamples, DisconnectSample{
ReceiverMachine: r.machine, ReceiverIndex: account.Index, Category: category,
Error: message, At: time.Now().UTC(),
})
}
r.disconnectMu.Unlock()
}
func disconnectCategory(err error) string {
if err == nil {
return "unknown"
}
if errors.Is(err, io.EOF) {
return "eof"
}
if errors.Is(err, net.ErrClosed) {
return "closed_connection"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "timeout"
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "close 1006") || strings.Contains(message, "abnormal closure"):
return "abnormal_close"
case strings.Contains(message, "close 1000") || strings.Contains(message, "normal closure"):
return "normal_close"
case strings.Contains(message, "connection reset"):
return "connection_reset"
case strings.Contains(message, "broken pipe"):
return "broken_pipe"
case strings.Contains(message, "timeout") || strings.Contains(message, "deadline exceeded"):
return "timeout"
case strings.Contains(message, "use of closed network connection"):
return "closed_connection"
default:
return "other"
}
} }
func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage) AgentSummary { func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage) AgentSummary {
base.DeliveryLatency = r.latency.Snapshot() base.DeliveryLatency = r.latency.Snapshot()
base.DeliveryLatencyLocal = r.localLatency.Snapshot() base.DeliveryLatencyLocal = r.localLatency.Snapshot()
base.DeliveryLatencyCross = r.crossLatency.Snapshot() base.DeliveryLatencyCross = r.crossLatency.Snapshot()
r.disconnectMu.Lock()
base.Disconnects = r.disconnects.Load() base.Disconnects = r.disconnects.Load()
base.DisconnectReasons = cloneCountMap(r.disconnectReasons)
base.DisconnectSamples = append([]DisconnectSample(nil), r.disconnectSamples...)
r.disconnectMu.Unlock()
runID, _ := r.runID.Load().(string) runID, _ := r.runID.Load().(string)
accepted := make(map[string]AcceptedMessage, len(ledger)) accepted := make(map[string]AcceptedMessage, len(ledger))
const missingLimit = 100000 const missingLimit = 100000
r.mu.Lock() base.Duplicates = r.duplicates.Load()
defer r.mu.Unlock() base.OutOfOrder = r.outOfOrder.Load()
base.Duplicates = r.duplicates base.CrossClockSkewSamples = r.crossClockSkewSamples.Load()
base.OutOfOrder = r.outOfOrder r.orderingMu.Lock()
base.CrossClockSkewSamples = r.crossClockSkewSamples
base.OrderingViolations = append([]OrderingViolation(nil), r.ordering...) base.OrderingViolations = append([]OrderingViolation(nil), r.ordering...)
if r.malformed > 0 { r.orderingMu.Unlock()
base.Errors = append(base.Errors, fmt.Sprintf("malformed run messages=%d", r.malformed)) if malformed := r.malformed.Load(); malformed > 0 {
base.Errors = append(base.Errors, fmt.Sprintf("malformed run messages=%d", malformed))
} }
for _, message := range ledger { for _, message := range ledger {
if message.RunID != runID { if message.RunID != runID {
continue continue
} }
accepted[message.ClientMid] = message accepted[message.ClientMid] = message
obs := r.observed[message.ClientMid] obs := r.observationSnapshot(message.ClientMid)
switch message.Kind { switch message.Kind {
case "group": case "group":
base.ExpectedGroup += int64(r.localUsers) base.ExpectedGroup += int64(r.localUsers)
@@ -273,11 +399,16 @@ func (r *deliveryRecorder) summarize(base AgentSummary, ledger []AcceptedMessage
} }
} }
} }
for mid, obs := range r.observed { for i := range r.shards {
shard := &r.shards[i]
shard.mu.RLock()
for mid, obs := range shard.observed {
if _, ok := accepted[mid]; !ok { if _, ok := accepted[mid]; !ok {
base.UnexpectedMessages += bitsetCount(obs.bits) base.UnexpectedMessages += atomicBitsetCount(obs.bits)
} }
} }
shard.mu.RUnlock()
}
base.RealtimeLost = (base.ExpectedPeer - base.ReceivedPeer) + (base.ExpectedGroup - base.ReceivedGroup) base.RealtimeLost = (base.ExpectedPeer - base.ReceivedPeer) + (base.ExpectedGroup - base.ReceivedGroup)
if len(base.Missing) == missingLimit { if len(base.Missing) == missingLimit {
base.Errors = append(base.Errors, "missing delivery details truncated at 100000") base.Errors = append(base.Errors, "missing delivery details truncated at 100000")
@@ -291,14 +422,12 @@ func (r *deliveryRecorder) countRealtimeLostStored(ledger []AcceptedMessage, sto
for _, mid := range storedMessageIDs { for _, mid := range storedMessageIDs {
stored[mid] = true stored[mid] = true
} }
r.mu.Lock()
defer r.mu.Unlock()
var count int64 var count int64
for _, message := range ledger { for _, message := range ledger {
if message.RunID != runID || !stored[message.ClientMid] { if message.RunID != runID || !stored[message.ClientMid] {
continue continue
} }
obs := r.observed[message.ClientMid] obs := r.observationSnapshot(message.ClientMid)
switch message.Kind { switch message.Kind {
case "group": case "group":
for index := 1; index <= r.localUsers; index++ { for index := 1; index <= r.localUsers; index++ {
@@ -315,7 +444,44 @@ func (r *deliveryRecorder) countRealtimeLostStored(ledger []AcceptedMessage, sto
return count return count
} }
func observationHas(obs *observation, receiverIndex int) bool { func (r *deliveryRecorder) shard(mid string) *observationShard {
var hash uint32 = 2166136261
for i := 0; i < len(mid); i++ {
hash ^= uint32(mid[i])
hash *= 16777619
}
return &r.shards[hash%uint32(len(r.shards))]
}
func (r *deliveryRecorder) observationSnapshot(mid string) *observationView {
shard := r.shard(mid)
shard.mu.RLock()
obs := shard.observed[mid]
shard.mu.RUnlock()
if obs == nil {
return nil
}
view := &observationView{topic: obs.topic, seq: obs.seq, senderMachine: obs.senderMachine,
senderIndex: obs.senderIndex, scenario: obs.scenario, sentAt: obs.sentAt,
bits: make([]uint64, len(obs.bits))}
for i := range obs.bits {
view.bits[i] = obs.bits[i].Load()
}
return view
}
func cloneCountMap(source map[string]int64) map[string]int64 {
if len(source) == 0 {
return nil
}
clone := make(map[string]int64, len(source))
for key, value := range source {
clone[key] = value
}
return clone
}
func observationHas(obs *observationView, receiverIndex int) bool {
if obs == nil || receiverIndex <= 0 { if obs == nil || receiverIndex <= 0 {
return false return false
} }
@@ -324,6 +490,14 @@ func observationHas(obs *observation, receiverIndex int) bool {
return word < len(obs.bits) && obs.bits[word]&(uint64(1)<<bit) != 0 return word < len(obs.bits) && obs.bits[word]&(uint64(1)<<bit) != 0
} }
func atomicBitsetCount(words []atomic.Uint64) int64 {
var count int64
for i := range words {
count += int64(bits.OnesCount64(words[i].Load()))
}
return count
}
func bitsetCount(words []uint64) int64 { func bitsetCount(words []uint64) int64 {
var count int64 var count int64
for _, word := range words { for _, word := range words {
+62
View File
@@ -2,6 +2,9 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -111,6 +114,65 @@ func TestDeliveryRecorderSeparatesLocalAndCrossMachineLatency(t *testing.T) {
} }
} }
func TestDeliveryRecorderConcurrentGroupFanout(t *testing.T) {
const users = 32
const messages = 64
recorder := newDeliveryRecorder("run1", "a", users)
ledger := make([]AcceptedMessage, messages)
for messageIndex := range ledger {
ledger[messageIndex] = AcceptedMessage{
RunID: "run1", Kind: "group", Topic: "grp-one", Seq: 1,
ClientMid: fmt.Sprintf("run1-g-b-%04d-01", messageIndex+1), SenderMachine: "b",
}
}
var writers sync.WaitGroup
for receiver := 1; receiver <= users; receiver++ {
for _, message := range ledger {
writers.Add(1)
go func(receiver int, message AcceptedMessage) {
defer writers.Done()
recorder.onData(AccountRecord{Index: receiver}, testData(message, time.Now().Add(-time.Millisecond)))
}(receiver, message)
}
}
writers.Wait()
summary := recorder.summarize(AgentSummary{}, ledger)
want := int64(users * messages)
if summary.ExpectedGroup != want || summary.ReceivedGroup != want || summary.RealtimeLost != 0 {
t.Fatalf("group expected/received/lost = %d/%d/%d, want %d/%d/0",
summary.ExpectedGroup, summary.ReceivedGroup, summary.RealtimeLost, want, want)
}
if summary.Duplicates != 0 || summary.DeliveryLatency.Count != want {
t.Fatalf("duplicates=%d latency samples=%d want=0/%d", summary.Duplicates, summary.DeliveryLatency.Count, want)
}
}
func TestDeliveryRecorderDisconnectDiagnostics(t *testing.T) {
recorder := newDeliveryRecorder("run1", "a", 1)
recorder.onDisconnect(AccountRecord{Index: 1}, errors.New("websocket: close 1006 (abnormal closure): unexpected EOF"))
recorder.onDisconnect(AccountRecord{Index: 1}, errors.New("read tcp: connection reset by peer"))
summary := recorder.summarize(AgentSummary{}, nil)
if summary.Disconnects != 2 || summary.DisconnectReasons["abnormal_close"] != 1 ||
summary.DisconnectReasons["connection_reset"] != 1 || len(summary.DisconnectSamples) != 2 {
t.Fatalf("unexpected disconnect diagnostics: count=%d reasons=%v samples=%+v",
summary.Disconnects, summary.DisconnectReasons, summary.DisconnectSamples)
}
recorder.resetDisconnects()
summary = recorder.summarize(AgentSummary{}, nil)
if summary.Disconnects != 0 || len(summary.DisconnectReasons) != 0 || len(summary.DisconnectSamples) != 0 {
t.Fatalf("disconnect diagnostics were not reset: %+v", summary)
}
}
func TestBitsetCountCountsEachBitOnce(t *testing.T) {
if got := bitsetCount([]uint64{0b1011, 0b100000}); got != 4 {
t.Fatalf("bitset count=%d want=4", got)
}
}
func testData(message AcceptedMessage, sentAt time.Time) dataPacket { func testData(message AcceptedMessage, sentAt time.Time) dataPacket {
content, _ := json.Marshal(map[string]any{"run_id": message.RunID, "scenario": message.Scenario, content, _ := json.Marshal(map[string]any{"run_id": message.RunID, "scenario": message.Scenario,
"sender_machine": message.SenderMachine, "sender_index": message.SenderIndex, "sender_machine": message.SenderMachine, "sender_index": message.SenderIndex,
+132 -10
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -35,11 +36,42 @@ type dataPacket struct {
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
} }
type queuedDataPacket struct {
packet dataPacket
queuedAt time.Time
}
type ctrlWait struct {
ch chan ctrlPacket
kind string
sentAt time.Time
}
type timedOutCtrl struct {
kind string
sentAt time.Time
}
type ctrlObservation struct {
ID string
Kind string
Code int
SentAt time.Time
ReceivedAt time.Time
Latency time.Duration
Late bool
}
type ctrlError struct { type ctrlError struct {
Code int Code int
Text string Text string
} }
const (
dataCallbackQueueSize = 512
lateCtrlRetention = 10 * time.Minute
)
func (e *ctrlError) Error() string { func (e *ctrlError) Error() string {
return fmt.Sprintf("ctrl code=%d text=%s", e.Code, e.Text) return fmt.Sprintf("ctrl code=%d text=%s", e.Code, e.Text)
} }
@@ -50,17 +82,22 @@ type WSClient struct {
commandTimeout time.Duration commandTimeout time.Duration
onData func(AccountRecord, dataPacket) onData func(AccountRecord, dataPacket)
onDisconnect func(AccountRecord, error) onDisconnect func(AccountRecord, error)
onCtrlObserved func(AccountRecord, ctrlObservation)
callbackQueue *callbackQueueMetrics
sendMu sync.Mutex sendMu sync.Mutex
mu sync.Mutex mu sync.Mutex
seq uint64 seq uint64
waits map[string]chan ctrlPacket waits map[string]ctrlWait
timedOutCtrls map[string]timedOutCtrl
done chan struct{} done chan struct{}
data chan queuedDataPacket
closed atomic.Bool closed atomic.Bool
} }
func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, apiKey, password string, func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, apiKey, password string,
onData func(AccountRecord, dataPacket), onDisconnect func(AccountRecord, error)) (*WSClient, error) { onData func(AccountRecord, dataPacket), onDisconnect func(AccountRecord, error),
onCtrlObserved func(AccountRecord, ctrlObservation), callbackQueue *callbackQueueMetrics) (*WSClient, error) {
timeout, err := configDuration(cfg.CommandTimeout) timeout, err := configDuration(cfg.CommandTimeout)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -70,6 +107,17 @@ func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, ap
return nil, err return nil, err
} }
dialer := *websocket.DefaultDialer dialer := *websocket.DefaultDialer
if account.DialIP != "" {
targetHost := endpointHostname(endpoint)
var netDialer net.Dialer
dialer.NetDialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, splitErr := net.SplitHostPort(address)
if splitErr == nil && strings.EqualFold(host, targetHost) {
address = net.JoinHostPort(account.DialIP, port)
}
return netDialer.DialContext(ctx, network, address)
}
}
if cfg.InsecureTLS { if cfg.InsecureTLS {
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option. dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option.
} }
@@ -82,8 +130,11 @@ func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, ap
} }
client := &WSClient{ client := &WSClient{
account: account, conn: conn, commandTimeout: timeout, onData: onData, onDisconnect: onDisconnect, account: account, conn: conn, commandTimeout: timeout, onData: onData, onDisconnect: onDisconnect,
waits: make(map[string]chan ctrlPacket), done: make(chan struct{}), onCtrlObserved: onCtrlObserved,
callbackQueue: callbackQueue,
waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{}),
} }
client.startDataProcessor()
go client.readLoop() go client.readLoop()
if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{ if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{
"id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure/1.0", "id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure/1.0",
@@ -125,6 +176,14 @@ func connectWSClient(ctx context.Context, cfg *Config, account AccountRecord, ap
return client, nil return client, nil
} }
func endpointHostname(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Hostname()
}
func connectForPrepare(ctx context.Context, cfg *Config, account AccountRecord, api *apiClient, password, verificationCode string) (*WSClient, bool, error) { func connectForPrepare(ctx context.Context, cfg *Config, account AccountRecord, api *apiClient, password, verificationCode string) (*WSClient, bool, error) {
timeout, err := configDuration(cfg.CommandTimeout) timeout, err := configDuration(cfg.CommandTimeout)
if err != nil { if err != nil {
@@ -142,7 +201,8 @@ func connectForPrepare(ctx context.Context, cfg *Config, account AccountRecord,
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
client := &WSClient{account: account, conn: conn, commandTimeout: timeout, waits: make(map[string]chan ctrlPacket), done: make(chan struct{})} client := &WSClient{account: account, conn: conn, commandTimeout: timeout,
waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{})}
go client.readLoop() go client.readLoop()
if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{ if _, err = client.command(ctx, "hi", map[string]any{"hi": map[string]any{
"id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure-prepare/1.0", "tenant": cfg.Tenant, "id": client.nextID("hi"), "ver": "0.25", "ua": "im-pressure-prepare/1.0", "tenant": cfg.Tenant,
@@ -285,14 +345,15 @@ func ctrlInt64(raw json.RawMessage) (int64, error) {
return strconv.ParseInt(text, 10, 64) return strconv.ParseInt(text, 10, 64)
} }
func (c *WSClient) command(ctx context.Context, _ string, message map[string]any) (ctrlPacket, error) { func (c *WSClient) command(ctx context.Context, kind string, message map[string]any) (ctrlPacket, error) {
id := nestedMessageID(message) id := nestedMessageID(message)
if id == "" { if id == "" {
return ctrlPacket{}, errors.New("command id is required") return ctrlPacket{}, errors.New("command id is required")
} }
wait := make(chan ctrlPacket, 1) wait := make(chan ctrlPacket, 1)
sentAt := time.Now()
c.mu.Lock() c.mu.Lock()
c.waits[id] = wait c.waits[id] = ctrlWait{ch: wait, kind: kind, sentAt: sentAt}
c.mu.Unlock() c.mu.Unlock()
c.sendMu.Lock() c.sendMu.Lock()
err := c.conn.WriteJSON(message) err := c.conn.WriteJSON(message)
@@ -310,7 +371,33 @@ func (c *WSClient) command(ctx context.Context, _ string, message map[string]any
} }
return ctrl, nil return ctrl, nil
case <-timer.C: case <-timer.C:
c.removeWait(id) c.mu.Lock()
entry, pending := c.waits[id]
if pending {
delete(c.waits, id)
if entry.kind == "pub" {
c.timedOutCtrls[id] = timedOutCtrl{kind: entry.kind, sentAt: entry.sentAt}
}
}
c.mu.Unlock()
if !pending {
// The read loop won the timer race and placed the response in the
// buffered channel before removing the waiter.
ctrl := <-wait
if ctrl.Code < 200 || ctrl.Code >= 300 {
return ctrl, &ctrlError{Code: ctrl.Code, Text: ctrl.Text}
}
return ctrl, nil
}
if entry.kind == "pub" {
time.AfterFunc(lateCtrlRetention, func() {
c.mu.Lock()
if current, ok := c.timedOutCtrls[id]; ok && current.sentAt.Equal(sentAt) {
delete(c.timedOutCtrls, id)
}
c.mu.Unlock()
})
}
return ctrlPacket{}, fmt.Errorf("ctrl timeout id=%s", id) return ctrlPacket{}, fmt.Errorf("ctrl timeout id=%s", id)
case <-ctx.Done(): case <-ctx.Done():
c.removeWait(id) c.removeWait(id)
@@ -346,6 +433,9 @@ func (c *WSClient) removeWait(id string) {
func (c *WSClient) readLoop() { func (c *WSClient) readLoop() {
defer func() { defer func() {
_ = c.conn.Close() _ = c.conn.Close()
if c.data != nil {
close(c.data)
}
close(c.done) close(c.done)
}() }()
for { for {
@@ -360,11 +450,23 @@ func (c *WSClient) readLoop() {
var ctrl ctrlPacket var ctrl ctrlPacket
if json.Unmarshal(raw, &ctrl) == nil && ctrl.ID != "" { if json.Unmarshal(raw, &ctrl) == nil && ctrl.ID != "" {
c.mu.Lock() c.mu.Lock()
wait := c.waits[ctrl.ID] wait, pending := c.waits[ctrl.ID]
late, timedOut := c.timedOutCtrls[ctrl.ID]
receivedAt := time.Now()
if pending {
wait.ch <- ctrl
delete(c.waits, ctrl.ID) delete(c.waits, ctrl.ID)
} else if timedOut {
delete(c.timedOutCtrls, ctrl.ID)
}
c.mu.Unlock() c.mu.Unlock()
if wait != nil { if c.onCtrlObserved != nil && (pending && wait.kind == "pub" || timedOut && late.kind == "pub") {
wait <- ctrl kind, sentAt, isLate := wait.kind, wait.sentAt, false
if timedOut {
kind, sentAt, isLate = late.kind, late.sentAt, true
}
c.onCtrlObserved(c.account, ctrlObservation{ID: ctrl.ID, Kind: kind, Code: ctrl.Code,
SentAt: sentAt, ReceivedAt: receivedAt, Latency: receivedAt.Sub(sentAt), Late: isLate})
} }
} }
continue continue
@@ -372,10 +474,30 @@ func (c *WSClient) readLoop() {
if raw := envelope["data"]; len(raw) > 0 && c.onData != nil { if raw := envelope["data"]; len(raw) > 0 && c.onData != nil {
var data dataPacket var data dataPacket
if json.Unmarshal(raw, &data) == nil { if json.Unmarshal(raw, &data) == nil {
if c.data == nil {
c.onData(c.account, data) c.onData(c.account, data)
} else {
queued := queuedDataPacket{packet: data, queuedAt: time.Now()}
c.callbackQueue.enqueue()
c.data <- queued
} }
} }
} }
}
}
func (c *WSClient) startDataProcessor() {
if c.onData == nil || c.data != nil {
return
}
c.data = make(chan queuedDataPacket, dataCallbackQueueSize)
go func() {
for data := range c.data {
c.callbackQueue.startCallback(data.queuedAt)
c.onData(c.account, data.packet)
c.callbackQueue.finishCallback()
}
}()
} }
func (c *WSClient) startKeepalive(interval time.Duration) { func (c *WSClient) startKeepalive(interval time.Duration) {
+101 -2
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -10,6 +11,49 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
func TestWSClientObservesCtrlAfterCommandTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var command map[string]any
if err = conn.ReadJSON(&command); err != nil {
return
}
time.Sleep(40 * time.Millisecond)
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": "pub-a-0001", "code": 202, "text": "accepted"}})
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
observed := make(chan ctrlObservation, 1)
client := &WSClient{
account: AccountRecord{Machine: "a", Index: 1}, conn: conn, commandTimeout: 10 * time.Millisecond,
waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{}),
onCtrlObserved: func(_ AccountRecord, observation ctrlObservation) { observed <- observation },
}
go client.readLoop()
defer client.Close()
_, err = client.command(context.Background(), "pub", map[string]any{"pub": map[string]any{"id": "pub-a-0001"}})
if err == nil || !strings.Contains(err.Error(), "ctrl timeout") {
t.Fatalf("command error=%v, want ctrl timeout", err)
}
select {
case observation := <-observed:
if !observation.Late || observation.Kind != "pub" || observation.Code != 202 || observation.Latency < 10*time.Millisecond {
t.Fatalf("unexpected observation: %+v", observation)
}
case <-time.After(time.Second):
t.Fatal("late ctrl response was not observed")
}
}
func TestWSClientKeepaliveSendsPing(t *testing.T) { func TestWSClientKeepaliveSendsPing(t *testing.T) {
ping := make(chan struct{}, 1) ping := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -37,7 +81,7 @@ func TestWSClientKeepaliveSendsPing(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := &WSClient{conn: conn, waits: make(map[string]chan ctrlPacket), done: make(chan struct{})} client := &WSClient{conn: conn, waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{})}
go client.readLoop() go client.readLoop()
client.startKeepalive(10 * time.Millisecond) client.startKeepalive(10 * time.Millisecond)
defer client.Close() defer client.Close()
@@ -70,7 +114,7 @@ func TestWSClientReadLoopClosesConnectionAfterDecodeError(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
client := &WSClient{conn: conn, waits: make(map[string]chan ctrlPacket), done: make(chan struct{})} client := &WSClient{conn: conn, waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{})}
go client.readLoop() go client.readLoop()
select { select {
@@ -84,3 +128,58 @@ func TestWSClientReadLoopClosesConnectionAfterDecodeError(t *testing.T) {
t.Fatal("read loop exited without closing the underlying connection") t.Fatal("read loop exited without closing the underlying connection")
} }
} }
func TestWSClientDataCallbackDoesNotBlockPong(t *testing.T) {
pong := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetPongHandler(func(string) error {
select {
case pong <- struct{}{}:
default:
}
return nil
})
if err = conn.WriteJSON(map[string]any{"data": map[string]any{
"topic": "grp-test", "seq": 1, "head": map[string]any{}, "content": map[string]any{},
}}); err != nil {
return
}
if err = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil {
return
}
conn.SetReadDeadline(time.Now().Add(time.Second))
for {
if _, _, readErr := conn.ReadMessage(); readErr != nil {
return
}
}
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
blocked := make(chan struct{})
client := &WSClient{
conn: conn, waits: make(map[string]ctrlWait), timedOutCtrls: make(map[string]timedOutCtrl), done: make(chan struct{}),
onData: func(AccountRecord, dataPacket) { <-blocked },
}
client.startDataProcessor()
go client.readLoop()
defer func() {
close(blocked)
client.Close()
}()
select {
case <-pong:
case <-time.After(2 * time.Second):
t.Fatal("pong was blocked by the data callback")
}
}
+1 -1
View File
@@ -467,7 +467,7 @@ CREATE TABLE IF NOT EXISTS `im_filemsglinks` (
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`createdat` datetime(3) NOT NULL, `createdat` datetime(3) NOT NULL,
`fileid` bigint NOT NULL, `fileid` bigint NOT NULL,
`msgid` int DEFAULT NULL, `msgid` bigint DEFAULT NULL,
`topic` char(25) DEFAULT NULL, `topic` char(25) DEFAULT NULL,
`userid` bigint DEFAULT NULL, `userid` bigint DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
+2 -1
View File
@@ -276,8 +276,9 @@ func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenant
return &result, nil 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 := url.Values{}
query.Set("app_code", strings.ToLower(strings.TrimSpace(appCode)))
query.Set("platform", strings.ToLower(strings.TrimSpace(platform))) query.Set("platform", strings.ToLower(strings.TrimSpace(platform)))
query.Set("channel", strings.ToLower(strings.TrimSpace(channel))) query.Set("channel", strings.ToLower(strings.TrimSpace(channel)))
var dto struct { 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"}, config: adminInternalAPIConfig{BaseURL: server.URL, ServiceName: "im-server", Secret: "test-secret"},
http: server.Client(), http: server.Client(),
} }
release, err := client.LatestAppRelease(" IOS ", "STABLE") release, err := client.LatestAppRelease(" IM ", " IOS ", "STABLE")
if err != nil { if err != nil {
t.Fatal(err) 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) { 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) 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)) query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1))
+32 -8
View File
@@ -284,6 +284,9 @@ type ClusterResp struct {
// Original request type. // Original request type.
OrigReqType ProxyReqType OrigReqType ProxyReqType
// Set after RPC decoding on the receiving node; not serialized by gob.
proxyQueuedAt time.Time
} }
// ClusterPing is used to detect node restarts. // ClusterPing is used to detect node restarts.
@@ -489,14 +492,16 @@ func clusterReqShard(msg *ClusterReq, count int) int {
return int(h.Sum32() % uint32(count)) return int(h.Sum32() % uint32(count))
} }
// masterToProxyAsync forwards response from topic master to topic proxy // masterToProxy forwards responses synchronously. A multiplexing session drains
// in a fire-and-forget manner. // one topic in order, so waiting for each RPC prevents the RPC server from
func (n *ClusterNode) masterToProxyAsync(msg *ClusterResp) error { // concurrently enqueueing later sequence IDs before earlier ones.
var unused bool func (n *ClusterNode) masterToProxy(msg *ClusterResp) error {
if c := n.callAsync("Cluster.TopicProxy", msg, &unused, nil); c.Error != nil { var rejected bool
return c.Error err := n.call("Cluster.TopicProxy", msg, &rejected)
if err == nil && rejected {
return errClusterOverloaded
} }
return nil return err
} }
// route routes server message within the cluster. // route routes server message within the cluster.
@@ -741,6 +746,7 @@ func (Cluster) TopicProxy(msg *ClusterResp, unused *bool) error {
// Find appropriate topic, send the message to it. // Find appropriate topic, send the message to it.
if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil { if t := globals.hub.topicGet(types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}); t != nil {
msg.SrvMsg.uid = types.ParseUserId(msg.SrvMsg.AsUser) msg.SrvMsg.uid = types.ParseUserId(msg.SrvMsg.AsUser)
msg.proxyQueuedAt = time.Now()
select { select {
case t.proxy <- msg: case t.proxy <- msg:
immetrics.ObserveInternalQueueEnqueue("cluster_topic_proxy", len(t.proxy), cap(t.proxy), true) immetrics.ObserveInternalQueueEnqueue("cluster_topic_proxy", len(t.proxy), cap(t.proxy), true)
@@ -1215,7 +1221,10 @@ func clusterInit(configString json.RawMessage, self *string) int {
func registerClusterGobTypes() { func registerClusterGobTypes() {
gob.Register([]any{}) gob.Register([]any{})
gob.Register([]map[string]any{})
gob.Register(map[string]any{}) gob.Register(map[string]any{})
gob.Register(types.KVMap{})
gob.Register([]types.KVMap{})
gob.Register(map[string]int{}) gob.Register(map[string]int{})
gob.Register(map[string]string{}) gob.Register(map[string]string{})
gob.Register(MsgAccessMode{}) gob.Register(MsgAccessMode{})
@@ -1393,6 +1402,13 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
terminate := true terminate := true
defer func() { defer func() {
if terminate { if terminate {
// The topic may retain this multiplexing session briefly while its
// unsubscribe request is processed. Reject writes to the stale session
// instead of filling a send queue which no longer has a drain owner.
sess.clusterQueueMu.Lock()
atomic.StoreInt32(&sess.terminating, 1)
sess.purgeChannels()
sess.clusterQueueMu.Unlock()
sess.closeRPC() sess.closeRPC()
globals.sessionStore.Delete(sess) globals.sessionStore.Delete(sess)
sess.inflightReqs = nil sess.inflightReqs = nil
@@ -1408,6 +1424,8 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
return return
} }
srvMsg := msg.(*ServerComMessage) srvMsg := msg.(*ServerComMessage)
immetrics.ObserveClusterM2PDequeue(sess.clnode.name, srvMsg.clusterQueuedAt)
srvMsg.clusterQueuedAt = time.Time{}
response := &ClusterResp{TenantID: srvMsg.TenantID, SrvMsg: srvMsg} response := &ClusterResp{TenantID: srvMsg.TenantID, SrvMsg: srvMsg}
if srvMsg.sess == nil { if srvMsg.sess == nil {
response.OrigSid = "*" response.OrigSid = "*"
@@ -1434,7 +1452,7 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
srvMsg.RcptTo = forTopic srvMsg.RcptTo = forTopic
response.RcptTo = forTopic response.RcptTo = forTopic
if err := sess.clnode.masterToProxyAsync(response); err != nil { if err := sess.clnode.masterToProxy(response); err != nil {
logs.Warn.Printf("cluster: response to proxy failed \"%s\": %s", sess.sid, err.Error()) logs.Warn.Printf("cluster: response to proxy failed \"%s\": %s", sess.sid, err.Error())
return return
} }
@@ -1451,6 +1469,12 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
case <-sess.detach: case <-sess.detach:
return return
default: default:
sess.clusterWriteScheduled.Store(false)
// An enqueue can race with releasing ownership. Either the enqueuer
// schedules the next drain or this check does; the CAS prevents both.
if len(sess.send) > 0 {
sess.scheduleClusterWriteLoop()
}
terminate = false terminate = false
return return
} }
+3
View File
@@ -28,6 +28,9 @@ func TestClusterGobRegistersSystemMessagePayloadTypes(t *testing.T) {
Content: map[string]any{ Content: map[string]any{
"text": "成员加入了群聊", "text": "成员加入了群聊",
"template": "group_member_joined", "template": "group_member_joined",
"items": []map[string]any{{"type": "member", "id": "usr123"}},
"action": types.KVMap{"type": "group_profile", "target_id": "grpTest"},
"rows": []types.KVMap{{"type": "member", "id": "usr123"}},
"actors": []imSystemMessageActor{{ "actors": []imSystemMessageActor{{
UserID: "usr123", UserID: "usr123",
Click: map[string]any{"type": "user_profile", "target_id": "usr123"}, Click: map[string]any{"type": "user_profile", "target_id": "usr123"},
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"net"
"net/rpc"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/tinode/chat/server/concurrency"
"github.com/tinode/chat/server/store/types"
)
type orderedTopicProxy struct {
mu sync.Mutex
received []int64
done chan struct{}
}
func (proxy *orderedTopicProxy) TopicProxy(msg *ClusterResp, rejected *bool) error {
if msg.SrvMsg.Data.SeqId == 1 {
time.Sleep(50 * time.Millisecond)
}
proxy.mu.Lock()
proxy.received = append(proxy.received, msg.SrvMsg.Data.SeqId)
if len(proxy.received) == 2 {
close(proxy.done)
}
proxy.mu.Unlock()
return nil
}
func TestClusterWriteLoopPreservesTopicOrder(t *testing.T) {
serverConn, clientConn := net.Pipe()
rpcServer := rpc.NewServer()
proxy := &orderedTopicProxy{done: make(chan struct{})}
if err := rpcServer.RegisterName("Cluster", proxy); err != nil {
t.Fatal(err)
}
go rpcServer.ServeConn(serverConn)
pool := concurrency.NewGoRoutinePool(4)
previousCluster := globals.cluster
globals.cluster = &Cluster{proxyEventQueue: pool}
t.Cleanup(func() {
globals.cluster = previousCluster
_ = clientConn.Close()
_ = serverConn.Close()
pool.Stop()
})
sess := &Session{
sid: "multiplex-order-test",
proto: MULTIPLEX,
proxiedTopic: "p2p-order-test",
send: make(chan any, 2),
clnode: &ClusterNode{endpoint: rpc.NewClient(clientConn), connected: true},
}
tenantID := types.TenantID(1)
sess.send <- &ServerComMessage{TenantID: tenantID, Data: &MsgServerData{SeqId: 1}}
sess.send <- &ServerComMessage{TenantID: tenantID, Data: &MsgServerData{SeqId: 2}}
// Scheduling twice used to start two concurrent drains for the same topic.
sess.scheduleClusterWriteLoop()
sess.scheduleClusterWriteLoop()
select {
case <-proxy.done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for cluster responses")
}
deadline := time.Now().Add(2 * time.Second)
for sess.clusterWriteScheduled.Load() && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if sess.clusterWriteScheduled.Load() {
t.Fatal("cluster write task did not release session ownership")
}
proxy.mu.Lock()
defer proxy.mu.Unlock()
if len(proxy.received) != 2 || proxy.received[0] != 1 || proxy.received[1] != 2 {
t.Fatalf("cluster responses arrived out of order: %v", proxy.received)
}
}
func TestClusterWriteLoopRejectsWritesAfterTermination(t *testing.T) {
previousSessionStore := globals.sessionStore
globals.sessionStore = NewSessionStore(time.Minute)
t.Cleanup(func() { globals.sessionStore = previousSessionStore })
sess := &Session{
sid: "multiplex-termination-test",
proto: MULTIPLEX,
send: make(chan any, 1),
stop: make(chan any, 1),
detach: make(chan string, 1),
subs: make(map[string]*Subscription),
}
sess.stop <- nil
sess.clusterWriteLoop("grp-termination-test")
if atomic.LoadInt32(&sess.terminating) == 0 {
t.Fatal("terminated multiplexing session still accepts writes")
}
if !sess.queueOut(&ServerComMessage{TenantID: types.TenantID(1)}) {
t.Fatal("write to terminated multiplexing session should be discarded")
}
if len(sess.send) != 0 {
t.Fatalf("terminated multiplexing session retained %d outbound messages", len(sess.send))
}
}
func TestMultiplexEnqueueWaitsForDrain(t *testing.T) {
sess := &Session{proto: MULTIPLEX, send: make(chan any, 1)}
sess.send <- "existing"
go func() {
time.Sleep(10 * time.Millisecond)
<-sess.send
}()
payload := &ServerComMessage{TenantID: types.TenantID(1)}
if !sess.enqueueMultiplexOutbound(payload, 200*time.Millisecond) {
t.Fatal("multiplex enqueue failed while the drain was making progress")
}
if got := <-sess.send; got != payload {
t.Fatalf("unexpected queued payload: %#v", got)
}
}
func TestMultiplexEnqueueTimesOutWithoutDrain(t *testing.T) {
sess := &Session{proto: MULTIPLEX, send: make(chan any, 1)}
sess.send <- "existing"
started := time.Now()
if sess.enqueueMultiplexOutbound("next", 20*time.Millisecond) {
t.Fatal("multiplex enqueue succeeded without queue capacity")
}
if elapsed := time.Since(started); elapsed < 15*time.Millisecond || elapsed > 200*time.Millisecond {
t.Fatalf("multiplex enqueue timeout outside expected bounds: %s", elapsed)
}
}
func TestMultiplexEnqueueStopsWhenSessionTerminates(t *testing.T) {
sess := &Session{proto: MULTIPLEX, send: make(chan any, 1)}
sess.send <- "existing"
go func() {
time.Sleep(10 * time.Millisecond)
sess.clusterQueueMu.Lock()
atomic.StoreInt32(&sess.terminating, 1)
<-sess.send
sess.clusterQueueMu.Unlock()
}()
if !sess.enqueueMultiplexOutbound("next", 200*time.Millisecond) {
t.Fatal("terminating multiplex session should discard the pending write")
}
if len(sess.send) != 0 {
t.Fatalf("terminating multiplex session retained %d outbound messages", len(sess.send))
}
}
+5
View File
@@ -382,6 +382,8 @@ type ClientComMessage struct {
MetaWhat int `json:"-"` MetaWhat int `json:"-"`
// Timestamp when this message was received by the server. // Timestamp when this message was received by the server.
Timestamp time.Time `json:"-"` Timestamp time.Time `json:"-"`
// Timestamp when this message entered the current topic client queue.
queuedAt time.Time
// Tenant context inherited from the originating session. // Tenant context inherited from the originating session.
TenantID types.TenantID `json:"-"` TenantID types.TenantID `json:"-"`
@@ -879,6 +881,8 @@ type ServerComMessage struct {
PreserveTopicName bool `json:"-"` PreserveTopicName bool `json:"-"`
// User id affected by this message. // User id affected by this message.
uid types.Uid 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, // Deep-shallow copy of ServerComMessage. Deep copy of service fields,
@@ -897,6 +901,7 @@ func (src *ServerComMessage) copy() *ServerComMessage {
SkipSid: src.SkipSid, SkipSid: src.SkipSid,
PreserveTopicName: src.PreserveTopicName, PreserveTopicName: src.PreserveTopicName,
uid: src.uid, uid: src.uid,
clusterQueuedAt: src.clusterQueuedAt,
} }
dst.Ctrl = src.Ctrl.copy() dst.Ctrl = src.Ctrl.copy()
+2
View File
@@ -405,7 +405,9 @@ type TenantBusinessAdapter interface {
TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error) TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error)
TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error) TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error)
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, 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) 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) TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
TenantGroupInvitationAccept(t.TenantID, string, t.Uid, any, time.Time) (*t.GroupInvitation, error) TenantGroupInvitationAccept(t.TenantID, string, t.Uid, any, time.Time) (*t.GroupInvitation, error)
TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error) TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error)
+39 -7
View File
@@ -4407,10 +4407,10 @@ func (a *adapter) FileStartUpload(fd *t.FileDef) error {
user = 0 user = 0
} }
_, err := a.db.ExecContext(ctx, _, err := a.db.ExecContext(ctx,
"INSERT INTO im_fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+ "INSERT INTO im_fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location,original_name) "+
"VALUES(?,?,?,?,?,?,?,?,?)", "VALUES(?,?,?,?,?,?,?,?,?,?)",
store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user,
fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location, strings.TrimSpace(fd.OriginalName))
return err return err
} }
@@ -4432,8 +4432,8 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.
now := t.TimeNow() now := t.TimeNow()
if success { if success {
_, err = tx.ExecContext(ctx, "UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE id=?", _, err = tx.ExecContext(ctx, "UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=?,original_name=? WHERE id=?",
now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid())) now, t.UploadCompleted, size, fd.ETag, fd.Location, strings.TrimSpace(fd.OriginalName), store.DecodeUid(fd.Uid()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -4471,11 +4471,11 @@ func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
defer cancel() defer cancel()
} }
var fd t.FileDef var fd t.FileDef
err := a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ err := a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location,IFNULL(original_name,'') AS original_name "+
"FROM im_fileuploads WHERE id=?", store.DecodeUid(id)) "FROM im_fileuploads WHERE id=?", store.DecodeUid(id))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
storageName := id.String32() storageName := id.String32()
err = a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ err = a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location,IFNULL(original_name,'') AS original_name "+
"FROM im_fileuploads WHERE location=? OR location LIKE ? ORDER BY createdat DESC LIMIT 1", storageName, "%/"+storageName) "FROM im_fileuploads WHERE location=? OR location LIKE ? ORDER BY createdat DESC LIMIT 1", storageName, "%/"+storageName)
} }
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -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' 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 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 ) LIMIT 1`, args
} }
run := func() error { run := func() error {
@@ -4836,6 +4847,27 @@ func isDupe(err error) bool {
return ok && myerr.Number == 1062 return ok && myerr.Number == 1062
} }
func isInvalidConnection(err error) bool {
return errors.Is(err, ms.ErrInvalidConn)
}
// retryInvalidConnection retries only operations which are independently
// idempotent. Callers must not use it for unconstrained writes.
func retryInvalidConnection(operation string, fn func() error) error {
err := fn()
if !isInvalidConnection(err) {
return err
}
immetrics.IncDBInvalidConnectionRetry(operation, "attempt")
err = fn()
if err == nil || isDupe(err) {
immetrics.IncDBInvalidConnectionRetry(operation, "recovered")
} else {
immetrics.IncDBInvalidConnectionRetry(operation, "failed")
}
return err
}
func isMissingTable(err error) bool { func isMissingTable(err error) bool {
if err == nil { if err == nil {
return false return false
+6 -1
View File
@@ -6,6 +6,7 @@ package mysql
import ( import (
"context" "context"
"database/sql" "database/sql"
"strings"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types" 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 { func insertFileReviewTasks(ctx context.Context, exec fileReviewExecContext, fd *t.FileDef) error {
storageProvider := ""
if strings.HasPrefix(fd.Location, "minio://") {
storageProvider = "minio"
}
fileID := decodeUID(fd.Uid()) fileID := decodeUID(fd.Uid())
userID := int64(0) userID := int64(0)
if fd.User != "" { 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 _, 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) (id,task_id,file_id,user_id,task_type,provider,storage_provider,object_name,mime_type,file_size,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, 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) t.FileReviewStatusPending, fd.UpdatedAt, fd.UpdatedAt)
if err != nil { if err != nil {
return err return err
+42
View File
@@ -4,6 +4,7 @@ package mysql
import ( import (
"database/sql" "database/sql"
"os"
"strings" "strings"
"testing" "testing"
@@ -150,3 +151,44 @@ func TestTenantShardSQL(t *testing.T) {
t.Fatalf("tenant shard query rewrote index name: %s", query) t.Fatalf("tenant shard query rewrote index name: %s", query)
} }
} }
func TestSharedDBGroupFileLinkIncludesTenantID(t *testing.T) {
source, err := os.ReadFile("tenant_groups.go")
if err != nil {
t.Fatal(err)
}
body := sourceBetween(t, string(source), "func (a *adapter) TenantGroupFileCreate", "\nfunc (a *adapter) enrichGroupFileFromUpload")
for _, required := range []string{
"INSERT INTO im_filemsglinks(tenant_id,createdat,fileid,topic)",
"SELECT ?,?,?,? WHERE NOT EXISTS",
"SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)",
} {
if !strings.Contains(body, required) {
t.Fatalf("TenantGroupFileCreate shared-db file link must include tenant_id; missing %q", required)
}
}
if strings.Contains(body, "INSERT INTO im_filemsglinks(createdat,fileid,topic)") {
t.Fatal("TenantGroupFileCreate must not rely on im_filemsglinks.tenant_id default")
}
}
func TestTenantUsersShareOpenPrivateGroupChecksBothMembersAndSetting(t *testing.T) {
source, err := os.ReadFile("tenant_groups.go")
if err != nil {
t.Fatal(err)
}
body := sourceBetween(t, string(source), "func (a *adapter) TenantUsersShareOpenPrivateGroup", "\nfunc (a *adapter) TenantGroupMemberUpdate")
for _, required := range []string{
"INNER JOIN im_group_members AS gm2",
"gm2.topic=gm1.topic",
"INNER JOIN im_group_settings AS gs",
"gs.member_private_chat=1",
"gm1.member_status=?",
"gm2.member_status=?",
"gs.group_status=?",
} {
if !strings.Contains(body, required) {
t.Fatalf("TenantUsersShareOpenPrivateGroup must check active shared group and setting; missing %q", required)
}
}
}
@@ -0,0 +1,55 @@
//go:build mysql
package mysql
import (
"context"
"database/sql/driver"
"errors"
"testing"
ms "github.com/go-sql-driver/mysql"
)
func TestRetryInvalidConnectionRetriesOnce(t *testing.T) {
attempts := 0
err := retryInvalidConnection("test", func() error {
attempts++
if attempts == 1 {
return ms.ErrInvalidConn
}
return nil
})
if err != nil || attempts != 2 {
t.Fatalf("err=%v attempts=%d", err, attempts)
}
}
func TestRetryInvalidConnectionDoesNotRetryOtherErrors(t *testing.T) {
want := errors.New("write failed")
attempts := 0
err := retryInvalidConnection("test", func() error {
attempts++
return want
})
if !errors.Is(err, want) || attempts != 1 {
t.Fatalf("err=%v attempts=%d", err, attempts)
}
}
func TestInstrumentedQueryDiscardsInvalidConnection(t *testing.T) {
conn := instrumentedConn{conn: invalidQueryConn{}}
_, err := conn.QueryContext(context.Background(), "SELECT 1", nil)
if !errors.Is(err, driver.ErrBadConn) {
t.Fatalf("err=%v want=%v", err, driver.ErrBadConn)
}
}
type invalidQueryConn struct{}
func (invalidQueryConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
func (invalidQueryConn) Close() error { return nil }
func (invalidQueryConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (invalidQueryConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
return nil, ms.ErrInvalidConn
}
+10
View File
@@ -113,6 +113,12 @@ func (c instrumentedConn) QueryContext(ctx context.Context, query string, args [
started := time.Now() started := time.Now()
rows, err := queryer.QueryContext(ctx, query, args) rows, err := queryer.QueryContext(ctx, query, args)
immetrics.ObserveDB("mysql", "query", started, err) immetrics.ObserveDB("mysql", "query", started, err)
if isInvalidConnection(err) {
// Queries are idempotent. Returning ErrBadConn makes database/sql discard
// this connection and transparently retry on a fresh one.
immetrics.IncDBInvalidConnectionRetry("query", "discard")
return nil, driver.ErrBadConn
}
return rows, err return rows, err
} }
@@ -211,6 +217,10 @@ func (s instrumentedStmt) QueryContext(ctx context.Context, args []driver.NamedV
started := time.Now() started := time.Now()
rows, err := queryer.QueryContext(ctx, args) rows, err := queryer.QueryContext(ctx, args)
immetrics.ObserveDB("mysql", "query", started, err) immetrics.ObserveDB("mysql", "query", started, err)
if isInvalidConnection(err) {
immetrics.IncDBInvalidConnectionRetry("prepared_query", "discard")
return nil, driver.ErrBadConn
}
return rows, err return rows, err
} }
+139 -42
View File
@@ -2233,75 +2233,146 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
if a.tenantScopedDB { if a.tenantScopedDB {
return a.MessageSave(msg) return a.MessageSave(msg)
} }
save := func() error {
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, err := a.db.ExecContext(ctx, res, saveErr := a.db.ExecContext(ctx,
fmt.Sprintf("INSERT INTO %s(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", tenantMessagesTable(id)), fmt.Sprintf("INSERT INTO %s(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", tenantMessagesTable(id)),
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion) id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
if isDupe(err) { if saveErr == nil {
return t.ErrDuplicate
}
if err == nil {
lastID, _ := res.LastInsertId() lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID)) msg.SetUid(t.Uid(lastID))
} }
return saveErr
}
if msg.ClientMsgID == "" {
err = save()
} else {
err = retryInvalidConnection("tenant_message_save", save)
}
if isDupe(err) {
return t.ErrDuplicate
}
return err 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 { func (a *adapter) TenantMessageSchemaValidate(tenantIDs []t.TenantID) error {
if len(tenantIDs) == 0 { if len(tenantIDs) == 0 {
return errors.New("mysql: no active tenants available for message schema validation") 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{}) 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 { for _, tenantID := range tenantIDs {
tenantAdp, err := a.routedTenantAdapter(tenantID) tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil { if err != nil {
return fmt.Errorf("mysql: resolve tenant %d for message schema validation: %w", tenantID, err) return fmt.Errorf("mysql: resolve tenant %d for message schema validation: %w", tenantID, err)
} }
table := tenantMessagesTable(tenantID) for _, chk := range perTenant(tenantID, tenantAdp.tenantScopedDB) {
if tenantAdp.tenantScopedDB { if err = run(tenantAdp, chk); err != nil {
table = "im_messages"
}
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 return err
} }
validated[tenantAdp][table] = struct{}{} }
} }
if !a.tenantScopedDB { if !a.tenantScopedDB {
if err := a.validateMessageIDColumn("im_messages"); err != nil { for _, chk := range perTenant(0, true) {
if err := run(a, chk); err != nil {
return err return err
} }
} }
}
return nil return nil
} }
func (a *adapter) validateMessageIDColumn(table string) error { // validateColumnContract fails when the live column shape does not match the
if !regexp.MustCompile(`^im_(?:[0-9]+_)?messages$`).MatchString(table) { // contract. The caller turns the error into a fatal startup failure so a
return fmt.Errorf("mysql: unsafe message table name %q", table) // 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 var dataType, nullable, extra string
err := a.db.QueryRow(`SELECT DATA_TYPE,IS_NULLABLE,EXTRA FROM information_schema.COLUMNS 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) Scan(&dataType, &nullable, &extra)
if err == sql.ErrNoRows { 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 { 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") || if !strings.EqualFold(dataType, want.dataType) || want.notNull != strings.EqualFold(nullable, "NO") ||
!strings.Contains(strings.ToLower(extra), "auto_increment") { (want.autoInc && !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", return fmt.Errorf("mysql: incompatible %s.%s schema: type=%s nullable=%s extra=%s; want %s "+
table, dataType, nullable, extra) "(apply scripts/IM-租户业务库初始化.sql equivalents or the matching BIGINT migration before starting)",
table, column, dataType, nullable, extra, want.describe())
} }
return nil return nil
} }
@@ -2349,7 +2420,17 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.TenantGroupMessageSave(id, msg) return tenantAdp.TenantGroupMessageSave(id, msg)
} }
if err = a.ensureCurrentGroupMessageShardWindow(); err != nil { err = retryInvalidConnection("tenant_group_message_save", func() error {
return a.tenantGroupMessageSaveOnce(id, msg)
})
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) tenantGroupMessageSaveOnce(id t.TenantID, msg *t.Message) error {
if err := a.ensureCurrentGroupMessageShardWindow(); err != nil {
return err return err
} }
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
@@ -2398,9 +2479,6 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
id, messageID, msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal", id, messageID, msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"), contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt, msg.ContentNonce, msg.KeyVersion) 0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt, msg.ContentNonce, msg.KeyVersion)
if isDupe(err) {
return t.ErrDuplicate
}
if err != nil { if err != nil {
return err return err
} }
@@ -5200,8 +5278,8 @@ func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error {
defer cancel() defer cancel()
} }
_, err = a.db.ExecContext(ctx, _, err = a.db.ExecContext(ctx,
"INSERT INTO im_fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)", "INSERT INTO im_fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location,original_name) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location, strings.TrimSpace(fd.OriginalName))
return err return err
} }
@@ -5232,8 +5310,8 @@ func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success b
}() }()
now := t.TimeNow() now := t.TimeNow()
if success { if success {
_, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?", _, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=?,original_name=? WHERE tenant_id=? AND id=?",
now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid())) now, t.UploadCompleted, size, fd.ETag, fd.Location, strings.TrimSpace(fd.OriginalName), id, decodeUID(fd.Uid()))
if err == nil { if err == nil {
fd.UpdatedAt = now fd.UpdatedAt = now
fd.Size = size fd.Size = size
@@ -5274,12 +5352,12 @@ func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) {
} }
var fd t.FileDef var fd t.FileDef
err = a.db.GetContext(ctx, &fd, err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ "SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location,IFNULL(original_name,'') AS original_name "+
"FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID)) "FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
storageName := fileID.String32() storageName := fileID.String32()
err = a.db.GetContext(ctx, &fd, err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ "SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location,IFNULL(original_name,'') AS original_name "+
"FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1", "FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1",
id, storageName, "%/"+storageName) id, storageName, "%/"+storageName)
} }
@@ -5507,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' 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 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))), ) LIMIT 1`, tenantFriendshipsTable(id), tenantMessagesTable(id))),
id, decodeUID(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), t.RelationshipFriend, 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), decodeUID(uid), decodeUID(uid), t.GroupMemberStatusActive, decodeUID(uid), decodeUID(uid),
@@ -6872,7 +6961,7 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
eventType := "friend.request." + t.FriendRequestAccepted eventType := "friend.request." + t.FriendRequestAccepted
applicantEventID := t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted, eventType, applicant.UserId()) applicantEventID := t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted, eventType, applicant.UserId())
if err = insertSystemNotificationTx(tx, id, applicant, t.StableEventID(applicantEventID, "notification"), eventType, 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 return nil, err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
@@ -6899,8 +6988,12 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac
defer cancel() defer cancel()
} }
req, err := a.friendRequestByRequestID(ctx, id, requestID) req, err := a.friendRequestByRequestID(ctx, id, requestID)
if err != nil || req == nil { if err != nil {
return req, err return nil, err
}
if req == nil {
// 好友申请不存在必须返回 ErrNotFound,否则上层拿到 (nil,nil) 会解引用 panic(网关 502)。
return nil, t.ErrNotFound
} }
if req.TargetID != actor { if req.TargetID != actor {
return nil, t.ErrPermissionDenied return nil, t.ErrPermissionDenied
@@ -6920,8 +7013,12 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac
} }
eventType := "friend.request." + status eventType := "friend.request." + status
eventID := t.StableEventID("relation", req.RequestID+":"+status, eventType, req.ApplicantID.UserId()) 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, 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 return nil, err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
+95 -12
View File
@@ -394,6 +394,83 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
return items, next, rows.Err() 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
} else if tenantAdp != a {
return tenantAdp.TenantUsersShareOpenPrivateGroup(id, userID, peerID)
}
if userID.IsZero() || peerID.IsZero() || userID == peerID {
return false, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var exists int
err := a.db.GetContext(ctx, &exists, tenantShardSQL(id, `
SELECT 1
FROM im_group_members AS gm1
INNER JOIN im_group_members AS gm2
ON gm2.tenant_id=gm1.tenant_id AND gm2.topic=gm1.topic
INNER JOIN im_group_settings AS gs
ON gs.tenant_id=gm1.tenant_id AND gs.topic=gm1.topic
WHERE gm1.tenant_id=? AND gm1.user_id=? AND gm1.member_status=?
AND gm2.user_id=? AND gm2.member_status=?
AND gs.group_status=? AND gs.member_private_chat=1
LIMIT 1`),
id, decodeUID(userID), t.GroupMemberStatusActive,
decodeUID(peerID), t.GroupMemberStatusActive, t.GroupStatusActive)
if err == sql.ErrNoRows {
return false, nil
}
return exists == 1, err
}
func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid, patch t.GroupMemberPatch) (*t.GroupMember, error) { func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid, patch t.GroupMemberPatch) (*t.GroupMember, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil { if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err return nil, err
@@ -1132,10 +1209,10 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`, SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`,
id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic) id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic)
} else { } else {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(createdat,fileid,topic) _, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(tenant_id,createdat,fileid,topic)
SELECT ?,?,? WHERE NOT EXISTS ( SELECT ?,?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE fileid=? AND topic=?)`, SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`,
now, decodeUID(fid), file.Topic, decodeUID(fid), file.Topic) id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1478,27 +1555,33 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
return tx.Commit() 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 { if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err return false, err
} else if tenantAdp != a { } else if tenantAdp != a {
return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now) return tenantAdp.TenantGroupCanPost(id, topic, uid, false, now)
}
if admin {
return true, nil
} }
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
var role string
var muteAll, memberMuted bool 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), 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>?))`, 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) id, topic, decodeUID(uid), t.GroupMemberStatusActive,
id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&role, &muteAll, &memberMuted)
if err != nil { if err != nil {
return false, err return false, err
} }
if role == t.GroupMemberRoleOwner || role == t.GroupMemberRoleAdmin {
return true, nil
}
if role != t.GroupMemberRoleMember {
return false, nil
}
return !muteAll && !memberMuted, 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"])) avatar := strings.TrimSpace(stringFromAny(data["avatar_file_id"]))
if isAvatarURLValue(avatar) { if isAvatarURLValue(avatar) {
data["avatar_url"] = 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. // Preflight request: process before any security checks.
if req.Method == http.MethodOptions { 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 { if err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err) writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return return
@@ -239,6 +239,9 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
} }
msgID := req.FormValue("id") 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 // Check authorization: either auth information or SID must be present
authMethod, secret := getHttpAuth(req) authMethod, secret := getHttpAuth(req)
principal, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(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) writeHttpResponse(InfoChallenge(msgID, now, challenge), nil)
return return
} }
if principal.UID.IsZero() && req.FormValue("topic") != "newacc" { if principal.UID.IsZero() && uploadTopic != "newacc" {
// Not authenticated and not signup. // Not authenticated and not signup.
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil) writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
return 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. // Check if uploads are handled elsewhere.
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true) headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
if err != nil { 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) 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()} params := map[string]any{"url": url, "file_id": fdef.Uid().String()}
if globals.mediaGcPeriod > 0 { if globals.mediaGcPeriod > 0 {
// How long this file is guaranteed to exist without being attached to a message or a topic. // How long this file is guaranteed to exist without being attached to a message or a topic.
@@ -484,6 +500,40 @@ func authorizeLargeFileRequestURL(tenantID types.TenantID, uid types.Uid, rawURL
return authorizeLargeFileDownload(tenantID, uid, rawURL, fd) 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 { func authorizeLargeFileDownload(tenantID types.TenantID, uid types.Uid, rawURL string, fd *types.FileDef) error {
if fd == nil { if fd == nil {
return types.ErrNotFound return types.ErrNotFound
+60 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings"
"testing" "testing"
"time" "time"
@@ -23,11 +24,16 @@ type testLargeFileMediaHandler struct {
data string data string
fid types.Uid fid types.Uid
headersCalled bool headersCalled bool
corsOrigins []media.AllowedOrigin
} }
func (h *testLargeFileMediaHandler) Init(string) error { return nil } 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 h.headersCalled = true
if h.corsOrigins != nil {
response, status := media.CORSHandler(method, headers, h.corsOrigins, serve)
return response, status, nil
}
return nil, 0, nil return nil, 0, nil
} }
func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) { 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 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) { func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
oldSessionStore := globals.sessionStore oldSessionStore := globals.sessionStore
globals.sessionStore = NewSessionStore(time.Minute) globals.sessionStore = NewSessionStore(time.Minute)
+144 -26
View File
@@ -11,26 +11,52 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"net"
"net/http" "net/http"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
) )
const ( const (
// Time allowed to write a message to the peer. // Time allowed to write a message to the peer.
writeWait = 10 * time.Second writeWait = 10 * time.Second
// Heartbeat defaults tolerate transient mobile and public-network stalls while
// Time allowed to read the next pong message from the peer. // still detecting dead connections in a bounded amount of time.
pongWait = idleSessionTimeout defaultWSPongWait = 90 * time.Second
defaultWSPingPeriod = 15 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = 15 * time.Second
) )
var (
pongWait = defaultWSPongWait
pingPeriod = defaultWSPingPeriod
)
func configureWSHeartbeat(pingSeconds, pongSeconds int) error {
ping := defaultWSPingPeriod
pong := defaultWSPongWait
if pingSeconds > 0 {
ping = time.Duration(pingSeconds) * time.Second
}
if pongSeconds > 0 {
pong = time.Duration(pongSeconds) * time.Second
}
if ping >= pong {
return errors.New("websocket ping period must be shorter than pong wait")
}
pingPeriod = ping
pongWait = pong
immetrics.SetWSHeartbeatConfig(ping, pong)
return nil
}
func (sess *Session) closeWS() { func (sess *Session) closeWS() {
if sess.proto == WEBSOCK { if sess.proto == WEBSOCK {
sess.ws.Close() sess.ws.Close()
@@ -44,41 +70,57 @@ func (sess *Session) readLoop() {
}() }()
sess.ws.SetReadLimit(globals.maxMessageSize) sess.ws.SetReadLimit(globals.maxMessageSize)
sess.ws.SetReadDeadline(time.Now().Add(pongWait)) refreshReadDeadline := func(event, presenceSource string) {
sess.ws.SetPongHandler(func(string) error {
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano()) atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
sess.refreshPresence("ws.pong") immetrics.IncWSHeartbeat(event)
sess.ws.SetReadDeadline(time.Now().Add(pongWait)) if presenceSource != "" {
sess.refreshPresence(presenceSource)
}
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
}
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
sess.ws.SetPongHandler(func(string) error {
refreshReadDeadline("pong_received", "ws.pong")
return nil return nil
}) })
sess.ws.SetPingHandler(func(appData string) error {
refreshReadDeadline("ping_received", "")
return sess.ws.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(writeWait))
})
for { for {
// Read a ClientComMessage // Read a ClientComMessage
_, raw, err := sess.ws.ReadMessage() _, raw, err := sess.ws.ReadMessage()
if err != nil { if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, reason := classifyWSError(err)
websocket.CloseNormalClosure) { immetrics.IncWSDisconnect(reason)
logs.Err.Println("ws: readLoop", sess.sid, err) if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
logs.Err.Printf("ws: readLoop sid=%s uid=%s reason=%s err=%v", sess.sid, sess.uid.UserId(), reason, err)
} }
return return
} }
// Any successfully read client frame proves that the connection is alive.
// This prevents a busy client from timing out merely because its Pong was
// delayed behind application traffic.
atomic.StoreInt64(&sess.lastAction, types.TimeNow().UnixNano())
_ = sess.ws.SetReadDeadline(time.Now().Add(pongWait))
statsInc("IncomingMessagesWebsockTotal", 1) statsInc("IncomingMessagesWebsockTotal", 1)
dispatchStarted := time.Now()
sess.dispatchRaw(raw) sess.dispatchRaw(raw)
immetrics.ObserveWSReadDispatch(dispatchStarted)
} }
} }
func (sess *Session) sendMessage(msg any) bool { func (sess *Session) sendMessage(msg any) bool {
if len(sess.send) > sendQueueLimit { if depth := len(sess.send); depth > sendQueueLimit {
logs.Err.Println("ws: outbound queue limit exceeded", sess.sid) immetrics.ObserveWSWrite("text", "rejected", "queue_limit", time.Now(), 0)
logs.Err.Printf("ws: outbound queue limit exceeded sid=%s uid=%s depth=%d capacity=%d",
sess.sid, sess.uid.UserId(), depth, cap(sess.send))
return false return false
} }
statsInc("OutgoingMessagesWebsockTotal", 1) statsInc("OutgoingMessagesWebsockTotal", 1)
if err := wsWrite(sess.ws, websocket.TextMessage, msg); err != nil { if err := sess.writeWSFrame(websocket.TextMessage, msg); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,
websocket.CloseNormalClosure) {
logs.Err.Println("ws: writeLoop", sess.sid, err)
}
return false return false
} }
return true return true
@@ -100,6 +142,7 @@ func (sess *Session) writeLoop() {
// Channel closed. // Channel closed.
return return
} }
sess.observeOutboundDequeue(false)
switch v := msg.(type) { switch v := msg.(type) {
case []*ServerComMessage: // batch of unserialized messages case []*ServerComMessage: // batch of unserialized messages
for _, msg := range v { for _, msg := range v {
@@ -107,12 +150,23 @@ func (sess *Session) writeLoop() {
if !sess.sendMessage(w) { if !sess.sendMessage(w) {
return return
} }
if isAcceptedCtrl(msg) {
immetrics.ObserveCtrlACKDelivery("write", msg.Timestamp)
}
} }
case *ServerComMessage: // single unserialized message case *ServerComMessage: // single unserialized message
w := sess.serializeAndUpdateStats(v) w := sess.serializeAndUpdateStats(v)
if !sess.sendMessage(w) { if !sess.sendMessage(w) {
return return
} }
if isAcceptedCtrl(v) {
immetrics.ObserveCtrlACKDelivery("write", v.Timestamp)
}
case sharedServerMessage:
statsAddHistSample("OutgoingMessageSize", float64(len(v)))
if !sess.sendMessage([]byte(v)) {
return
}
default: // serialized message default: // serialized message
if !sess.sendMessage(v) { if !sess.sendMessage(v) {
return return
@@ -128,7 +182,7 @@ func (sess *Session) writeLoop() {
case msg := <-sess.stop: case msg := <-sess.stop:
// Shutdown requested, don't care if the message is delivered // Shutdown requested, don't care if the message is delivered
if msg != nil { if msg != nil {
wsWrite(sess.ws, websocket.TextMessage, msg) sess.writeWSFrame(websocket.TextMessage, msg)
} }
return return
@@ -136,17 +190,81 @@ func (sess *Session) writeLoop() {
sess.delSub(topic) sess.delSub(topic)
case <-ticker.C: case <-ticker.C:
if err := wsWrite(sess.ws, websocket.PingMessage, nil); err != nil { if err := sess.writeWSFrame(websocket.PingMessage, nil); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,
websocket.CloseNormalClosure) {
logs.Err.Println("ws: writeLoop ping", sess.sid, err)
}
return return
} }
} }
} }
} }
func (sess *Session) writeWSFrame(mt int, msg any) error {
frame := "text"
if mt == websocket.PingMessage {
frame = "ping"
}
payloadBytes := 0
if bits, ok := msg.([]byte); ok {
payloadBytes = len(bits)
}
started := time.Now()
err := wsWrite(sess.ws, mt, msg)
if err == nil {
immetrics.ObserveWSWrite(frame, "ok", "none", started, payloadBytes)
return nil
}
reason := classifyWSError(err)
immetrics.ObserveWSWrite(frame, "error", reason, started, payloadBytes)
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"
}
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
switch closeErr.Code {
case websocket.CloseNormalClosure:
return "close_normal"
case websocket.CloseGoingAway:
return "close_going_away"
case websocket.CloseAbnormalClosure:
return "close_abnormal"
default:
return "close_other"
}
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "timeout"
}
switch {
case errors.Is(err, syscall.ECONNRESET):
return "connection_reset"
case errors.Is(err, syscall.EPIPE):
return "broken_pipe"
case errors.Is(err, net.ErrClosed):
return "closed"
case errors.Is(err, io.EOF):
return "eof"
default:
return "other"
}
}
// Writes a message with the given message type (mt) and payload. // Writes a message with the given message type (mt) and payload.
func wsWrite(ws *websocket.Conn, mt int, msg any) error { func wsWrite(ws *websocket.Conn, mt int, msg any) error {
var bits []byte var bits []byte
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"testing"
"time"
)
func TestConfigureWSHeartbeat(t *testing.T) {
originalPing, originalPong := pingPeriod, pongWait
t.Cleanup(func() {
pingPeriod, pongWait = originalPing, originalPong
})
if err := configureWSHeartbeat(20, 120); err != nil {
t.Fatal(err)
}
if pingPeriod != 20*time.Second || pongWait != 120*time.Second {
t.Fatalf("heartbeat ping=%s pong=%s", pingPeriod, pongWait)
}
if err := configureWSHeartbeat(90, 90); err == nil {
t.Fatal("expected invalid heartbeat configuration to fail")
}
if err := configureWSHeartbeat(0, 0); err != nil {
t.Fatal(err)
}
if pingPeriod != defaultWSPingPeriod || pongWait != defaultWSPongWait {
t.Fatalf("default heartbeat ping=%s pong=%s", pingPeriod, pongWait)
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"fmt"
"io"
"net"
"os"
"syscall"
"testing"
"github.com/gorilla/websocket"
)
func TestClassifyWSError(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{name: "none", want: "none"},
{name: "normal close", err: &websocket.CloseError{Code: websocket.CloseNormalClosure}, want: "close_normal"},
{name: "abnormal close", err: &websocket.CloseError{Code: websocket.CloseAbnormalClosure}, want: "close_abnormal"},
{name: "timeout", err: &net.OpError{Op: "write", Err: os.ErrDeadlineExceeded}, want: "timeout"},
{name: "reset", err: fmt.Errorf("write failed: %w", syscall.ECONNRESET), want: "connection_reset"},
{name: "broken pipe", err: fmt.Errorf("write failed: %w", syscall.EPIPE), want: "broken_pipe"},
{name: "closed", err: net.ErrClosed, want: "closed"},
{name: "eof", err: io.EOF, want: "eof"},
{name: "other", err: fmt.Errorf("unknown"), want: "other"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := classifyWSError(tc.err); got != tc.want {
t.Fatalf("classifyWSError()=%q want=%q", got, tc.want)
}
})
}
}
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")
if !sess.queueOutBytes(payload) {
t.Fatal("queueOutBytes rejected payload")
}
queued := <-sess.send
sess.observeOutboundDequeue(false)
if string(queued.([]byte)) != string(payload) {
t.Fatalf("payload=%q want=%q", queued, payload)
}
}
+123 -3
View File
@@ -10,6 +10,8 @@
package main package main
import ( import (
"os"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "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, 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} 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 // Request to hub to remove the topic
type topicUnreg struct { type topicUnreg struct {
// Tenant which owns the topic. // Tenant which owns the topic.
@@ -57,6 +97,12 @@ type userStatusReq struct {
state types.ObjState state types.ObjState
} }
type deferredPresenceKey struct {
tenantID types.TenantID
topic string
source string
}
// Hub is the core structure which holds topics. // Hub is the core structure which holds topics.
type Hub struct { type Hub struct {
@@ -74,6 +120,9 @@ type Hub struct {
// Channel for routing server-generated messages, buffered at 4096 // Channel for routing server-generated messages, buffered at 4096
routeSrv chan *ServerComMessage routeSrv chan *ServerComMessage
// Presence updates which could not immediately enter a topic queue.
deferredPresence chan *ServerComMessage
deferredPresenceStop chan struct{}
// subscribe session to topic, possibly creating a new topic, buffered at 256 // subscribe session to topic, possibly creating a new topic, buffered at 256
join chan *ClientComMessage join chan *ClientComMessage
@@ -123,6 +172,8 @@ func newHub() *Hub {
// TODO: verify if these channels have to be buffered. // TODO: verify if these channels have to be buffered.
routeCli: make(chan *ClientComMessage, 4096), routeCli: make(chan *ClientComMessage, 4096),
routeSrv: make(chan *ServerComMessage, 4096), routeSrv: make(chan *ServerComMessage, 4096),
deferredPresence: make(chan *ServerComMessage, 8192),
deferredPresenceStop: make(chan struct{}),
join: make(chan *ClientComMessage, 256), join: make(chan *ClientComMessage, 256),
unreg: make(chan *topicUnreg, 256), unreg: make(chan *topicUnreg, 256),
rehash: make(chan bool), rehash: make(chan bool),
@@ -154,11 +205,70 @@ func newHub() *Hub {
statsRegisterHistogram("RequestLatency", requestLatencyDistribution) statsRegisterHistogram("RequestLatency", requestLatencyDistribution)
statsRegisterHistogram("OutgoingMessageSize", outgoingMessageSizeDistribution) statsRegisterHistogram("OutgoingMessageSize", outgoingMessageSizeDistribution)
go h.runDeferredPresence()
go h.run() go h.run()
return h return h
} }
func (h *Hub) deferPresence(msg *ServerComMessage) bool {
select {
case h.deferredPresence <- msg:
immetrics.ObserveInternalQueueEnqueue("topic_presence_deferred", len(h.deferredPresence), cap(h.deferredPresence), true)
return true
default:
immetrics.ObserveInternalQueueEnqueue("topic_presence_deferred", len(h.deferredPresence), cap(h.deferredPresence), false)
return false
}
}
func (h *Hub) runDeferredPresence() {
const maxPending = 16384
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
pending := make(map[deferredPresenceKey]*ServerComMessage)
for {
select {
case msg := <-h.deferredPresence:
if msg == nil || msg.Pres == nil {
continue
}
key := deferredPresenceKey{tenantID: msg.TenantID, topic: msg.RcptTo, source: msg.Pres.Src}
if _, exists := pending[key]; exists || len(pending) < maxPending {
pending[key] = msg
} else {
immetrics.ObserveInternalQueueEnqueue("topic_presence_pending", len(pending), maxPending, false)
logs.Err.Printf("hub: deferred presence queue full topic=%s tenant=%d source=%s",
msg.RcptTo, msg.TenantID, msg.Pres.Src)
}
immetrics.SetInternalQueueCurrent("topic_presence_pending", len(pending))
case <-ticker.C:
budget := 64
for key, msg := range pending {
dst := h.topicGet(types.TopicKey{TenantID: key.tenantID, Topic: key.topic})
if dst == nil || dst.serverMsg == nil {
delete(pending, key)
continue
}
select {
case dst.serverMsg <- msg:
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), true)
delete(pending, key)
budget--
default:
}
if budget == 0 {
break
}
}
immetrics.SetInternalQueueCurrent("topic_presence_pending", len(pending))
case <-h.deferredPresenceStop:
return
}
}
}
func (h *Hub) run() { func (h *Hub) run() {
for { for {
select { select {
@@ -191,8 +301,8 @@ func (h *Hub) run() {
// Indicates a proxy topic. // Indicates a proxy topic.
isProxy: globals.cluster.isRemoteTopic(key), isProxy: globals.cluster.isRemoteTopic(key),
sessions: make(map[*Session]perSessionData), sessions: make(map[*Session]perSessionData),
clientMsg: make(chan *ClientComMessage, 192), clientMsg: make(chan *ClientComMessage, topicQueueConfig.clientBuffer),
serverMsg: make(chan *ServerComMessage, 64), serverMsg: make(chan *ServerComMessage, topicQueueConfig.serverBuffer),
reg: make(chan *ClientComMessage, 256), reg: make(chan *ClientComMessage, 256),
unreg: make(chan *ClientComMessage, 256), unreg: make(chan *ClientComMessage, 256),
meta: make(chan *ClientComMessage, 64), meta: make(chan *ClientComMessage, 64),
@@ -246,12 +356,17 @@ func (h *Hub) run() {
if dst := h.topicGet(key); dst != nil { if dst := h.topicGet(key); dst != nil {
// Everything is OK, sending packet to known topic // Everything is OK, sending packet to known topic
if dst.clientMsg != nil { if dst.clientMsg != nil {
msg.queuedAt = time.Now()
select { select {
case dst.clientMsg <- msg: case dst.clientMsg <- msg:
immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), true) immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), true)
default: default:
immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), false) immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), false)
logs.Err.Println("hub: topic's broadcast queue is full", dst.name) if msg.sess != nil {
msg.sess.queueOut(ErrServiceUnavailableReply(msg, types.TimeNow()))
}
logs.Err.Printf("hub: topic's broadcast queue is full topic=%s tenant=%d len=%d cap=%d",
dst.name, dst.tenantID, len(dst.clientMsg), cap(dst.clientMsg))
} }
} else { } else {
logs.Warn.Println("hub: invalid topic category for broadcast", dst.name) logs.Warn.Println("hub: invalid topic category for broadcast", dst.name)
@@ -280,9 +395,11 @@ func (h *Hub) run() {
case dst.serverMsg <- msg: case dst.serverMsg <- msg:
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), true) immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), true)
default: default:
if msg.Pres == nil || !h.deferPresence(msg) {
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), false) immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), false)
logs.Err.Println("hub: topic's broadcast queue is full", dst.name) logs.Err.Println("hub: topic's broadcast queue is full", dst.name)
} }
}
} else if (strings.HasPrefix(msg.RcptTo, "usr") || strings.HasPrefix(msg.RcptTo, "grp")) && } else if (strings.HasPrefix(msg.RcptTo, "usr") || strings.HasPrefix(msg.RcptTo, "grp")) &&
globals.cluster.isRemoteTopic(key) { globals.cluster.isRemoteTopic(key) {
// It is a remote topic. // It is a remote topic.
@@ -353,6 +470,9 @@ func (h *Hub) run() {
} }
logs.Info.Printf("Hub shutdown completed with %d topics", topicCount) logs.Info.Printf("Hub shutdown completed with %d topics", topicCount)
if h.deferredPresenceStop != nil {
close(h.deferredPresenceStop)
}
// let the main goroutine know we are done with the cleanup // let the main goroutine know we are done with the cleanup
hubdone <- true hubdone <- true
+45
View File
@@ -102,3 +102,48 @@ func TestHubRejectsUnroutedClientMessageInsteadOfAcceptingWithoutSeq(t *testing.
t.Fatal("timeout waiting for unrouted message response") t.Fatal("timeout waiting for unrouted message response")
} }
} }
func TestHubReturnsServiceUnavailableWhenTopicClientQueueIsFull(t *testing.T) {
h := &Hub{
topics: &sync.Map{},
routeCli: make(chan *ClientComMessage, 1),
shutdown: make(chan chan<- bool),
routeSrv: make(chan *ServerComMessage),
join: make(chan *ClientComMessage),
meta: make(chan *ClientComMessage),
unreg: make(chan *topicUnreg),
userStatus: make(chan *userStatusReq),
rehash: make(chan bool),
}
topic := &Topic{
tenantID: 7, name: "grpBusy", clientMsg: make(chan *ClientComMessage, 1),
exit: make(chan *shutDown, 1),
}
topic.clientMsg <- &ClientComMessage{Id: "occupy"}
h.topicPut(types.TopicKey{TenantID: 7, Topic: topic.name}, topic)
go h.run()
done := make(chan bool)
defer func() {
h.topicDel(types.TopicKey{TenantID: 7, Topic: topic.name})
h.shutdown <- done
<-done
}()
sess := &Session{tenantID: 7, send: make(chan any, 1)}
now := time.Now().UTC()
h.routeCli <- &ClientComMessage{
Id: "pub-overload", TenantID: 7, RcptTo: topic.name, Original: topic.name,
Timestamp: now, sess: sess,
}
select {
case raw := <-sess.send:
msg, ok := raw.(*ServerComMessage)
if !ok || msg.Ctrl == nil || msg.Ctrl.Code != http.StatusServiceUnavailable {
t.Fatalf("unexpected response: %#v", raw)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for overload response")
}
}
+2 -2
View File
@@ -162,7 +162,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
switch eventType { switch eventType {
case "group.member.left": case "group.member.left":
copyPayload["member_sync"] = "remove" 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 { if _, ok := copyPayload["user_id"]; ok {
copyPayload["member_sync"] = "single" copyPayload["member_sync"] = "single"
} else { } else {
@@ -171,7 +171,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
} }
if isAdminOnlyIMGroupEvent(eventType) { if isAdminOnlyIMGroupEvent(eventType) {
copyPayload["visibility"] = "admins_and_affected" 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" copyPayload["visibility"] = "all_members"
} }
return copyPayload return copyPayload
+11
View File
@@ -11,6 +11,17 @@ import (
"github.com/tinode/chat/server/store/types" "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) { func TestPublishIMGroupRealtimeEventRestrictsMemberRemoval(t *testing.T) {
oldGroupBusiness := store.GroupBusiness oldGroupBusiness := store.GroupBusiness
oldSessions := globals.sessionStore oldSessions := globals.sessionStore
+273 -29
View File
@@ -1494,8 +1494,12 @@ func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) {
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
eventType := "security.risk." + decision eventType := "security.risk." + decision
eventID := stableIMEventID("risk", challengeID, decision, challenge.State) eventID := stableIMEventID("risk", challengeID, decision, challenge.State)
summary := "你已取消本次操作"
if decision == "continue" {
summary = "你已确认继续执行该操作"
}
if err = createIMSystemNotification(principal.TenantID, principal.Uid, 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 { types.KVMap{"risk_challenge_id": challengeID}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
@@ -1704,6 +1708,155 @@ func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback)) writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
} }
type imMePasswordChangeRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
// serveIMMePasswordChange lets an authenticated user change own password by
// presenting the current one. All sessions are evicted afterwards.
func serveIMMePasswordChange(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imMePasswordChangeRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if strings.TrimSpace(body.OldPassword) == "" || strings.TrimSpace(body.NewPassword) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
basicAuth := store.Store.GetLogicalAuthHandler("basic")
if basicAuth == nil || !basicAuth.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(principal.TenantID, principal.Uid)
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
authCtx := auth.AuthContext{TenantID: principal.TenantID, RemoteAddr: req.RemoteAddr}
if _, _, err = basicAuth.Authenticate(authCtx, []byte(login+":"+body.OldPassword)); err != nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30001, imReasonAuthRequired, "invalid credentials", nil)
return
}
if _, err = basicAuth.UpdateRecord(authCtx, &auth.Rec{
TenantID: principal.TenantID,
Uid: principal.Uid,
AuthLevel: authLevel,
}, []byte(":"+body.NewPassword)); err != nil {
if err == types.ErrPolicy {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if store.Security != nil {
if _, err = store.Security.BumpAuthVersion(principal.TenantID, principal.Uid, now); err != nil {
logs.Warn.Println("im me password change: bump auth version failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(principal.Uid, "")
}
publishRealtimeEvictUser(principal.TenantID, principal.Uid, "password_changed", "")
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
// serveIMMeDeletion deletes the authenticated user's account. Requires a
// confirmed "delete_account" sensitive-action token obtained through
// POST /v1/im/security/challenges + POST /v1/im/security/challenges/{id}.
func serveIMMeDeletion(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if globals.permanentAccounts {
writeIMError(wrt, req, http.StatusForbidden, 30040, imReasonForbidden, "account deletion is disabled", nil)
return
}
if !consumeIMActionTokenHeader(wrt, req, principal, "delete_account") {
return
}
tenantID, uid := principal.TenantID, principal.Uid
if state, err := userGetState(tenantID, uid); err != nil || state == types.StateDeleted {
logs.Warn.Println("im me deletion: user is missing or already deleted", err)
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonResourceNotFound, "user not found", nil)
return
}
// Disable all authenticators.
for _, name := range store.Store.GetAuthNames() {
hdl := store.Store.GetLogicalAuthHandler(name)
if !hdl.IsInitialized() {
continue
}
if err := hdl.DelRecords(tenantID, uid); err != nil {
// This could be completely benign, i.e. authenticator exists but not used.
logs.Warn.Println("im me deletion: failed to delete auth record", uid.UserId(), name, err)
if storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrUnsupported {
writeIMError(wrt, req, http.StatusForbidden, 30040, imReasonForbidden, "account cannot be deleted", nil)
return
}
}
}
// Terminate all sessions.
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(uid, "", "user_deleted")
}
publishRealtimeEvictUser(tenantID, uid, "user_deleted", "")
// Remove user from cache and announce to cluster that the user is deleted.
usersRemoveUser(tenantID, uid)
// Stop topics where the user is the owner and p2p topics.
done := make(chan bool)
globals.hub.unreg <- &topicUnreg{tenantID: tenantID, forUser: uid, del: false, done: done}
<-done
// Notify users of interest that the user is gone.
if uoi, err := store.Users.GetSubs(tenantID, uid); err == nil {
presUsersOfInterestOffline(tenantID, uid, uoi, "gone")
} else {
logs.Warn.Println("im me deletion: failed to send notifications to users", err)
}
// Notify subscribers of the group topics where the user was the owner.
if ownTopics, err := store.Users.GetOwnTopics(tenantID, uid); err == nil {
for _, topicName := range ownTopics {
if subs, err := store.Topics.GetSubs(tenantID, topicName, nil); err == nil {
presSubsOfflineOffline(tenantID, topicName, types.TopicCatGrp, subs, "gone", &presParams{}, "")
} else {
logs.Warn.Println("im me deletion: failed to notify topic subscribers", err, topicName)
}
}
} else {
logs.Warn.Println("im me deletion: failed to send notifications to owned topics", err)
}
// Delete user's records from the database (soft delete).
if err := store.Users.Delete(tenantID, uid, false); err != nil {
logs.Warn.Println("im me deletion: failed to delete user", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) { func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -2134,10 +2287,18 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
} }
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform"))) platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel"))) 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")) currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version"))
if channel == "" { if channel == "" {
channel = "stable" 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" { if platform != "ios" && platform != "android" && platform != "web" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
return return
@@ -2147,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"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "current_version"})
return return
} }
release, err := store.AppReleases.Latest(platform, channel) release, err := store.AppReleases.Latest(appCode, platform, channel)
if err != nil { if err != nil {
if platform == "web" { if platform == "web" {
logs.Warn.Println("im app release latest: using web fallback", err) logs.Warn.Println("im app release latest: using web fallback", err)
@@ -2488,6 +2649,10 @@ func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
return return
} }
readAt = parsed.UTC().Round(time.Millisecond) readAt = parsed.UTC().Round(time.Millisecond)
if readAt.After(time.Now().UTC().Round(time.Millisecond)) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at", "reason": "future_read_at"})
return
}
} }
notification, err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt) notification, err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt)
if err != nil { if err != nil {
@@ -3873,15 +4038,6 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil)
return return
} }
settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic)
if settingsErr != nil {
writeIMRelationError(wrt, req, settingsErr)
return
}
if settings == nil || !settings.MemberPrivateChat {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil)
return
}
default: default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil)
return return
@@ -3930,6 +4086,12 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, acceptErr) writeIMRelationError(wrt, req, acceptErr)
return return
} }
if accepted == nil {
logs.Err.Printf("friend request auto-accept returned an empty result: tenant=%d request=%s actor=%s",
principal.TenantID, created.RequestID, target.String())
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
created = accepted created = accepted
} }
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
@@ -4055,6 +4217,25 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
items = append(items, item) items = append(items, item)
seen++ 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 != ""}) writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
} }
@@ -4148,7 +4329,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return 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) { func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
@@ -4427,6 +4608,10 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return return
} }
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() { if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
// 群不存在(404)或已解散(410)要与"存在但无权限"(403)区分开。
if selfSub == nil && writeIMGroupMissingIfGone(wrt, req, principal.TenantID, topic) {
return
}
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return return
} }
@@ -4441,7 +4626,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return 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, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
if settings != nil && settings.GroupStatus == types.GroupStatusDissolved { if settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
@@ -4537,7 +4722,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
payload := groupDetailPayload(updated, selfSub) payload := groupDetailPayload(updated, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settingsUpdated { if settingsUpdated {
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings) payload = mergeGroupSettingsPayload(payload, settings)
@@ -4603,7 +4788,7 @@ func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic stri
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
payload := groupSettingsPayload(group, selfSub) payload := groupSettingsPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings) payload = mergeGroupSettingsPayload(payload, settings)
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub) payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
@@ -4707,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 _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
if errors.Is(err, types.ErrNotFound) { 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()) 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()}) 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)}) writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
return return
@@ -4718,7 +4903,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return 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()}) 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)}) writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
} }
@@ -4780,7 +4965,7 @@ func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic strin
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
groupPayload := groupDetailPayload(group, selfSub) groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic))
writeIMSuccess(wrt, req, map[string]any{ writeIMSuccess(wrt, req, map[string]any{
"qr_token": token, "qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group", "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
@@ -5922,7 +6107,7 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) {
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() { if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
status = "joined" status = "joined"
} }
groupPayload := groupDetailPayload(group, selfSub) groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, claims.Topic))
writeIMSuccess(wrt, req, map[string]any{ writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose, "purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
@@ -6238,6 +6423,12 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
} }
if updated == nil {
logs.Err.Printf("friend request resolution returned an empty result: tenant=%d request=%s actor=%s status=%s",
principal.TenantID, requestID, principal.Uid.String(), status)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) { if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) {
topic := updated.ApplicantID.P2PName(updated.TargetID) topic := updated.ApplicantID.P2PName(updated.TargetID)
publishIMConversationSystemNotice(principal.TenantID, topic, publishIMConversationSystemNotice(principal.TenantID, topic,
@@ -6250,11 +6441,13 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status eventType := "friend.request." + status
action := types.KVMap{} action := types.KVMap{}
summary := "对方已婉拒你的好友申请"
if status == types.FriendRequestAccepted { if status == types.FriendRequestAccepted {
action["topic"] = updated.ApplicantID.P2PName(updated.TargetID) action["topic"] = updated.ApplicantID.P2PName(updated.TargetID)
summary = "对方已同意你的好友申请"
} }
if err = publishIMRelationEventWithAction(principal.TenantID, principal.Uid, updated.ApplicantID, 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) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -6838,6 +7031,8 @@ func defaultIMActionTarget(action, fallback string) string {
return "youth_mode" return "youth_mode"
case "group_owner_transfer", "group_dissolve": case "group_owner_transfer", "group_dissolve":
return action return action
case "delete_account":
return "account"
default: default:
return fallback return fallback
} }
@@ -7244,13 +7439,15 @@ func forwardTargetPayload(sub types.Subscription) map[string]any {
default: default:
return nil return nil
} }
return map[string]any{ target := map[string]any{
"topic": sub.Topic, "topic": sub.Topic,
"type": topicType, "type": topicType,
"display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic), "display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic),
"avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()), "avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()),
"can_receive_forward": true, "can_receive_forward": true,
} }
applyAvatarURL(target)
return target
} }
func forwardTargetMatches(topic, displayName, query string) bool { func forwardTargetMatches(topic, displayName, query string) bool {
@@ -7329,15 +7526,27 @@ func mergeGroupListSettingsPayload(payload map[string]any, settings *types.Group
return payload 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{} public := map[string]any{}
topic := "" topic := ""
memberCount := 0
ownerID := "" ownerID := ""
if group != nil { if group != nil {
public = groupPublicMap(group.Public) public = groupPublicMap(group.Public)
topic = group.Id topic = group.Id
memberCount = group.SubCnt
if owner := types.ParseUid(group.Owner); !owner.IsZero() { if owner := types.ParseUid(group.Owner); !owner.IsZero() {
ownerID = owner.UserId() ownerID = owner.UserId()
} }
@@ -7366,8 +7575,8 @@ func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[str
} }
} }
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any {
data := groupDetailPayload(group, selfSub) data := groupDetailPayload(group, selfSub, memberCount)
private := map[string]any{} private := map[string]any{}
if selfSub != nil { if selfSub != nil {
private = groupPrivateMap(selfSub.Private) private = groupPrivateMap(selfSub.Private)
@@ -7436,7 +7645,7 @@ func groupBusinessMemberPayload(member types.GroupMember, remark string) map[str
} }
func maskIMGroupMemberProfileEntrypoint(data map[string]any) { 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) delete(data, key)
} }
data["can_add"] = false data["can_add"] = false
@@ -8512,7 +8721,7 @@ func currentUserAccountPayload(tenantID types.TenantID, uid types.Uid) (map[stri
func validIMSecurityChallengeScene(scene string) bool { func validIMSecurityChallengeScene(scene string) bool {
switch strings.TrimSpace(scene) { switch strings.TrimSpace(scene) {
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password", case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password",
"group_owner_transfer", "group_dissolve": "group_owner_transfer", "group_dissolve", "delete_account":
return true return true
default: default:
return false return false
@@ -8614,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) { func parseNumericAppVersion(raw string) ([]uint64, error) {
raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v") raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v")
parts := strings.Split(raw, ".") parts := strings.Split(raw, ".")
@@ -9244,12 +9466,33 @@ func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic stri
access = sub.ModeGiven & sub.ModeWant access = sub.ModeGiven & sub.ModeWant
} }
if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) { if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) {
// 群不存在(404)或已解散(410)要与"存在但无权限"(403)区分开。
if sub == nil && writeIMGroupMissingIfGone(wrt, req, principal.TenantID, topic) {
return nil, nil, false
}
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return nil, nil, false return nil, nil, false
} }
return principal, sub, true return principal, sub, true
} }
// writeIMGroupMissingIfGone 在群不存在或已删除时写出 404/410 并返回 true;查询失败时返回 false 保持原错误路径。
func writeIMGroupMissingIfGone(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) bool {
group, err := store.Topics.Get(tenantID, topic)
if err != nil {
return false
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "group not found", nil)
return true
}
if group.State == types.StateDeleted {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
return true
}
return false
}
func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) { func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) {
principal, ok := authenticateIMHTTPBearer(wrt, req) principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok { if !ok {
@@ -10574,6 +10817,7 @@ func cleanStringList(values []string, limit int) []string {
func isAllowedPublicUploadPurpose(purpose string) bool { func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) { switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback, case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeFeedback,
types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeUserAvatar,
types.PublicUploadPurposeGroupAvatar: types.PublicUploadPurposeGroupAvatar:
return true return true
@@ -10590,7 +10834,7 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool {
strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") || strings.HasPrefix(mimeType, "image/webp") ||
mimeType == "application/pdf" mimeType == "application/pdf"
case types.PublicUploadPurposeTenantFeedback: case types.PublicUploadPurposeTenantFeedback, types.PublicUploadPurposeFeedback:
return strings.HasPrefix(mimeType, "image/jpeg") || return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") strings.HasPrefix(mimeType, "image/webp")
+67 -11
View File
@@ -42,6 +42,7 @@ type testVerificationStore struct {
type testHTTPInfrastructureStore struct { type testHTTPInfrastructureStore struct {
deadLetters []types.KafkaDeadLetter deadLetters []types.KafkaDeadLetter
markSucceededErr error
} }
type testTenantEntryStore struct { type testTenantEntryStore struct {
@@ -110,6 +111,9 @@ func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record
} }
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeSucceeded(_ types.TenantID, _, _ string, _ time.Time) error { func (s *testHTTPInfrastructureStore) MarkKafkaConsumeSucceeded(_ types.TenantID, _, _ string, _ time.Time) error {
if s.markSucceededErr != nil {
return s.markSucceededErr
}
return nil return nil
} }
@@ -143,6 +147,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
type testRelationsStore struct { type testRelationsStore struct {
context *types.RelationshipContext context *types.RelationshipContext
err error err error
directAdded bool
created *types.FriendRequest created *types.FriendRequest
blocked *types.UserBlock blocked *types.UserBlock
deleted bool deleted bool
@@ -158,6 +163,7 @@ type testRelationsStore struct {
deleteTenant types.TenantID deleteTenant types.TenantID
deleteUser types.Uid deleteUser types.Uid
deletePeer types.Uid deletePeer types.Uid
resolveNil bool
} }
type testFavoritesStore struct { type testFavoritesStore struct {
@@ -268,6 +274,7 @@ type testGroupBusinessStore struct {
listMembers []types.GroupMember listMembers []types.GroupMember
listMembersOpts types.GroupMemberListOptions listMembersOpts types.GroupMemberListOptions
listMembersNext string listMembersNext string
membersCounts map[string]int64
updatedSettings types.GroupSettingsPatch updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode updatedRoleMode types.AccessMode
@@ -349,6 +356,13 @@ func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts ty
return s.listMembers, s.listMembersNext, nil 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) { func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
if s.updateMemberErr != nil { if s.updateMemberErr != nil {
return nil, s.updateMemberErr return nil, s.updateMemberErr
@@ -725,7 +739,7 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
return privacy, nil 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 { if s.err != nil {
return nil, s.err return nil, s.err
} }
@@ -951,6 +965,11 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr
return req, nil 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) { func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) {
return s.friendRequests, "", nil return s.friendRequests, "", nil
} }
@@ -962,6 +981,9 @@ func (s *testRelationsStore) CountPendingFriendRequests(tenantID types.TenantID,
} }
func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) {
if s.resolveNil {
return nil, nil
}
return &types.FriendRequest{ return &types.FriendRequest{
TenantID: 7, TenantID: 7,
RequestID: requestID, RequestID: requestID,
@@ -975,6 +997,28 @@ func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID st
}, nil }, nil
} }
func TestServeIMFriendRequestActionRejectsEmptyStoreResult(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldRelations := store.Relations
store.Relations = &testRelationsStore{resolveNil: true}
t.Cleanup(func() { store.Relations = oldRelations })
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests/frq-1/accept", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFriendRequestAction(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String())
}
if response := decodeIMHTTPResponse(t, rec); response.Reason != imReasonInternal {
t.Fatalf("reason: got %q, want %q", response.Reason, imReasonInternal)
}
}
func (s *testRelationsStore) ListFriends(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendListItem, string, error) { func (s *testRelationsStore) ListFriends(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendListItem, string, error) {
return s.friends, "", nil return s.friends, "", nil
} }
@@ -3140,6 +3184,18 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
if readAgainData["id"] != "evt-2" || readAgainData["read_at"] != readAt.UTC().Format(time.RFC3339Nano) { if readAgainData["id"] != "evt-2" || readAgainData["read_at"] != readAt.UTC().Format(time.RFC3339Nano) {
t.Fatalf("read again response should keep persisted read_at: %#v", readAgainData) t.Fatalf("read again response should keep persisted read_at: %#v", readAgainData)
} }
expectIMBearer(t, ctrl, "token-read-future", 7, 42)
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{"read_at":"2099-01-01T00:00:00Z"}`))
req.Header.Set("Authorization", "Bearer token-read-future")
req.Header.Set("Idempotency-Key", "read-future")
rec = httptest.NewRecorder()
serveIMSystemNotificationRead(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("future read_at status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
} }
func TestServeIMMessageReceipts(t *testing.T) { func TestServeIMMessageReceipts(t *testing.T) {
@@ -7814,26 +7870,23 @@ func mustP2PNameForUser(t *testing.T, uid types.Uid, topic string) string {
return name return name
} }
func TestServeIMFriendRequestCreateHonorsGroupPrivateChatSetting(t *testing.T) { func TestServeIMFriendRequestCreateAllowsGroupSourceWhenPrivateChatDisabled(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1) mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1)
mockStore.EXPECT().GetUidString().Return("group-request-1")
oldRelations := store.Relations oldRelations := store.Relations
oldProfiles := store.Profiles oldProfiles := store.Profiles
oldSubs := store.Subs oldSubs := store.Subs
oldGroups := store.GroupBusiness relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Relations = &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}} store.Relations = relations
store.Profiles = &testProfilesStore{} store.Profiles = &testProfilesStore{}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Subs = subs store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
Topic: "grpAlpha", MemberPrivateChat: false, GroupStatus: types.GroupStatusActive,
}}
defer func() { defer func() {
store.Relations = oldRelations store.Relations = oldRelations
store.Profiles = oldProfiles store.Profiles = oldProfiles
store.Subs = oldSubs store.Subs = oldSubs
store.GroupBusiness = oldGroups
}() }()
memberMode := types.ModeCPublic memberMode := types.ModeCPublic
for _, uid := range []types.Uid{1, 2} { for _, uid := range []types.Uid{1, 2} {
@@ -7848,8 +7901,11 @@ func TestServeIMFriendRequestCreateHonorsGroupPrivateChatSetting(t *testing.T) {
req.Header.Set("Idempotency-Key", "fr-group-1") req.Header.Set("Idempotency-Key", "fr-group-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMFriendRequests(rec, req) serveIMFriendRequests(rec, req)
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if relations.created == nil || relations.created.Source != "group" || relations.created.SourceTopic != "grpAlpha" {
t.Fatalf("unexpected group friend request: %#v", relations.created)
} }
} }
+36
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
@@ -20,6 +21,9 @@ const (
imYouthActionMessage = "message" imYouthActionMessage = "message"
imYouthActionCall = "call" imYouthActionCall = "call"
imSecurityPolicyCacheTTL = 5 * time.Second
imSecurityPolicyErrorCacheTTL = 2 * time.Second
) )
type imPolicyDecision struct { type imPolicyDecision struct {
@@ -29,6 +33,17 @@ type imPolicyDecision struct {
Details map[string]any 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 { func imAllowDecision() imPolicyDecision {
return imPolicyDecision{Allowed: true} return imPolicyDecision{Allowed: true}
} }
@@ -119,7 +134,28 @@ func imSecurityPolicy(tenantID types.TenantID, tenantCode string) (*types.Securi
if tenantCode == "" { if tenantCode == "" {
return nil, types.ErrNotFound 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) 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 { if err != nil {
return nil, err 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) writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return return
} }
if resource == "private-messages" {
spec = privateMessagesInternalQuerySpecForTenant(tenantID)
}
if resource == "group-messages" { if resource == "group-messages" {
var err error var err error
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID) 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 { 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` 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` 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) serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action": case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
serveInternalUserAction(wrt, req, tenant.ID, parts[3]) 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": case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
serveInternalLoginLockoutRelease(wrt, req, tenant.ID) serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users": 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 { func isInternalAdminPostRoute(parts []string) bool {
return (len(parts) == 3 && parts[2] == "users") || return (len(parts) == 3 && parts[2] == "users") ||
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") || (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") (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 { type internalUserCreateRequest struct {
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
+30
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -37,6 +38,35 @@ func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[s
return nil, types.ErrNotFound 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) { func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+13 -2
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store" "github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
@@ -39,6 +40,9 @@ func TestNormalizeKafkaConfigUsesLowLatencyBatchTimeout(t *testing.T) {
if cfg.MaxRetryCount != 3 { if cfg.MaxRetryCount != 3 {
t.Fatalf("unexpected kafka max retry count: %d", cfg.MaxRetryCount) t.Fatalf("unexpected kafka max retry count: %d", cfg.MaxRetryCount)
} }
if cfg.RequiredAcks != int(kafka.RequireAll) {
t.Fatalf("unexpected kafka required acks: %d", cfg.RequiredAcks)
}
} }
func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) { func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) {
@@ -89,6 +93,12 @@ func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) {
func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) { func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() 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 oldKafkaBus := kafkaBus
oldFiles := store.Files oldFiles := store.Files
@@ -100,7 +110,8 @@ func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
kafkaBus = &kafkaRuntime{} kafkaBus = &kafkaRuntime{}
files := mock_store.NewMockFilePersistenceInterface(ctrl) files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files store.Files = files
msgID := types.Uid(101) databaseMsgID := types.Uid(101)
msgID := store.EncodeUid(int64(databaseMsgID))
msg := &types.Message{ msg := &types.Message{
ObjHeader: types.ObjHeader{Id: msgID.String(), CreatedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)}, ObjHeader: types.ObjHeader{Id: msgID.String(), CreatedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)},
TenantID: 7, TenantID: 7,
@@ -110,7 +121,7 @@ func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) {
Head: types.KVMap{"x-im-type": "image"}, Head: types.KVMap{"x-im-type": "image"},
} }
attachments := []string{"https://api.118im.com/v1/im/files/file_001"} 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 { if err := enqueueKafkaFileAttachmentsBind(context.Background(), 7, msg, attachments); err != nil {
t.Fatalf("enqueueKafkaFileAttachmentsBind returned error: %v", err) 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 return types.ErrMalformed
} }
if types.GetTopicCat(payload.Topic) != types.TopicCatGrp { 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) uploader := types.ParseUserId(payload.UserID)
if uploader.IsZero() { if uploader.IsZero() {
+61 -16
View File
@@ -159,13 +159,29 @@ type kafkaMessagePersistItem struct {
envelope kafkaEnvelope envelope kafkaEnvelope
tenantID types.TenantID tenantID types.TenantID
payload kafkaMessagePersistPayload 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 { 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) groups := make(map[string][]*kafkaMessagePersistItem)
for _, message := range messages { for _, message := range messages {
item, terminal, err := prepareKafkaMessagePersistItem(ctx, message, sourceTopicName) item, terminal, err := prepare(ctx, message, sourceTopicName)
if err != nil { 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 return err
} }
if terminal { if terminal {
@@ -177,7 +193,14 @@ func handleKafkaMessagePersistBatch(ctx context.Context, messages []kafka.Messag
} }
groups[key] = append(groups[key], item) groups[key] = append(groups[key], item)
} }
return persist(ctx, groups)
}
func persistKafkaMessagePersistGroups(ctx context.Context, groups map[string][]*kafkaMessagePersistItem) error {
for _, items := range groups { for _, items := range groups {
if len(items) == 0 {
continue
}
kind := items[0].payload.MessageKind kind := items[0].payload.MessageKind
if kind == "" { if kind == "" {
kind = "p2p" kind = "p2p"
@@ -232,9 +255,12 @@ func prepareKafkaMessagePersistItem(ctx context.Context, message kafka.Message,
return nil, false, err return nil, false, err
} }
if !claimed { 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 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 { if err = json.Unmarshal(envelope.Payload, &item.payload); err != nil {
return nil, true, markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error()) return nil, true, markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
} }
@@ -281,27 +307,38 @@ func persistKafkaMessageIndividually(ctx context.Context, item *kafkaMessagePers
return store.HTTPInfrastructure.MarkKafkaConsumeFailed(item.tenantID, item.envelope.EventID, kafkaBus.config.ConsumerGroup, return store.HTTPInfrastructure.MarkKafkaConsumeFailed(item.tenantID, item.envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaMessagePersistFailureReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond)) kafkaMessagePersistFailureReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
} }
markDead := func(failed *types.KafkaConsumeRecord, err error) error { err := runKafkaMessageSingleRetries(ctx, kafkaBus.config.MessageRetries, operation, markFailed, waitKafkaRetry)
immetrics.KafkaMessagePersistTotal.WithLabelValues(kind, "dead").Inc() if err != nil {
immetrics.MessagePersistDuration.WithLabelValues(kind, "dead").Observe(time.Since(started).Seconds()) immetrics.KafkaMessagePersistTotal.WithLabelValues(kind, "retry_exhausted").Inc()
reason := kafkaMessagePersistFailureReason(err) immetrics.MessagePersistDuration.WithLabelValues(kind, "retry_exhausted").Observe(time.Since(started).Seconds())
immetrics.KafkaDeadLettersTotal.WithLabelValues(kind, reason).Inc() logs.Err.Printf("kafka message persist retry exhausted: tenant=%d event=%s topic=%s seq=%d kind=%s err=%v",
return markKafkaDeadLetter(ctx, item.tenantID, item.envelope, failed, reason, err.Error()) 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
} }
return runKafkaMessageSingleRetries(ctx, kafkaBus.config.MessageRetries, operation, markFailed, markDead, waitKafkaRetry) 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
} }
func runKafkaMessageSingleRetries(ctx context.Context, retryCount int, operation func() error, func runKafkaMessageSingleRetries(ctx context.Context, retryCount int, operation func() error,
markFailed func(error) (*types.KafkaConsumeRecord, error), markDead func(*types.KafkaConsumeRecord, error) error, markFailed func(error) (*types.KafkaConsumeRecord, error), wait kafkaRetryWaiter) error {
wait kafkaRetryWaiter) error {
var lastErr error var lastErr error
var failed *types.KafkaConsumeRecord
for attempt := 1; attempt <= retryCount+1; attempt++ { for attempt := 1; attempt <= retryCount+1; attempt++ {
if lastErr = operation(); lastErr == nil { if lastErr = operation(); lastErr == nil {
return nil return nil
} }
var err error var err error
if failed, err = markFailed(lastErr); err != nil { if _, err = markFailed(lastErr); err != nil {
return err return err
} }
if attempt <= retryCount { if attempt <= retryCount {
@@ -310,7 +347,7 @@ func runKafkaMessageSingleRetries(ctx context.Context, retryCount int, operation
} }
} }
} }
return markDead(failed, lastErr) return fmt.Errorf("message persistence retries exhausted: %w", lastErr)
} }
func finishKafkaMessagePersist(ctx context.Context, item *kafkaMessagePersistItem) error { func finishKafkaMessagePersist(ctx context.Context, item *kafkaMessagePersistItem) error {
@@ -319,8 +356,16 @@ func finishKafkaMessagePersist(ctx context.Context, item *kafkaMessagePersistIte
return fmt.Errorf("file lifecycle enqueue failed: %w", err) return fmt.Errorf("file lifecycle enqueue failed: %w", err)
} }
} }
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(item.tenantID, item.envelope.EventID, if err := store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(item.tenantID, item.envelope.EventID,
kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) 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 { func kafkaMessagePersistFailureReason(err error) string {
+21 -9
View File
@@ -182,7 +182,7 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
cfg.BatchTimeoutMS = 10 cfg.BatchTimeoutMS = 10
} }
if cfg.RequiredAcks == 0 { if cfg.RequiredAcks == 0 {
cfg.RequiredAcks = int(kafka.RequireOne) cfg.RequiredAcks = int(kafka.RequireAll)
} }
if cfg.MaxRetryCount <= 0 { if cfg.MaxRetryCount <= 0 {
cfg.MaxRetryCount = 3 cfg.MaxRetryCount = 3
@@ -301,6 +301,7 @@ type kafkaMessageCommitter func(context.Context, kafka.Message) error
type kafkaRetryWaiter func(context.Context, time.Duration) error type kafkaRetryWaiter func(context.Context, time.Duration) error
type kafkaBatchHandler func(context.Context, []kafka.Message) error type kafkaBatchHandler func(context.Context, []kafka.Message) error
type kafkaBatchCommitter 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) { func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
startKafkaConsumerWithGroup(topicName, "", handler) startKafkaConsumerWithGroup(topicName, "", handler)
@@ -389,19 +390,23 @@ func startKafkaBatchConsumer(topicName string, handler kafkaBatchHandler) {
defer kafkaBus.wg.Done() defer kafkaBus.wg.Done()
defer reader.Close() defer reader.Close()
for { 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) time.Duration(kafkaBus.config.MessageBatchWait)*time.Millisecond)
if err != nil { if fetchErr != nil && len(messages) == 0 {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) { if errors.Is(fetchErr, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return return
} }
logs.Err.Println("Kafka batch fetch failed:", err) logs.Err.Println("Kafka batch fetch failed:", fetchErr)
continue 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 { if len(messages) > 0 {
readerMetrics.observe(messages[0].Time) 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) { if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -480,13 +489,16 @@ func fetchKafkaMessageBatch(ctx context.Context, reader *kafka.Reader, maxSize i
break break
} }
fetchCtx, cancel := context.WithTimeout(ctx, remaining) fetchCtx, cancel := context.WithTimeout(ctx, remaining)
message, fetchErr := reader.FetchMessage(fetchCtx) message, fetchErr := fetch(fetchCtx)
cancel() cancel()
if errors.Is(fetchErr, context.DeadlineExceeded) && ctx.Err() == nil { if errors.Is(fetchErr, context.DeadlineExceeded) && ctx.Err() == nil {
break break
} }
if fetchErr != nil { 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) messages = append(messages, message)
} }
+161 -21
View File
@@ -3,10 +3,12 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
"github.com/segmentio/kafka-go" "github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
) )
@@ -47,6 +49,130 @@ func TestConsumeKafkaMessageBatchRetriesHandlerAndCommit(t *testing.T) {
} }
} }
func TestConsumeKafkaMessageBatchDoesNotCommitWhileHandlerFails(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
handlerCalls := 0
commitCalls := 0
err := consumeKafkaMessageBatch(ctx, []kafka.Message{{Offset: 1}},
func(context.Context, []kafka.Message) error {
handlerCalls++
return errors.New("database unavailable")
},
func(context.Context, ...kafka.Message) error {
commitCalls++
return nil
},
func(ctx context.Context, _ time.Duration) error {
cancel()
return ctx.Err()
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("consumeKafkaMessageBatch() error = %v, want context canceled", err)
}
if handlerCalls != 1 || commitCalls != 0 {
t.Fatalf("calls handler=%d commit=%d, want 1/0", handlerCalls, commitCalls)
}
}
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) { func TestConsumeKafkaMalformedMessageRetriesCommit(t *testing.T) {
commitCalls := 0 commitCalls := 0
waitCalls := 0 waitCalls := 0
@@ -76,10 +202,9 @@ func TestConsumeKafkaMalformedMessageRetriesCommit(t *testing.T) {
} }
} }
func TestRunKafkaMessageSingleRetriesThenDeadLetters(t *testing.T) { func TestRunKafkaMessageSingleRetriesReturnsErrorWithoutTerminalCommit(t *testing.T) {
operationCalls := 0 operationCalls := 0
markFailedCalls := 0 markFailedCalls := 0
markDeadCalls := 0
waitCalls := 0 waitCalls := 0
err := runKafkaMessageSingleRetries(context.Background(), 3, err := runKafkaMessageSingleRetries(context.Background(), 3,
@@ -91,31 +216,23 @@ func TestRunKafkaMessageSingleRetriesThenDeadLetters(t *testing.T) {
markFailedCalls++ markFailedCalls++
return &types.KafkaConsumeRecord{RetryCount: markFailedCalls}, nil return &types.KafkaConsumeRecord{RetryCount: markFailedCalls}, nil
}, },
func(record *types.KafkaConsumeRecord, err error) error {
markDeadCalls++
if record == nil || record.RetryCount != 4 || err == nil {
t.Fatalf("unexpected dead-letter input: record=%+v err=%v", record, err)
}
return nil
},
func(context.Context, time.Duration) error { func(context.Context, time.Duration) error {
waitCalls++ waitCalls++
return nil return nil
}) })
if err != nil { if err == nil || !strings.Contains(err.Error(), "message persistence retries exhausted") {
t.Fatalf("runKafkaMessageSingleRetries() error = %v", err) t.Fatalf("runKafkaMessageSingleRetries() error = %v, want retry exhaustion", err)
} }
if operationCalls != 4 || markFailedCalls != 4 || waitCalls != 3 || markDeadCalls != 1 { if operationCalls != 4 || markFailedCalls != 4 || waitCalls != 3 {
t.Fatalf("calls operation=%d failed=%d wait=%d dead=%d, want 4/4/3/1", t.Fatalf("calls operation=%d failed=%d wait=%d, want 4/4/3",
operationCalls, markFailedCalls, waitCalls, markDeadCalls) operationCalls, markFailedCalls, waitCalls)
} }
} }
func TestRunKafkaMessageSingleRetriesStopsOnSuccess(t *testing.T) { func TestRunKafkaMessageSingleRetriesStopsOnSuccess(t *testing.T) {
operationCalls := 0 operationCalls := 0
markFailedCalls := 0 markFailedCalls := 0
markDeadCalls := 0
err := runKafkaMessageSingleRetries(context.Background(), 3, err := runKafkaMessageSingleRetries(context.Background(), 3,
func() error { func() error {
operationCalls++ operationCalls++
@@ -128,16 +245,39 @@ func TestRunKafkaMessageSingleRetriesStopsOnSuccess(t *testing.T) {
markFailedCalls++ markFailedCalls++
return &types.KafkaConsumeRecord{RetryCount: markFailedCalls}, nil return &types.KafkaConsumeRecord{RetryCount: markFailedCalls}, nil
}, },
func(*types.KafkaConsumeRecord, error) error {
markDeadCalls++
return nil
},
func(context.Context, time.Duration) error { return nil }) func(context.Context, time.Duration) error { return nil })
if err != nil { if err != nil {
t.Fatalf("runKafkaMessageSingleRetries() error = %v", err) t.Fatalf("runKafkaMessageSingleRetries() error = %v", err)
} }
if operationCalls != 3 || markFailedCalls != 2 || markDeadCalls != 0 { if operationCalls != 3 || markFailedCalls != 2 {
t.Fatalf("calls operation=%d failed=%d dead=%d, want 3/2/0", operationCalls, markFailedCalls, markDeadCalls) 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)
} }
} }
+11 -1
View File
@@ -67,13 +67,14 @@ func executeKafkaWorkbenchEvent(event kafkaWorkbenchEvent) error {
if err != nil { if err != nil {
return err return err
} }
notificationEnabled := event.shouldCreateNotification()
pushTargets := make(map[types.Uid]push.Recipient) pushTargets := make(map[types.Uid]push.Recipient)
for _, targetID := range event.TargetUserIDs { for _, targetID := range event.TargetUserIDs {
target := store.EncodeUid(targetID) target := store.EncodeUid(targetID)
if target.IsZero() { if target.IsZero() {
continue continue
} }
if target != actor && event.Title != "" { if notificationEnabled && target != actor && event.Title != "" {
action := event.notificationAction() action := event.notificationAction()
if err = createIMSystemNotification(event.TenantID, target, stableIMEventID(event.EventID, "notification", target.UserId()), if err = createIMSystemNotification(event.TenantID, target, stableIMEventID(event.EventID, "notification", target.UserId()),
event.Action, event.Title, event.Summary, action); err != nil && err != types.ErrDuplicate { event.Action, event.Title, event.Summary, action); err != nil && err != types.ErrDuplicate {
@@ -133,6 +134,15 @@ func (e kafkaWorkbenchEvent) realtimePayload() (map[string]any, error) {
return payload, nil return payload, nil
} }
func (e kafkaWorkbenchEvent) shouldCreateNotification() bool {
switch e.Action {
case "workbench.ai.task.updated":
return false
default:
return true
}
}
func (e kafkaWorkbenchEvent) notificationAction() types.KVMap { func (e kafkaWorkbenchEvent) notificationAction() types.KVMap {
action := types.KVMap{ action := types.KVMap{
"module": e.Module, "module": e.Module,
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"testing"
"github.com/tinode/chat/server/store"
)
func TestExecuteKafkaWorkbenchAITaskEventSkipsNotification(t *testing.T) {
oldSecurity := store.Security
security := &testUserSecurityStore{}
store.Security = security
t.Cleanup(func() {
store.Security = oldSecurity
})
event := kafkaWorkbenchEvent{
EventID: "wbe_ai_1",
TenantID: 7,
ActorUserID: 1,
Module: "ai",
Action: "workbench.ai.task.updated",
ResourceID: "aitask_1",
TargetUserIDs: []int64{2},
Title: "AI 任务完成",
Summary: "表格分析已完成",
Data: map[string]any{
"task_id": "aitask_1",
"feature": "smart_table",
"status": "succeeded",
"result_type": "record",
"record_id": "airec_1",
},
}
if event.shouldCreateNotification() {
t.Fatal("ai task event must be realtime-only")
}
err := executeKafkaWorkbenchEvent(event)
if err != nil {
t.Fatalf("executeKafkaWorkbenchEvent returned error: %v", err)
}
if len(security.notifications) != 0 {
t.Fatalf("ai task event should not create offline notification: %#v", security.notifications)
}
}
+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>
+42 -26
View File
@@ -14,17 +14,17 @@
</head> </head>
<body> <body>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">尊敬的笔墨讯使用户:</span></p> <p class="p2"><span class="s1">尊敬的讯工捷使用户:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">欢迎您使用海口市枫群网络科技有限公司提供的“笔墨讯使”软件及相关服务!作为“笔墨讯使”的开发者(以下简称“我们”),我们深知个人信息对您的重要性,并会尽力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护和处理您的个人信息:权责一致原则,目的明确原则,选择同意原则,最少必要原则,确保安全原则,主体参与原则,公开透明原则。同时我们承诺,我们将按业界成熟的安全解决方案,采取相应的安全保护措施来保护您的个人信息。</span></p> <p class="p2"><span class="s1">欢迎您使用南京赫顺名网络科技有限公司提供的“讯工捷使”软件及相关服务!作为“讯工捷使”的开发者(以下简称“我们”),我们深知个人信息对您的重要性,并会尽力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护和处理您的个人信息:权责一致原则,目的明确原则,选择同意原则,最少必要原则,确保安全原则,主体参与原则,公开透明原则。同时我们承诺,我们将按业界成熟的安全解决方案,采取相应的安全保护措施来保护您的个人信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">版本生效日期:2025年8月1日</span></p> <p class="p2"><span class="s1">版本生效日期:2026年10月8日</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">如您有任何疑问、意见或建议,您可通过以下联系方式与我们联系:</span></p> <p class="p2"><span class="s1">如您有任何疑问、意见或建议,您可通过以下联系方式与我们联系:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">联系邮箱:fengqunwangluo2025@163.com</span></p> <p class="p2"><span class="s1">联系邮箱:support@heshunming.top</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">联系地址:海南省海口市美兰区海府街道海府路4号海口宾馆附楼第4层C区第7间</span></p> <p class="p2"><span class="s1">联系地址:江苏省南京市秦淮区光华路街道永智路6号云计算中心4号楼a栋303-93</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">【特别提示】</span></p> <p class="p2"><span class="s1">【特别提示】</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -38,7 +38,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">引言</span></p> <p class="p2"><span class="s1">引言</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">笔墨讯使重视用户信息安全与隐私保护,竭尽全力通过合理有效的信息安全技术及管理流程,防止您的信息泄露、损毁、丢失;为您提供便利的信息管理选项,增强您对信息管理的便捷性;保护您的通信秘密,保障您的信息及通信安全。笔墨讯使严格遵守法律法规,仅收集必要的信息,并提升信息处理透明度,在产品或服务开发的各个环节,综合法律、产品、设计等多方因素,融入隐私保护的理念,为您提供更加安全、可靠的服务。</span></p> <p class="p2"><span class="s1">讯工捷使重视用户信息安全与隐私保护,竭尽全力通过合理有效的信息安全技术及管理流程,防止您的信息泄露、损毁、丢失;为您提供便利的信息管理选项,增强您对信息管理的便捷性;保护您的通信秘密,保障您的信息及通信安全。讯工捷使严格遵守法律法规,仅收集必要的信息,并提升信息处理透明度,在产品或服务开发的各个环节,综合法律、产品、设计等多方因素,融入隐私保护的理念,为您提供更加安全、可靠的服务。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">我们制定并遵循本《隐私政策》(以下简称“本政策”),主要向您说明:</span></p> <p class="p2"><span class="s1">我们制定并遵循本《隐私政策》(以下简称“本政策”),主要向您说明:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -62,37 +62,53 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">1.3 匿名化处理后的信息,即通过对个人信息的技术处理,使得个人信息主体无法被识别,且处理后不能被复原的信息,匿名化处理后的信息不属于个人信息。</span></p> <p class="p2"><span class="s1">1.3 匿名化处理后的信息,即通过对个人信息的技术处理,使得个人信息主体无法被识别,且处理后不能被复原的信息,匿名化处理后的信息不属于个人信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">1.4 其他无法识别特定自然人身份或者反映特定自然人活动情况的信息不属于个人信息。除上述定义外,本政策所用其他术语与《笔墨讯使软件服务协议》中的术语具有相同的涵义。如您有问题,请联系我们。</span></p> <p class="p2"><span class="s1">1.4 其他无法识别特定自然人身份或者反映特定自然人活动情况的信息不属于个人信息。除上述定义外,本政策所用其他术语与《讯工捷使软件服务协议》中的术语具有相同的涵义。如您有问题,请联系我们。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">二、我们收集的信息</span></p> <p class="p2"><span class="s1">二、我们收集的信息</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.1 笔墨讯使软件功能分为核心功能和附加功能,我们将详细向您说明各业务功能所收集的个人信息以及收集方法、目的,拒绝提供的影响等。业务功能具体包括:</span></p> <p class="p2"><span class="s1">2.1 讯工捷使软件功能分为核心功能和附加功能,我们将详细向您说明各业务功能所收集的个人信息以及收集方法、目的,拒绝提供的影响等。业务功能具体包括:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.1.1 核心功能,包括创建账号并完善资料、会话功能、发现功能。</span></p> <p class="p2"><span class="s1">2.1.1 核心功能,包括创建账号并完善资料、会话功能、发现功能。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.1.2 附加功能,包括个人主页、身份认证。</span></p> <p class="p2"><span class="s1">2.1.2 附加功能,包括个人主页、身份认证。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.2 您使用笔墨讯使软件核心功能前,需要创建账号并完善个人资料。通过用户主动提供的方式,我们会收集您的以下信息:</span></p> <p class="p2"><span class="s1">2.2 您使用讯工捷使软件核心功能前,需要创建账号并完善个人资料。通过用户主动提供的方式,我们会收集您的以下信息:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.2.1 您需要向我们提供您的手机号码,并设置您在笔墨讯使的用户密码。手机号码确定后,暂时无法修改。如果您拒绝提供手机号码笔墨讯使将无法为您创建账号并提供服务。同时,为了保证软件正常运行,我们将会采集您的设备信息,具体包括: iOS系统:SDK版本、设备用户名、手机型号、系统型号、链接的无线MAC地址、设备随机序列号、IMSI、SIM卡国家、磁盘总空间、运行内存、电池电量、网络环境、运营商、系统版本、CPU型号、屏幕分辨率、IMEI、手机品牌、平台类型、设备序列号等。</span></p> <p class="p2"><span class="s1">2.2.1 您需要向我们提供您的手机号码,并设置您在讯工捷使的用户密码。手机号码确定后,暂时无法修改。如果您拒绝提供手机号码讯工捷使将无法为您创建账号并提供服务。同时,为了保证软件正常运行,我们将会采集您的设备信息,具体包括: iOS系统:SDK版本、设备用户名、手机型号、系统型号、链接的无线MAC地址、设备随机序列号、IMSI、SIM卡国家、磁盘总空间、运行内存、电池电量、网络环境、运营商、系统版本、CPU型号、屏幕分辨率、IMEI、手机品牌、平台类型、设备序列号等。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.2.2 您使用会话功能进行语音通话、发送语音信息、拍摄及相册时;我们会调用您以下设备权限:设备麦克风权限、系统相册权限、系统摄像头权限。上述功能可能需要您在您的设备中向我们开启您的相机(摄像头)、相册(图片库)、麦克风以及通讯录的访问权限,以实现这些功能所涉及的信息的收集和使用。请您注意,您开启这些权限即代表您授权我们可以收集和使用这些个人信息来实现上述的功能,您关闭权限即代表您取消了这些授权,则我们将不再继续收集和使用您的这些个人信息,也无法为您提供上述与这些授权所对应的功能。您关闭权限的决定不会影响此前基于您的授权所进行的个人信息的处理。</span></p> <p class="p2"><span class="s1">2.2.2 您使用会话功能进行语音通话、发送语音信息、拍摄及相册时;我们会调用您以下设备权限:设备麦克风权限、系统相册权限、系统摄像头权限。上述功能可能需要您在您的设备中向我们开启您的相机(摄像头)、相册(图片库)、麦克风以及通讯录的访问权限,以实现这些功能所涉及的信息的收集和使用。请您注意,您开启这些权限即代表您授权我们可以收集和使用这些个人信息来实现上述的功能,您关闭权限即代表您取消了这些授权,则我们将不再继续收集和使用您的这些个人信息,也无法为您提供上述与这些授权所对应的功能。您关闭权限的决定不会影响此前基于您的授权所进行的个人信息的处理。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.3 为了提高您的社交属性,在社群中结实更多志同道合的朋友,我们在以下附加功能中可能会收集和使用您的个人信息。如果您不提供这些个人信息,您依然可以使用笔墨讯使软件的核心功能。</span></p> <p class="p2"><span class="s1">2.3 为了提高您的社交属性,在社群中结实更多志同道合的朋友,我们在以下附加功能中可能会收集和使用您的个人信息。如果您不提供这些个人信息,您依然可以使用讯工捷使软件的核心功能。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.3.1 您可以选择向我们提供昵称、姓名、性别、年龄、所在地、行业、公司/机构、职业。您可以在个人主页资料中选择是否填写上述信息。如您提供上述信息,可以使其他用户对您有更全面的了解。您也可以在您的个人主页中将上述隐私事项设置为所有人/仅好友/仅自己可见。</span></p> <p class="p2"><span class="s1">2.3.1 您可以选择向我们提供昵称、姓名、性别、年龄、所在地、行业、公司/机构、职业。您可以在个人主页资料中选择是否填写上述信息。如您提供上述信息,可以使其他用户对您有更全面的了解。您也可以在您的个人主页中将上述隐私事项设置为所有人/仅好友/仅自己可见。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.4 您可以选择申请个人加V认证,申请加V认证您需要提供您的个人简介、昵称、并上传职位、技能证明材料,包含姓名、公司/组织信息、职位信息的加盖公章的截图材料、公开可查的官方个人信息页截图材料。成为笔墨讯使认证会员可以提升在社群的个人社交属性。</span></p> <p class="p2"><span class="s1">2.4 您可以选择申请个人加V认证,申请加V认证您需要提供您的个人简介、昵称、并上传职位、技能证明材料,包含姓名、公司/组织信息、职位信息的加盖公章的截图材料、公开可查的官方个人信息页截图材料。成为讯工捷使认证会员可以提升在社群的个人社交属性。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.5 为了帮助您更好地使用我们的产品或服务,我们还会收集包括:您的好友列表、群列表信息、声纹特征值信息。为确保您使用我们服务时能与您认识的人进行联系,如您选择开启导入通讯录功能,我们可能对您联系人的姓名和电话号码进行加密,并仅收集加密后的信息。</span></p> <p class="p2"><span class="s1">2.5 为了帮助您更好地使用我们的产品或服务,我们还会收集包括:您的好友列表、群列表信息、声纹特征值信息。为确保您使用我们服务时能与您认识的人进行联系,如您选择开启导入通讯录功能,我们可能对您联系人的姓名和电话号码进行加密,并仅收集加密后的信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.6 我们可能会获得您在使用第三方合作伙伴服务时所产生或分享的信息。包括:您使用笔墨讯使帐户登录第三方合作伙伴服务时,或者您使用第三方合作伙伴服务账户登录笔墨讯使时,我们会获得您登录第三方合作伙伴服务的名称、登录时间或登录笔墨讯使的登录时间,方便您进行授权管理。请您仔细阅读第三方合作伙伴服务的用户协议或隐私政策。</span></p> <p class="p2"><span class="s1">2.6 我们可能会获得您在使用第三方合作伙伴服务时所产生或分享的信息。包括:您使用讯工捷使帐户登录第三方合作伙伴服务时,或者您使用第三方合作伙伴服务账户登录讯工捷使时,我们会获得您登录第三方合作伙伴服务的名称、登录时间或登录讯工捷使的登录时间,方便您进行授权管理。请您仔细阅读第三方合作伙伴服务的用户协议或隐私政策。</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.7 AI智能服务。为向您提供智能回复、内容生成、内容总结、智能客服、搜索推荐等AI辅助功能,我们可能会接入DeepSeek AI服务或我们部署、接入的其他人工智能模型。当您主动使用相关功能时,我们可能会处理您主动输入、选择提交或授权处理的文本、问题、上下文、反馈信息,以及为实现功能所必要的账号标识、设备信息、网络日志等信息。请您避免在使用AI功能时输入身份证件号码、银行卡号、精确住址、健康信息、未成年人信息、商业秘密或他人隐私等敏感信息;如特定场景确需处理敏感个人信息,我们将依法取得您的单独同意或满足法律法规规定的其他处理条件。</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">2.8 我们使用AI服务处理相关信息的目的包括:理解您的指令、生成或优化回复、提升交互体验、保障服务安全、排查故障以及改进产品质量。我们会采取合理措施限制处理范围,并要求AI服务提供方按照协议约定、法律法规及安全要求处理相关信息,不得将相关信息用于与提供服务无关的目的。AI生成内容可能存在不准确、不完整或不适合特定场景的情况,请您结合实际情况判断后使用。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">我们的产品在应用运行期间需要收集您的设备唯一识别码(MAC地址及IMEI/IDFA、SIM 卡 IMSI 信息、OAID、ANDROID ID)以提供统计分析等服务,并通过应用启动数据及异常错误日志分析改进性能和用户体验,为用户提供更好的服务。</span></p> <p class="p2"><span class="s1">我们的产品在应用运行期间需要收集您的设备唯一识别码(MAC地址及IMEI/IDFA、SIM 卡 IMSI 信息、OAID、ANDROID ID)以提供统计分析等服务,并通过应用启动数据及异常错误日志分析改进性能和用户体验,为用户提供更好的服务。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">APP涉及嵌入第三方代码、插件传输个人信息的情形逐项列举如下:</span></p> <p class="p2"><span class="s1">APP涉及嵌入第三方代码、插件传输个人信息的情形逐项列举如下:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">DeepSeek AI服务</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">第三方主体:杭州深度求索人工智能基础技术研究有限公司及其关联公司</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">用途:提供智能回复、内容生成、内容总结、智能客服、搜索推荐等AI辅助能力</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">涉及个人信息:您主动输入、选择提交或授权处理的文本、问题、上下文、反馈信息,以及为实现功能所必要的账号标识、设备信息、网络日志等信息</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">收集方式:用户主动提交或在使用AI功能时通过接口传输</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">隐私政策:https://cdn.deepseek.com/policies/zh-CN/deepseek-privacy-policy.html</span></p>
<p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">获取设备的传感器</span></p> <p class="p2"><span class="s1">获取设备的传感器</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">用途:获取设备的传感器列表 为了检测、防止安卓模拟器等三方模拟器的使用</span></p> <p class="p2"><span class="s1">用途:获取设备的传感器列表 为了检测、防止安卓模拟器等三方模拟器的使用</span></p>
@@ -231,7 +247,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">B. 为了提升我们的产品及服务质量或向您提供全新的产品及服务,我们会在关联公司内部共享您的相关信息。</span></p> <p class="p2"><span class="s1">B. 为了提升我们的产品及服务质量或向您提供全新的产品及服务,我们会在关联公司内部共享您的相关信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">C. 您通过笔墨讯使软件使用第三方服务时,笔墨讯使会按照“个人信息的收集”条款列明的共享内容与提供服务的第三方或合作伙伴(广告、分析服务类的授权合作伙伴、供应商、服务提供商和其他合作伙伴)共享您的个人信息。</span></p> <p class="p2"><span class="s1">C. 您通过讯工捷使软件使用第三方服务时,讯工捷使会按照“个人信息的收集”条款列明的共享内容与提供服务的第三方或合作伙伴(广告、分析服务类的授权合作伙伴、供应商、服务提供商和其他合作伙伴)共享您的个人信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">D. 为维护用户合法权益,在协助处理与您有关的纠纷或争议时,我们可能向与您存在利害关系的第三方提供解决纠纷或争议所必需的信息。</span></p> <p class="p2"><span class="s1">D. 为维护用户合法权益,在协助处理与您有关的纠纷或争议时,我们可能向与您存在利害关系的第三方提供解决纠纷或争议所必需的信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -260,7 +276,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">F. 从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道。</span></p> <p class="p2"><span class="s1">F. 从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">4.5 如发生收购、兼并、重组等变更,我们会要求变更后的主体依然遵守本隐私政策约定,履行原有责任及义务。如变更后的主体需变更个人信息使用目的,我们会要求其事先获得您的明示同意。对内而言,在之前的收购、兼并活动中,对于被收购、被兼并对象管理的数据(包括用户数据)均采用与笔墨讯使自有数据相同的标准和要求进行处理和保护。</span></p> <p class="p2"><span class="s1">4.5 如发生收购、兼并、重组等变更,我们会要求变更后的主体依然遵守本隐私政策约定,履行原有责任及义务。如变更后的主体需变更个人信息使用目的,我们会要求其事先获得您的明示同意。对内而言,在之前的收购、兼并活动中,对于被收购、被兼并对象管理的数据(包括用户数据)均采用与讯工捷使自有数据相同的标准和要求进行处理和保护。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">五、我们如何保护您的个人信息</span></p> <p class="p2"><span class="s1">五、我们如何保护您的个人信息</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -294,7 +310,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">E. 为执行相关服务协议或本政策、维护社会公共利益,为保护我们的客户、我们或我们的关联公司、其他用户或雇员的人身财产安全或其他合法权益所合理必需的用途。</span></p> <p class="p2"><span class="s1">E. 为执行相关服务协议或本政策、维护社会公共利益,为保护我们的客户、我们或我们的关联公司、其他用户或雇员的人身财产安全或其他合法权益所合理必需的用途。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">6.3 请您注意,您在使用我们的服务期间,我们仅在法律法规规定、监管要求及为实现本条款声明的目的所必需的时限内保留您的个人信息,在您注销账号后,我们仅在法律法规规定、监管要求的期限内保留个人信息,且不会开展除存储和采取必要的安全保护措施之外的处理活动,但已经匿名化处理后的个人信息除外。当您成功注销笔墨讯使账户或超出保留期间后,我们将对您的个人信息进行删除或匿名化处理。</span></p> <p class="p2"><span class="s1">6.3 请您注意,您在使用我们的服务期间,我们仅在法律法规规定、监管要求及为实现本条款声明的目的所必需的时限内保留您的个人信息,在您注销账号后,我们仅在法律法规规定、监管要求的期限内保留个人信息,且不会开展除存储和采取必要的安全保护措施之外的处理活动,但已经匿名化处理后的个人信息除外。当您成功注销讯工捷使账户或超出保留期间后,我们将对您的个人信息进行删除或匿名化处理。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">6.4 当我们的产品或服务发生停止运营的情形时,我们将采取例如,推送通知、公告等形式通知您,并在合理的期限内删除或匿名化处理您的个人信息。</span></p> <p class="p2"><span class="s1">6.4 当我们的产品或服务发生停止运营的情形时,我们将采取例如,推送通知、公告等形式通知您,并在合理的期限内删除或匿名化处理您的个人信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -310,7 +326,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">您可以通过以下方式访问及管理您的信息:</span></p> <p class="p2"><span class="s1">您可以通过以下方式访问及管理您的信息:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">8.1 查询、更正和补充您的信息 您有权查询、更正或补充您的信息。您可以通过以下方式自行进行: 登录笔墨讯使客户端,进入“我的”,点击用户名对个人资料、基本信息、认证信息、个人资料等进行查询、更正或补充;</span></p> <p class="p2"><span class="s1">8.1 查询、更正和补充您的信息 您有权查询、更正或补充您的信息。您可以通过以下方式自行进行: 登录讯工捷使客户端,进入“我的”,点击用户名对个人资料、基本信息、认证信息、个人资料等进行查询、更正或补充;</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">8.2 删除您的信息 您可以通过“8.1查询、更正和补充您的信息”中列明的方式删除您的部分信息。</span></p> <p class="p2"><span class="s1">8.2 删除您的信息 您可以通过“8.1查询、更正和补充您的信息”中列明的方式删除您的部分信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -329,13 +345,13 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">8.3 改变您授权同意的范围</span></p> <p class="p2"><span class="s1">8.3 改变您授权同意的范围</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">每个业务功能需要一些基本的个人信息才能得以完成(见本隐私政策“第一条 我们收集的信息”)。除此之外,您可以在笔墨讯使客户端“我的-设置-隐私设置”中设置或改变您的智能移动设备的设置等方式给予或收回您的授权同意的范围。 当您收回同意后,我们将不再处理相应的个人信息。但您收回同意的决定,不会影响此前基于您的授权而开展的个人信息处理。</span></p> <p class="p2"><span class="s1">每个业务功能需要一些基本的个人信息才能得以完成(见本隐私政策“第一条 我们收集的信息”)。除此之外,您可以在讯工捷使客户端“我的-设置-隐私设置”中设置或改变您的智能移动设备的设置等方式给予或收回您的授权同意的范围。 当您收回同意后,我们将不再处理相应的个人信息。但您收回同意的决定,不会影响此前基于您的授权而开展的个人信息处理。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">8.4 注销您的账户</span></p> <p class="p2"><span class="s1">8.4 注销您的账户</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">您可以通过以下方式申请注销您的账户:</span></p> <p class="p2"><span class="s1">您可以通过以下方式申请注销您的账户:</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">登录笔墨讯使官网并选择联系客服寻求帮助,协助您申请注销您的账户。</span></p> <p class="p2"><span class="s1">登录讯工捷使官网并选择联系客服寻求帮助,协助您申请注销您的账户。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">在您主动注销账户之后,我们将停止为您提供产品或服务,根据适用法律的要求删除您的个人信息,或使其匿名化处理。</span></p> <p class="p2"><span class="s1">在您主动注销账户之后,我们将停止为您提供产品或服务,根据适用法律的要求删除您的个人信息,或使其匿名化处理。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -343,7 +359,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">在某些业务功能中,我们可能仅依据信息系统、算法等在内的非人工自动决策机制做出决定。</span></p> <p class="p2"><span class="s1">在某些业务功能中,我们可能仅依据信息系统、算法等在内的非人工自动决策机制做出决定。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">如果这些决定显著影响您的合法权益,您有权要求我们做出解释,我们也将在不侵害笔墨讯使商业秘密或其他用户权益、社会公共利益的前提下提供申诉方法。</span></p> <p class="p2"><span class="s1">如果这些决定显著影响您的合法权益,您有权要求我们做出解释,我们也将在不侵害讯工捷使商业秘密或其他用户权益、社会公共利益的前提下提供申诉方法。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">8.6 响应您的上述请求</span></p> <p class="p2"><span class="s1">8.6 响应您的上述请求</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -379,11 +395,11 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">9.3 我们建议您查看此类社交网络或第三方服务提供商的隐私声明,以了解他们如何处理您的信息。对于您使用第三方服务时向其提供或与他人分享个人信息而产生的后果由您自行承担,并由相应责任方在法律规定范围内承担责任。</span></p> <p class="p2"><span class="s1">9.3 我们建议您查看此类社交网络或第三方服务提供商的隐私声明,以了解他们如何处理您的信息。对于您使用第三方服务时向其提供或与他人分享个人信息而产生的后果由您自行承担,并由相应责任方在法律规定范围内承担责任。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">9.4 当您在笔墨讯使平台公开分享任何信息时,任何人都可能在使用笔墨讯使平台时查看或访问这些信息,如您在笔墨讯使平台上传的照片、评价等信息。您在使用我们服务时自愿共享的信息,可能会涉及您或他人的个人信息,请您谨慎选择并自行承担责任。</span></p> <p class="p2"><span class="s1">9.4 当您在讯工捷使平台公开分享任何信息时,任何人都可能在使用讯工捷使平台时查看或访问这些信息,如您在讯工捷使平台上传的照片、评价等信息。您在使用我们服务时自愿共享的信息,可能会涉及您或他人的个人信息,请您谨慎选择并自行承担责任。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">十、未成年人保护</span></p> <p class="p2"><span class="s1">十、未成年人保护</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">10.1 我们非常重视对未成年人个人信息的保护。根据相关法律法规的规定,若您是18周岁以下的未成年人,在使用笔墨讯使的服务前,应事先取得您的家长或法定监护人的同意。若您是未满14周岁的未成年人,您的监护人需要仔细阅读本政策并同意您依照本政策适用笔墨讯使平台。</span></p> <p class="p2"><span class="s1">10.1 我们非常重视对未成年人个人信息的保护。根据相关法律法规的规定,若您是18周岁以下的未成年人,在使用讯工捷使的服务前,应事先取得您的家长或法定监护人的同意。若您是未满14周岁的未成年人,您的监护人需要仔细阅读本政策并同意您依照本政策适用讯工捷使平台。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">10.2 我们不会主动直接向未成年人收集其个人信息。对于经监护人同意而收集未成年人个人信息的情况,我们只会在受到法律允许、监护人同意或者保护未成年人所必要的情况下使用、共享、转让或披露此信息。</span></p> <p class="p2"><span class="s1">10.2 我们不会主动直接向未成年人收集其个人信息。对于经监护人同意而收集未成年人个人信息的情况,我们只会在受到法律允许、监护人同意或者保护未成年人所必要的情况下使用、共享、转让或披露此信息。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -391,11 +407,11 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">十一、适用范围</span></p> <p class="p2"><span class="s1">十一、适用范围</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">我们的所有服务均适用本政策。但某些服务有其特定的隐私指引/声明,该特定隐私指引/声明更具体地说明我们在该服务中如何处理您的信息。如本政策与特定服务的隐私指引/声明有不一致之处,请以该特定隐私指引/声明为准。 请您注意,本政策不适用由其他公司或个人提供的服务。例如,您通过使用笔墨讯使帐号登录其他公司或个人提供的服务。 您使用该等第三方服务须受其隐私政策(而非本政策)约束,您需要仔细阅读其政策内容。</span></p> <p class="p2"><span class="s1">我们的所有服务均适用本政策。但某些服务有其特定的隐私指引/声明,该特定隐私指引/声明更具体地说明我们在该服务中如何处理您的信息。如本政策与特定服务的隐私指引/声明有不一致之处,请以该特定隐私指引/声明为准。 请您注意,本政策不适用由其他公司或个人提供的服务。例如,您通过使用讯工捷使帐号登录其他公司或个人提供的服务。 您使用该等第三方服务须受其隐私政策(而非本政策)约束,您需要仔细阅读其政策内容。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">十二、通知和修订</span></p> <p class="p2"><span class="s1">十二、通知和修订</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">12.1 为给您提供更好的服务以及随着笔墨讯使业务的发展,本隐私政策也会随之更新。但未经您明确同意,我们不会削减您依据本隐私政策所应享有的权利。我们会通过在笔墨讯使官方网站、笔墨讯使移动端上发出更新版本并在生效前通过网站公告或以其他适当方式提醒您相关内容的更新,也请您访问及时了解最新的隐私政策。</span></p> <p class="p2"><span class="s1">12.1 为给您提供更好的服务以及随着讯工捷使业务的发展,本隐私政策也会随之更新。但未经您明确同意,我们不会削减您依据本隐私政策所应享有的权利。我们会通过在讯工捷使官方网站、讯工捷使移动端上发出更新版本并在生效前通过网站公告或以其他适当方式提醒您相关内容的更新,也请您访问及时了解最新的隐私政策。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">12.2 对于重大变更,我们还会提供更为显著的通知(我们会通过包括但不限于邮件、短信或在浏览页面做特别提示等方式,说明隐私政策的具体变更内容)。</span></p> <p class="p2"><span class="s1">12.2 对于重大变更,我们还会提供更为显著的通知(我们会通过包括但不限于邮件、短信或在浏览页面做特别提示等方式,说明隐私政策的具体变更内容)。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -434,7 +450,7 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">十四、如何联系我们</span></p> <p class="p2"><span class="s1">十四、如何联系我们</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">14.1 如您对本隐私政策或您个人信息的相关事宜有任何问题、意见或建议,请联系笔墨讯使客服(联系邮箱:【fengqunwangluo2025@163.com】)。</span></p> <p class="p2"><span class="s1">14.1 如您对本隐私政策或您个人信息的相关事宜有任何问题、意见或建议,请联系讯工捷使客服(联系邮箱:【support@heshunming.top】)。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">14.2 一般情况下,我们将在15天内回复和处理您的疑问、意见或建议。需注意的是,我们可能不会回复与本政策或您的个人信息权利无关的问题。如果您对我们的回复不满意,特别是我们的个人信息处理行为损害了您的合法权益,您还可以向网信、电信、公安及工商等监管部门进行投诉或举报。或通过向我们所在地有管辖权的法院提起诉讼来寻求解决方案。</span></p> <p class="p2"><span class="s1">14.2 一般情况下,我们将在15天内回复和处理您的疑问、意见或建议。需注意的是,我们可能不会回复与本政策或您的个人信息权利无关的问题。如果您对我们的回复不满意,特别是我们的个人信息处理行为损害了您的合法权益,您还可以向网信、电信、公安及工商等监管部门进行投诉或举报。或通过向我们所在地有管辖权的法院提起诉讼来寻求解决方案。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
@@ -445,6 +461,6 @@
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">15.3 本政策的标题仅为方便阅读而设计,不影响本政策任何条款的含义或解释。</span></p> <p class="p2"><span class="s1">15.3 本政策的标题仅为方便阅读而设计,不影响本政策任何条款的含义或解释。</span></p>
<p class="p1"><span class="s1"></span><br></p> <p class="p1"><span class="s1"></span><br></p>
<p class="p2"><span class="s1">开发者名称:【海口市枫群网络科技有限公司】</span></p> <p class="p2"><span class="s1">开发者名称:【南京赫顺名网络科技有限公司】</span></p>
</body> </body>
</html> </html>
+60 -56
View File
@@ -13,14 +13,14 @@
</style> </style>
</head> </head>
<body> <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="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="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">欢迎您使用【海口市枫群网络科技有限公司】公司(下称“我公司”或“我们”)为您提供的笔墨讯使的服务!</span></p> <p class="p1"><span class="s1">欢迎您使用【南京赫顺名网络科技有限公司】公司(下称“我公司”或“我们”)为您提供的讯工捷使的服务!</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">特别提示:</span></p> <p class="p1"><span class="s1">特别提示:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">为使用笔墨讯使软件(以下简称“本软件”)及服务,您应当阅读并遵守《笔墨讯使软件服务协议》(以下简称“本协议”)。请您务必仔细阅读、充分理解本协议各条款内容,特别是豁免或者限制我公司责任的条款、对用户权利进行限制的条款、约定管辖与法律适用条款,以及开通或使用某项服务的单独协议。限制、免责条款或者其他涉及您重大权益的条款可能以加粗等形式提示您重点注意。如您对本协议有任何疑问,可通过文末联系方式联系我们。</span></p> <p class="p1"><span class="s1">为使用讯工捷使软件(以下简称“本软件”)及服务,您应当阅读并遵守《讯工捷使软件服务协议》(以下简称“本协议”)。请您务必仔细阅读、充分理解本协议各条款内容,特别是豁免或者限制我公司责任的条款、对用户权利进行限制的条款、约定管辖与法律适用条款,以及开通或使用某项服务的单独协议。限制、免责条款或者其他涉及您重大权益的条款可能以加粗等形式提示您重点注意。如您对本协议有任何疑问,可通过文末联系方式联系我们。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">除非您已阅读并接受本协议所有条款,否则您无权注册或登录使用本软件及相关服务。您点击“同意”,或者您注册或登录本软件的行为,或者其他明示或者默示方式表示接受本协议的行为即视为您已阅读并同意接受本协议的约束。本协议即在您与我公司之间产生法律效力,成为对双方均具有约束力的法律文件。</span></p> <p class="p1"><span class="s1">除非您已阅读并接受本协议所有条款,否则您无权注册或登录使用本软件及相关服务。您点击“同意”,或者您注册或登录本软件的行为,或者其他明示或者默示方式表示接受本协议的行为即视为您已阅读并同意接受本协议的约束。本协议即在您与我公司之间产生法律效力,成为对双方均具有约束力的法律文件。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -34,20 +34,20 @@
<p class="p1"><span class="s1">本协议内容同时包括我们可能不断发布的关于本服务的相关协议、业务规则、通知等内容。上述内容一经正式发布,即为本协议不可分割的组成部分,您同样应当遵守。</span></p> <p class="p1"><span class="s1">本协议内容同时包括我们可能不断发布的关于本服务的相关协议、业务规则、通知等内容。上述内容一经正式发布,即为本协议不可分割的组成部分,您同样应当遵守。</span></p>
<p class="p2"><span class="s1"></span><br></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">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></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="p1"><span class="s1"><span class="Apple-converted-space"> </span>二、关于本服务</span></p>
<p class="p2"><span class="s1"></span><br></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">2.1 本服务的内容</span></p>
<p class="p1"><span class="s1">本服务内容是指我们向用户提供的跨平台的通讯工具(以下简称“笔墨讯使”),支持单人、多人参与,在发送语音短信、视频、图片和文字等即时通讯服务的基础上,同时为用户提供包括但不限于网络媒体、互联网增值、互动娱乐、电子商务、电子钱包、开放平台、与其他软件或硬件信息互通等功能或内容的软件许可及服务(以下简称“本服务”)和广告等服务(以下简称“本服务”)。</span></p> <p class="p1"><span class="s1">本服务内容是指我们向用户提供的跨平台的通讯工具(以下简称“讯工捷使”),支持单人、多人参与,在发送语音短信、视频、图片和文字等即时通讯服务的基础上,同时为用户提供包括但不限于网络媒体、互联网增值、互动娱乐、电子商务、电子钱包、开放平台、与其他软件或硬件信息互通等功能或内容的软件许可及服务(以下简称“本服务”)和广告等服务(以下简称“本服务”)。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2 本服务的形式</span></p> <p class="p1"><span class="s1">2.2 本服务的形式</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2.1 您使用本服务需要下载笔墨讯使客户端软件,对于这些软件,我们给予您一项个人的、不可转让及非排他性的许可。您仅可为访问或使用本服务的目的而使用这些软件及服务。</span></p> <p class="p1"><span class="s1">2.2.1 您使用本服务需要下载讯工捷使客户端软件,对于这些软件,我们给予您一项个人的、不可转让及非排他性的许可。您仅可为访问或使用本服务的目的而使用这些软件及服务。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.2.2 本服务中笔墨讯使客户端软件可能会提供包括但不限于iOS、Android、Windows、Mac等多个应用版本,用户必须选择与所安装终端设备相匹配的软件版本。</span></p> <p class="p1"><span class="s1">2.2.2 本服务中讯工捷使客户端软件可能会提供包括但不限于iOS、Android、Windows、Mac等多个应用版本,用户必须选择与所安装终端设备相匹配的软件版本。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">2.3 本服务许可的范围</span></p> <p class="p1"><span class="s1">2.3 本服务许可的范围</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -56,7 +56,7 @@
<p class="p1"><span class="s1">2.3.2 本条及本协议其他条款未明确授权的其他一切权利仍由我们保留,您在行使这些权利时须另外取得我们的书面许可。我们如果未行使前述任何权利,并不构成对该权利的放弃。</span></p> <p class="p1"><span class="s1">2.3.2 本条及本协议其他条款未明确授权的其他一切权利仍由我们保留,您在行使这些权利时须另外取得我们的书面许可。我们如果未行使前述任何权利,并不构成对该权利的放弃。</span></p>
<p class="p2"><span class="s1"></span><br></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">2.4 按现状提供服务</span></p>
<p class="p1"><span class="s1">您理解并同意,笔墨讯使的服务是按照现有技术和条件所能达到的现状提供的。笔墨讯使会尽最大努力向您提供服务,确保服务的连续性和安全性;但笔墨讯使不能随时预见和防范法律、技术以及其他风险,包括但不限于不可抗力、病毒、木马、黑客攻击、系统不稳定、第三方服务缺陷、政府行为等原因可能导致的服务中断、数据丢失以及其他的损失和风险。</span></p> <p class="p1"><span class="s1">您理解并同意,讯工捷使的服务是按照现有技术和条件所能达到的现状提供的。讯工捷使会尽最大努力向您提供服务,确保服务的连续性和安全性;但讯工捷使不能随时预见和防范法律、技术以及其他风险,包括但不限于不可抗力、病毒、木马、黑客攻击、系统不稳定、第三方服务缺陷、政府行为等原因可能导致的服务中断、数据丢失以及其他的损失和风险。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">三、软件的获取与使用</span></p> <p class="p1"><span class="s1">三、软件的获取与使用</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -70,7 +70,7 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.5 为提供更加优质、安全的服务,在本软件安装时我们可能会推荐您安装其他软件,您可以选择安装或不安装。</span></p> <p class="p1"><span class="s1">3.5 为提供更加优质、安全的服务,在本软件安装时我们可能会推荐您安装其他软件,您可以选择安装或不安装。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.6 为了改善用户体验、保证服务的安全性及产品功能的一致性,我们可能会不时对笔墨讯使软件进行更新。我们有权不经过向您特别通知而对软件进行更新,或者对软件的部分功能效果进行改变或限制。您应当将相关软件更新到最新版本,否则我们并不保证其能正常使用。</span></p> <p class="p1"><span class="s1">3.6 为了改善用户体验、保证服务的安全性及产品功能的一致性,我们可能会不时对讯工捷使软件进行更新。我们有权不经过向您特别通知而对软件进行更新,或者对软件的部分功能效果进行改变或限制。您应当将相关软件更新到最新版本,否则我们并不保证其能正常使用。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">3.7 如果您不再需要使用本软件或者需要安装新版软件,可以自行卸载。如果您愿意帮助我们改进产品服务,请告知卸载的原因。</span></p> <p class="p1"><span class="s1">3.7 如果您不再需要使用本软件或者需要安装新版软件,可以自行卸载。如果您愿意帮助我们改进产品服务,请告知卸载的原因。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -78,7 +78,7 @@
<p class="p2"><span class="s1"></span><br></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">4.1 保护用户个人信息是我们的一项基本原则,我们将按照本协议的规定收集、使用、存储您的个人信息,并会采取合理的措施保护您的个人信息。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.2 您在注册账号或使用本服务的过程中,需要提供一些必要的信息,例如:为向您提供账号注册服务或进行用户身份识别,需要您填写手机号码、身份证号码等;笔墨讯使某些功能可能需要收集您的个人信息包括:昵称、头像、手机号、身份证号、加入群组、好友关系等。若国家法律法规或政策有特殊规定的,您需要提供真实的身份信息。若您提供的信息不完整,则无法使用本服务或在使用过程中受到限制。您了解并同意,您有义务保持您提供信息的真实性、有效性及完整性。</span></p> <p class="p1"><span class="s1">4.2 您在注册账号或使用本服务的过程中,需要提供一些必要的信息,例如:为向您提供账号注册服务或进行用户身份识别,需要您填写手机号码、身份证号码等;讯工捷使某些功能可能需要收集您的个人信息包括:昵称、头像、手机号、身份证号、加入群组、好友关系等。若国家法律法规或政策有特殊规定的,您需要提供真实的身份信息。若您提供的信息不完整,则无法使用本服务或在使用过程中受到限制。您了解并同意,您有义务保持您提供信息的真实性、有效性及完整性。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4.3 一般情况下,您可随时查看、修改自己提交的信息,但出于安全性和身份识别(如号码申诉服务)的考虑,您可能无法修改注册时提供的初始注册信息及其他验证信息。</span></p> <p class="p1"><span class="s1">4.3 一般情况下,您可随时查看、修改自己提交的信息,但出于安全性和身份识别(如号码申诉服务)的考虑,您可能无法修改注册时提供的初始注册信息及其他验证信息。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -105,13 +105,13 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1 账号使用规范</span></p> <p class="p1"><span class="s1">5.1 账号使用规范</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.1 您在使用本服务前需要注册一个笔墨讯使账号。本软件为您提供注册及登录通道,笔墨讯使账号通过手机号码进行注册,您可按照页面提示填写信息、阅读并同意本协议,在完成全部注册登录程序后,成为本软件用户。另外我们有权根据用户需求或产品需要对账号注册的方式进行变更,关于您使用账号的具体规则,请遵守相关账号使用协议以及我们为此发布的专项规则。</span></p> <p class="p1"><span class="s1">5.1.1 您在使用本服务前需要注册一个讯工捷使账号。本软件为您提供注册及登录通道,讯工捷使账号通过手机号码进行注册,您可按照页面提示填写信息、阅读并同意本协议,在完成全部注册登录程序后,成为本软件用户。另外我们有权根据用户需求或产品需要对账号注册的方式进行变更,关于您使用账号的具体规则,请遵守相关账号使用协议以及我们为此发布的专项规则。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.2 笔墨讯使账号的所有权归我们公司所有,用户完成申请注册手续后,仅获得笔墨讯使账号的使用权,且该使用权仅属于初始申请注册人。同时,初始申请注册人不得赠与、借用、出租、转让或售卖笔墨讯使账号或者以其他方式许可非初始申请注册人使用笔墨讯使账号。非初始申请注册人不得通过受赠、继承、承租、受让或者其他任何方式使用笔墨讯使账号。如我们判断您账号的使用可能违反法律法规、危及您的账号安全及/或本软件信息安全的,我们可拒绝提供相应服务或终止本协议。我们有权视情况封禁或注销、回收该账号,由此带来的包括但不限于内容、虚拟财产清空等损失由您本人及该账号使用者自行承担。</span></p> <p class="p1"><span class="s1">5.1.2 讯工捷使账号的所有权归我们公司所有,用户完成申请注册手续后,仅获得讯工捷使账号的使用权,且该使用权仅属于初始申请注册人。同时,初始申请注册人不得赠与、借用、出租、转让或售卖讯工捷使账号或者以其他方式许可非初始申请注册人使用讯工捷使账号。非初始申请注册人不得通过受赠、继承、承租、受让或者其他任何方式使用讯工捷使账号。如我们判断您账号的使用可能违反法律法规、危及您的账号安全及/或本软件信息安全的,我们可拒绝提供相应服务或终止本协议。我们有权视情况封禁或注销、回收该账号,由此带来的包括但不限于内容、虚拟财产清空等损失由您本人及该账号使用者自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.3 您注册获得的账号、设置的密码是您登录并以用户身份使用本软件的凭证,您的账户仅限您本人使用,您有责任妥善保管您的注册用户信息及用户密码的安全,您需要对您的注册用户以及密码下的行为承担法律责任。您同意在任何情况下不向他人泄露用户及密码信息。当在您怀疑他人在使用您的账号时,您应立即通知我们。</span></p> <p class="p1"><span class="s1">5.1.3 您注册获得的账号、设置的密码是您登录并以用户身份使用本软件的凭证,您的账户仅限您本人使用,您有责任妥善保管您的注册用户信息及用户密码的安全,您需要对您的注册用户以及密码下的行为承担法律责任。您同意在任何情况下不向他人泄露用户及密码信息。当在您怀疑他人在使用您的账号时,您应立即通知我们。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.4 用户注册笔墨讯使账号后如果长期(超过【6】个月)不登录该账号,我们有权回收该账号,以免造成资源浪费,由此带来的任何损失均由用户自行承担。</span></p> <p class="p1"><span class="s1">5.1.4 用户注册讯工捷使账号后如果长期(超过【6】个月)不登录该账号,我们有权回收该账号,以免造成资源浪费,由此带来的任何损失均由用户自行承担。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.1.5 我们特别提醒,您应妥善保管您的账号和密码。当您使用完本服务后,应安全退出。您须对账号进行的所有活动和行为负责,因您保管不善导致遭受账号或密码失窃、账号被非法使用,相关责任由您自行承担,您可通过司法、行政等救济途径向侵权行为人追偿。</span></p> <p class="p1"><span class="s1">5.1.5 我们特别提醒,您应妥善保管您的账号和密码。当您使用完本服务后,应安全退出。您须对账号进行的所有活动和行为负责,因您保管不善导致遭受账号或密码失窃、账号被非法使用,相关责任由您自行承担,您可通过司法、行政等救济途径向侵权行为人追偿。</span></p>
<p class="p1"><span class="s1">5.1.6您不得恶意注册账号,包括但不限于频繁注册、批量注册、使用他人身份注册或其他不以正常使用本软件服务为目的的账号注册行为。除此外,您的账户不得以任何方式转让,否则我们有权追究您的违约责任,且由此产生的责任及后果均由您自行承担。</span></p> <p class="p1"><span class="s1">5.1.6您不得恶意注册账号,包括但不限于频繁注册、批量注册、使用他人身份注册或其他不以正常使用本软件服务为目的的账号注册行为。除此外,您的账户不得以任何方式转让,否则我们有权追究您的违约责任,且由此产生的责任及后果均由您自行承担。</span></p>
@@ -120,9 +120,9 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.1 您理解并同意:为了向您提供有效的服务,本软件会利用您终端设备的处理器和带宽等资源。本软件使用过程中可能产生数据流量的费用,用户需自行向运营商了解相关资费信息,并自行承担相关费用。</span></p> <p class="p1"><span class="s1">5.2.1 您理解并同意:为了向您提供有效的服务,本软件会利用您终端设备的处理器和带宽等资源。本软件使用过程中可能产生数据流量的费用,用户需自行向运营商了解相关资费信息,并自行承担相关费用。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.2 您理解并同意:本软件的某些功能可能会让第三方知晓用户的信息,例如:用户选择笔墨讯使与其他软件或硬件信息互通后,其他软件或硬件的提供方可以获取用户在笔墨讯使主动公开或传输的相关信息,用户在选择前应充分了解其他软件或硬件的产品功能及信息保护策略。</span></p> <p class="p1"><span class="s1">5.2.2 您理解并同意:本软件的某些功能可能会让第三方知晓用户的信息,例如:用户选择讯工捷使与其他软件或硬件信息互通后,其他软件或硬件的提供方可以获取用户在讯工捷使主动公开或传输的相关信息,用户在选择前应充分了解其他软件或硬件的产品功能及信息保护策略。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.3 您在使用本软件某一特定服务时,该服务可能会另有单独的协议、相关业务规则等(以下统称为“单独协议”),您在使用该项服务前请阅读并同意相关的单独协议;您使用前述特定服务,即视为您接受相关单独协议。例如:如果您需要使用笔墨讯使支付相关服务(包括但不限于笔墨讯使红包、转账、收款、充值、提现等支付服务)时,需阅读并遵守《笔墨讯使支付服务协议》;您使用笔墨讯使支付相关服务,即视为您接受《笔墨讯使支付服务协议》。</span></p> <p class="p1"><span class="s1">5.2.3 您在使用本软件某一特定服务时,该服务可能会另有单独的协议、相关业务规则等(以下统称为“单独协议”),您在使用该项服务前请阅读并同意相关的单独协议;您使用前述特定服务,即视为您接受相关单独协议。例如:如果您需要使用讯工捷使支付相关服务(包括但不限于讯工捷使红包、转账、收款、充值、提现等支付服务)时,需阅读并遵守《讯工捷使支付服务协议》;您使用讯工捷使支付相关服务,即视为您接受《讯工捷使支付服务协议》。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.4 您理解并同意我们将尽其商业上的合理努力保障您在本软件及服务中的数据存储安全,但是,我们并不能就此提供完全保证,包括但不限于以下情形:</span></p> <p class="p1"><span class="s1">5.2.4 您理解并同意我们将尽其商业上的合理努力保障您在本软件及服务中的数据存储安全,但是,我们并不能就此提供完全保证,包括但不限于以下情形:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -142,20 +142,24 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.5.4 用户发布的内容被他人转发、分享,因此类传播可能带来的风险和责任;</span></p> <p class="p1"><span class="s1">5.2.5.4 用户发布的内容被他人转发、分享,因此类传播可能带来的风险和责任;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.5.5 由于无线网络信号不稳定、无线网络带宽小等原因,所引起的笔墨讯使登录失败、资料同步不完整、页面打开速度慢等风险。</span></p> <p class="p1"><span class="s1">5.2.5.5 由于无线网络信号不稳定、无线网络带宽小等原因,所引起的讯工捷使登录失败、资料同步不完整、页面打开速度慢等风险。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.2.6 您理解并同意,笔墨讯使可能会不时发布并执行一些用户奖励活动,相关活动的具体规则的制定权和最终解释权归我们所有,我们有权根据自己的判断决定提前终止活动或延长活动时间。同一笔墨讯使用户名、同一手机号、同一身份证号码、同一手机设备均视为同一用户。如果我们发现有用户利用不正当手段参与活动,或者如果用户存在或涉嫌违法违规行为情况(包括不限于洗钱、外挂、作弊、套现、赌博、刷奖励类的活动、其他风险行为),我们有权取消您参与活动的资格,并有权追回您已经获得的奖励或代币或其他奖品。如果因非实名认证导致您无法参与活动的,视为您自动放弃活动资格。</span></p> <p class="p1"><span class="s1">5.2.6 您理解并同意,讯工捷使可能会不时发布并执行一些用户奖励活动,相关活动的具体规则的制定权和最终解释权归我们所有,我们有权根据自己的判断决定提前终止活动或延长活动时间。同一讯工捷使用户名、同一手机号、同一身份证号码、同一手机设备均视为同一用户。如果我们发现有用户利用不正当手段参与活动,或者如果用户存在或涉嫌违法违规行为情况(包括不限于洗钱、外挂、作弊、套现、赌博、刷奖励类的活动、其他风险行为),我们有权取消您参与活动的资格,并有权追回您已经获得的奖励或代币或其他奖品。如果因非实名认证导致您无法参与活动的,视为您自动放弃活动资格。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3 第三方产品和服务</span></p> <p class="p1"><span class="s1">5.3 第三方产品和服务</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.1 您在使用本软件第三方提供的产品或服务时,除遵守本协议约定外,还应遵守第三方的用户协议。我们和第三方对可能出现的纠纷在法律规定和约定的范围内各自承担责任。</span></p> <p class="p1"><span class="s1">5.3.1 您在使用本软件第三方提供的产品或服务时,除遵守本协议约定外,还应遵守第三方的用户协议。我们和第三方对可能出现的纠纷在法律规定和约定的范围内各自承担责任。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.2 因用户使用本软件或要求我们提供特定服务时,本软件可能会调用第三方系统或者通过第三方支持用户的使用或访问,使用或访问的结果由该第三方提供(包括但不限于第三方通过笔墨讯使开放平台接入的内容等),我们不保证通过第三方提供服务及内容的安全性、准确性、有效性及其它不确定的风险,您应自行判断此类第三方网站或者服务的安全性和可用性,并自行承担相关风险和责任,由此若引发的任何争议及损害,与我们无关,我们不承担任何责任。</span></p> <p class="p1"><span class="s1">5.3.2 因用户使用本软件或要求我们提供特定服务时,本软件可能会调用第三方系统或者通过第三方支持用户的使用或访问,使用或访问的结果由该第三方提供(包括但不限于第三方通过讯工捷使开放平台接入的内容等),我们不保证通过第三方提供服务及内容的安全性、准确性、有效性及其它不确定的风险,您应自行判断此类第三方网站或者服务的安全性和可用性,并自行承担相关风险和责任,由此若引发的任何争议及损害,与我们无关,我们不承担任何责任。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.3 第三方服务提供者可以通过本软件向您提供产品或者服务,比如您可以购买并使用第三方开发的应用程序、接受第三方提供的服务及产品,或者通过笔墨讯使授权登录能力登录并使用其他互联网服务。在此过程中,第三方服务提供者可能会收集、使用和存储您的相关数据或信息,我们通过与第三方服务提供者签订的有关协议、笔墨讯使相关规范以及合理的产品流程设计,严格要求第三方服务提供者获取您的任何数据均应遵守相关法律法规的规定,必须事先获得您的明确同意,采取必要的数据保护措施,且仅为产品或服务之目的合理使用您的相关数据,向您提供修改或删除自己数据的方式,并在停止服务时必须删除全部数据等,尽最大可能保障您的数据和隐私不受侵犯。您在接受或使用第三方产品或服务前应充分了解第三方产品或服务的条款及政策。如果您发现有第三方服务提供者存在相关违法违规行为,可以向我们投诉,我们将查实后予以处理。</span></p> <p class="p1"><span class="s1">5.3.3 第三方服务提供者可以通过本软件向您提供产品或者服务,比如您可以购买并使用第三方开发的应用程序、接受第三方提供的服务及产品,或者通过讯工捷使授权登录能力登录并使用其他互联网服务。在此过程中,第三方服务提供者可能会收集、使用和存储您的相关数据或信息,我们通过与第三方服务提供者签订的有关协议、讯工捷使相关规范以及合理的产品流程设计,严格要求第三方服务提供者获取您的任何数据均应遵守相关法律法规的规定,必须事先获得您的明确同意,采取必要的数据保护措施,且仅为产品或服务之目的合理使用您的相关数据,向您提供修改或删除自己数据的方式,并在停止服务时必须删除全部数据等,尽最大可能保障您的数据和隐私不受侵犯。您在接受或使用第三方产品或服务前应充分了解第三方产品或服务的条款及政策。如果您发现有第三方服务提供者存在相关违法违规行为,可以向我们投诉,我们将查实后予以处理。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.4 您理解并同意,我们有权决定将本软件作商业用途,包括但不限于开发、使用本软件的部分服务为第三方作推广等,我们承诺在推广过程中严格按照本协议约定保护您的个人信息,同时您也可以根据系统设置选择屏蔽、拒绝接收相关推广信息。</span></p> <p class="p1"><span class="s1">5.3.4 您理解并同意,我们有权决定将本软件作商业用途,包括但不限于开发、使用本软件的部分服务为第三方作推广等,我们承诺在推广过程中严格按照本协议约定保护您的个人信息,同时您也可以根据系统设置选择屏蔽、拒绝接收相关推广信息。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.5 AI智能服务。我们可能在本软件中接入DeepSeek AI服务或我们部署、接入的其他人工智能模型,为您提供智能回复、内容生成、内容总结、智能客服、搜索推荐等AI辅助能力。您理解并同意,AI生成内容基于算法模型自动生成,可能存在不准确、不完整、不及时、不适合特定场景或与事实不一致的情况,相关内容仅供参考,不构成法律、医疗、金融、投资、职业等专业建议。您应结合实际情况独立判断并谨慎使用AI生成内容。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.3.6 您使用AI功能时,应遵守法律法规、本协议及相关平台规则,不得输入、生成、传播违法违规、侵害他人合法权益、泄露国家秘密、商业秘密、个人隐私或含有恶意代码、欺诈、歧视、暴力、色情低俗等内容的信息。您应确保输入内容来源合法并已取得必要授权,不得将AI功能用于自动化批量骚扰、虚假信息生成、绕过安全限制、侵犯知识产权或其他不当用途。因您输入、使用、发布或传播AI生成内容产生的责任,由您依法自行承担;我们有权依法依约对违规内容或违规账号采取限制、删除、暂停或终止服务等措施。</span></p>
<p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4账号信息设置及维护</span></p> <p class="p1"><span class="s1">5.4账号信息设置及维护</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5.4.1您在注册或使用本软件时设置的名称、头像和简介等账号信息资料应遵守法律法规、社会道德风尚和信息真实性等原则,不得出现不良信息,不得冒用他人姓名、名称、字号、头像等或采取其他足以引起混淆的方式设置账号,不得侵害第三方的合法权益。</span></p> <p class="p1"><span class="s1">5.4.1您在注册或使用本软件时设置的名称、头像和简介等账号信息资料应遵守法律法规、社会道德风尚和信息真实性等原则,不得出现不良信息,不得冒用他人姓名、名称、字号、头像等或采取其他足以引起混淆的方式设置账号,不得侵害第三方的合法权益。</span></p>
@@ -179,9 +183,9 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1 信息内容规范</span></p> <p class="p1"><span class="s1">6.1 信息内容规范</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.1 本条所述信息内容是指用户使用本软件及服务过程中所制作、复制、发布、传播的任何内容,包括但不限于笔墨讯使账号、昵称、头像、姓名、用户说明等注册信息,或文字、语音、图片等发送、回复、图文和相关链接页面,以及其他使用笔墨讯使账号或本软件及服务所产生的内容。</span></p> <p class="p1"><span class="s1">6.1.1 本条所述信息内容是指用户使用本软件及服务过程中所制作、复制、发布、传播的任何内容,包括但不限于讯工捷使账号、昵称、头像、姓名、用户说明等注册信息,或文字、语音、图片等发送、回复、图文和相关链接页面,以及其他使用讯工捷使账号或本软件及服务所产生的内容。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2 您理解并同意,笔墨讯使一直致力于为用户提供文明健康、规范有序的网络环境,您不得利用笔墨讯使账号或本软件及服务制作、复制、发布、传播如下干扰笔墨讯使正常运行,以及侵犯其他用户或第三方合法权益的内容,包括但不限于:</span></p> <p class="p1"><span class="s1">6.1.2 您理解并同意,讯工捷使一直致力于为用户提供文明健康、规范有序的网络环境,您不得利用讯工捷使账号或本软件及服务制作、复制、发布、传播如下干扰讯工捷使正常运行,以及侵犯其他用户或第三方合法权益的内容,包括但不限于:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.1 发布、传送、传播、存储违反国家法律法规禁止的内容:</span></p> <p class="p1"><span class="s1">6.1.2.1 发布、传送、传播、存储违反国家法律法规禁止的内容:</span></p>
<p class="p1"><span class="s1">(1)违反宪法规定的基本原则,煽动抗拒或者破坏宪法等法律法规实施的;</span></p> <p class="p1"><span class="s1">(1)违反宪法规定的基本原则,煽动抗拒或者破坏宪法等法律法规实施的;</span></p>
@@ -205,18 +209,18 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.4 发表、传送、传播骚扰、广告信息、过度营销信息及垃圾信息或含有任何性或性暗示的;</span></p> <p class="p1"><span class="s1">6.1.2.4 发表、传送、传播骚扰、广告信息、过度营销信息及垃圾信息或含有任何性或性暗示的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.5 其他违反法律法规、政策及公序良俗、社会公德或干扰笔墨讯使正常运行和侵犯其他用户或第三方合法权益内容的信息。</span></p> <p class="p1"><span class="s1">6.1.2.5 其他违反法律法规、政策及公序良俗、社会公德或干扰讯工捷使正常运行和侵犯其他用户或第三方合法权益内容的信息。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.6 您通过本服务发送或传播的内容(包括但不限于网页、文字、图片、音频、视频、图表等)均由您自行承担责任。</span></p> <p class="p1"><span class="s1">6.1.2.6 您通过本服务发送或传播的内容(包括但不限于网页、文字、图片、音频、视频、图表等)均由您自行承担责任。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.6.1 您发送或传播的内容应有合法来源,相关内容为您所有或您已获得权利人的授权。</span></p> <p class="p1"><span class="s1">6.1.2.6.1 您发送或传播的内容应有合法来源,相关内容为您所有或您已获得权利人的授权。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.6.2 您同意笔墨讯使可为履行本协议或提供本服务的目的而使用您发送或传播的内容。</span></p> <p class="p1"><span class="s1">6.1.2.6.2 您同意讯工捷使可为履行本协议或提供本服务的目的而使用您发送或传播的内容。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.6.3 如果笔墨讯使收到权利人通知,主张您发送或传播的内容侵犯其相关权利的,您同意笔墨讯使有权进行独立判断并采取删除、屏蔽或断开链接等措施。</span></p> <p class="p1"><span class="s1">6.1.2.6.3 如果讯工捷使收到权利人通知,主张您发送或传播的内容侵犯其相关权利的,您同意讯工捷使有权进行独立判断并采取删除、屏蔽或断开链接等措施。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.1.2.7 您使用本服务时不得违反国家法律法规、侵犯他人合法权益。</span></p> <p class="p1"><span class="s1">6.1.2.7 您使用本服务时不得违反国家法律法规、侵犯他人合法权益。</span></p>
<p class="p1"><span class="s1">您理解并同意,如果您被他人投诉侵权或您投诉他人侵权,笔墨讯使有权将争议中相关方的主体、联系方式、投诉相关内容等必要信息提供给其他争议方或相关部门,以便及时解决投诉纠纷,保护他人合法权益。</span></p> <p class="p1"><span class="s1">您理解并同意,如果您被他人投诉侵权或您投诉他人侵权,讯工捷使有权将争议中相关方的主体、联系方式、投诉相关内容等必要信息提供给其他争议方或相关部门,以便及时解决投诉纠纷,保护他人合法权益。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2 软件使用规范</span></p> <p class="p1"><span class="s1">6.2 软件使用规范</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -242,7 +246,7 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.1.10 其他以任何不合法的方式、为任何不合法的目的、或与我们为此制定的其他规范和标准不一致的方式使用本软件及未经我们明确授权的行为。</span></p> <p class="p1"><span class="s1">6.2.1.10 其他以任何不合法的方式、为任何不合法的目的、或与我们为此制定的其他规范和标准不一致的方式使用本软件及未经我们明确授权的行为。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.2 您理解并同意,基于用户体验、笔墨讯使或相关服务平台运营安全、平台规则要求及健康发展等综合因素,我们有权选择提供服务的对象,有权决定功能设置,有权决定功能开放、数据接口和相关数据披露的对象和范围。针对以下情形,有权视具体情况中止或终止提供本服务,包括但不限于:</span></p> <p class="p1"><span class="s1">6.2.2 您理解并同意,基于用户体验、讯工捷使或相关服务平台运营安全、平台规则要求及健康发展等综合因素,我们有权选择提供服务的对象,有权决定功能设置,有权决定功能开放、数据接口和相关数据披露的对象和范围。针对以下情形,有权视具体情况中止或终止提供本服务,包括但不限于:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.2.1 违反法律法规或本协议规定的;</span></p> <p class="p1"><span class="s1">6.2.2.1 违反法律法规或本协议规定的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -250,19 +254,19 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.2.3 存在安全隐患的;</span></p> <p class="p1"><span class="s1">6.2.2.3 存在安全隐患的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.2.4 与笔墨讯使或其服务平台已有主要功能或功能组件相似、相同,或可实现上述功能或功能组件的主要效用的;</span></p> <p class="p1"><span class="s1">6.2.2.4 与讯工捷使或其服务平台已有主要功能或功能组件相似、相同,或可实现上述功能或功能组件的主要效用的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.2.5 界面、风格、功能、描述或使用者体验与笔墨讯使或其服务平台相似,可能造成笔墨讯使用户认为其所使用的功能或服务来源于我们或经我们授权的;</span></p> <p class="p1"><span class="s1">6.2.2.5 界面、风格、功能、描述或使用者体验与讯工捷使或其服务平台相似,可能造成讯工捷使用户认为其所使用的功能或服务来源于我们或经我们授权的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.2.2.6 违反笔墨讯使或其服务平台运营原则,或不符合我们其他管理要求的。</span></p> <p class="p1"><span class="s1">6.2.2.6 违反讯工捷使或其服务平台运营原则,或不符合我们其他管理要求的。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3 服务运营规范</span></p> <p class="p1"><span class="s1">6.3 服务运营规范</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.1 除非您与笔墨讯使另有约定,您同意本服务仅为您个人非商业性质的使用。</span></p> <p class="p1"><span class="s1">6.3.1 除非您与讯工捷使另有约定,您同意本服务仅为您个人非商业性质的使用。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.2 您应当通过笔墨讯使提供或认可的方式使用本服务。您依本协议条款所获得的权利不可转让。</span></p> <p class="p1"><span class="s1">6.3.2 您应当通过讯工捷使提供或认可的方式使用本服务。您依本协议条款所获得的权利不可转让。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.3 您不得使用未经笔墨讯使授权的插件、外挂或第三方工具对本协议项下的服务进行干扰、破坏、修改或施加其他影响。</span></p> <p class="p1"><span class="s1">6.3.3 您不得使用未经讯工捷使授权的插件、外挂或第三方工具对本协议项下的服务进行干扰、破坏、修改或施加其他影响。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.4 除非法律允许或我们书面许可,您使用本服务过程中不得从事下列行为:</span></p> <p class="p1"><span class="s1">6.3.4 除非法律允许或我们书面许可,您使用本服务过程中不得从事下列行为:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -274,15 +278,15 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">4) 侵犯他人名誉权、肖像权、知识产权、商业秘密等合法权利的;</span></p> <p class="p1"><span class="s1">4) 侵犯他人名誉权、肖像权、知识产权、商业秘密等合法权利的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">5) 未经我们书面许可利用笔墨讯使账号和任何功能,以及第三方运营平台进行推广或相互推广的;</span></p> <p class="p1"><span class="s1">5) 未经我们书面许可利用讯工捷使账号和任何功能,以及第三方运营平台进行推广或相互推广的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6) 利用笔墨讯使账号或本软件及服务从事任何违法犯罪活动的;</span></p> <p class="p1"><span class="s1">6) 利用讯工捷使账号或本软件及服务从事任何违法犯罪活动的;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8) 其他违反法律法规规定、侵犯其他用户合法权益、干扰产品正常运营或我们未明确授权的行为。</span></p> <p class="p1"><span class="s1">8) 其他违反法律法规规定、侵犯其他用户合法权益、干扰产品正常运营或我们未明确授权的行为。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.5 您在使用本服务过程中应当遵守当地相关的法律法规,并尊重当地的道德和风俗习惯。如果您的行为违反了当地法律法规或道德风俗,您应当为此单独承担责任。</span></p> <p class="p1"><span class="s1">6.3.5 您在使用本服务过程中应当遵守当地相关的法律法规,并尊重当地的道德和风俗习惯。如果您的行为违反了当地法律法规或道德风俗,您应当为此单独承担责任。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">6.3.6 您应避免因使用本服务而使笔墨讯使卷入政治和公共事件,否则笔墨讯使有权暂停或终止对您的服务。</span></p> <p class="p1"><span class="s1">6.3.6 您应避免因使用本服务而使讯工捷使卷入政治和公共事件,否则讯工捷使有权暂停或终止对您的服务。</span></p>
<p class="p2"><span class="s1"></span><br></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">6.4 对自己行为负责</span></p>
<p class="p1"><span class="s1">您充分了解并同意,您必须为自己注册账号下的一切行为负责,包括您所发表的任何内容以及由此产生的任何后果。您应对本服务中的内容自行加以判断,并承担因使用内容而引起的所有风险,包括因对内容的正确性、完整性或实用性的依赖而产生的风险。我们无法且不会对因前述风险而导致的任何损失或损害承担责任。</span></p> <p class="p1"><span class="s1">您充分了解并同意,您必须为自己注册账号下的一切行为负责,包括您所发表的任何内容以及由此产生的任何后果。您应对本服务中的内容自行加以判断,并承担因使用内容而引起的所有风险,包括因对内容的正确性、完整性或实用性的依赖而产生的风险。我们无法且不会对因前述风险而导致的任何损失或损害承担责任。</span></p>
@@ -307,19 +311,19 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">七、知识产权声明</span></p> <p class="p1"><span class="s1">七、知识产权声明</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.1 笔墨讯使在本服务中提供的内容(包括但不限于网页、文字、图片、音频、视频、图表、文字表述及其组合、界面设计、版面框架、图标、商标等)的知识产权归笔墨讯使所有,受中华人民共和国著作权法、商标法、专利法、反不正当竞争法及国际条约等法律法规的保护。未经我们书面允许,用户不得为任何目的擅自使用、复制、再造这些内容、或创造与内容有关的派生产品。用户在使用本服务中所产生的内容的知识产权归用户或相关权利人所有。</span></p> <p class="p1"><span class="s1">7.1 讯工捷使在本服务中提供的内容(包括但不限于网页、文字、图片、音频、视频、图表、文字表述及其组合、界面设计、版面框架、图标、商标等)的知识产权归讯工捷使所有,受中华人民共和国著作权法、商标法、专利法、反不正当竞争法及国际条约等法律法规的保护。未经我们书面允许,用户不得为任何目的擅自使用、复制、再造这些内容、或创造与内容有关的派生产品。用户在使用本服务中所产生的内容的知识产权归用户或相关权利人所有。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.2 除另有特别声明外,笔墨讯使提供本服务时所依据软件的著作权、专利权及其他知识产权均归笔墨讯使所有。</span></p> <p class="p1"><span class="s1">7.2 除另有特别声明外,讯工捷使提供本服务时所依据软件的著作权、专利权及其他知识产权均归讯工捷使所有。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.3 笔墨讯使在本服务中所使用的“笔墨讯使”等形象等商业标识,其著作权或商标权归笔墨讯使所有。</span></p> <p class="p1"><span class="s1">7.3 讯工捷使在本服务中所使用的“讯工捷使”等形象等商业标识,其著作权或商标权归讯工捷使所有。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">7.4 上述及其他任何本服务包含的内容的知识产权均受到法律保护,未经笔墨讯使、用户或相关权利人书面许可,任何人不得以任何形式进行使用或创作相关衍生作品。</span></p> <p class="p1"><span class="s1">7.4 上述及其他任何本服务包含的内容的知识产权均受到法律保护,未经讯工捷使、用户或相关权利人书面许可,任何人不得以任何形式进行使用或创作相关衍生作品。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">八、终端安全责任</span></p> <p class="p1"><span class="s1">八、终端安全责任</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.1 您理解并同意,本软件同大多数互联网软件一样,可能会受多种因素影响,包括但不限于用户原因、网络服务质量、社会环境等;也可能会受各种安全问题的侵扰,包括但不限于他人非法利用用户资料,进行现实中的骚扰;用户下载安装的其他软件或访问的其他网站中可能含有病毒、木马程序或其他恶意程序,威胁您的终端设备信息和数据安全,继而影响本软件的正常使用等。因此,您应加强信息安全及个人信息的保护意识,注意密码保护,以免遭受损失。</span></p> <p class="p1"><span class="s1">8.1 您理解并同意,本软件同大多数互联网软件一样,可能会受多种因素影响,包括但不限于用户原因、网络服务质量、社会环境等;也可能会受各种安全问题的侵扰,包括但不限于他人非法利用用户资料,进行现实中的骚扰;用户下载安装的其他软件或访问的其他网站中可能含有病毒、木马程序或其他恶意程序,威胁您的终端设备信息和数据安全,继而影响本软件的正常使用等。因此,您应加强信息安全及个人信息的保护意识,注意密码保护,以免遭受损失。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.2 您不得制作、发布、使用、传播用于窃取笔墨讯使账号及他人个人信息、财产的恶意程序。</span></p> <p class="p1"><span class="s1">8.2 您不得制作、发布、使用、传播用于窃取讯工捷使账号及他人个人信息、财产的恶意程序。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">8.3 维护软件安全与正常使用是我们和您的共同责任,我们将按照行业标准合理审慎地采取必要技术措施保护您的终端设备信息和数据安全,但是您承认和同意我们并不能就此提供完全保证。</span></p> <p class="p1"><span class="s1">8.3 维护软件安全与正常使用是我们和您的共同责任,我们将按照行业标准合理审慎地采取必要技术措施保护您的终端设备信息和数据安全,但是您承认和同意我们并不能就此提供完全保证。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -407,21 +411,21 @@
<p class="p2"><span class="s1"></span><br></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"><span class="Apple-converted-space"> </span>十、不可抗力及其他免责事由</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.1 您理解并同意,在使用本服务的过程中,可能会遇到不可抗力等风险因素,使本服务发生中断。不可抗力是指不能预见、不能克服并不能避免且对一方或双方造成重大影响的客观事件,包括但不限于自然灾害如水灾、地震、瘟疫流行和风暴等以及社会事件如战争、动乱、政府行为等。出现上述情况时,笔墨讯使将努力在第一时间与相关单位配合,及时进行修复,但由此给您造成的损失笔墨讯使在法律允许的范围内免责。</span></p> <p class="p1"><span class="s1">10.1 您理解并同意,在使用本服务的过程中,可能会遇到不可抗力等风险因素,使本服务发生中断。不可抗力是指不能预见、不能克服并不能避免且对一方或双方造成重大影响的客观事件,包括但不限于自然灾害如水灾、地震、瘟疫流行和风暴等以及社会事件如战争、动乱、政府行为等。出现上述情况时,讯工捷使将努力在第一时间与相关单位配合,及时进行修复,但由此给您造成的损失讯工捷使在法律允许的范围内免责。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.2 在法律允许的范围内,笔墨讯使对以下情形导致的服务中断或受阻不承担责任:</span></p> <p class="p1"><span class="s1">10.2 在法律允许的范围内,讯工捷使对以下情形导致的服务中断或受阻不承担责任:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(1)受到计算机病毒、木马或其他恶意程序、黑客攻击的破坏;</span></p> <p class="p1"><span class="s1">(1)受到计算机病毒、木马或其他恶意程序、黑客攻击的破坏;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(2)用户或笔墨讯使的电脑软件、系统、硬件和通信线路出现故障;</span></p> <p class="p1"><span class="s1">(2)用户或讯工捷使的电脑软件、系统、硬件和通信线路出现故障;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(3)用户操作不当;</span></p> <p class="p1"><span class="s1">(3)用户操作不当;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(4)用户通过非笔墨讯使授权的方式使用本服务;</span></p> <p class="p1"><span class="s1">(4)用户通过非讯工捷使授权的方式使用本服务;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(5)其他笔墨讯使无法控制或合理预见的情形。</span></p> <p class="p1"><span class="s1">(5)其他讯工捷使无法控制或合理预见的情形。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.3 您理解并同意,在使用本服务的过程中,可能会遇到网络信息或其他用户行为带来的风险,笔墨讯使不对任何信息的真实性、适用性、合法性承担责任,也不对因侵权行为给您造成的损害负责。这些风险包括但不限于:</span></p> <p class="p1"><span class="s1">10.3 您理解并同意,在使用本服务的过程中,可能会遇到网络信息或其他用户行为带来的风险,讯工捷使不对任何信息的真实性、适用性、合法性承担责任,也不对因侵权行为给您造成的损害负责。这些风险包括但不限于:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(1)来自他人匿名或冒名的含有威胁、诽谤、令人反感或非法内容的信息;</span></p> <p class="p1"><span class="s1">(1)来自他人匿名或冒名的含有威胁、诽谤、令人反感或非法内容的信息;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -429,19 +433,19 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(3)其他因网络信息或用户行为引起的风险。</span></p> <p class="p1"><span class="s1">(3)其他因网络信息或用户行为引起的风险。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.4 您理解并同意,本服务并非为某些特定目的而设计,包括但不限于核设施、军事用途、医疗设施、交通通信等重要领域。如果因为软件或服务的原因导致上述操作失败而带来的人员伤亡、财产损失和环境污染等,笔墨讯使不承担法律责任。</span></p> <p class="p1"><span class="s1">10.4 您理解并同意,本服务并非为某些特定目的而设计,包括但不限于核设施、军事用途、医疗设施、交通通信等重要领域。如果因为软件或服务的原因导致上述操作失败而带来的人员伤亡、财产损失和环境污染等,讯工捷使不承担法律责任。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.5 笔墨讯使根据本协议约定获得处理违法违规内容的权利,该权利不构成笔墨讯使的义务或承诺,笔墨讯使不能保证及时发现违法行为或进行相应处理。</span></p> <p class="p1"><span class="s1">10.5 讯工捷使根据本协议约定获得处理违法违规内容的权利,该权利不构成讯工捷使的义务或承诺,讯工捷使不能保证及时发现违法行为或进行相应处理。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">10.6 在任何情况下,您不应轻信借款、索要密码或其他涉及财产的网络信息。涉及财产操作的,请一定先核实对方身份,并请经常留意笔墨讯使有关防范诈骗犯罪的提示。</span></p> <p class="p1"><span class="s1">10.6 在任何情况下,您不应轻信借款、索要密码或其他涉及财产的网络信息。涉及财产操作的,请一定先核实对方身份,并请经常留意讯工捷使有关防范诈骗犯罪的提示。</span></p>
<p class="p2"><span class="s1"></span><br></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"><span class="Apple-converted-space"> </span>十一、服务的变更、中断、终止</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.1 笔墨讯使可能会对服务内容进行变更,也可能会中断、中止或终止服务。</span></p> <p class="p1"><span class="s1">11.1 讯工捷使可能会对服务内容进行变更,也可能会中断、中止或终止服务。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.2 您理解并同意,笔墨讯使有权自主决定经营策略。在笔墨讯使发生合并、分立、收购、资产转让时,笔墨讯使可向第三方转让本服务下相关资产;笔墨讯使也可在单方通知您后,将本协议下部分或全部服务转交由第三方经营或履行。具体受让主体以笔墨讯使通知的为准。</span></p> <p class="p1"><span class="s1">11.2 您理解并同意,讯工捷使有权自主决定经营策略。在讯工捷使发生合并、分立、收购、资产转让时,讯工捷使可向第三方转让本服务下相关资产;讯工捷使也可在单方通知您后,将本协议下部分或全部服务转交由第三方经营或履行。具体受让主体以讯工捷使通知的为准。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.3 如发生下列任何一种情形,笔墨讯使有权不经通知而中断或终止向您提供的服务:</span></p> <p class="p1"><span class="s1">11.3 如发生下列任何一种情形,讯工捷使有权不经通知而中断或终止向您提供的服务:</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(1)根据法律规定您应提交真实信息,而您提供的个人资料不真实、或与注册时信息不一致又未能提供合理证明;</span></p> <p class="p1"><span class="s1">(1)根据法律规定您应提交真实信息,而您提供的个人资料不真实、或与注册时信息不一致又未能提供合理证明;</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -451,13 +455,13 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">(4)出于安全的原因或其他必要的情形。</span></p> <p class="p1"><span class="s1">(4)出于安全的原因或其他必要的情形。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.4 笔墨讯使有权对部分服务进行收费。若您未按时足额付费,笔墨讯使有权中断、中止或终止提供服务。</span></p> <p class="p1"><span class="s1">11.4 讯工捷使有权对部分服务进行收费。若您未按时足额付费,讯工捷使有权中断、中止或终止提供服务。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.5 我们始终在不断变更和改进本软件服务,我们会尽最大努力向您提供服务,确保服务的连贯性和安全性。基于此,我们可能会对本软件进行更新,您应将软件更新到最新版本,否则我们不保证您可正常使用本软件。</span></p> <p class="p1"><span class="s1">11.5 我们始终在不断变更和改进本软件服务,我们会尽最大努力向您提供服务,确保服务的连贯性和安全性。基于此,我们可能会对本软件进行更新,您应将软件更新到最新版本,否则我们不保证您可正常使用本软件。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.6 您理解并同意,我公司根据自身商业决策、政府行为、不可抗力等原因可能会选择中止、中断及终止本软件的部分或全部服务。如有此等情形发生,我们会采取合理的方式并以合理的期限提前通知您,除法律法规另有规定外,我们不承担由此对您造成的损失。</span></p> <p class="p1"><span class="s1">11.6 您理解并同意,我公司根据自身商业决策、政府行为、不可抗力等原因可能会选择中止、中断及终止本软件的部分或全部服务。如有此等情形发生,我们会采取合理的方式并以合理的期限提前通知您,除法律法规另有规定外,我们不承担由此对您造成的损失。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">11.7 您有责任自行备份存储在本服务中的数据。如果您的服务被终止,笔墨讯使可以从服务器上永久地删除您的数据,但法律法规另有规定的除外。服务终止后,笔墨讯使没有义务向您返还数据。</span></p> <p class="p1"><span class="s1">11.7 您有责任自行备份存储在本服务中的数据。如果您的服务被终止,讯工捷使可以从服务器上永久地删除您的数据,但法律法规另有规定的除外。服务终止后,讯工捷使没有义务向您返还数据。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">十二、未成年人使用条款</span></p> <p class="p1"><span class="s1">十二、未成年人使用条款</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
@@ -517,7 +521,7 @@
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.6 本协议条款无论因何种原因部分无效或不可执行,其余条款仍有效,对双方具有约束力。</span></p> <p class="p1"><span class="s1">15.6 本协议条款无论因何种原因部分无效或不可执行,其余条款仍有效,对双方具有约束力。</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
<p class="p1"><span class="s1">15.7 如您对本协议有任何意见或建议,您可通过本平台联系我们,联系方式为【fengqunwangluo2025@163.com】,我们会给予您必要的帮助。</span></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="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"><span class="Apple-converted-space"> </span>自启动或关联启动说明</span></p>
<p class="p2"><span class="s1"></span><br></p> <p class="p2"><span class="s1"></span><br></p>
+4 -2
View File
@@ -62,8 +62,10 @@ type legalDocument struct {
} }
var legalDocuments = map[string]legalDocument{ var legalDocuments = map[string]legalDocument{
"/legal/user-terms": loadLegalDocument("legal/user-terms.html", "笔墨讯使软件服务协议"), "/legal/user-terms": loadLegalDocument("legal/user-terms.html", "讯工捷使软件服务协议"),
"/legal/privacy-policy": loadLegalDocument("legal/privacy-policy.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 { func loadLegalDocument(name, title string) legalDocument {
+2 -2
View File
@@ -12,8 +12,8 @@ func TestServeLegalDocument(t *testing.T) {
path string path string
title string title string
}{ }{
{path: "/legal/user-terms", title: "笔墨讯使软件服务协议"}, {path: "/legal/user-terms", title: "讯工捷使软件服务协议"},
{path: "/legal/privacy-policy", title: "笔墨讯使隐私政策"}, {path: "/legal/privacy-policy", title: "讯工捷使隐私政策"},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) { t.Run(tc.path, func(t *testing.T) {
+18 -4
View File
@@ -281,17 +281,17 @@ func resolveMediaHandlerConfig(config *mediaConfig) (string, string, error) {
conf = string(params) conf = string(params)
} }
} }
if handler == "oss" { if handler == "oss" || handler == "minio" {
ossConf, err := ensureOSSUsesSysConfig(conf) storageConf, err := ensureObjectStorageUsesSysConfig(conf, handler)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
return handler, ossConf, nil return handler, storageConf, nil
} }
return handler, conf, nil return handler, conf, nil
} }
func ensureOSSUsesSysConfig(conf string) (string, error) { func ensureObjectStorageUsesSysConfig(conf, provider string) (string, error) {
params := map[string]any{} params := map[string]any{}
if strings.TrimSpace(conf) != "" { if strings.TrimSpace(conf) != "" {
if err := json.Unmarshal([]byte(conf), &params); err != nil { 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["use_sys_config"] = true
params["provider"] = provider
encoded, err := json.Marshal(params) encoded, err := json.Marshal(params)
if err != nil { if err != nil {
return "", err return "", err
@@ -322,6 +323,10 @@ type configType struct {
// If true, do not attempt to negotiate websocket per message compression (RFC 7692.4). // If true, do not attempt to negotiate websocket per message compression (RFC 7692.4).
// It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy. // It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy.
WSCompressionDisabled bool `json:"ws_compression_disabled"` WSCompressionDisabled bool `json:"ws_compression_disabled"`
// WebSocket heartbeat period and the maximum time to wait for a Pong, in seconds.
// Zero values use the built-in defaults.
WSPingPeriodSeconds int `json:"ws_ping_period_seconds"`
WSPongWaitSeconds int `json:"ws_pong_wait_seconds"`
// Address:port to listen for gRPC clients. If blank gRPC support will not be initialized. // Address:port to listen for gRPC clients. If blank gRPC support will not be initialized.
// Could be overridden from the command line with --grpc_listen. // Could be overridden from the command line with --grpc_listen.
GrpcListen string `json:"grpc_listen"` GrpcListen string `json:"grpc_listen"`
@@ -716,6 +721,10 @@ func main() {
// Websocket compression. // Websocket compression.
globals.wsCompression = !config.WSCompressionDisabled globals.wsCompression = !config.WSCompressionDisabled
if err = configureWSHeartbeat(config.WSPingPeriodSeconds, config.WSPongWaitSeconds); err != nil {
logs.Err.Fatal("Invalid WebSocket heartbeat configuration: ", err)
}
logs.Info.Printf("WebSocket heartbeat ping=%s pong_wait=%s", pingPeriod, pongWait)
if config.Media != nil { if config.Media != nil {
if config.Media.UseHandler == "" { if config.Media.UseHandler == "" {
@@ -889,6 +898,9 @@ func main() {
// Public legal pages are opened directly by clients before login. // Public legal pages are opened directly by clients before login.
mux.HandleFunc("/legal/user-terms", serveLegalDocument) mux.HandleFunc("/legal/user-terms", serveLegalDocument)
mux.HandleFunc("/legal/privacy-policy", 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. // Handle websocket clients.
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket) mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
@@ -919,6 +931,8 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe) mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe)
mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile) mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile)
mux.HandleFunc(config.ApiPath+"v1/im/me/privacy", serveIMMePrivacy) mux.HandleFunc(config.ApiPath+"v1/im/me/privacy", serveIMMePrivacy)
mux.HandleFunc(config.ApiPath+"v1/im/me/password", serveIMMePasswordChange)
mux.HandleFunc(config.ApiPath+"v1/im/me/deletion", serveIMMeDeletion)
mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/enable", serveIMYouthModeEnable) mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/enable", serveIMYouthModeEnable)
mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/disable", serveIMYouthModeDisable) mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/disable", serveIMYouthModeDisable)
mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode", serveIMYouthMode) mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode", serveIMYouthMode)

Some files were not shown because too many files have changed in this diff Show More