Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc101b3278 | ||
|
|
39b0392545 | ||
|
|
93a6ff0d03 | ||
|
|
9c10862a6d | ||
|
|
5197960e58 | ||
|
|
fe5f870b6b | ||
|
|
5b270860e7 | ||
|
|
209f105b12 | ||
|
|
5d49008960 | ||
|
|
73c3dc0541 | ||
|
|
4c2c35b55b | ||
|
|
178ecff21f | ||
|
|
9132207e21 | ||
|
|
ea29fef40f | ||
|
|
dbc7c6a3e9 | ||
|
|
3b186298a0 | ||
|
|
ab572a6c36 | ||
|
|
441009b81c | ||
|
|
297f1fa0b7 | ||
|
|
027dda786e | ||
|
|
87ec8f9833 | ||
|
|
6930b23cce | ||
|
|
e8804883fc | ||
|
|
43808c36d1 | ||
|
|
b126b13b77 | ||
|
|
f3b1a06da5 | ||
|
|
6be7796049 | ||
|
|
0a33d21eb8 | ||
|
|
5d792f9f96 | ||
|
|
c219ef6455 | ||
|
|
1e0b4c181d | ||
|
|
54c594f39b | ||
|
|
2e1ceec717 | ||
|
|
1d8f9e3be4 | ||
|
|
8228ceb89f | ||
|
|
f24133f5de | ||
|
|
e30b70a3a7 | ||
|
|
ac6cd3c6e8 | ||
|
|
58c15efad5 | ||
|
|
2732e05a83 | ||
|
|
d3054f62b5 | ||
|
|
965f94f154 | ||
|
|
b05e8f60dd | ||
|
|
c46209f4e7 | ||
|
|
7b25365ec3 | ||
|
|
746f75ab0f | ||
|
|
db92c41241 | ||
|
|
80808349fb | ||
|
|
a406d63154 | ||
|
|
d43b1b63d3 | ||
|
|
dccd0969d8 | ||
|
|
73a594146f | ||
|
|
19584d0939 | ||
|
|
2b631ab176 | ||
|
|
b83800c0f3 | ||
|
|
05b3da55ac | ||
|
|
99ae6328ff | ||
|
|
7685fb0c02 | ||
|
|
097b028a19 | ||
|
|
dcb131dcea | ||
|
|
d75c6fa0b4 | ||
|
|
06e08a65e4 | ||
|
|
9f30995787 | ||
|
|
da8efb6e38 | ||
|
|
b1545b6876 | ||
|
|
4b00b5ae37 | ||
|
|
ab73b69870 | ||
|
|
cf9d559944 | ||
|
|
1a8043bee2 | ||
|
|
7c550aaf9d | ||
|
|
0718da784f | ||
|
|
2f8ded104d | ||
|
|
1ff0894205 | ||
|
|
a974572f72 | ||
|
|
d3c679878d | ||
|
|
cb2210aa3e | ||
|
|
622fc50418 | ||
|
|
6b8ed4a2cc | ||
|
|
fc875cc84e | ||
|
|
e90e92a59e | ||
|
|
72e6c7b2c7 | ||
|
|
ea8732e2b1 | ||
|
|
bde02e8eac | ||
|
|
8e778726e0 | ||
|
|
36b52e120c | ||
|
|
3bb6774786 | ||
|
|
36eb76a5e3 | ||
|
|
0cf88e6e97 | ||
|
|
3fb95fd5d9 | ||
|
|
d96f661f30 | ||
|
|
704c65a7a4 | ||
|
|
188164ce82 | ||
|
|
d0e1e98f5f | ||
|
|
ecfe13ff0e | ||
|
|
8280ac5987 | ||
|
|
f9f7321b11 | ||
|
|
a57d1e8d4a | ||
|
|
96eb30b46f | ||
|
|
ce3250be64 | ||
|
|
a2de100f2b | ||
|
|
ae2dc0d45e | ||
|
|
c8d08e26e2 | ||
|
|
9958b3c462 | ||
|
|
07eb0b05ef | ||
|
|
6d17154a72 | ||
|
|
b3b5dd55a6 | ||
|
|
021169555a | ||
|
|
6e416fcfd0 | ||
|
|
c235139d83 | ||
|
|
2b90a2e759 | ||
|
|
f898a7171c | ||
|
|
7f2df1dd4f | ||
|
|
1f35d4f786 | ||
|
|
31a70c0bac | ||
|
|
7405277f27 | ||
|
|
6453abdbd1 | ||
|
|
d41956019a | ||
|
|
f952249dc3 | ||
|
|
0881256103 | ||
|
|
5cea4d1e2e | ||
|
|
5ce04bad05 | ||
|
|
48ca3ae684 | ||
|
|
75216c311d | ||
|
|
9893452817 | ||
|
|
a3df9d269c | ||
|
|
68a0228dff | ||
|
|
da02eae6a4 | ||
|
|
3e92021254 | ||
|
|
aa9f562e1e | ||
|
|
204043382c | ||
|
|
7d4bc4d049 | ||
|
|
134ac8580e | ||
|
|
40aba2a3f5 | ||
|
|
6bbe955e75 | ||
|
|
b8b21d3548 | ||
|
|
a0d0bf574e | ||
|
|
c4355ac236 | ||
|
|
ac20f1ed25 | ||
|
|
b413560455 | ||
|
|
6303d07580 | ||
|
|
7f0903cd31 | ||
|
|
35a70ce9ff | ||
|
|
6fa3009987 | ||
|
|
a968fd252e | ||
|
|
7c1fab5065 | ||
|
|
137d6fb6da | ||
|
|
e37b52c3c0 | ||
|
|
1ec2feee47 | ||
|
|
62856194e5 | ||
|
|
1909074898 | ||
|
|
9ddf6ec5ee | ||
|
|
49e43aa3cf | ||
|
|
8e04f00e96 | ||
|
|
e4416da04a | ||
|
|
b8c93e8d6a | ||
|
|
76b462e334 | ||
|
|
2e0612acbb | ||
|
|
865b436264 | ||
|
|
ac10b31aa1 | ||
|
|
3276c6d1cd | ||
|
|
dc36ce292e | ||
|
|
186cfa21c8 | ||
|
|
fc393cd685 | ||
|
|
b30cce950a | ||
|
|
4abd6604d7 | ||
|
|
eca2ee56c0 | ||
|
|
9820e89e1c | ||
|
|
55c59878cf | ||
|
|
a147dc718a | ||
|
|
407842db91 | ||
|
|
5f717c2162 | ||
|
|
d398467fb9 | ||
|
|
9748c7a478 | ||
|
|
7829990180 | ||
|
|
05ad37ba80 | ||
|
|
c891afba73 | ||
|
|
e3b93113f1 | ||
|
|
841c0c77cd | ||
|
|
21063d4af0 | ||
|
|
532ff49737 | ||
|
|
5c276596b8 | ||
|
|
48b3369799 | ||
|
|
b2e8b64c0b | ||
|
|
db66a965fa | ||
|
|
de01a95c96 | ||
|
|
a08c0bd921 | ||
|
|
0fcbbb9318 | ||
|
|
753a04ed12 | ||
|
|
f4cedc7d75 | ||
|
|
8b20118570 | ||
|
|
9482bb19f6 | ||
|
|
153f541078 | ||
|
|
192bdede33 | ||
|
|
4c809129eb | ||
|
|
3a92d81fca | ||
|
|
7a52db3437 | ||
|
|
c6e7f6b923 | ||
|
|
0e2f4e73d1 | ||
|
|
69a777c81f | ||
|
|
b9aaccc830 | ||
|
|
ac75caba86 | ||
|
|
c8d9146604 | ||
|
|
ed976c76ff | ||
|
|
50f8581667 | ||
|
|
760de35e10 | ||
|
|
8279ae577f | ||
|
|
734e042af6 | ||
|
|
816be46e03 | ||
|
|
7df3313c78 | ||
|
|
0f8e3b82b7 | ||
|
|
0ae4af0c31 | ||
|
|
6729f40665 | ||
|
|
7461216c03 | ||
|
|
370e9878ed | ||
|
|
fa95dd72af | ||
|
|
1038d25abd | ||
|
|
a01cdae971 | ||
|
|
c0ab94723c | ||
|
|
846615db69 | ||
|
|
4280b535c2 | ||
|
|
68b196215f | ||
|
|
1de69fcc5b | ||
|
|
9626e8ee7c | ||
|
|
139681fadb | ||
|
|
b7c2536527 | ||
|
|
dbb0a06db0 | ||
|
|
563ae9ee5e | ||
|
|
e29b5d3589 | ||
|
|
152e2402fa | ||
|
|
bc27a5c574 | ||
|
|
55afd976c9 | ||
|
|
5dd0a42002 | ||
|
|
4f6a996922 | ||
|
|
9ec5da5b02 | ||
|
|
ef8091d805 | ||
|
|
15abc1b6e8 | ||
|
|
0cf2fb61a7 | ||
|
|
5384a8fa7b | ||
|
|
fca0a5baf9 | ||
|
|
40510a616e | ||
|
|
7196d0c839 | ||
|
|
79babdb6aa | ||
|
|
6291fa2b60 | ||
|
|
275c9e5fcd | ||
|
|
02eddb6010 | ||
|
|
2581d7b8f0 | ||
|
|
8f849efc25 | ||
|
|
1c7417b8f3 | ||
|
|
6f8933c35a | ||
|
|
789e662bdb | ||
|
|
4854e090ef | ||
|
|
990526647c | ||
|
|
67e8c2c6f6 | ||
|
|
c46cb4b382 | ||
|
|
7b304412b0 | ||
|
|
a9afc6ff6a | ||
|
|
f1279ac607 | ||
|
|
ccbd385d2f | ||
|
|
eefc3c2fbd | ||
|
|
d107fba8bf |
@@ -1,48 +1,69 @@
|
||||
name: Deploy Backend (Blue-Green)
|
||||
name: Deploy im-server production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- pro
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像tag,留空则使用commit短哈希'
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: production-im-server
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: git.olns.shop
|
||||
IMAGE: git.olns.shop/pro/im_server
|
||||
SERVICE: im-server
|
||||
DEPLOY_HOST: 145.223.69.9
|
||||
DEPLOY_USER: olns-deploy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy-host
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 确定镜像tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.image_tag }}"
|
||||
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 构建镜像(从本仓库源码编译,非下载官方release)
|
||||
run: docker build -t im-server:${{ steps.vars.outputs.tag }} -f docker/tinode/Dockerfile.build .
|
||||
|
||||
- name: 校验后台治理启动配置
|
||||
- name: Log in to Gitea Container Registry
|
||||
env:
|
||||
ADMIN_INTERNAL_API_BASE_URL: ${{ secrets.ADMIN_INTERNAL_API_BASE_URL }}
|
||||
ADMIN_INTERNAL_API_SECRET: ${{ secrets.ADMIN_INTERNAL_API_SECRET }}
|
||||
INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
test -n "$ADMIN_INTERNAL_API_BASE_URL" || { echo "missing secret: ADMIN_INTERNAL_API_BASE_URL"; exit 1; }
|
||||
test -n "$ADMIN_INTERNAL_API_SECRET" || { echo "missing secret: ADMIN_INTERNAL_API_SECRET"; exit 1; }
|
||||
test -n "$INTERNAL_API_HOTGO_SECRET" || { echo "missing secret: INTERNAL_API_HOTGO_SECRET"; exit 1; }
|
||||
set -euo pipefail
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
|
||||
|
||||
- name: 蓝绿部署
|
||||
- name: Build and push image
|
||||
env:
|
||||
MYSQL_DSN: ${{ secrets.MYSQL_DSN }}
|
||||
REDIS_ADDR: ${{ secrets.REDIS_ADDR }}
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
|
||||
API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
|
||||
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
|
||||
UID_ENCRYPTION_KEY: ${{ secrets.UID_ENCRYPTION_KEY }}
|
||||
ADMIN_INTERNAL_API_BASE_URL: ${{ secrets.ADMIN_INTERNAL_API_BASE_URL }}
|
||||
ADMIN_INTERNAL_API_SECRET: ${{ secrets.ADMIN_INTERNAL_API_SECRET }}
|
||||
INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
IMAGE=im-server:${{ steps.vars.outputs.tag }} /opt/apps/im-server/deploy.sh
|
||||
set -euo pipefail
|
||||
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
|
||||
docker build -f docker/tinode/Dockerfile.build \
|
||||
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||
docker push "$IMAGE:pro"
|
||||
docker push "$IMAGE:$SHORT_SHA"
|
||||
|
||||
- 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: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 "$HOME/.ssh"
|
||||
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
|
||||
cleanup() {
|
||||
shred -u "$KEY_FILE" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
|
||||
chmod 600 "$KEY_FILE"
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
chmod 600 "$HOME/.ssh/known_hosts"
|
||||
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login git.olns.shop -u admin --password-stdin"
|
||||
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd /opt/olns && docker compose pull im-server && docker compose up -d --no-deps --wait im-server"
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
# IDE workspace files.
|
||||
.idea/
|
||||
|
||||
# Trae workspace: AI test scripts, temp files, docs.
|
||||
/.trae/
|
||||
|
||||
# Local Go build output.
|
||||
/server/server
|
||||
/.codex-gocache/
|
||||
|
||||
@@ -80,8 +80,8 @@ See [instructions](./docker/README.md)
|
||||
5. Open `tinode.conf` (located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/server/`). Check that the database connection parameters are correct for your database. If you are using MySQL make sure [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) in `"mysql"` section is appropriate for your MySQL installation. Option `parseTime=true` is required.
|
||||
```js
|
||||
"mysql": {
|
||||
"dsn": "root@tcp(localhost)/tinode?parseTime=true",
|
||||
"database": "tinode"
|
||||
"dsn": "root@tcp(localhost)/meeting_im?parseTime=true",
|
||||
"database": "meeting_im"
|
||||
},
|
||||
```
|
||||
|
||||
|
||||
@@ -99,24 +99,116 @@ QUIC传输技术
|
||||
|
||||
|
||||
群成员信息这个是很耗性能的 一个群几千个人 你要考虑是进一次就拿一次群成员信息 还是拿一次
|
||||
网页上一个tab一个用户
|
||||
|
||||
|
||||
我说的进不是给你拉进群 是你点击群聊这个会话框
|
||||
|
||||
|
||||
敏感词 开关
|
||||
|
||||
组织机构树的处理逻辑 我现在处理逻辑
|
||||
|
||||
1,手机号注册,或者上架用的测试账号 可以看到好友的组织结构,
|
||||
|
||||
2,通过邀请进来的 只能看到自己
|
||||
|
||||
|
||||
对了,强调一点,移动端所有加好友的入口都是需要后端控制的,管理后台需要开关控制能不能加好友。
|
||||
然后还要增加宵禁功能,宵禁功能是单聊私聊都不能发消息。这个也是管理后台控制的。
|
||||
|
||||
tinode 移除
|
||||
监控接口
|
||||
制定联调计划
|
||||
|
||||
多租户的测试
|
||||
|
||||
不能全存
|
||||
|
||||
图片只存oss地址 给前端的是oss地址 文件也是oss地址
|
||||
本地只存100条 历史分页50条拉
|
||||
群聊要跨月建表
|
||||
|
||||
|
||||
|
||||
聊天的菜单元素是前端写死的么
|
||||
|
||||
群成员变动发消息 然后拉取 群成员 要考虑何时全部 何时只是一个
|
||||
|
||||
只有加入的群管理消息都可见 其他的比如移除啥的 只有管理可见
|
||||
|
||||
离线用户的推送 未读消息数的处理 每个用户的不一样 群成员每个消息的推送
|
||||
|
||||
前端返回一个现有序号 后端跟最新序号比对 然后一减 就是未读消息
|
||||
|
||||
全局禁止加好友 比如下班时间到了 不能加 从群成员去看 不能显示个人信息 个人名片也不能看 二维码也不能加
|
||||
|
||||
阿里云的oss放一个域名清单 要加密 apikey动态更换
|
||||
设备类型 ip 全部采集入库
|
||||
离线时间 和上线时间的采集
|
||||
检查一下多处存同一个信息的。尤其是用户的
|
||||
|
||||
|
||||
音视频要加上开关 控制前端的显示 已经处理
|
||||
|
||||
拆成工作台 + IM
|
||||
|
||||
文案部分全部拆出来 做配置
|
||||
|
||||
个人注册的企业码是默认system 不需要企业码的位置
|
||||
|
||||
先是企业合作,填写信息,审核之后得到企业码
|
||||
企业注册的要带上企业码
|
||||
|
||||
kafka批量消费 批量落库
|
||||
|
||||
1.进群的时候拿群成员列表信息 这个怎么做成缓存 然后怎么更新缓存
|
||||
2.多标签要独立的登录状态,可以用不同域名来解决这个问题,但是要token过期时间要限制一下 7天或者10天 每次登录都重置这个时间
|
||||
3.转发消息 涉及到转发群和转发个人 大批量转发 消息会不会串
|
||||
4.我在第一个标签转发了100个群,然后直接切换到第二个标签,你第一个标签还没转发完 你该怎么处理 会不会串到第二个标签页
|
||||
|
||||
新增图片的功能 ??
|
||||
拉一下git提交记录 已经处理
|
||||
多实例 断线重连 其他ws的坑
|
||||
|
||||
|
||||
### 1. Fitness-Food(GitHub,MIT 协议|中文友好!重点推荐)
|
||||
|
||||
🔗 [https://github.com/saudademjj/fitness-food](https://github.com/saudademjj/fitness-food)
|
||||
|
||||
- 定位:**中文智能饮食管理系统**,完全匹配你的健身 APP 饮食模块
|
||||
- 自带接口能力:
|
||||
- 输入中文食物名称,返回全套营养素(热量 / 蛋白 / 碳水 / 脂肪、微量元素)
|
||||
- 三餐记录、每日营养汇总、目标对比(减脂 / 增肌)
|
||||
- PostgreSQL 内置中文食物库
|
||||
- 技术栈:Next.js + Postgres,可以单独拆出后端 API 给你的 APP 调用
|
||||
- 协议:MIT,可商用,二次开发无压力
|
||||
我打算弄一个饮食健康的产品,主要是想记录三餐,目标对比,返回食物热量
|
||||
|
||||
默认system企业
|
||||
内容审核要根据企业来分。做成开关 新建企业的时候加上
|
||||
用户要和商户绑定 进去只能看某一个商户的数据 这个要改造 要从多商户的角度思考系统还有哪些问题
|
||||
|
||||
调研:
|
||||
阿里云上传apk包 下载下来就装不上
|
||||
|
||||
|
||||
各个厂商push的申请
|
||||
|
||||
新加一个状态,消息免打扰后,对方发消息,红点这么显示
|
||||
|
||||
Bundle ID:com.ysl.IM
|
||||
KEY ID:J4ZUVHW59G
|
||||
Team ID:2DN6KF37CH
|
||||
|
||||
单号登录 多设备顶号 已经实现
|
||||
多标签登录的消息到达率
|
||||
建一个千人大群
|
||||
|
||||
telegram报警功能
|
||||
|
||||
加上ai翻译的功能 ai笔记
|
||||
|
||||
后端要做一下 redis 的 sentinel适配, 不然重新选举之后可能会写错节点报错
|
||||
|
||||
https://api.118im.com 简单弄一个官网,https://api.118im.com/legal/privacy-policy需要把名字改对,TestFlight的beta版需要审核,这两个可能会是卡点
|
||||
|
||||
@@ -58,6 +58,11 @@ type audit struct {
|
||||
usernameB string
|
||||
phoneB string
|
||||
passB string
|
||||
tokenC string
|
||||
userC string
|
||||
usernameC string
|
||||
phoneC string
|
||||
passC string
|
||||
groupTopic string
|
||||
groupVer int
|
||||
groupQR string
|
||||
@@ -110,6 +115,7 @@ func (a *audit) run() {
|
||||
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
|
||||
a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123"
|
||||
a.usernameB, a.phoneB, a.passB = "b"+suffix, "156"+suffix, "AuditPass123"
|
||||
a.usernameC, a.phoneC, a.passC = "c"+suffix, "159"+suffix, "AuditPass123"
|
||||
|
||||
a.testHTTP("1", "1.1", "企业码解析", "POST", "/v1/im/tenants/resolve", map[string]any{"tenant_code": a.tenantCode, "platform": "ios", "app_version": "1.0.0"}, "", true, []int{200}, func(r imResp) string {
|
||||
return fmt.Sprintf("tenant=%v endpoints=%v", dig(r.Data, "tenant", "code"), dig(r.Data, "endpoints", "api"))
|
||||
@@ -143,7 +149,6 @@ func (a *audit) run() {
|
||||
a.testHTTP("1", "1.8-submit", "实名提交", "POST", "/v1/im/security/realname/submissions", map[string]any{"name": "张三", "id_type": "cn_id", "id_number": "110101199001011234"}, a.tokenA, true, []int{200, 409}, nil)
|
||||
a.testHTTP("1", "1.11", "设置独立密码", "POST", "/v1/im/settings/independent-password", map[string]any{"password": "1234", "purpose": "youth_mode", "action_token": a.actionTok}, a.tokenA, true, []int{200, 400, 403}, nil)
|
||||
a.testHTTP("1", "1.12", "应用版本查询", "GET", "/v1/im/app/releases/latest?platform=ios¤t_version=1.0.0&channel=stable", nil, "", false, []int{200}, nil)
|
||||
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
|
||||
|
||||
a.testHTTP("2", "2.1", "Bootstrap", "GET", "/v1/im/bootstrap", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||||
return fmt.Sprintf("entries=%v limits=%v", r.Data["entries"] != nil, r.Data["limits"] != nil)
|
||||
@@ -174,7 +179,7 @@ func (a *audit) run() {
|
||||
a.testHTTP("4", "4.forward", "消息逐条转发", "POST", "/v1/im/messages/forward", map[string]any{"mode": "separate", "sources": []map[string]any{{"topic": a.p2pTopic, "seq": 1}}, "target_topics": []string{"slf"}}, a.tokenA, true, []int{200, 404, 405}, nil)
|
||||
a.testHTTP("4", "4.bundle", "合并转发详情", "GET", "/v1/im/forward-bundles/fbundle_missing?cursor=", nil, a.tokenA, false, []int{200, 404, 405}, nil)
|
||||
a.testHTTP("4", "4.recall", "消息撤回不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/recall", map[string]any{"version": 1, "reason": "audit"}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
|
||||
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
|
||||
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 403, 404, 409}, nil)
|
||||
a.testHTTP("4", "4.receipts", "消息回执查询", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/receipts?cursor=", nil, a.tokenA, false, []int{200, 400, 404, 403}, nil)
|
||||
|
||||
a.testHTTP("5", "5.friends", "好友列表", "GET", "/v1/im/friends?query=&initial=&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||||
@@ -191,7 +196,7 @@ func (a *audit) run() {
|
||||
a.testHTTP("5", "5.qr-resolve", "二维码解析", "POST", "/v1/im/qrcodes/resolve", map[string]any{"token": a.meQR, "purpose": a.meQRPurpose}, a.tokenB, true, []int{200}, nil)
|
||||
}
|
||||
|
||||
a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "联调群" + suffix, "description": "自动化测试群", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||||
a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "auditgroup" + suffix, "description": "audit group", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||||
a.groupTopic = asString(r.Data["topic"])
|
||||
a.groupVer = asInt(r.Data["version"])
|
||||
return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer)
|
||||
@@ -218,10 +223,58 @@ func (a *audit) run() {
|
||||
a.testHTTP("6", "6.join-requests", "入群申请列表", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.summary", "群概要", "GET", groupPath+"/summary", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.ann-latest", "群公告最新", "GET", groupPath+"/announcements/latest", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||||
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.governance-patch", "更新群治理配置", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
|
||||
announcementID := ""
|
||||
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||||
announcementID = asString(r.Data["announcement_id"])
|
||||
return "announcement_id=" + announcementID
|
||||
})
|
||||
if announcementID != "" {
|
||||
a.testHTTP("6", "6.ann-detail", "群公告详情", "GET", groupPath+"/announcements/"+url.PathEscape(announcementID), nil, a.tokenB, false, []int{200}, nil)
|
||||
a.testHTTP("6", "6.ann-read", "群公告已读", "POST", groupPath+"/announcements/"+url.PathEscape(announcementID)+"/read", map[string]any{}, a.tokenB, true, []int{200}, nil)
|
||||
}
|
||||
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||||
if version := asInt(r.Data["version"]); version > 0 {
|
||||
a.groupVer = version
|
||||
}
|
||||
return fmt.Sprintf("version=%d", a.groupVer)
|
||||
})
|
||||
a.testHTTP("6", "6.governance-member-forbidden", "普通成员更新群治理被拒绝", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "version": a.groupVer}, a.tokenB, true, []int{403}, nil)
|
||||
a.testHTTP("6", "6.governance-patch", "更新群治理配置成功", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "member_upload_files": true, "version": a.groupVer}, a.tokenA, true, []int{200}, func(r imResp) string {
|
||||
if version := asInt(r.Data["version"]); version > 0 {
|
||||
a.groupVer = version
|
||||
}
|
||||
return fmt.Sprintf("version=%d", a.groupVer)
|
||||
})
|
||||
a.testHTTP("6", "6.role-limits", "管理员数量限制", "GET", groupPath+"/role-limits", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.role-limits-member-forbidden", "普通成员读取管理员限制被拒绝", "GET", groupPath+"/role-limits", nil, a.tokenB, false, []int{403}, nil)
|
||||
a.testHTTP("6", "6.mute-member-forbidden", "普通成员禁言他人被拒绝", "POST", groupPath+"/mutes/batch", map[string]any{"user_ids": []string{a.userA}, "reason": "越权测试"}, a.tokenB, true, []int{403}, nil)
|
||||
a.testHTTP("6", "6.mute-member", "群主禁言成员", "POST", groupPath+"/mutes/batch", map[string]any{"user_ids": []string{a.userB}, "reason": "联调禁言"}, a.tokenA, true, []int{200}, nil)
|
||||
a.testHTTP("6", "6.mute-self-view", "成员查看自身禁言状态", "GET", groupPath+"/mutes?cursor=", nil, a.tokenB, false, []int{200}, func(r imResp) string {
|
||||
return fmt.Sprintf("items=%v", r.Data["items"])
|
||||
})
|
||||
a.testHTTP("6", "6.unmute-member", "群主解除成员禁言", "DELETE", groupPath+"/mutes/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
|
||||
a.testHTTP("6", "6.admin-add", "群主设置管理员", "POST", groupPath+"/admins/batch", map[string]any{"user_ids": []string{a.userB}, "version": a.groupVer}, a.tokenA, true, []int{200}, nil)
|
||||
a.testHTTP("6", "6.admin-governance", "管理员更新群治理", "PATCH", groupPath+"/governance", map[string]any{"member_private_chat": true, "version": a.groupVer}, a.tokenB, true, []int{200, 409}, func(r imResp) string {
|
||||
if version := asInt(r.Data["version"]); version > 0 {
|
||||
a.groupVer = version
|
||||
}
|
||||
return fmt.Sprintf("version=%d", a.groupVer)
|
||||
})
|
||||
a.testHTTP("6", "6.admin-remove", "群主取消管理员", "DELETE", groupPath+"/admins/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
|
||||
a.registerThird("6", "6.join-user", a.usernameC, a.phoneC, a.passC)
|
||||
joinRequestID, joinRequestVersion := "", 0
|
||||
a.testHTTP("6", "6.join-create", "非成员提交入群申请", "POST", groupPath+"/join-requests", map[string]any{"message": "申请加入联调群", "source": "manual"}, a.tokenC, true, []int{200}, func(r imResp) string {
|
||||
joinRequestID = asString(r.Data["request_id"])
|
||||
joinRequestVersion = asInt(r.Data["version"])
|
||||
return fmt.Sprintf("request_id=%s version=%d", joinRequestID, joinRequestVersion)
|
||||
})
|
||||
a.testHTTP("6", "6.join-list-member-forbidden", "普通成员读取入群申请被拒绝", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenB, false, []int{403}, nil)
|
||||
a.testHTTP("6", "6.join-list-owner", "群主读取待审批申请", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200}, nil)
|
||||
if joinRequestID != "" {
|
||||
a.testHTTP("6", "6.join-accept", "群主通过入群申请", "POST", groupPath+"/join-requests/"+url.PathEscape(joinRequestID)+"/accept", map[string]any{"version": joinRequestVersion}, a.tokenA, true, []int{200}, nil)
|
||||
a.testHTTP("6", "6.join-detail", "审批后新成员读取群详情", "GET", groupPath, nil, a.tokenC, false, []int{200}, nil)
|
||||
a.testHTTP("6", "6.join-leave", "新成员主动退群", "POST", groupPath+"/leave", map[string]any{}, a.tokenC, true, []int{200}, nil)
|
||||
}
|
||||
a.testHTTP("6", "6.admins", "管理员列表", "GET", groupPath+"/members?role=admin&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.mutes", "禁言列表", "GET", groupPath+"/mutes?cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
|
||||
a.testHTTP("6", "6.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil)
|
||||
@@ -310,11 +363,23 @@ func (a *audit) run() {
|
||||
return "category=" + a.feedbackCat
|
||||
})
|
||||
a.testHTTP("11", "11.feedback", "提交反馈", "POST", "/v1/im/feedback", map[string]any{"category": a.feedbackCat, "content": "自动化联调反馈", "contact": "", "attachment_file_ids": []string{}, "client_context": map[string]any{"source": "imtestaudit"}, "include_diagnostics": true}, a.tokenA, true, []int{200}, nil)
|
||||
a.testHTTP("6", "6.owner-version", "转让前读取群版本", "GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/governance", nil, a.tokenA, false, []int{200}, func(r imResp) string {
|
||||
if version := asInt(r.Data["version"]); version > 0 {
|
||||
a.groupVer = version
|
||||
}
|
||||
return fmt.Sprintf("version=%d", a.groupVer)
|
||||
})
|
||||
transferToken := a.securityActionToken("6", "6.owner-transfer", "group_owner_transfer", a.tokenA)
|
||||
a.testHTTPHeaders("6", "6.owner-transfer-action", "群主转让", "POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/owner-transfer", map[string]any{"target_user_id": a.userB, "version": a.groupVer}, a.tokenA, true, []int{200}, map[string]string{"X-Action-Token": transferToken}, nil)
|
||||
dissolveToken := a.securityActionToken("6", "6.dissolve", "group_dissolve", a.tokenB)
|
||||
a.testHTTPHeaders("6", "6.dissolve-action", "新群主解散群", "DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic), map[string]any{"reason": "自动化验收完成"}, a.tokenB, true, []int{200}, map[string]string{"X-Action-Token": dissolveToken}, nil)
|
||||
a.testHTTP("6", "6.dissolved-detail", "解散后群详情返回 Gone", "GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic), nil, a.tokenA, false, []int{410}, nil)
|
||||
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
|
||||
}
|
||||
|
||||
func (a *audit) register(chapter, id, username, phone, password string, primary bool) {
|
||||
var challenge, verification string
|
||||
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) string {
|
||||
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "", true, []int{200}, func(r imResp) string {
|
||||
challenge = asString(r.Data["challenge_id"])
|
||||
return "challenge_id=" + mask(challenge)
|
||||
})
|
||||
@@ -337,6 +402,27 @@ func (a *audit) register(chapter, id, username, phone, password string, primary
|
||||
}
|
||||
}
|
||||
|
||||
func (a *audit) registerThird(chapter, id, username, phone, password string) {
|
||||
var challenge, verification string
|
||||
a.testHTTP(chapter, id+"-code", "入群用户注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "", true, []int{200}, func(r imResp) string {
|
||||
challenge = asString(r.Data["challenge_id"])
|
||||
return "challenge_id=" + mask(challenge)
|
||||
})
|
||||
if challenge != "" {
|
||||
a.testHTTP(chapter, id+"-verify", "入群用户验证码校验 "+username, "POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(r imResp) string {
|
||||
verification = asString(r.Data["verification_token"])
|
||||
return "verification_token=" + mask(verification)
|
||||
})
|
||||
}
|
||||
token, userID, note, err := a.wsRegister(username, phone, password, challenge, verification)
|
||||
res := result{Chapter: chapter, ID: id + "-ws-acc", Name: "入群用户 WebSocket 注册 " + username, Method: "WS", Path: "/v0/channels", Pass: err == nil, Note: note}
|
||||
if err != nil {
|
||||
res.Reason = err.Error()
|
||||
}
|
||||
a.results = append(a.results, res)
|
||||
a.tokenC, a.userC = token, userID
|
||||
}
|
||||
|
||||
func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) {
|
||||
u, _ := url.Parse(a.wsURL)
|
||||
q := u.Query()
|
||||
@@ -360,13 +446,14 @@ func (a *audit) wsRegister(username, phone, password, challenge, verification st
|
||||
accID := "acc-" + username
|
||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
err = conn.WriteJSON(map[string]any{"acc": map[string]any{
|
||||
"id": accID,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"id": accID,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"registration_type": 1,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"cred": []map[string]any{{
|
||||
"meth": "tel",
|
||||
"val": phone,
|
||||
@@ -408,7 +495,11 @@ func waitCtrl(conn *websocket.Conn, id string) (map[string]any, error) {
|
||||
}
|
||||
|
||||
func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token string, idem bool, want []int, note func(imResp) string) {
|
||||
status, parsed, raw, err := a.call(method, path, body, token, idem)
|
||||
a.testHTTPHeaders(chapter, id, name, method, path, body, token, idem, want, nil, note)
|
||||
}
|
||||
|
||||
func (a *audit) testHTTPHeaders(chapter, id, name, method, path string, body any, token string, idem bool, want []int, headers map[string]string, note func(imResp) string) {
|
||||
status, parsed, raw, err := a.callHeaders(method, path, body, token, idem, headers)
|
||||
r := result{Chapter: chapter, ID: id, Name: name, Method: method, Path: path, Status: status, Code: parsed.Code, Reason: parsed.Reason, Body: compact(raw)}
|
||||
if err != nil {
|
||||
r.Reason = err.Error()
|
||||
@@ -427,6 +518,10 @@ func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token
|
||||
}
|
||||
|
||||
func (a *audit) call(method, path string, body any, token string, idem bool) (int, imResp, string, error) {
|
||||
return a.callHeaders(method, path, body, token, idem, nil)
|
||||
}
|
||||
|
||||
func (a *audit) callHeaders(method, path string, body any, token string, idem bool, headers map[string]string) (int, imResp, string, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
@@ -458,6 +553,9 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
|
||||
if idem {
|
||||
req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano()))
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, imResp{}, "", err
|
||||
@@ -469,6 +567,21 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
|
||||
return resp.StatusCode, parsed, string(b), nil
|
||||
}
|
||||
|
||||
func (a *audit) securityActionToken(chapter, id, scene, token string) string {
|
||||
challengeID, actionToken := "", ""
|
||||
a.testHTTP(chapter, id+"-challenge", scene+" 二次验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": scene, "channel": "sms"}, token, true, []int{200}, func(r imResp) string {
|
||||
challengeID = asString(r.Data["challenge_id"])
|
||||
return "challenge_id=" + mask(challengeID)
|
||||
})
|
||||
if challengeID != "" {
|
||||
a.testHTTP(chapter, id+"-verify", scene+" 二次验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challengeID)+"/verify", map[string]any{"code": "123456"}, token, true, []int{200}, func(r imResp) string {
|
||||
actionToken = asString(r.Data["action_token"])
|
||||
return "action_token=" + mask(actionToken)
|
||||
})
|
||||
}
|
||||
return actionToken
|
||||
}
|
||||
|
||||
func (a *audit) report() string {
|
||||
var b strings.Builder
|
||||
now := time.Now().Format("2006-01-02 15:04:05 -0700")
|
||||
|
||||
@@ -55,6 +55,8 @@ type audit struct {
|
||||
p2pTopic string
|
||||
bobTopic string
|
||||
groupTopic string
|
||||
groupSeq int
|
||||
groupFile string
|
||||
fileURL string
|
||||
messageID string
|
||||
}
|
||||
@@ -96,7 +98,7 @@ func main() {
|
||||
}
|
||||
|
||||
func (a *audit) run() {
|
||||
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
|
||||
suffix := fmt.Sprintf("%08d", (time.Now().UnixNano()+int64(os.Getpid())*100003)%100000000)
|
||||
nameSuffix := suffix[len(suffix)-6:]
|
||||
a.usernameA, a.phoneA, a.passA = "wsa"+nameSuffix, "157"+suffix, "AuditPass123"
|
||||
a.usernameB, a.phoneB, a.passB = "wsb"+nameSuffix, "158"+suffix, "AuditPass123"
|
||||
@@ -330,9 +332,26 @@ func (a *audit) run() {
|
||||
return "topic=" + topic
|
||||
})
|
||||
a.step("WS-21", "A/B 订阅群 Topic", "WS", a.groupTopic, func() string {
|
||||
if alice == nil || bob == nil || a.groupTopic == "" {
|
||||
if a.groupTopic == "" {
|
||||
return "FAIL: websocket client or group missing"
|
||||
}
|
||||
// Reconnect before the group-specific checks. A read timeout permanently
|
||||
// poisons a gorilla/websocket connection and must not cascade into this section.
|
||||
if alice != nil && alice.conn != nil {
|
||||
_ = alice.conn.Close()
|
||||
}
|
||||
if bob != nil && bob.conn != nil {
|
||||
_ = bob.conn.Close()
|
||||
}
|
||||
var err error
|
||||
alice, err = a.openLoggedIn("A-group", a.userA, a.tokenA)
|
||||
if err != nil {
|
||||
return "FAIL: A reconnect: " + err.Error()
|
||||
}
|
||||
bob, err = a.openLoggedIn("B-group", a.userB, a.tokenB)
|
||||
if err != nil {
|
||||
return "FAIL: B reconnect: " + err.Error()
|
||||
}
|
||||
if err := alice.sub(a.groupTopic); err != nil {
|
||||
return "FAIL: A sub group: " + err.Error()
|
||||
}
|
||||
@@ -345,11 +364,117 @@ func (a *audit) run() {
|
||||
if alice == nil || bob == nil || a.groupTopic == "" {
|
||||
return "FAIL: websocket client or group missing"
|
||||
}
|
||||
text := "WS 群消息 " + suffix
|
||||
text := "group audit message " + suffix
|
||||
seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
a.groupSeq = seq
|
||||
return fmt.Sprintf("seq=%d topic=%s", seq, topic)
|
||||
})
|
||||
a.step("WS-22E", "群消息 client_mid 幂等去重", "WS", a.groupTopic, func() string {
|
||||
if alice == nil {
|
||||
return "FAIL: websocket client missing"
|
||||
}
|
||||
head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix}
|
||||
first, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix)
|
||||
if err != nil {
|
||||
return "FAIL: first pub: " + err.Error()
|
||||
}
|
||||
second, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix)
|
||||
if err != nil {
|
||||
return "FAIL: duplicate pub: " + err.Error()
|
||||
}
|
||||
if first <= 0 || second != first {
|
||||
return fmt.Sprintf("FAIL: first_seq=%d duplicate_seq=%d", first, second)
|
||||
}
|
||||
return fmt.Sprintf("first_seq=%d duplicate_seq=%d", first, second)
|
||||
})
|
||||
a.step("WS-22F", "群消息上下文查询", "HTTP", "/v1/im/topics/{topic}/messages/{seq}/context", func() string {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
status, r, err := a.call("GET", fmt.Sprintf("/v1/im/topics/%s/messages/%d/context?before=5&after=5", url.PathEscape(a.groupTopic), a.groupSeq), nil, a.tokenB)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
|
||||
}
|
||||
anchor, count := asInt(r.Data["anchor_seq"]), len(asSlice(r.Data["items"]))
|
||||
if anchor != a.groupSeq || count == 0 {
|
||||
return fmt.Sprintf("FAIL: anchor_seq=%d items=%d", anchor, count)
|
||||
}
|
||||
return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count)
|
||||
})
|
||||
a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string {
|
||||
status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("group audit message")+"&type=text", nil, a.tokenB)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
|
||||
}
|
||||
count := len(asSlice(r.Data["items"]))
|
||||
if count == 0 {
|
||||
return "FAIL: items=0"
|
||||
}
|
||||
return fmt.Sprintf("items=%d", count)
|
||||
})
|
||||
a.step("WS-22H", "群消息发送者撤回", "HTTP", "/v1/im/topics/{topic}/messages/{seq}/recall", func() string {
|
||||
status, r, err := a.call("POST", fmt.Sprintf("/v1/im/topics/%s/messages/%d/recall", url.PathEscape(a.groupTopic), a.groupSeq), map[string]any{"version": 1, "reason": "WS 群消息撤回联调"}, a.tokenA)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
|
||||
}
|
||||
return fmt.Sprintf("seq=%d status=%s version=%d", asInt(r.Data["seq"]), asString(r.Data["status"]), asInt(r.Data["version"]))
|
||||
})
|
||||
a.step("WS-22A", "HTTP 禁言群成员 B", "HTTP", "/v1/im/groups/{topic}/mutes/batch", func() string {
|
||||
status, r, err := a.call("POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/mutes/batch", map[string]any{
|
||||
"user_ids": []string{a.userB}, "reason": "WS 禁言策略联调",
|
||||
}, a.tokenA)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
|
||||
}
|
||||
return "member B muted"
|
||||
})
|
||||
a.step("WS-22B", "被禁言成员 B pub 被拒绝", "WS", a.groupTopic, func() string {
|
||||
if bob == nil {
|
||||
return "FAIL: websocket client missing"
|
||||
}
|
||||
id := "pub-muted-" + suffix
|
||||
code, text, err := bob.sendCtrl(id, map[string]any{"pub": map[string]any{
|
||||
"id": id, "topic": a.groupTopic, "head": map[string]any{"mime": "text/plain"}, "content": "本消息不应发出",
|
||||
}}, 15*time.Second)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if code != http.StatusForbidden {
|
||||
return fmt.Sprintf("FAIL: unexpected code=%d text=%s", code, text)
|
||||
}
|
||||
return fmt.Sprintf("code=%d text=%s", code, text)
|
||||
})
|
||||
a.step("WS-22C", "HTTP 解除群成员 B 禁言", "HTTP", "/v1/im/groups/{topic}/mutes/{user_id}", func() string {
|
||||
status, r, err := a.call("DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/mutes/"+url.PathEscape(a.userB), nil, a.tokenA)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
|
||||
}
|
||||
return "member B unmuted"
|
||||
})
|
||||
a.step("WS-22D", "解除禁言后 B pub,A 收到 data", "WS", a.groupTopic, func() string {
|
||||
if alice == nil || bob == nil {
|
||||
return "FAIL: websocket client missing"
|
||||
}
|
||||
seq, topic, err := bob.pubWithHeadAndPeer(alice, a.groupTopic, map[string]any{"mime": "text/plain", "x-im-client-mid": "unmute-" + suffix}, "解除禁言消息 "+suffix)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("seq=%d topic=%s", seq, topic)
|
||||
})
|
||||
a.step("WS-23", "get 群 desc/sub/data", "WS", a.groupTopic, func() string {
|
||||
@@ -369,6 +494,43 @@ func (a *audit) run() {
|
||||
a.fileURL = fileURL
|
||||
return "url=" + mask(fileURL)
|
||||
})
|
||||
a.step("WS-24A", "群文件关联 OSS 上传文件", "HTTP", "/v1/im/groups/{topic}/files", func() string {
|
||||
status, r, err := a.call("POST", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files", map[string]any{
|
||||
"file_id": a.fileURL, "file_name": "imwsaudit.txt", "mime_type": "text/plain", "file_type": "document",
|
||||
}, a.tokenA)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s", status, r.Reason)
|
||||
}
|
||||
a.groupFile = asString(r.Data["group_file_id"])
|
||||
if a.groupFile == "" {
|
||||
return "FAIL: missing group_file_id"
|
||||
}
|
||||
return "group_file_id=" + a.groupFile
|
||||
})
|
||||
a.step("WS-24B", "群文件列表可查询", "HTTP", "/v1/im/groups/{topic}/files", func() string {
|
||||
status, r, err := a.call("GET", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files?query=imwsaudit&type=document", nil, a.tokenB)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
count := len(asSlice(r.Data["items"]))
|
||||
if status != http.StatusOK || count == 0 {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s items=%d", status, r.Reason, count)
|
||||
}
|
||||
return fmt.Sprintf("items=%d", count)
|
||||
})
|
||||
a.step("WS-24C", "群主删除群文件关联", "HTTP", "/v1/im/groups/{topic}/files/{group_file_id}", func() string {
|
||||
status, r, err := a.call("DELETE", "/v1/im/groups/"+url.PathEscape(a.groupTopic)+"/files/"+url.PathEscape(a.groupFile), nil, a.tokenA)
|
||||
if err != nil {
|
||||
return "FAIL: " + err.Error()
|
||||
}
|
||||
if status != http.StatusOK || asString(r.Data["status"]) != "deleted" {
|
||||
return fmt.Sprintf("FAIL: status=%d reason=%s file_status=%s", status, r.Reason, asString(r.Data["status"]))
|
||||
}
|
||||
return "status=deleted"
|
||||
})
|
||||
a.step("WS-25", "WS 文件消息下发", "WS", a.p2pTopic, func() string {
|
||||
if alice == nil || bob == nil || a.fileURL == "" {
|
||||
return "FAIL: websocket client or file missing"
|
||||
@@ -475,13 +637,14 @@ func (a *audit) register(username, phone, password string) (string, string, erro
|
||||
id := "acc-" + username
|
||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
if err = c.write(map[string]any{"acc": map[string]any{
|
||||
"id": id,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"id": id,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"registration_type": 1,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"cred": []map[string]any{{
|
||||
"meth": "tel",
|
||||
"val": phone,
|
||||
@@ -505,7 +668,7 @@ func (a *audit) register(username, phone, password string) (string, string, erro
|
||||
}
|
||||
|
||||
func (a *audit) verificationCode(phone string) (string, error) {
|
||||
status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "")
|
||||
status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -562,8 +725,8 @@ func (a *audit) createFriendship() (string, error) {
|
||||
|
||||
func (a *audit) createGroup(suffix string) (string, error) {
|
||||
status, r, err := a.call("POST", "/v1/im/groups", map[string]any{
|
||||
"name": "WS协议群" + suffix,
|
||||
"description": "WS audit group",
|
||||
"name": "auditgroup" + suffix,
|
||||
"description": "audit group",
|
||||
"member_user_ids": []string{a.userB},
|
||||
"avatar_file_id": "",
|
||||
}, a.tokenA)
|
||||
@@ -665,9 +828,10 @@ func (a *audit) openBasicLoggedIn(name, username, password string) (*wsClient, e
|
||||
loginID := "login-" + strings.ToLower(name)
|
||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
if err = c.write(map[string]any{"login": map[string]any{
|
||||
"id": loginID,
|
||||
"scheme": "basic",
|
||||
"secret": secret,
|
||||
"id": loginID,
|
||||
"scheme": "basic",
|
||||
"login_type": 1,
|
||||
"secret": secret,
|
||||
}}); err != nil {
|
||||
_ = c.conn.Close()
|
||||
return nil, err
|
||||
@@ -1028,6 +1192,11 @@ func asInt(v any) int {
|
||||
}
|
||||
}
|
||||
|
||||
func asSlice(v any) []any {
|
||||
items, _ := v.([]any)
|
||||
return items
|
||||
}
|
||||
|
||||
func mask(s string) string {
|
||||
if len(s) <= 8 {
|
||||
return s
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# im-server-test ArgoCD patch
|
||||
|
||||
The live StatefulSet is owned by the ArgoCD Application `im-server-test`.
|
||||
Copy `statefulset-performance-patch.yaml` into that application's Git source
|
||||
and reference it from the test overlay:
|
||||
|
||||
```yaml
|
||||
patches:
|
||||
- path: statefulset-performance-patch.yaml
|
||||
```
|
||||
|
||||
Also include `backend-debug-metrics-rbac.yaml` as a resource in the test
|
||||
overlay. It grants the existing `argocd/backend-debug` service account
|
||||
namespaced read access to Pod metrics so the load-test collector can run
|
||||
`kubectl top pods -n im-server-system`:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- backend-debug-metrics-rbac.yaml
|
||||
```
|
||||
|
||||
After ArgoCD sync, verify the effective configuration:
|
||||
|
||||
```bash
|
||||
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].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 \
|
||||
--as=system:serviceaccount:argocd:backend-debug \
|
||||
-n im-server-system
|
||||
```
|
||||
|
||||
Expected values are requests `2 CPU / 512Mi`, limits `2 CPU / 1Gi`, and
|
||||
liveness path `/livez`. The MySQL pool environment must be
|
||||
`MYSQL_MAX_OPEN_CONNS=24`, `MYSQL_MAX_IDLE_CONNS=8`,
|
||||
`MYSQL_REGISTRY_MAX_OPEN_CONNS=4`, and `MYSQL_REGISTRY_MAX_IDLE_CONNS=2`.
|
||||
The MySQL connection lifetime must be `MYSQL_CONN_MAX_LIFETIME_SECONDS=1800`.
|
||||
The WebSocket heartbeat environment must be `WS_PING_PERIOD_SECONDS=15` and
|
||||
`WS_PONG_WAIT_SECONDS=90`.
|
||||
The Pod DNS options must be `ndots:1`, `timeout:2`, and `attempts:2`. Keeping
|
||||
`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
|
||||
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.
|
||||
@@ -0,0 +1,27 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: backend-debug-metrics-reader
|
||||
namespace: im-server-system
|
||||
rules:
|
||||
- apiGroups:
|
||||
- metrics.k8s.io
|
||||
resources:
|
||||
- pods
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: backend-debug-metrics-reader
|
||||
namespace: im-server-system
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: backend-debug-metrics-reader
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: backend-debug
|
||||
namespace: argocd
|
||||
@@ -0,0 +1,71 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: im-server
|
||||
namespace: im-server-system
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
dnsPolicy: ClusterFirst
|
||||
dnsConfig:
|
||||
options:
|
||||
- name: ndots
|
||||
value: "1"
|
||||
- name: timeout
|
||||
value: "2"
|
||||
- name: attempts
|
||||
value: "2"
|
||||
containers:
|
||||
- name: im-server
|
||||
env:
|
||||
- name: WS_PING_PERIOD_SECONDS
|
||||
value: "15"
|
||||
- name: WS_PONG_WAIT_SECONDS
|
||||
value: "90"
|
||||
- name: CLUSTER_PROXY_TO_MASTER_BUFFER
|
||||
value: "4096"
|
||||
- name: CLUSTER_PROXY_TO_MASTER_WORKERS
|
||||
value: "16"
|
||||
- name: CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS
|
||||
value: "300"
|
||||
- name: CLUSTER_ROUTE_RETRIES
|
||||
value: "3"
|
||||
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
|
||||
value: "25"
|
||||
- name: TOPIC_CLIENT_QUEUE_BUFFER
|
||||
value: "4096"
|
||||
- name: TOPIC_SERVER_QUEUE_BUFFER
|
||||
value: "512"
|
||||
- name: TOPIC_PUBLISH_ENQUEUE_WAIT_MS
|
||||
value: "200"
|
||||
- name: MYSQL_MAX_OPEN_CONNS
|
||||
value: "24"
|
||||
- name: MYSQL_MAX_IDLE_CONNS
|
||||
value: "8"
|
||||
- name: MYSQL_REGISTRY_MAX_OPEN_CONNS
|
||||
value: "4"
|
||||
- name: MYSQL_REGISTRY_MAX_IDLE_CONNS
|
||||
value: "2"
|
||||
- name: MYSQL_CONN_MAX_LIFETIME_SECONDS
|
||||
value: "1800"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "2"
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 6060
|
||||
timeoutSeconds: 3
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: 6060
|
||||
timeoutSeconds: 3
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
@@ -188,6 +188,14 @@ You can specify the following environment variables when issuing `docker run` co
|
||||
| `AWS_S3_ENDPOINT`[^2] | string | | An endpoint URL (hostname only or fully qualified URI) to override the default endpoint; can be of any S3-compatible service, such as `minio-api.x.io` |
|
||||
| `AWS_SECRET_ACCESS_KEY`[^2] | string | | AWS [Secret Access Key](https://aws.amazon.com/blogs/security/wheres-my-secret-access-key/) when using `s3` media handler. |
|
||||
| `CLUSTER_SELF` | string | | Node name if the server is running in a Tinode cluster. |
|
||||
| `CLUSTER_PROXY_TO_MASTER_BUFFER` | number | `4096` | Total bounded proxy-to-master queue capacity for each peer. |
|
||||
| `CLUSTER_PROXY_TO_MASTER_WORKERS` | number | `16` | Ordered queue shards and synchronous RPC workers for each peer. |
|
||||
| `CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS` | number | `300` | Maximum wait for a full proxy-to-master shard before returning 503. |
|
||||
| `CLUSTER_ROUTE_RETRIES` | number | `3` | Number of retries after a remote server-message route is rejected as overloaded. |
|
||||
| `CLUSTER_ROUTE_RETRY_BACKOFF_MS` | number | `25` | Initial exponential retry delay for remote server-message routes. |
|
||||
| `TOPIC_CLIENT_QUEUE_BUFFER` | number | `4096` | Per-topic client publish queue capacity. Increase for large groups with bursty publishers. |
|
||||
| `TOPIC_SERVER_QUEUE_BUFFER` | number | `512` | Per-topic server-message queue capacity. |
|
||||
| `TOPIC_PUBLISH_ENQUEUE_WAIT_MS` | number | `200` | Maximum wait for an attached session publish to enter a full topic queue before returning 503. |
|
||||
| `DEBUG_EMAIL_VERIFICATION_CODE`[^2] | string | | Enable dummy email verification code, e.g. `123456`. Disabled by default (empty string). |
|
||||
| `DEFAULT_COUNTRY_CODE`[^2] | string | `US` | 2-letter country code to assign to sessions by default when the country isn't specified by the client explicitly and it's impossible to infer it. |
|
||||
| `EXT_CONFIG`[^1] | string | | Path to external config file to use instead of the built-in one. If this parameter is used, most other variables are ignored[^1]. |
|
||||
@@ -202,10 +210,10 @@ You can specify the following environment variables when issuing `docker run` co
|
||||
| `FCM_MEASUREMENT_ID` | string | | Google Analytics ID of the form `G-123ABCD789`. |
|
||||
| `FS_CORS_ORIGINS`[^2] | string | `["*"]` | Cors origins when media is served from the file system. See `AWS_CORS_ORIGINS` for details. |
|
||||
| `ICE_SERVERS_FILE`[^2] | string | | Path to JSON file with configuration of ICE servers to be used for video calls. |
|
||||
| `MEDIA_HANDLER`[^2] | string | `fs` | Handler of large files, either `fs` or `s3`. |
|
||||
| `MYSQL_DSN`[^2] | string | <code>'root@tcp(mysql)/tinode?​parseTime=true​&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). |
|
||||
| `MEDIA_HANDLER`[^2] | string | `sys_config` | Legacy override for the large file handler. The generated runtime config uses `sys_config` so production can resolve `oss` from backend configuration. |
|
||||
| `MYSQL_DSN`[^2] | string | <code>'root@tcp(mysql)/meeting_im?​parseTime=true​&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). |
|
||||
| `PLUGIN_PYTHON_CHAT_BOT_ENABLED`[^2] | bool | `false` | Enable calling into the plugin provided by Python chatbot. |
|
||||
| `POSTGRES_DSN`[^2] | string | <code>'postgresql://postgres:postgres@​localhost:5432/tinode?​sslmode=disable​&connect_timeout=10'</code> | PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). |
|
||||
| `POSTGRES_DSN`[^2] | string | <code>'postgresql://postgres:postgres@​localhost:5432/meeting_im?​sslmode=disable​&connect_timeout=10'</code> | PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). |
|
||||
| `RESET_DB` | bool | `false` | Drop and recreate the database. |
|
||||
| `SAMPLE_DATA` | string | _see comment →_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `` (empty string) to disable. |
|
||||
| `SMTP_AUTH_MECHANISM`[^2] | string | `"plain"` | SMTP authentication mechanism to use; one of "login", "cram-md5", "plain". |
|
||||
@@ -224,7 +232,7 @@ You can specify the following environment variables when issuing `docker run` co
|
||||
| `TLS_DOMAIN_NAME`[^2] | string | | If non-empty, enables TLS (http**s**) and configures domain name of your container, e.g. `www.example.com`. In order for TLS to work you have to expose your HTTPS port to the Internet and correctly configure DNS. It WILL FAIL with `localhost` or unroutable IPs. |
|
||||
| `TNPG_AUTH_TOKEN` | string | | Tinode Push Gateway authentication token. |
|
||||
| `TNPG_ORG`[^2] | string | | Tinode Push Gateway organization name as registered at https://console.tinode.co |
|
||||
| `UID_ENCRYPTION_KEY`[^2] | string | `la6YsO+bNX/+XIkOqc5Svw==` | base64-encoded 16 random bytes used as an encryption key for user IDs. |
|
||||
| `UID_ENCRYPTION_KEY`[^2] | string | `dkXxWLwqUNRfYmdXRzOkZw==` | base64-encoded 16 random bytes used as an encryption key for user IDs. |
|
||||
| `UPGRADE_DB` | bool | `false` | Upgrade database schema, if necessary. |
|
||||
| `WAIT_FOR` | string | | If non-empty, waits for the specified database `host:port` to be available before starting the server. |
|
||||
|
||||
|
||||
@@ -51,8 +51,10 @@ ENV SAMPLE_DATA=$SAMPLE_DATA
|
||||
ENV DEFAULT_COUNTRY_CODE=US
|
||||
|
||||
# The MySQL DSN connection.
|
||||
ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci'
|
||||
ENV MYSQL_DSN='root@tcp(mysql)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci'
|
||||
ENV REDIS_ADDR=
|
||||
ENV REDIS_SENTINEL_MASTER_NAME=
|
||||
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
|
||||
ENV REDIS_USERNAME=
|
||||
ENV REDIS_PASSWORD=
|
||||
ENV REDIS_DB=0
|
||||
@@ -85,6 +87,9 @@ ENV KAFKA_SASL_USERNAME=
|
||||
ENV KAFKA_SASL_PASSWORD=
|
||||
ENV KAFKA_MAX_RETRY_COUNT=3
|
||||
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
|
||||
ENV KAFKA_MESSAGE_BATCH_SIZE=100
|
||||
ENV KAFKA_MESSAGE_BATCH_WAIT_MS=10
|
||||
ENV KAFKA_MESSAGE_SINGLE_RETRY_COUNT=3
|
||||
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
|
||||
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
|
||||
ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
|
||||
@@ -97,10 +102,12 @@ ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
|
||||
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
|
||||
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
|
||||
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
|
||||
ENV INTERNAL_API_ENABLED=false
|
||||
ENV INTERNAL_API_ENABLED=true
|
||||
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
|
||||
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
|
||||
ENV INTERNAL_API_HOTGO_SECRET=
|
||||
ENV INTERNAL_API_CULTIVATE_SERVICE_NAME=cultivate-server
|
||||
ENV INTERNAL_API_CULTIVATE_SECRET=
|
||||
ENV ADMIN_INTERNAL_API_ENABLED=false
|
||||
ENV ADMIN_INTERNAL_API_BASE_URL=
|
||||
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
|
||||
@@ -108,7 +115,7 @@ ENV ADMIN_INTERNAL_API_SECRET=
|
||||
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
|
||||
|
||||
# The PostgreSQL DSN connection.
|
||||
ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10'
|
||||
ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/meeting_im?sslmode=disable&connect_timeout=10'
|
||||
|
||||
# Disable chatbot plugin by default.
|
||||
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
|
||||
@@ -153,7 +160,7 @@ ENV API_KEY_SALT=T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=
|
||||
ENV AUTH_TOKEN_KEY=wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc=
|
||||
|
||||
# Key to initialize UID generator
|
||||
ENV UID_ENCRYPTION_KEY=la6YsO+bNX/+XIkOqc5Svw==
|
||||
ENV UID_ENCRYPTION_KEY=dkXxWLwqUNRfYmdXRzOkZw==
|
||||
|
||||
# Disable TLS by default.
|
||||
ENV TLS_ENABLED=false
|
||||
@@ -182,6 +189,20 @@ ENV TNPG_AUTH_TOKEN=
|
||||
# Tinode Push Gateway organization name as registered at console.tinode.co
|
||||
ENV TNPG_ORG=
|
||||
|
||||
# Disable JPush by default. The handler can be enabled from HotGo sys_config
|
||||
# through the im_jpush dictionary group when the config stanza is present.
|
||||
ENV JPUSH_PUSH_ENABLED=false
|
||||
ENV JPUSH_API_DOMAIN=https://api.jpush.cn
|
||||
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_CALL_TTL_SECONDS=60
|
||||
ENV JPUSH_APNS_PRODUCTION=false
|
||||
ENV JPUSH_USE_ALIAS=false
|
||||
ENV JPUSH_CLASSIFICATION=1
|
||||
ENV JPUSH_TITLE=MeetingIM
|
||||
|
||||
# Video calls configuration.
|
||||
ENV WEBRTC_ENABLED=false
|
||||
ENV ICE_SERVERS_FILE=/opt/tinode/ice-servers.json
|
||||
|
||||
@@ -14,7 +14,7 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 go build -tags mysql -o /out/tinode ./server
|
||||
RUN CGO_ENABLED=0 go build -tags mysql -o /out/init-db ./tinode-db
|
||||
# scripts/apply_hotgo_tenant_db.go is `//go:build ignore`-tagged (a standalone
|
||||
# migration script, not part of any package) - build it by exact file path.
|
||||
# startup database validator, not part of any package) - build it by exact path.
|
||||
RUN CGO_ENABLED=0 go build -o /out/apply-hotgo-tenant-db ./scripts/apply_hotgo_tenant_db.go
|
||||
|
||||
# ---- Stage 2: runtime image ----
|
||||
@@ -35,6 +35,8 @@ ENV DEFAULT_COUNTRY_CODE=US
|
||||
|
||||
ENV STORE_USE_ADAPTER=mysql
|
||||
ENV REDIS_ADDR=
|
||||
ENV REDIS_SENTINEL_MASTER_NAME=
|
||||
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
|
||||
ENV REDIS_USERNAME=
|
||||
ENV REDIS_DB=0
|
||||
ENV REDIS_DIAL_TIMEOUT_MS=2000
|
||||
@@ -65,6 +67,9 @@ ENV KAFKA_SASL_USERNAME=
|
||||
ENV KAFKA_SASL_PASSWORD=
|
||||
ENV KAFKA_MAX_RETRY_COUNT=3
|
||||
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
|
||||
ENV KAFKA_MESSAGE_BATCH_SIZE=100
|
||||
ENV KAFKA_MESSAGE_BATCH_WAIT_MS=10
|
||||
ENV KAFKA_MESSAGE_SINGLE_RETRY_COUNT=3
|
||||
ENV KAFKA_DEAD_LETTER_RETENTION_SECONDS=7776000
|
||||
ENV KAFKA_TOPIC_ADMIN_COMMAND=im.admin.command
|
||||
ENV KAFKA_TOPIC_ADMIN_COMMAND_RESULT=im.admin.command.result
|
||||
@@ -77,10 +82,12 @@ ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
|
||||
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
|
||||
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
|
||||
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
|
||||
ENV INTERNAL_API_ENABLED=false
|
||||
ENV INTERNAL_API_ENABLED=true
|
||||
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
|
||||
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
|
||||
ENV INTERNAL_API_HOTGO_SECRET=
|
||||
ENV INTERNAL_API_CULTIVATE_SERVICE_NAME=cultivate-server
|
||||
ENV INTERNAL_API_CULTIVATE_SECRET=
|
||||
ENV ADMIN_INTERNAL_API_ENABLED=false
|
||||
ENV ADMIN_INTERNAL_API_BASE_URL=
|
||||
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
|
||||
@@ -88,7 +95,7 @@ ENV ADMIN_INTERNAL_API_SECRET=
|
||||
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
|
||||
|
||||
# Tenant registry lives in the hotgo database (shared with im_manager); tenant
|
||||
# DB routing resolves to the same tinode DB for the seeded "default" tenant.
|
||||
# business data lives in the shared meeting_im DB and is scoped by tenant_id.
|
||||
ENV TENANT_REGISTRY_DB=hotgo
|
||||
|
||||
ENV MEDIA_HANDLER=sys_config
|
||||
@@ -100,6 +107,17 @@ ENV TLS_ENABLED=false
|
||||
ENV FCM_PUSH_ENABLED=false
|
||||
ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true
|
||||
ENV TNPG_PUSH_ENABLED=false
|
||||
ENV JPUSH_PUSH_ENABLED=false
|
||||
ENV JPUSH_API_DOMAIN=https://api.jpush.cn
|
||||
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_CALL_TTL_SECONDS=60
|
||||
ENV JPUSH_APNS_PRODUCTION=false
|
||||
ENV JPUSH_USE_ALIAS=false
|
||||
ENV JPUSH_CLASSIFICATION=1
|
||||
ENV JPUSH_TITLE=MeetingIM
|
||||
ENV WEBRTC_ENABLED=false
|
||||
ENV ICE_SERVERS_FILE=/opt/tinode/ice-servers.json
|
||||
ENV SERVER_STATUS_PATH=''
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"static_mount": "/",
|
||||
"grpc_listen": ":16060",
|
||||
"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",
|
||||
"max_message_size": 4194304,
|
||||
"max_subscriber_count": 128,
|
||||
@@ -15,7 +17,7 @@
|
||||
"default_country_code": "$DEFAULT_COUNTRY_CODE",
|
||||
|
||||
"media": {
|
||||
"use_handler": "$MEDIA_HANDLER",
|
||||
"use_handler": "sys_config",
|
||||
"max_size": 33554432,
|
||||
"gc_period": 60,
|
||||
"gc_block_size": 100,
|
||||
@@ -77,31 +79,35 @@
|
||||
"use_adapter": "$STORE_USE_ADAPTER",
|
||||
"adapters": {
|
||||
"mysql": {
|
||||
"database": "tinode",
|
||||
"database": "meeting_im",
|
||||
"dsn": "$MYSQL_DSN",
|
||||
"max_open_conns": 64,
|
||||
"max_idle_conns": 64,
|
||||
"conn_max_lifetime": 60,
|
||||
"max_open_conns": $MYSQL_MAX_OPEN_CONNS,
|
||||
"max_idle_conns": $MYSQL_MAX_IDLE_CONNS,
|
||||
"registry_max_open_conns": $MYSQL_REGISTRY_MAX_OPEN_CONNS,
|
||||
"registry_max_idle_conns": $MYSQL_REGISTRY_MAX_IDLE_CONNS,
|
||||
"conn_max_lifetime": $MYSQL_CONN_MAX_LIFETIME_SECONDS,
|
||||
"sql_timeout": 10,
|
||||
"tenant_registry_db": "$TENANT_REGISTRY_DB",
|
||||
"tenant_db_routing": true
|
||||
},
|
||||
"postgres": {
|
||||
"database": "tinode",
|
||||
"database": "meeting_im",
|
||||
"dsn": "$POSTGRES_DSN"
|
||||
},
|
||||
"rethinkdb": {
|
||||
"database": "tinode",
|
||||
"database": "meeting_im",
|
||||
"addresses": "rethinkdb"
|
||||
},
|
||||
"mongodb": {
|
||||
"database": "tinode",
|
||||
"database": "meeting_im",
|
||||
"addresses": "mongodb",
|
||||
"replica_set": "rs0"
|
||||
}
|
||||
},
|
||||
"redis": {
|
||||
"addr": "$REDIS_ADDR",
|
||||
"sentinel_master_name": "$REDIS_SENTINEL_MASTER_NAME",
|
||||
"sentinel_addrs": $REDIS_SENTINEL_ADDRS_JSON,
|
||||
"username": "$REDIS_USERNAME",
|
||||
"password": "$REDIS_PASSWORD",
|
||||
"db": $REDIS_DB,
|
||||
@@ -121,50 +127,57 @@
|
||||
},
|
||||
|
||||
"kafka": {
|
||||
"enabled": $KAFKA_ENABLED,
|
||||
"enabled": true,
|
||||
"brokers": $KAFKA_BROKERS_JSON,
|
||||
"client_id": "$KAFKA_CLIENT_ID",
|
||||
"consumer_group": "$KAFKA_CONSUMER_GROUP",
|
||||
"dial_timeout_ms": $KAFKA_DIAL_TIMEOUT_MS,
|
||||
"write_timeout_ms": $KAFKA_WRITE_TIMEOUT_MS,
|
||||
"read_timeout_ms": $KAFKA_READ_TIMEOUT_MS,
|
||||
"batch_timeout_ms": $KAFKA_BATCH_TIMEOUT_MS,
|
||||
"required_acks": $KAFKA_REQUIRED_ACKS,
|
||||
"allow_auto_topic_creation": $KAFKA_ALLOW_AUTO_TOPIC_CREATION,
|
||||
"tls_enabled": $KAFKA_TLS_ENABLED,
|
||||
"sasl_enabled": $KAFKA_SASL_ENABLED,
|
||||
"sasl_mechanism": "$KAFKA_SASL_MECHANISM",
|
||||
"sasl_username": "$KAFKA_SASL_USERNAME",
|
||||
"sasl_password": "$KAFKA_SASL_PASSWORD",
|
||||
"max_retry_count": $KAFKA_MAX_RETRY_COUNT,
|
||||
"retry_backoff_ms": $KAFKA_RETRY_BACKOFF_MS,
|
||||
"dead_letter_retention_seconds": $KAFKA_DEAD_LETTER_RETENTION_SECONDS,
|
||||
"client_id": "im-server",
|
||||
"consumer_group": "im-server",
|
||||
"dial_timeout_ms": 3000,
|
||||
"write_timeout_ms": 3000,
|
||||
"read_timeout_ms": 3000,
|
||||
"batch_timeout_ms": 10,
|
||||
"required_acks": -1,
|
||||
"allow_auto_topic_creation": false,
|
||||
"tls_enabled": false,
|
||||
"sasl_enabled": false,
|
||||
"sasl_mechanism": "SCRAM-SHA-512",
|
||||
"sasl_username": "",
|
||||
"sasl_password": "",
|
||||
"max_retry_count": 3,
|
||||
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
|
||||
"message_batch_size": $KAFKA_MESSAGE_BATCH_SIZE,
|
||||
"message_batch_wait_ms": $KAFKA_MESSAGE_BATCH_WAIT_MS,
|
||||
"message_single_retry_count": $KAFKA_MESSAGE_SINGLE_RETRY_COUNT,
|
||||
"dead_letter_retention_seconds": 7776000,
|
||||
"topics": {
|
||||
"admin_command": "$KAFKA_TOPIC_ADMIN_COMMAND",
|
||||
"admin_command_result": "$KAFKA_TOPIC_ADMIN_COMMAND_RESULT",
|
||||
"config_event": "$KAFKA_TOPIC_CONFIG_EVENT",
|
||||
"business_event": "$KAFKA_TOPIC_BUSINESS_EVENT",
|
||||
"message_persist": "$KAFKA_TOPIC_MESSAGE_PERSIST",
|
||||
"group_message_persist": "$KAFKA_TOPIC_GROUP_MESSAGE_PERSIST",
|
||||
"message_push": "$KAFKA_TOPIC_MESSAGE_PUSH",
|
||||
"file_security": "$KAFKA_TOPIC_FILE_SECURITY",
|
||||
"cache_invalidate": "$KAFKA_TOPIC_CACHE_INVALIDATE",
|
||||
"audit_event": "$KAFKA_TOPIC_AUDIT_EVENT",
|
||||
"dead_letter": "$KAFKA_TOPIC_DEAD_LETTER"
|
||||
"admin_command": "im.admin.command",
|
||||
"admin_command_result": "im.admin.command.result",
|
||||
"config_event": "im.config.event",
|
||||
"business_event": "im.business.event",
|
||||
"message_persist": "im.message.persist",
|
||||
"group_message_persist": "im.group.message.persist",
|
||||
"message_push": "im.message.push",
|
||||
"file_security": "im.file.security",
|
||||
"cache_invalidate": "im.cache.invalidate",
|
||||
"audit_event": "im.audit.event",
|
||||
"dead_letter": "im.dead.letter"
|
||||
}
|
||||
},
|
||||
"internal_api": {
|
||||
"enabled": true,
|
||||
"time_window_seconds": 300,
|
||||
"enabled": $INTERNAL_API_ENABLED,
|
||||
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
|
||||
"services": {
|
||||
"hotgo-admin": {
|
||||
"$INTERNAL_API_HOTGO_SERVICE_NAME": {
|
||||
"secret": "$INTERNAL_API_HOTGO_SECRET",
|
||||
"allow": ["admin_query"]
|
||||
},
|
||||
"$INTERNAL_API_CULTIVATE_SERVICE_NAME": {
|
||||
"secret": "$INTERNAL_API_CULTIVATE_SECRET",
|
||||
"allow": ["cultivate"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_internal_api": {
|
||||
"enabled": true,
|
||||
"enabled": $ADMIN_INTERNAL_API_ENABLED,
|
||||
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
|
||||
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
|
||||
"secret": "$ADMIN_INTERNAL_API_SECRET",
|
||||
@@ -246,6 +259,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jpush",
|
||||
"config": {
|
||||
"enabled": $JPUSH_PUSH_ENABLED,
|
||||
"api_domain": "$JPUSH_API_DOMAIN",
|
||||
"push_path": "$JPUSH_PUSH_PATH",
|
||||
"workers": $JPUSH_WORKERS,
|
||||
"device_cache_ttl_seconds": $JPUSH_DEVICE_CACHE_TTL_SECONDS,
|
||||
"default_ttl_seconds": $JPUSH_DEFAULT_TTL_SECONDS,
|
||||
"call_ttl_seconds": $JPUSH_CALL_TTL_SECONDS,
|
||||
"apns_production": $JPUSH_APNS_PRODUCTION,
|
||||
"use_alias": $JPUSH_USE_ALIAS,
|
||||
"classification": $JPUSH_CLASSIFICATION,
|
||||
"title": "$JPUSH_TITLE"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -257,11 +286,12 @@
|
||||
|
||||
"cluster_config": {
|
||||
"self": "",
|
||||
"nodes": [
|
||||
{"name": "tinode-0", "addr": "tinode-0:12000"},
|
||||
{"name": "tinode-1", "addr": "tinode-1:12001"},
|
||||
{"name": "tinode-2", "addr": "tinode-2:12002"}
|
||||
],
|
||||
"nodes": $CLUSTER_NODES_JSON,
|
||||
"proxy_to_master_buffer": $CLUSTER_PROXY_TO_MASTER_BUFFER,
|
||||
"proxy_to_master_workers": $CLUSTER_PROXY_TO_MASTER_WORKERS,
|
||||
"proxy_to_master_enqueue_timeout_ms": $CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS,
|
||||
"route_retries": $CLUSTER_ROUTE_RETRIES,
|
||||
"route_retry_backoff_ms": $CLUSTER_ROUTE_RETRY_BACKOFF_MS,
|
||||
"failover": {
|
||||
"enabled": true,
|
||||
"heartbeat": 100,
|
||||
|
||||
@@ -13,6 +13,21 @@ else
|
||||
CONFIG=working.config
|
||||
|
||||
: "${REDIS_KEY_PREFIX:=im}"
|
||||
: "${WS_PING_PERIOD_SECONDS:=15}"
|
||||
: "${WS_PONG_WAIT_SECONDS:=90}"
|
||||
: "${CLUSTER_PROXY_TO_MASTER_BUFFER:=4096}"
|
||||
: "${CLUSTER_PROXY_TO_MASTER_WORKERS:=16}"
|
||||
: "${CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS:=300}"
|
||||
: "${CLUSTER_ROUTE_RETRIES:=3}"
|
||||
: "${CLUSTER_ROUTE_RETRY_BACKOFF_MS:=25}"
|
||||
: "${MYSQL_MAX_OPEN_CONNS:=24}"
|
||||
: "${MYSQL_MAX_IDLE_CONNS:=8}"
|
||||
: "${MYSQL_REGISTRY_MAX_OPEN_CONNS:=4}"
|
||||
: "${MYSQL_REGISTRY_MAX_IDLE_CONNS:=2}"
|
||||
: "${MYSQL_CONN_MAX_LIFETIME_SECONDS:=1800}"
|
||||
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"}]'
|
||||
fi
|
||||
|
||||
# Remove the old config.
|
||||
rm -f working.config
|
||||
@@ -161,8 +176,8 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create/upgrade the hotgo tenant registry and the workbench/org/meetings/
|
||||
# reports/mailbox/etc. tables that init-db above does not manage.
|
||||
# Validate that startup is pointed at the shared meeting_im database. Schema
|
||||
# changes are applied separately and are never performed during pod startup.
|
||||
run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "./apply-hotgo-tenant-db failed. Quitting."
|
||||
@@ -185,5 +200,12 @@ fi
|
||||
|
||||
args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL")
|
||||
|
||||
# Run the tinode server.
|
||||
./tinode "${args[@]}" 2>> /var/log/tinode.log
|
||||
# Run the tinode server, mirroring output to both stdout (so kubectl logs/Promtail's
|
||||
# standard container-log collection actually sees it -- confirmed live that the old
|
||||
# file-only redirect meant Loki never received a single real tinode log line, only
|
||||
# this script's own earlier init-db output) and the log file (kept for exec-based
|
||||
# inspection, e.g. `kubectl exec ... -- cat /var/log/tinode.log`). PIPESTATUS[0] is
|
||||
# required here: with output piped through tee, a bare $? would report tee's exit
|
||||
# status (almost always 0) instead of tinode's.
|
||||
./tinode "${args[@]}" 2>&1 | tee -a /var/log/tinode.log
|
||||
exit "${PIPESTATUS[0]}"
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
# IM 006 商户双机消息压力测试方案
|
||||
|
||||
日期:2026-09-25
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
> 双机实际操作以 [`loadtest/im-pressure/README.md`](../loadtest/im-pressure/README.md) 为准。当前开发机固定为机器 A,负责数据准备、协调器、agent A、集中采集和最终报告;另一台机器固定为机器 B,只运行 agent B 并回传结果。
|
||||
|
||||
> 当前测试环境实际企业码为 `allrich`,沿用本方案最初为 006 商户定义的账号规模、场景和统计口径。已完成的 200 对复测、故障根因和修复证据见 [`docs/test/IM-单聊压测Pod日志根因分析与改进方案-20260927.md`](./test/IM-单聊压测Pod日志根因分析与改进方案-20260927.md)。
|
||||
|
||||
本次测试使用 006 商户的 2000 个账号,在两台压测机上各运行 1000 个在线 WebSocket 用户,验证多 Pod 场景下:
|
||||
|
||||
1. 1000 对 P2P 会话同时收发时,消息是否被接受、实时投递并最终持久化。
|
||||
2. 2000 人大群中 1000 个成员轮流发言时,跨 Pod 群广播的丢失率、重复率、乱序率和延迟。
|
||||
3. 300 对 P2P 与 500 个群发送者同时工作时,混合负载是否相互影响。
|
||||
4. Tinode 原生 Cluster、Kafka 消息持久化、数据库分表和 WebSocket 下行是否出现容量瓶颈。
|
||||
|
||||
本轮不在正式发压窗口内注册账号、加好友或建群。所有数据准备完成并核验后,再开始消息压力测试。
|
||||
|
||||
## 2. 本方案对需求的精确定义
|
||||
|
||||
### 2.1 账号与机器分片
|
||||
|
||||
账号使用只包含小写字母和数字、且不超过 10 个字符的用户名,以满足当前注册校验:
|
||||
|
||||
| 压测机 | 账号 | 数量 | 用户名示例 |
|
||||
| --- | --- | ---: | --- |
|
||||
| Machine A | A0001-A1000 | 1000 | `lta0001`、`lta1000` |
|
||||
| Machine B | B0001-B1000 | 1000 | `ltb0001`、`ltb1000` |
|
||||
|
||||
固定配对关系为 `A0001 <-> B0001`,依此类推到 `A1000 <-> B1000`。“号码相同”指账号序号相同,不使用相同手机号。
|
||||
|
||||
每个账号最终记录:`username`、`user_id`、`token`、`token_expires`、`machine`、`pair_no`、`p2p_topic`、`group_topic`。密码只从环境变量读取,不写入结果文件或日志。
|
||||
|
||||
### 2.2 群聊需求解释
|
||||
|
||||
“1000 个人间隔 0.1 秒发消息,2 轮合计 2000 条”按全局调度解释:
|
||||
|
||||
- 每 100ms 只安排一个发送者发送一条消息。
|
||||
- 每轮 1000 个发送者各发送 1 条,共 1000 条,持续约 100 秒。
|
||||
- 连续执行 2 轮,共 2000 条,正式发压约 200 秒。
|
||||
- 不是每个发送者每 100ms 发一条;后一种解释会产生 10000 条/秒和 100 万条/100 秒,与“合计 2000 条”矛盾。
|
||||
|
||||
### 2.3 P2P 场景默认强度
|
||||
|
||||
原需求未指定每对消息数。本方案默认每对双方各发 10 条:
|
||||
|
||||
- 1000 对同时开始。
|
||||
- 每个用户每秒发送 1 条,共 10 秒;每次加 0-200ms 确定性抖动,避免所有请求落在同一毫秒。
|
||||
- 总发布量:`1000 × 2 × 10 = 20000` 条。
|
||||
- 主要期望投递量:20000 次对端投递;发送方自身回显单独统计,不混入对端丢失率。
|
||||
|
||||
消息数、间隔和抖动必须可通过命令行参数修改,但每轮结果必须记录实际参数。
|
||||
|
||||
## 3. 协议与接口契约
|
||||
|
||||
### 3.1 WebSocket 建连与账号注册
|
||||
|
||||
WebSocket 地址为 `/v0/channels`,Upgrade 携带普通 API Key。每个连接严格执行:
|
||||
|
||||
```text
|
||||
Upgrade
|
||||
-> hi(tenant="006")
|
||||
-> acc 或 login
|
||||
-> sub(me)
|
||||
-> sub(目标 P2P/群 Topic)
|
||||
-> READY
|
||||
```
|
||||
|
||||
006 是企业商户,注册使用 `registration_type: 0` 和 `tenant_code: "006"`:
|
||||
|
||||
```json
|
||||
{
|
||||
"acc": {
|
||||
"id": "prep-acc-lta0001",
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": "lta0001",
|
||||
"registration_type": 0,
|
||||
"tenant_code": "006",
|
||||
"secret": "base64(lta0001:password)",
|
||||
"login": true,
|
||||
"desc": {"public": {"fn": "LT A0001"}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当前 006 环境必须确认未强制手机号或邀请码注册。准备程序应先尝试 basic 登录:已存在且密码正确则复用;仅在账号不存在时注册。账号存在但密码不匹配必须停止,禁止覆盖真实账号。
|
||||
|
||||
### 3.2 HTTP 公共请求头
|
||||
|
||||
好友、隐私和群接口使用登录返回的同一 Token:
|
||||
|
||||
```text
|
||||
X-MeetingIM-APIKey: <ordinary api key>
|
||||
X-IM-Schema: 1
|
||||
X-Request-ID: <unique request id>
|
||||
Authorization: Bearer <token>
|
||||
Idempotency-Key: <unique key for write requests>
|
||||
```
|
||||
|
||||
所有密钥和 Token 通过环境变量或权限受控文件传入,日志只记录账号索引和脱敏 ID。
|
||||
|
||||
### 3.3 好友准备
|
||||
|
||||
对每个序号执行:
|
||||
|
||||
1. A 调用 `POST /v1/im/friend-requests`,Body 为 `{"target_user_id":"usr_b","message":"loadtest","source":"search","source_topic":""}`。
|
||||
2. 若返回 `pending`,B 调用 `POST /v1/im/friend-requests/{request_id}/accept`。
|
||||
3. 若已经是好友,读取好友列表或会话 context 并复用关系。
|
||||
4. 保存响应中的 P2P `topic`;必要时调用 `POST /v1/im/conversations/p2p`,Body 为 `{"peer_user_id":"usr_b"}`。
|
||||
5. 最终核验 1000 对关系均为 `friend`,且双方能订阅同一个 P2P Topic。
|
||||
|
||||
好友创建会产生 `friend_added` 系统消息,因此正式统计只识别本轮 `run_id` 对应的 `x-im-client-mid`,不能把 Topic 的初始 `seq` 当成 0。
|
||||
|
||||
### 3.4 2000 人群准备
|
||||
|
||||
1. 使用 A0001 调用 `POST /v1/im/groups` 创建群。创建接口最多附带 99 个成员,建议只创建群主。
|
||||
2. 先确认所有账号的 `group_invite_verification=false`,避免进入当前尚未完整开放的邀请确认链路。
|
||||
3. 调用 `POST /v1/im/groups/{topic}/invitations` 分批邀请,每批不超过 100 人,每批使用独立 `Idempotency-Key`。
|
||||
4. 邀请完成后分页调用 `GET /v1/im/groups/{topic}/members`,核验去重后的有效成员恰好为 2000。
|
||||
5. 2000 个 WebSocket 全部订阅该群,并完成屏障消息验证后才允许发压。
|
||||
|
||||
直接邀请会产生群业务事件和系统消息。准备结束后至少静默 30 秒,并记录正式测试前的群 `baseline_seq`;统计时仍以 `run_id/client_mid` 为准。
|
||||
|
||||
## 4. 环境硬门槛
|
||||
|
||||
任一门槛不满足,本轮标记为 `INVALID`,不能把结果当作服务端性能结论:
|
||||
|
||||
1. `max_subscriber_count >= 2500`。仓库配置默认是 128,必须在所有 Pod 上统一调高并滚动重启。
|
||||
2. 006 商户 `max_group_members` 为 0(不限)或不小于 2000;目标群 `max_member_count` 同样满足要求。
|
||||
3. IM Server 所有 Pod 版本和配置一致,原生 Cluster 成员数与 Running Pod 数一致。
|
||||
4. Kafka 的 P2P、群消息持久化 Topic 可写,消费组无持续积压,死信在基线时为 0;测试环境的 `im.message.persist` 和 `im.group.message.persist` 均为 3 分区、3 副本。
|
||||
5. 两台压测机到目标入口网络稳定,文件描述符上限、临时端口和 NAT 容量足够。
|
||||
6. 两台压测机使用 chrony/NTP,同一时刻的时钟偏差绝对值小于 5ms,否则跨机器单向延迟无效。
|
||||
7. 每台压测机至少预留 2 CPU、4GB 内存和稳定的 100Mbps 网络;测试进程自身 CPU 不应长期超过 70%。
|
||||
8. 2000 个连接全部 READY;正式阶段不允许把连接失败用户从分母中静默删除。
|
||||
|
||||
## 5. 多 Pod 拓扑
|
||||
|
||||
仅通过同一个负载均衡入口连接,不能证明消息确实跨 Pod。推荐提供仅供压测使用的三个直连入口,并保持业务数据路径不变:
|
||||
|
||||
```text
|
||||
Machine A: A0001-A0500 -> Pod 0
|
||||
A0501-A1000 -> Pod 1
|
||||
Machine B: B0001-B1000 -> Pod 2
|
||||
```
|
||||
|
||||
这样 1000 对 P2P 的双方一定不在同一个 Pod;群成员也覆盖全部 3 个 Pod。若只能提供两个直连入口,则 Machine A 固定 Pod 0、Machine B 固定 Pod 1,并在另一轮把 Machine B 切到 Pod 2 重跑。
|
||||
|
||||
入口必须能明确确认实际 Pod,例如握手诊断日志或反向代理访问日志能关联 `session -> pod`。不要依赖负载均衡概率分布。
|
||||
|
||||
Topic 消息在启用 Tinode 原生 Cluster 时由 Cluster 路由;此时 Redis realtime 指标中的 `publish/skip_native_cluster` 是预期状态,不代表丢失。Redis 仍需观察用户级业务事件和 Session 控制类 fanout。
|
||||
|
||||
## 6. 统一消息格式
|
||||
|
||||
所有正式测试消息都使用 JSON 文本消息,并设置 `noecho:false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"pub": {
|
||||
"id": "s1-a0001-00001",
|
||||
"topic": "p2p_or_grp",
|
||||
"noecho": false,
|
||||
"head": {
|
||||
"mime": "application/json",
|
||||
"x-im-schema": 1,
|
||||
"x-im-type": "text",
|
||||
"x-im-client-mid": "lt-20260925-s1-a0001-00001"
|
||||
},
|
||||
"content": {
|
||||
"text": "loadtest",
|
||||
"run_id": "lt-20260925-s1",
|
||||
"scenario": "p2p",
|
||||
"sender_index": 1,
|
||||
"message_index": 1,
|
||||
"sent_at_unix_nano": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`x-im-client-mid` 在 `tenant + topic + sender` 范围内必须唯一,重试时必须复用原值。发送程序用 `{ctrl.id}` 关联请求,用 `ctrl.params.seq` 记录服务端分配的 Topic 序号。
|
||||
|
||||
## 7. 执行阶段
|
||||
|
||||
### 7.1 阶段 P0:预检与热身
|
||||
|
||||
1. 两台机器各以 50 连接/秒建立 1000 条连接,总建连时间约 20 秒。
|
||||
2. 每个用户订阅 `me`、自己的 P2P Topic 和群 Topic。
|
||||
3. 群主发送 1 条不计入正式结果的群屏障消息;必须在 10 秒内被 2000 个连接全部收到。
|
||||
4. 每个 P2P Topic 发送 1 条不计入正式结果的屏障消息;双方必须收到,且对端投递为 1000/1000。
|
||||
5. 等待 30 秒,确认错误计数、Kafka lag、CPU、内存和连接数回到稳定状态。
|
||||
|
||||
### 7.2 阶段 S1:1000 对 P2P 同时聊天
|
||||
|
||||
参与者:全部 2000 人,1000 个 P2P Topic。
|
||||
|
||||
默认调度:
|
||||
|
||||
- A、B 双方各发送 10 条,每用户 1 条/秒,持续 10 秒。
|
||||
- 每个用户使用由账号索引计算的 0-200ms 固定抖动,确保可复现。
|
||||
- 总发布量 20000,平均发布速率约 2000 条/秒。
|
||||
- 发布完成后保留连接并等待 30 秒排空,再进行历史核对。
|
||||
|
||||
主要期望:每条被服务端接受的消息恰好被对应的另一方实时收到一次。发送方回显也应收到一次,但作为独立指标。
|
||||
|
||||
### 7.3 阶段 S2:2000 人群、1000 人发言
|
||||
|
||||
参与者:2000 个在线订阅者。发送者选择 A0001-A0500 与 B0001-B0500,共 1000 人,避免所有发布压力只落在一台压测机上。
|
||||
|
||||
默认调度:
|
||||
|
||||
- Machine A 每 200ms 发 1 条,Machine B 在其后错开 100ms 发 1 条,合并后全局间隔为 100ms。
|
||||
- 每轮每个发送者恰好发送 1 条,共 1000 条、约 100 秒。
|
||||
- 连续 2 轮,共 2000 条、约 200 秒。
|
||||
- `noecho:false`,因此每条被接受的消息期望投递到 2000 个在线连接。
|
||||
- 理论实时投递总量:`2000 × 2000 = 4000000` 次,平均约 20000 次/秒。
|
||||
- 发布结束后等待 60 秒排空,然后由多个校验账号分页拉取历史。
|
||||
|
||||
### 7.4 阶段 S3:P2P 与群聊混合
|
||||
|
||||
正式窗口为 100 秒:
|
||||
|
||||
- P2P:A0001-A0300 与 B0001-B0300,共 300 对;双方各发 5 条,总发布量 `300 × 2 × 5 = 3000`。在第 0、20、40、60、80 秒附近发送,并加入固定抖动。
|
||||
- 群聊:A0301-A0550 与 B0301-B0550,共 500 个发送者;每人发 2 条。两台机器错峰形成全局 100ms 间隔,两轮共 1000 条,持续约 100 秒。
|
||||
- 其余用户保持在线并接收群消息。
|
||||
- 群实时理论投递总量:`1000 × 2000 = 2000000` 次。
|
||||
- P2P 主要理论对端投递总量:3000 次;发送方回显另计 3000 次。
|
||||
- 发布结束后等待 60 秒排空,再做 P2P 和群历史核对。
|
||||
|
||||
混合场景的群发送者与 P2P 发送者故意分开,便于判断瓶颈来自连接写队列、群广播还是同一连接并发写。通过后可增加一轮“发送者重叠”增强测试。
|
||||
|
||||
## 8. 丢失率与正确性口径
|
||||
|
||||
不能只统计压测端成功调用 `WriteJSON` 的数量。每轮必须同时输出以下四层结果。
|
||||
|
||||
### 8.1 发送接受率
|
||||
|
||||
```text
|
||||
publish_accept_rate = ctrl_202_with_seq / publish_attempted
|
||||
publish_reject_rate = (ctrl_4xx + ctrl_5xx + ctrl_timeout + write_error) / publish_attempted
|
||||
```
|
||||
|
||||
只有收到 `ctrl.code=202` 且包含有效 `params.seq` 的消息,才进入后续“期望投递”分母。
|
||||
|
||||
### 8.2 实时投递丢失率
|
||||
|
||||
实时到达率必须由 2000 个 WebSocket 连接实际监听得到,不能使用发送 ACK、Kafka offset 或数据库行数代替。每个连接建立后启动一个常驻 read loop,全量读取服务端下行,并把 `{ctrl}`、`{data}`、`{meta}`、`{pres}`、`{info}` 分流处理。
|
||||
|
||||
监听器要求:
|
||||
|
||||
1. 每个 WebSocket 只能有一个 goroutine 调用 `ReadMessage/ReadJSON`,避免多个读取者争抢数据;`ctrl` 通过 `id` 分发给等待中的发布请求。
|
||||
2. 只统计 `head.x-im-client-mid` 前缀和 `content.run_id` 都匹配本轮的 `{data}`,忽略准备阶段系统消息、其他业务消息和历史噪声。
|
||||
3. 记录 `receiver_index + client_mid + seq + first_received_at`。相同接收者再次收到同一 `client_mid` 计为重复,不增加到达数。
|
||||
4. 服务端允许 `{data}` 早于对应 `{ctrl 202}` 到达,因此监听器先保存观测事实,最终再与 ACK ledger 对账,不能因为 ACK 尚未到达而丢弃该 `{data}`。
|
||||
5. P2P 以配对的另一方为主要期望接收者;发送者自身回显单独统计。
|
||||
6. 群消息以发压开始时的 2000 个 READY 成员为固定分母。因为 `noecho:false`,发送者本人也属于期望接收者。
|
||||
7. 正式窗口内连接断开时不得从分母中删除。记录断开时间、所属机器和 Pod;服务端导致的断开计入结果,压测机自身饱和导致的断开则将整轮标记为无效。
|
||||
8. P2P 发布结束后继续监听 30 秒,群聊和混合场景继续监听 60 秒。窗口内未收到计为实时丢失;窗口后到达另记 `late_delivery`,不能把它改写为正常实时到达。
|
||||
|
||||
期望投递账本由成功 ACK 生成:
|
||||
|
||||
```text
|
||||
P2P expected key = run_id + client_mid + peer_receiver_index
|
||||
Group expected key = run_id + client_mid + each_ready_receiver_index
|
||||
```
|
||||
|
||||
群场景 2000 条消息对应 400 万个期望键。实现时为每个接收者维护按消息序号索引的 bitset,不保存 400 万份完整 JSON;延迟使用 HDR Histogram 或固定桶,缺失和重复才输出逐条明细。
|
||||
|
||||
P2P 主要口径:
|
||||
|
||||
```text
|
||||
p2p_realtime_loss =
|
||||
(accepted_messages - unique_peer_data_received_within_drain_window)
|
||||
/ accepted_messages
|
||||
```
|
||||
|
||||
群聊口径:
|
||||
|
||||
```text
|
||||
group_expected_deliveries = accepted_group_messages × ready_group_subscribers
|
||||
group_realtime_loss =
|
||||
(group_expected_deliveries - unique_group_deliveries_within_drain_window)
|
||||
/ group_expected_deliveries
|
||||
```
|
||||
|
||||
每个接收者按 `run_id + x-im-client-mid` 去重。重复到达不用于抵消其他消息的缺失。
|
||||
|
||||
同时输出:
|
||||
|
||||
```text
|
||||
arrival_rate = unique_deliveries_within_drain_window / expected_deliveries
|
||||
loss_rate = 1 - arrival_rate
|
||||
late_rate = unique_late_deliveries / expected_deliveries
|
||||
duplicate_rate = duplicate_deliveries / expected_deliveries
|
||||
```
|
||||
|
||||
实时监听结束后继续执行历史回查,将缺失分为:
|
||||
|
||||
- `realtime_lost_but_persisted`:WS 窗口内没收到,但历史中存在,说明实时投递链路丢失,客户端可通过重连补偿。
|
||||
- `persisted_but_late`:超过实时排空窗口才由 WS 收到,同时历史中存在。
|
||||
- `durable_lost`:WS 未收到且最长等待后历史也不存在,属于真正的持久化消息丢失。
|
||||
- `receiver_disconnected`:期望接收者在消息窗口内断线,单独统计并保留在实时丢失分母中。
|
||||
|
||||
### 8.3 持久化丢失率
|
||||
|
||||
Kafka 持久化是异步链路,`ctrl 202` 不等于数据库已经可查。排空后通过 `{get what:"data"}` 分页回查:
|
||||
|
||||
```text
|
||||
persistence_loss =
|
||||
(accepted_unique_messages - unique_messages_found_in_history)
|
||||
/ accepted_unique_messages
|
||||
```
|
||||
|
||||
- P2P:1000 个 Topic 各由一侧账号回查。
|
||||
- 群聊:至少使用分别连接不同 Pod 的 3 个成员回查,三份结果应完全一致。
|
||||
- 最长等待 120 秒,每 5 秒重试一次;超时仍不存在才记为持久化丢失。
|
||||
|
||||
### 8.4 重复与乱序
|
||||
|
||||
```text
|
||||
duplicate_rate = duplicate_deliveries / expected_deliveries
|
||||
out_of_order_rate = receivers_observing_seq_regression / ready_receivers
|
||||
```
|
||||
|
||||
同时记录:同一 `client_mid` 对应多个 `seq`、同一 `seq` 对应多个测试消息、接收端 Topic `seq` 回退、ACK `seq` 与下行 `seq` 不一致。这些都必须单独报错,不能归入普通丢失。
|
||||
|
||||
## 9. 延迟口径
|
||||
|
||||
记录以下时间点:
|
||||
|
||||
- `t_write`:压测端开始写 `{pub}`。
|
||||
- `t_ack`:收到对应 `{ctrl 202}`。
|
||||
- `t_data`:接收者收到并完成 JSON 解析。
|
||||
|
||||
输出:
|
||||
|
||||
```text
|
||||
ack_latency = t_ack - t_write
|
||||
e2e_realtime_latency = t_data - sent_at
|
||||
```
|
||||
|
||||
每个场景输出 p50、p95、p99、p99.9 和 max。跨机器延迟仅在 NTP 偏差达标时有效;否则仍可统计 ACK 延迟,但端到端单向延迟标记为无效。
|
||||
|
||||
群聊 400 万次投递不应把所有延迟样本常驻内存。使用固定桶直方图或 HDR Histogram,并按接收端机器和 Pod 分组。
|
||||
|
||||
## 10. 建议通过标准
|
||||
|
||||
第一轮基线建议使用:
|
||||
|
||||
| 指标 | 标准 |
|
||||
| --- | --- |
|
||||
| READY 连接 | 2000/2000 |
|
||||
| 发布接受率 | 100% |
|
||||
| 持久化丢失率 | 0 |
|
||||
| 同一 `client_mid` 多 `seq` | 0 |
|
||||
| P2P 对端实时丢失率 | <= 0.001%,且任何丢失都要输出明细 |
|
||||
| 群实时投递丢失率 | <= 0.001%,发布版本验收目标为 0 |
|
||||
| 重复投递率 | 0 |
|
||||
| Topic `seq` 回退 | 0 |
|
||||
| ACK p95 / p99 | <= 500ms / <= 1s;必须用原始 ACK 时间戳计算,不能用直方图桶上界代替 |
|
||||
| 实时投递 p95 / p99 | <= 1s / <= 2s;跨机器时要求时钟偏差达标 |
|
||||
| `deliver/hub_full`、WS 写队列满 | 0 |
|
||||
| Kafka producer/commit 错误 | 0 |
|
||||
| Kafka 消费重试、单条降级、死信新增 | 0 |
|
||||
| Kafka 排空 | 发布结束后 60 秒内 lag 回到基线/0 |
|
||||
| Kafka 到 MySQL 落库 p95 / p99 | < 200ms / < 500ms |
|
||||
| MySQL 实际落库数 | 与 `ctrl 202` 唯一消息数完全一致 |
|
||||
| MySQL deadlock、lock timeout、连接池耗尽 | 0 |
|
||||
| Redis 操作错误、淘汰、拒绝连接 | 0 |
|
||||
| Redis command p95 / p99 | < 5ms / < 10ms |
|
||||
| Pod CPU throttling ratio | < 10% |
|
||||
| Pod CPU、内存峰值 | < limit 的 80% |
|
||||
| 容器/节点网络 drop、error | 0 新增或不高于基线噪声 |
|
||||
| 压测机 CPU、内存、网络 | < 70%,无 read-loop backlog |
|
||||
| Pod 重启、OOM、持续 GC/内存增长 | 0 |
|
||||
|
||||
如果实时有缺失但历史回查完整,结论应写“实时链路丢失、可由历史恢复”,不能写成“消息未丢失”。如果历史也缺失,则属于持久化消息丢失,直接判失败。
|
||||
|
||||
## 11. 全链路观测与资源采集
|
||||
|
||||
### 11.1 观测目标与时间窗口
|
||||
|
||||
监控必须能把同一轮测试拆成以下链路:
|
||||
|
||||
```text
|
||||
压测机 WriteJSON
|
||||
-> IM Pod 收到 pub
|
||||
-> Redis 分配消息 seq / 幂等 claim
|
||||
-> Kafka producer 写入消息持久化 Topic
|
||||
-> IM Pod 返回 ctrl 202 并实时广播
|
||||
-> Kafka consumer 拉取与组批
|
||||
-> MySQL 批量提交
|
||||
-> Kafka offset commit
|
||||
-> WebSocket 历史查询可见
|
||||
```
|
||||
|
||||
每轮采集窗口为:正式 `start_at` 前 10 分钟、正式测试全程、排空与历史核对结束后 10 分钟。Prometheus 在压测期间建议使用 5 秒抓取周期,重要 counter 以 `increase` 计算,不使用瞬时值推算总量。
|
||||
|
||||
每轮在 Grafana 或结果文件中记录四个时间点:
|
||||
|
||||
- `baseline_start`:基线开始。
|
||||
- `load_start`:正式发送开始。
|
||||
- `load_end`:最后一次发布结束。
|
||||
- `reconcile_end`:历史核对完成。
|
||||
|
||||
所有图表使用相同 UTC 时间轴,并标注 `run_id`、scenario、Git commit、Pod 镜像版本和压测参数。
|
||||
|
||||
### 11.2 采集组件
|
||||
|
||||
推荐最小采集栈:
|
||||
|
||||
| 对象 | 采集方式 | 主要用途 |
|
||||
| --- | --- | --- |
|
||||
| IM Server | `/metrics`、`/debug/vars` | WS、HTTP、DB、fanout、Go runtime、连接池 |
|
||||
| Kubernetes Pod/Container | kube-state-metrics + kubelet/cAdvisor | CPU、内存、磁盘 IO、网络、重启、限流 |
|
||||
| Kubernetes Node | node-exporter | 节点 CPU、内存、磁盘、网络、conntrack |
|
||||
| Kafka | kafka-exporter + Broker JMX Exporter | consumer lag、吞吐、ISR、请求队列、磁盘 |
|
||||
| Redis | redis-exporter | OPS、延迟、内存、连接、命中率、淘汰、网络 |
|
||||
| MySQL | mysqld-exporter + performance_schema | QPS、事务、锁、Buffer Pool、慢 SQL、IO |
|
||||
| 两台压测机 | node-exporter + 压测 agent `/metrics` | 生成端 CPU/网络/连接和客户端统计 |
|
||||
| 链路探测 | blackbox-exporter 或压测 agent 主动探测 | DNS、TCP、HTTP、依赖 RTT |
|
||||
|
||||
如果环境没有完整 Prometheus 栈,压测前至少以 5 秒周期抓取上述 HTTP 指标,并同步保存 `kubectl top`、Pod 日志、Kafka consumer group、Redis INFO 和 MySQL status。手工快照只能作为兜底,不能替代连续时间序列。
|
||||
|
||||
### 11.3 当前已有与必须补充的应用指标
|
||||
|
||||
当前 IM Server 已经提供:
|
||||
|
||||
| 指标 | 状态 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `ws_connections_active` | 已有 | 当前 Pod/实例的活跃 WS 数 |
|
||||
| `http_requests_total` | 已有 | HTTP 请求量和错误码 |
|
||||
| `http_request_duration_seconds` | 已有 | HTTP 延迟 |
|
||||
| `db_query_duration_seconds` | 已有 | MySQL query/exec/commit 等通用耗时 |
|
||||
| `realtime_fanout_events_total` | 已有 | Redis realtime 发布、接收和本地投递结果 |
|
||||
| `realtime_fanout_delivery_duration_seconds` | 已有 | Redis realtime 消息年龄 |
|
||||
| `/debug/vars` 中 `DbStats` | 已有 | Go SQL 连接池状态 |
|
||||
| `/debug/vars` 中 Cluster/Session/Topic 计数 | 已有 | Cluster 节点、会话和活跃 Topic |
|
||||
|
||||
为准确测量 Kafka 落库速度,压测版本必须再补以下低基数指标。严禁把 `topic`、`user_id`、`client_mid`、`run_id` 或 tenant ID 放入 Prometheus label:
|
||||
|
||||
```text
|
||||
message_persist_events_total{stage,kind,status}
|
||||
message_persist_duration_seconds{stage,kind,status}
|
||||
message_persist_end_to_end_seconds{kind,status}
|
||||
message_persist_batch_size{kind}
|
||||
message_persist_inflight{kind}
|
||||
kafka_consumer_commit_total{topic_kind,status}
|
||||
redis_operation_duration_seconds{operation,status}
|
||||
redis_operation_errors_total{operation}
|
||||
mysql_pool_connections{state}
|
||||
mysql_pool_wait_total
|
||||
mysql_pool_wait_duration_seconds_total
|
||||
cluster_route_events_total{operation,status}
|
||||
cluster_route_duration_seconds{operation,status}
|
||||
ws_outbound_events_total{status}
|
||||
ws_outbound_queue_depth
|
||||
```
|
||||
|
||||
标签约定:
|
||||
|
||||
- `kind`:仅允许 `p2p`、`group`。
|
||||
- `stage`:`producer_write`、`consumer_fetch`、`db_batch`、`consume_finish`。
|
||||
- `status`:`ok`、`error`、`duplicate`、`retry`、`dead`。
|
||||
- `operation`:Redis 使用 `seq_reserve`、`unique_claim`、`hot_cache`、`fanout_publish`、`fanout_receive`;Cluster 使用 `route`、`proxy_join`、`proxy_leave`。
|
||||
|
||||
`message_persist_end_to_end_seconds` 从 Kafka envelope 的 `occurred_at` 计算到 MySQL 提交并标记消费成功的时刻。它是回答“消息从服务端接受到真正落库用了多久”的核心指标。
|
||||
|
||||
另外应在自定义 Prometheus Registry 中注册 Go collector 和 process collector,提供标准 `go_*`、`process_*` 指标;在完成前从 `/debug/vars` 采集对应 runtime 数据。
|
||||
|
||||
### 11.4 Kafka 处理速度与积压
|
||||
|
||||
Kafka 需要同时观察应用层和 Broker 层,至少包含:
|
||||
|
||||
1. P2P Topic `im.message.persist` 的生产/消费条数与字节数。
|
||||
2. 群 Topic `im.group.message.persist` 的生产/消费条数与字节数。
|
||||
3. 对应 consumer group 每个 partition 的 lag、总 lag 和 lag 恢复到 0 的时间。
|
||||
4. producer 写入延迟、错误、重试和 timeout。
|
||||
5. consumer fetch 速率、批量大小、DB 批次耗时、offset commit 延迟和失败。
|
||||
6. 批量落库失败后降级为单条写入的次数。
|
||||
7. 消费重试、Redis 幂等 claim 失败和死信新增数。
|
||||
8. Broker request queue、network processor idle、request handler idle、ISR shrink、under-replicated/offline partition 和磁盘使用率。
|
||||
|
||||
不同 exporter 的 lag 指标名可能是 `kafka_consumergroup_lag` 或 `kafka_consumergroup_group_lag`,部署时必须以实际 `/metrics` 为准建立 dashboard,不允许直接照抄不存在的指标名。
|
||||
|
||||
典型查询:
|
||||
|
||||
```promql
|
||||
sum by (kind, stage, status) (
|
||||
rate(message_persist_events_total[1m])
|
||||
)
|
||||
```
|
||||
|
||||
```promql
|
||||
histogram_quantile(
|
||||
0.99,
|
||||
sum by (le, kind) (
|
||||
rate(message_persist_end_to_end_seconds_bucket[5m])
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
```promql
|
||||
sum by (consumergroup, topic) (
|
||||
kafka_consumergroup_lag{
|
||||
topic=~"im.message.persist|im.group.message.persist"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
每轮报告必须给出:峰值生产速度、峰值消费速度、峰值 lag、lag 清零耗时、平均/最大 batch size、DB batch p95/p99、端到端落库 p95/p99/max、重试数和死信数。
|
||||
|
||||
### 11.5 数据库落库正确性与速度
|
||||
|
||||
006 商户使用默认库 `meeting_im`,但消息表名按数据库中的数值 `tenant_id` 生成,不能直接把企业码字符串当表后缀。准备阶段先从租户注册信息解析并记录 006 的真实 `tenant_id`,再生成只读核对 SQL。
|
||||
|
||||
当前消息存储位置:
|
||||
|
||||
- P2P:`im_<tenant_id三位补零>_messages`,例如真实 tenant ID 为 6 时是 `im_006_messages`。
|
||||
- 群聊:`im_<tenant_id三位补零>_group_messages_YYYYMM`。
|
||||
- 群消息分片注册:`im_group_message_shards`。
|
||||
- Kafka 消费幂等状态:Redis `kafka_consume` namespace,TTL 7 天。
|
||||
- Kafka 死信:MySQL `im_kafka_dead_letters`。
|
||||
|
||||
正式核对优先使用 WebSocket 历史接口,SQL 作为服务端审计证据。SQL 由核对程序使用白名单表名生成,不允许把外部输入直接拼入表名。
|
||||
|
||||
P2P 落库数量示例:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS rows_written,
|
||||
COUNT(DISTINCT client_msg_id) AS unique_client_mid,
|
||||
MIN(createdat) AS first_persisted_at,
|
||||
MAX(createdat) AS last_persisted_at
|
||||
FROM im_006_messages
|
||||
WHERE client_msg_id LIKE 'lt-20260925-s1-%';
|
||||
```
|
||||
|
||||
群消息落库数量示例:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS rows_written,
|
||||
COUNT(DISTINCT client_msg_id) AS unique_client_mid,
|
||||
MIN(created_at) AS first_persisted_at,
|
||||
MAX(created_at) AS last_persisted_at
|
||||
FROM im_006_group_messages_202609
|
||||
WHERE client_msg_id LIKE 'lt-20260925-s2-%';
|
||||
```
|
||||
|
||||
重复检查:
|
||||
|
||||
```sql
|
||||
SELECT client_msg_id, COUNT(*) AS cnt, MIN(seqid), MAX(seqid)
|
||||
FROM im_006_messages
|
||||
WHERE client_msg_id LIKE 'lt-20260925-s1-%'
|
||||
GROUP BY client_msg_id
|
||||
HAVING COUNT(*) <> 1;
|
||||
```
|
||||
|
||||
群表执行同样检查。P2P 有 1000 个 Topic,不能用整张表的 `MAX(seq)-MIN(seq)+1` 判断丢失;必须以压测端保存的 `client_mid -> topic -> ack_seq` manifest 与数据库逐条比对。
|
||||
|
||||
MySQL 性能至少采集:
|
||||
|
||||
- `Threads_connected`、`Threads_running`、连接使用率和连接池 wait count/duration。
|
||||
- QPS、Com_insert、事务提交/回滚、行写入速度。
|
||||
- InnoDB Buffer Pool 命中率、dirty pages、checkpoint age、log waits。
|
||||
- row lock waits/time、deadlock、慢 SQL、临时磁盘表。
|
||||
- data/log fsync、读写 IOPS、吞吐、磁盘 await、util 和剩余空间。
|
||||
- P2P 批量 INSERT、群月表批量 INSERT 的 p95/p99。
|
||||
- 主从架构下的 replication lag;压测历史回查必须明确读主还是读从。
|
||||
|
||||
数据库落库速度报告包含两个值:
|
||||
|
||||
```text
|
||||
db_write_throughput = 测试消息成功落库数 / 首次到最后一次 DB commit 时间
|
||||
drain_time = 最后一条 ctrl 202 到 Kafka lag=0 且全部历史可见的时间
|
||||
```
|
||||
|
||||
### 11.6 Redis 观测
|
||||
|
||||
Redis 在当前消息链路中承担 seq 分配、`client_mid` 幂等 claim、群热消息缓存、Kafka 消费幂等状态和用户级 realtime fanout。需要采集:
|
||||
|
||||
- `instantaneous_ops_per_sec`、命令总量与各命令耗时。
|
||||
- connected/blocked clients、连接拒绝和连接创建速度。
|
||||
- used memory、RSS、碎片率、maxmemory 使用率。
|
||||
- evicted/expired keys、keyspace hit/miss。
|
||||
- network input/output bytes、Pub/Sub channel/pattern/subscriber 数。
|
||||
- Redis 主线程 CPU、事件循环延迟、slowlog 数量。
|
||||
- Sentinel/主从环境的 role、replication offset、replication lag、failover。
|
||||
- `seq_reserve`、`unique_claim`、`hot_cache`、`kafka_consume` 操作错误和 p95/p99。
|
||||
|
||||
压测期间禁止使用无界 `KEYS` 或全库 `SCAN` 做统计,这会污染结果。Kafka 消费状态只通过应用指标抽样统计,不逐条扫描 Redis key。
|
||||
|
||||
常见异常判定:
|
||||
|
||||
- evicted keys 或 rejected connections 增加:直接判失败。
|
||||
- used memory 持续增长且排空后不回落:检查 hot message、幂等 key TTL 和消费记录泄漏。
|
||||
- p99 command latency 超过 10ms:标记风险;超过 50ms 且与消息延迟尖刺相关时判失败。
|
||||
- replication lag 持续扩大或发生 failover:本轮单独标记,不与无故障基线混合。
|
||||
|
||||
### 11.7 Pod、容器与节点资源
|
||||
|
||||
每个 IM Pod 单独展示,不只看 namespace 汇总:
|
||||
|
||||
```promql
|
||||
sum by (pod) (
|
||||
rate(container_cpu_usage_seconds_total{namespace="im-server-system",container="im-server"}[1m])
|
||||
)
|
||||
```
|
||||
|
||||
```promql
|
||||
sum by (pod) (
|
||||
container_memory_working_set_bytes{namespace="im-server-system",container="im-server"}
|
||||
)
|
||||
```
|
||||
|
||||
```promql
|
||||
sum by (pod) (
|
||||
rate(container_network_receive_bytes_total{namespace="im-server-system",pod=~"im-server-.*"}[1m])
|
||||
)
|
||||
```
|
||||
|
||||
```promql
|
||||
sum by (pod) (
|
||||
rate(container_network_transmit_bytes_total{namespace="im-server-system",pod=~"im-server-.*"}[1m])
|
||||
)
|
||||
```
|
||||
|
||||
必须采集:
|
||||
|
||||
- CPU 使用核数、CPU limit 使用率、CFS throttled seconds/periods。
|
||||
- memory working set、RSS、cache、limit 使用率、OOM 和 page fault。
|
||||
- 容器文件系统 read/write bytes、IOPS、IO wait;若根文件系统不承载业务数据,也要观察日志写入压力。
|
||||
- 网络接收/发送 bytes、packets、errors、drops、TCP retransmit。
|
||||
- Pod restart、readiness、liveness、Pending/NotReady 时间。
|
||||
- Go goroutine、heap、stack、GC 次数、GC pause、进程文件描述符。
|
||||
- `LiveSessions`、`LiveTopics`、`LiveClusterNodes`、Cluster leader 和代理 Topic 数。
|
||||
- Node CPU steal/iowait、内存压力、磁盘压力、网络错误、conntrack 使用率。
|
||||
|
||||
资源报告既要给峰值,也要给排空后是否回落。单个 Pod 明显高于其他 Pod 时,结合连接分布、Topic master 位置和网络流量判断是否发生热点。
|
||||
|
||||
建议风险线:
|
||||
|
||||
- Pod CPU 持续超过 limit 的 80%,或 throttling ratio 超过 10%。
|
||||
- memory working set 超过 limit 的 80%,或排空 10 分钟后仍持续增长。
|
||||
- 节点磁盘 util 持续超过 80%,await 持续超过 20ms。
|
||||
- 网络 drop/error 或 TCP retransmit 明显增长。
|
||||
- 任一 Pod restart/OOM/NotReady,直接判本轮失败。
|
||||
|
||||
### 11.8 网络时延与压测机资源
|
||||
|
||||
两台压测机分别持续测量:
|
||||
|
||||
- 到各 Pod 直连入口和负载均衡入口的 DNS、TCP connect、TLS handshake、HTTP `/healthz` RTT。
|
||||
- WebSocket Ping/Pong RTT p50/p95/p99/max,每 5 秒一次,不能用业务 `note kp` 代替协议 Ping。
|
||||
- 到 Kafka、Redis、MySQL 服务地址的 TCP connect RTT;只做低频只读探测,不绕过业务链路写数据。
|
||||
- 两台压测机之间的 RTT、丢包和 NTP offset。
|
||||
- 压测进程 CPU、RSS、GC、goroutine、FD、socket、send/receive bytes、read-loop queue depth。
|
||||
|
||||
压力生成端出现 CPU > 70%、网卡 > 70%、FD 接近上限、read-loop backlog 或 GC 尖刺时,本轮标记 `LOAD_GENERATOR_SATURATED`,不能把客户端处理不过来算成服务端丢消息。
|
||||
|
||||
### 11.9 现有 fanout 与 Cluster 查询
|
||||
|
||||
每轮开始前记录 counter 快照,结束后记录增量:
|
||||
|
||||
```promql
|
||||
sum by (stage, kind, target_type, status) (
|
||||
increase(realtime_fanout_events_total[10m])
|
||||
)
|
||||
```
|
||||
|
||||
```promql
|
||||
histogram_quantile(
|
||||
0.99,
|
||||
sum by (le, kind, target_type) (
|
||||
rate(realtime_fanout_delivery_duration_seconds_bucket[5m])
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Topic 消息使用原生 Cluster 时,`publish/skip_native_cluster` 增加属于正常现象。重点检查 Cluster 连接数、route/proxy 错误、`node for topic not found`、gob 编解码错误、Hub 队列满和 WS 写失败。
|
||||
|
||||
### 11.10 日志、快照与关联规则
|
||||
|
||||
压测日志统一带 `run_id`、scenario、machine、account_index 和脱敏 Topic hash。服务端日志不新增用户 ID 或 `client_mid` 的高基数 Prometheus label;需要定位时通过压测端保存的 manifest 关联。
|
||||
|
||||
每轮保存:
|
||||
|
||||
- Prometheus 查询结果或 Grafana dashboard snapshot。
|
||||
- IM Pod、Kafka、Redis、MySQL 在测试窗口内的错误日志。
|
||||
- Kafka consumer group 压测前、峰值、结束、排空后的 offset/lag 快照。
|
||||
- Redis INFO、SLOWLOG 摘要和 exporter 指标快照。
|
||||
- MySQL GLOBAL STATUS、InnoDB status 摘要、慢 SQL 摘要和表行数核对。
|
||||
- `kubectl top pod/node`、Pod restart/condition/event 和容器日志摘要。
|
||||
- 两台压测机 node/process 指标及 agent summary。
|
||||
|
||||
日志采集必须过滤 Token、密码、API Key、Kafka/Redis/MySQL 凭证和完整消息正文。
|
||||
|
||||
### 11.11 建议仪表盘布局
|
||||
|
||||
一张压测 dashboard 分为六行:
|
||||
|
||||
1. 业务结果:attempt、ACK、实时接收、历史可见、丢失、重复、乱序、p95/p99。
|
||||
2. IM Pod:连接数、发布/下行速率、Cluster route、CPU、内存、GC、goroutine。
|
||||
3. Kafka:producer/consumer rate、lag、batch、commit、retry、dead letter、broker health。
|
||||
4. Redis:OPS、command latency、memory、clients、network、eviction、replication。
|
||||
5. MySQL:insert/transaction rate、DB batch latency、pool wait、locks、Buffer Pool、IO。
|
||||
6. 基础设施:Pod/Node CPU、memory、disk IO、network、压测机资源和网络 RTT。
|
||||
|
||||
所有面板至少按 Pod、Kafka Topic/consumer group、Redis instance、MySQL instance 和压测机分组;不得只展示总和。
|
||||
|
||||
## 12. 压测程序结构
|
||||
|
||||
现有 `loadtest/realtime_fanout_probe.go` 只支持单群和单进程,不能直接用于本方案。新工具使用 Go 实现并提供六个子命令:
|
||||
|
||||
```text
|
||||
im-pressure prepare 注册、好友、群和 manifest
|
||||
im-pressure verify 只读核验 2000 账号、1000 对关系和 2000 群成员
|
||||
im-pressure coordinator 协调双机 READY、统一起跑时间、ACK 台账与结果上报
|
||||
im-pressure agent 在每台压测机运行连接与场景
|
||||
im-pressure collect 抓取 Prometheus、K8s 和依赖组件快照
|
||||
im-pressure merge 合并两台机器结果并生成报告
|
||||
```
|
||||
|
||||
推荐文件:
|
||||
|
||||
```text
|
||||
loadtest/im-pressure/config.example.yaml
|
||||
loadtest/im-pressure/cmd/im-pressure/
|
||||
loadtest/im-pressure/internal/protocol/
|
||||
loadtest/im-pressure/internal/prepare/
|
||||
loadtest/im-pressure/internal/runner/
|
||||
loadtest/im-pressure/internal/collector/
|
||||
loadtest/im-pressure/internal/report/
|
||||
```
|
||||
|
||||
协调方式: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` 只执行只读查询,并把各数据源的原始快照与查询时间一起保存;连接凭证通过环境变量传入,不写入结果目录。
|
||||
|
||||
## 13. 结果文件
|
||||
|
||||
每轮保留以下文件:
|
||||
|
||||
```text
|
||||
results/<run_id>/config.redacted.json
|
||||
results/<run_id>/manifest.sha256
|
||||
results/<run_id>/agent-a-summary.json
|
||||
results/<run_id>/agent-b-summary.json
|
||||
results/<run_id>/publish.jsonl
|
||||
results/<run_id>/missing.jsonl
|
||||
results/<run_id>/duplicate.jsonl
|
||||
results/<run_id>/history-reconcile.json
|
||||
results/<run_id>/db-reconcile-p2p.json
|
||||
results/<run_id>/db-reconcile-group.json
|
||||
results/<run_id>/timeline.json
|
||||
results/<run_id>/metrics/im-server.prom
|
||||
results/<run_id>/metrics/load-agent.prom
|
||||
results/<run_id>/metrics/kafka.json
|
||||
results/<run_id>/metrics/redis.json
|
||||
results/<run_id>/metrics/mysql.json
|
||||
results/<run_id>/metrics/kubernetes.json
|
||||
results/<run_id>/metrics/network.json
|
||||
results/<run_id>/logs/im-server-errors.log
|
||||
results/<run_id>/logs/kafka-errors.log
|
||||
results/<run_id>/logs/mysql-slow-summary.log
|
||||
results/<run_id>/grafana-snapshot.json
|
||||
results/<run_id>/report.md
|
||||
```
|
||||
|
||||
缺失明细至少包含 `run_id`、场景、Topic 脱敏哈希、发送者索引、接收者索引、`client_mid`、ACK `seq`、发送时间、最后等待时间、发送 Pod 和接收 Pod。不得写入 Token、密码、API Key 或完整手机号。
|
||||
|
||||
最终 `report.md` 必须分别给出:
|
||||
|
||||
- 压测业务量和消息正确性结论。
|
||||
- 实时投递与历史恢复结论。
|
||||
- Kafka 生产、消费、lag、排空和死信结论。
|
||||
- MySQL 落库数量、吞吐、耗时、锁与 IO 结论。
|
||||
- Redis OPS、耗时、内存、幂等/seq 错误和 fanout 结论。
|
||||
- 每个 Pod/Node 以及两台压测机的资源峰值和瓶颈判断。
|
||||
- 测试是否有效、是否通过,以及所有未通过项的原始证据路径。
|
||||
|
||||
## 14. 推荐执行顺序
|
||||
|
||||
1. 在测试窗口前完成环境门槛检查和 2000 账号准备。
|
||||
2. 完成 1000 对好友关系与 2000 人群,运行 `verify` 直到全部通过。
|
||||
3. 先用 20 对 P2P、100 人群做冒烟测试,验证统计口径。
|
||||
4. 执行 P0 热身。
|
||||
5. P2P 按 200 对、500 对、1000 对逐级执行;每一级通过后才能进入下一级。
|
||||
6. 执行 S2 群聊,等待恢复基线至少 10 分钟。
|
||||
7. 执行 S3 混合场景。
|
||||
8. 合并结果,按实时链路与持久化链路分别给出结论。
|
||||
9. 全部稳定后再单独进行 Pod 重启、断网或 Kafka/Redis 故障注入,不与本轮无故障基线混跑。
|
||||
|
||||
### 14.1 P2P 容量阶梯
|
||||
|
||||
三个档位复用同一批账号和好友关系,只裁剪 runtime manifest,不重新注册或重复加好友。每档至少执行两轮;发布验收档建议执行三轮。任何一轮出现消息丢失、重复、乱序、非预期拒绝、数据库不一致或 Pod 重启,都必须停止升级并先定位。
|
||||
|
||||
| 档位 | A/B 用户 | WS 总数 | 每用户消息 | Publish 总数 | 目标 |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| P2P-200 | 各 200 | 400 | 10 | 4000 | 验证正确性、指标和基础延迟 |
|
||||
| P2P-500 | 各 500 | 1000 | 10 | 10000 | 验证 Kafka 三分区、MySQL 和 Cluster 中档容量 |
|
||||
| P2P-1000 | 各 1000 | 2000 | 10 | 20000 | 单聊发布验收 |
|
||||
|
||||
每档的执行流程固定为:生成唯一 `run_id`;开始前采集至少 2 分钟基线;启动 coordinator;A/B agent 全部 READY;统一时间发压;P2P 发送结束后继续监听至少 30 秒;等待 Kafka lag 归零;执行历史回查与 MySQL `client_mid` 精确核验;发送结束后继续采集至少 5 分钟;最后合并报告。
|
||||
|
||||
每档升级门槛:Ready、Publish ACK、实时到达、历史查询和 MySQL 精确匹配全部达到期望数;丢失、重复、乱序、断连、403/502/503、Cluster queue overflow、Kafka retry/dead letter、MySQL deadlock、Pod 重启均为 0;Kafka lag 在发送结束后 60 秒内归零;Pod 无持续 CPU throttling。ACK P95 超标时必须按压测机分别报告,并先排除 DNS、TCP、TLS、WebSocket RTT 和时钟偏差。
|
||||
|
||||
### 14.2 P2P 通过后的业务场景
|
||||
|
||||
1. **GROUP-2000**:2000 条 WS 全部加入同一群,A/B 各 500 个发送者;每人发送 2 条,两轮合计 2000 条。实时投递期望量按每条消息的实际 Ready 接收者计算,并保留 60 秒 drain。
|
||||
2. **MIXED**:300 对私聊用户双方各发 5 条,共 3000 条私聊 Publish;同时 A/B 各 250 个群发送者每人发 2 条,共 1000 条群 Publish。
|
||||
3. **稳定性复跑**:GROUP 和 MIXED 至少各两轮,确认无冷启动偶然性。
|
||||
4. **故障注入**:分别执行单 Pod 滚动重启、Kafka 短暂不可用与恢复、Redis 短暂不可用与恢复;故障轮只验证恢复能力,不与无故障性能基线混合统计。
|
||||
|
||||
### 14.3 当前下一步
|
||||
|
||||
当前已通过 P2P-200 的消息正确性、Kafka 60 秒排空、MySQL 精确落库、零乱序、零审计死锁和零 CPU throttling。下一步执行 **P2P-500**:A/B 各取账号索引 `1..500`,共 1000 条 WS、10000 条 Publish。若连续两轮满足上述升级门槛,再进入 P2P-1000。
|
||||
@@ -0,0 +1,207 @@
|
||||
# MeetingIM App 版本更新前后端对接文档
|
||||
|
||||
## 1. 适用范围
|
||||
|
||||
本文档用于 iOS、Android、Web 客户端检查新版本,以及执行推荐更新和强制更新。
|
||||
|
||||
- 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。
|
||||
- iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。
|
||||
- Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。
|
||||
- Web 端没有安装包时,`store_url` 和 `package_url` 可以为空,使用 `update_type`、`min_supported_version` 和 `release_notes` 控制强刷、兼容提醒或维护提示。
|
||||
- 客户端不直接访问数据库,也不自行拼接 OSS 地址。
|
||||
|
||||
## 2. 检查版本接口
|
||||
|
||||
```http
|
||||
GET https://api.118im.com/v1/im/app/releases/latest
|
||||
```
|
||||
|
||||
该接口允许未登录调用。
|
||||
|
||||
### 2.1 请求 Header
|
||||
|
||||
| Header | 必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 |
|
||||
| `X-Client-Platform` | 建议 | `ios` / `android` / `web` | 客户端平台 |
|
||||
|
||||
### 2.2 Query 参数
|
||||
|
||||
| 参数 | 必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `apikey` | 是 | `AQAAAA...` | 应用 API Key |
|
||||
| `platform` | 是 | `android` | 仅支持 `ios`、`android`、`web` |
|
||||
| `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 |
|
||||
| `channel` | 否 | `stable` | 发布渠道,默认 `stable` |
|
||||
|
||||
Android 请求示例:
|
||||
|
||||
```bash
|
||||
curl -H 'X-IM-Schema: 1' \
|
||||
-H 'X-Client-Platform: android' \
|
||||
'https://api.118im.com/v1/im/app/releases/latest?apikey=AQAAAA...&platform=android¤t_version=1.2.0&channel=stable'
|
||||
```
|
||||
|
||||
## 3. 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"platform": "android",
|
||||
"channel": "stable",
|
||||
"current_version": "1.2.0",
|
||||
"latest_version": "1.3.0",
|
||||
"min_supported_version": "1.1.0",
|
||||
"has_update": true,
|
||||
"force_update": false,
|
||||
"update_type": "recommended",
|
||||
"release_notes": "优化消息稳定性并修复已知问题。",
|
||||
"store_url": "",
|
||||
"package_url": "https://download.example.com/app-releases/android/stable/1.3.0/meetingim-1.3.0.apk",
|
||||
"checksum": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"remind_after_seconds": 86400,
|
||||
"published_at": "2026-09-12T08:00:00Z"
|
||||
},
|
||||
"request_id": "req_xxx",
|
||||
"server_time": "2026-09-12T08:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `current_version` | string | 客户端传入的当前版本 |
|
||||
| `latest_version` | string | 后台发布的最新版本 |
|
||||
| `min_supported_version` | string | 最低可继续使用的版本 |
|
||||
| `has_update` | boolean | 是否存在更高版本 |
|
||||
| `force_update` | boolean | 是否必须完成更新后才能继续使用 |
|
||||
| `update_type` | string | `none`、`recommended`、`force` |
|
||||
| `release_notes` | string | 更新说明 |
|
||||
| `store_url` | string | iOS App Store 地址 |
|
||||
| `package_url` | string | Android APK 的 OSS/CDN HTTPS 下载地址 |
|
||||
| `checksum` | string | Android APK 的 SHA-256,小写十六进制 |
|
||||
| `remind_after_seconds` | number | 推荐更新关闭后再次提醒的间隔 |
|
||||
| `published_at` | string | 发布时间,UTC RFC 3339 |
|
||||
|
||||
## 4. 更新判断规则
|
||||
|
||||
客户端以服务端返回结果为准,不自行重新计算强制更新规则。
|
||||
|
||||
| 条件 | 客户端行为 |
|
||||
| --- | --- |
|
||||
| `has_update=false` | 不弹更新窗口;手动检查时提示已是最新版本 |
|
||||
| `has_update=true, force_update=false, update_type=recommended` | 弹推荐更新窗口,允许取消或稍后更新 |
|
||||
| `has_update=true, force_update=true` | 弹强制更新窗口,不允许关闭,不允许进入主业务 |
|
||||
| `update_type=none` | 不主动弹窗,可展示版本信息 |
|
||||
|
||||
当 `current_version < min_supported_version` 时,服务端无条件返回强制更新:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": true,
|
||||
"force_update": true,
|
||||
"update_type": "force"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Android APK 与 OSS
|
||||
|
||||
### 5.1 OSS 对象路径
|
||||
|
||||
建议使用独立于聊天附件的发布目录:
|
||||
|
||||
```text
|
||||
app-releases/android/{channel}/{version}/meetingim-{version}.apk
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
app-releases/android/stable/1.3.0/meetingim-1.3.0.apk
|
||||
```
|
||||
|
||||
### 5.2 下载地址要求
|
||||
|
||||
1. `package_url` 必须使用 HTTPS。
|
||||
2. 地址必须长期稳定,至少覆盖该版本的完整支持周期。
|
||||
3. 可以使用 OSS 公共读地址,或绑定 OSS/CDN 的固定下载域名。
|
||||
4. 不得把有效期只有几分钟的 OSS 签名 URL 保存到版本表。
|
||||
5. 如果 Bucket 必须私有,应提供固定公开下载接口,由接口实时生成签名并返回 302;版本表保存该固定接口地址。
|
||||
6. APK 响应建议设置 `Content-Type: application/vnd.android.package-archive`。
|
||||
7. 文件名、对象路径和 URL 必须包含版本号,禁止覆盖旧版本对象。
|
||||
|
||||
聊天附件接口 `/v0/file/s/{file_id}` 需要用户鉴权,不作为 Android 安装包的默认下载地址。
|
||||
|
||||
### 5.3 发布步骤
|
||||
|
||||
1. Android 构建正式签名 APK。
|
||||
2. 计算 APK 的 SHA-256。
|
||||
3. 上传到 OSS 的 `app-releases/android/{channel}/{version}/` 目录。
|
||||
4. 使用浏览器或 `curl -I` 验证 HTTPS 地址可以下载。
|
||||
5. 在 Admin 的“IM 全局版本更新”中编辑 Android 策略。
|
||||
6. 填写最新版本、最低支持版本、更新类型、更新说明、`package_url` 和 SHA-256。
|
||||
7. 保存后由 Kafka `app.release.updated` 事件通知 IM Server 清理缓存。
|
||||
8. 使用旧版本号请求检查接口,确认下载地址、校验值和强更状态。
|
||||
|
||||
计算 SHA-256 示例:
|
||||
|
||||
```bash
|
||||
shasum -a 256 meetingim-1.3.0.apk
|
||||
```
|
||||
|
||||
## 6. Android 客户端处理
|
||||
|
||||
1. 调用版本检查接口。
|
||||
2. `has_update=false` 时结束检查。
|
||||
3. 根据 `force_update` 展示推荐更新或不可关闭的强更弹窗。
|
||||
4. 使用系统下载组件请求 `package_url`,支持进度展示和失败重试。
|
||||
5. 下载完成后计算本地文件 SHA-256,并与 `checksum` 比较。
|
||||
6. 校验 APK 包名和签名证书必须与已安装应用一致。
|
||||
7. 通过 Android `FileProvider` 发起安装,不使用 `file://` URI。
|
||||
8. 未授予“安装未知应用”权限时,引导用户进入系统授权页。
|
||||
9. 强更下载或安装失败时保留重试、网络设置和退出应用入口,不能绕过进入主业务。
|
||||
|
||||
客户端不得在 SHA-256 或应用签名校验失败后继续安装。
|
||||
|
||||
## 7. iOS 客户端处理
|
||||
|
||||
1. iOS 只读取 `store_url`,忽略 `package_url` 和 `checksum`。
|
||||
2. `store_url` 必须是 `https://apps.apple.com/...` 地址。
|
||||
3. 推荐更新允许取消;强制更新不得进入主业务。
|
||||
4. 点击更新后使用系统能力打开 App Store。
|
||||
|
||||
## 8. 错误响应
|
||||
|
||||
缺少或错误的 `current_version`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 30000,
|
||||
"reason": "MALFORMED_REQUEST",
|
||||
"message": "malformed request",
|
||||
"details": {
|
||||
"field": "current_version"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP 状态 | 原因 |
|
||||
| --- | --- |
|
||||
| 400 | 缺少 `X-IM-Schema: 1` |
|
||||
| 400 | 缺少或错误的 `current_version` |
|
||||
| 400 | `platform` 不是 `ios`、`android` 或 `web` |
|
||||
| 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 |
|
||||
|
||||
## 9. 当前测试环境
|
||||
|
||||
```text
|
||||
Base URL: https://api.118im.com
|
||||
版本接口: /v1/im/app/releases/latest
|
||||
API Key: AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy
|
||||
协议版本: X-IM-Schema: 1
|
||||
默认渠道: stable
|
||||
```
|
||||
|
||||
当前测试库已配置 iOS、Android、Web `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。
|
||||
@@ -724,11 +724,19 @@ IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终
|
||||
1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。
|
||||
2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。
|
||||
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
|
||||
4. 消费者按 `message_kind` 区分私聊与群聊:
|
||||
- `p2p` 写入 `im_messages`。
|
||||
- `group` 写入 `im_group_messages`。
|
||||
4. 私聊和群聊使用独立 Topic、独立消费组和独立物理表:私聊批量写入租户分表 `im_%03d_messages`,群聊按租户和月份批量写入 `im_%03d_group_messages_YYYYMM`。
|
||||
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
|
||||
6. 失败按统一退避重试 3 次,超过上限先投递 `im.dead.letter`,再由 dead letter consumer 写入 `im_kafka_dead_letters`。
|
||||
6. 消费者最多聚合 `message_batch_size` 条消息,在 `message_batch_wait_ms` 时间窗内形成批次并执行一条多值 `INSERT`。
|
||||
7. 批量写入失败后逐条降级;每条执行首次写入和 `message_single_retry_count` 次重试,默认共尝试 4 次。
|
||||
8. 单条仍失败时,完成 `im.dead.letter` 投递和 `im_kafka_dead_letters` 终态写入后才提交该批 offset;提交失败会持续重试,避免跳过失败消息。
|
||||
|
||||
消息批量落库参数:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | ---: | --- |
|
||||
| `message_batch_size` | `100` | 单批最大消息数,上限 500。 |
|
||||
| `message_batch_wait_ms` | `10` | 从取得第一条消息开始的批次聚合等待时间。 |
|
||||
| `message_single_retry_count` | `3` | 批量失败后单条额外重试次数,不含首次执行。 |
|
||||
|
||||
### 11.10 IM Server 已接入的缓存失效消费
|
||||
|
||||
@@ -899,9 +907,11 @@ IM Server 不再生产或消费 `im.file.lifecycle`。文件相关的小流量
|
||||
3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。
|
||||
4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。
|
||||
|
||||
### 11.11 IM Server 工作台事件同步处理
|
||||
### 11.11 IM Server 工作台事件消费处理
|
||||
|
||||
IM Server 不再生产或消费 `im.workbench.event`。工作台小流量事件由业务接口同步创建系统通知和 `im_business_events_outbox`。
|
||||
工作台拆分为独立 `im_workbench` 服务后,工作台业务变更由 `im_workbench` 生产 `im.workbench.event`,IM Server 新增独立 Kafka consumer 消费该 Topic,并转为系统通知和 C 端业务实时事件。
|
||||
|
||||
说明:IM Server 当前保留旧工作台 HTTP 代码和同步处理函数,仅作为迁移过渡兼容。待 `im_workbench` 全部接口稳定后,再统一删除 IM Server 内的旧工作台业务代码。
|
||||
|
||||
当前生产范围:
|
||||
|
||||
@@ -914,8 +924,11 @@ IM Server 不再生产或消费 `im.workbench.event`。工作台小流量事件
|
||||
|
||||
当前消费行为:
|
||||
|
||||
1. 按 `target_user_ids` 同步生成系统通知。
|
||||
2. 同步写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
|
||||
1. `im_workbench` 直接发送 JSON 事件到 `im.workbench.event`,Topic 配置键为 `workbench_external`。
|
||||
2. IM Server 按 `event_id + consumer_group` 做消费幂等。
|
||||
3. 按 `target_user_ids` 为非发布人生成系统通知。
|
||||
4. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
|
||||
5. 消费失败按统一 Kafka 重试逻辑处理,超过最大重试次数后进入 `im.dead.letter`。
|
||||
|
||||
### 11.12 IM Server 已接入的审计事件
|
||||
|
||||
|
||||
@@ -545,27 +545,27 @@ Payload:
|
||||
1. 用户 A 与用户 B 建立私聊。
|
||||
2. A 发送带 `x-im-client-mid` 的私聊消息。
|
||||
3. 查询 Kafka `im.message.persist`。
|
||||
4. 查询租户库 `im_messages`。
|
||||
4. 查询租户私聊分表 `im_%03d_messages`。
|
||||
5. A 使用同一 `x-im-client-mid` 重复发送。
|
||||
|
||||
预期:
|
||||
|
||||
1. A 立即收到服务端 ack 和 `seq_id`。
|
||||
2. `seq_id` 为 BIGINT/int64。
|
||||
3. 消费成功后 `im_messages` 有一条记录。
|
||||
3. 消费成功后对应租户私聊分表有一条记录。
|
||||
4. 重复发送不重复落库。
|
||||
5. 消费日志 succeeded。
|
||||
|
||||
### TC-KAFKA-014 群聊消息异步落库
|
||||
|
||||
目标:验证群消息写入 `im_group_messages`,不混入私聊表。
|
||||
目标:验证群消息写入租户月度群消息分表,不混入私聊表。
|
||||
|
||||
步骤:
|
||||
|
||||
1. 创建群并加入至少两个成员。
|
||||
2. 成员发送群消息。
|
||||
3. 查询 `im_group_messages`。
|
||||
4. 查询 `im_messages`。
|
||||
3. 查询 `im_%03d_group_messages_YYYYMM`。
|
||||
4. 查询 `im_%03d_messages`。
|
||||
5. 重连客户端按 Topic seq 拉取消息。
|
||||
|
||||
预期:
|
||||
@@ -574,24 +574,27 @@ Payload:
|
||||
2. `seq_id` 单调增长,类型为 BIGINT/int64。
|
||||
3. 客户端补偿拉取成功。
|
||||
4. 24 小时热消息可进入 Redis 热缓存。
|
||||
5. 私聊与群聊分别由 `im.message.persist`、`im.group.message.persist` 消费。
|
||||
|
||||
### TC-KAFKA-015 消息消费失败重试
|
||||
|
||||
目标:验证 `im.message.persist` 消费失败可重试并最终成功。
|
||||
目标:验证私聊和群聊批量落库失败后均按“逐条降级、重试 3 次、死信后提交 offset”处理。
|
||||
|
||||
步骤:
|
||||
|
||||
1. 临时制造租户库写入失败,如锁表或停止库连接。
|
||||
2. 发送消息。
|
||||
3. 恢复数据库。
|
||||
4. 等待重试。
|
||||
2. 在批次窗口内连续发送多条私聊和群聊消息。
|
||||
3. 恢复数据库,验证逐条重试可成功落库。
|
||||
4. 再次持续制造单条失败直至超过重试次数。
|
||||
5. 查询 `im_kafka_consume_log`、`im_kafka_dead_letters` 和 `im.dead.letter`。
|
||||
|
||||
预期:
|
||||
|
||||
1. `im_kafka_consume_log.retry_count` 增加。
|
||||
2. 未超过重试上限时不进入死信。
|
||||
3. 数据库恢复后消息最终落库。
|
||||
4. 状态变为 succeeded。
|
||||
2. 批量失败后降级为单条写入,每条最多执行 4 次(首次加 3 次重试)。
|
||||
3. 未超过重试上限时不进入死信,数据库恢复后消息最终落库并标记 succeeded。
|
||||
4. 持续失败的单条消息写入死信表并投递死信 Topic。
|
||||
5. 只有批内全部消息达到 succeeded/dead 终态后才提交 offset,不能跳过失败消息。
|
||||
|
||||
### TC-KAFKA-016 文件上传完成事件
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败
|
||||
| `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 |
|
||||
| `im.file.lifecycle` | 已不采用 | 文件生命周期小流量动作同步处理 | 无 |
|
||||
| `im.audit.event` | 跨系统审计事件 | IM Server、Admin 后台 | Admin/HotGo 审计消费者 |
|
||||
| `im.workbench.event` | 已不采用 | 工作台小流量动作同步处理 | 无 |
|
||||
| `im.workbench.event` | 工作台拆分后的业务事件、红点、系统通知、WS 下发 | im_workbench | IM Server 工作台事件消费者 |
|
||||
| `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 |
|
||||
|
||||
## 3. 幂等与重试
|
||||
|
||||
@@ -475,6 +475,8 @@ im:{tenant_id}:...
|
||||
|
||||
当前代码将 Redis 配置挂在 `store_config.redis` 下;不配置则不启用 Redis 群消息发号和唯一 key,群消息仍兼容旧的本机 `lastID + 1` 发号。
|
||||
|
||||
群消息物理表按自然月拆分时,落库路由、跨月查询、幂等和迁移方案见:[IM-群消息按月分表改造方案](./IM-群消息按月分表改造方案.md)。
|
||||
|
||||
配置示例:
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
# IM 个人/企业注册登录前后端对接说明
|
||||
|
||||
## 1. 目标
|
||||
|
||||
当前客户端没有独立的企业码进入页面,登录页和注册页由用户选择“个人用户”或“企业用户”。前端提交登录和注册时必须显式带类型,后端不能再通过企业码是否为空来推断个人/企业。
|
||||
|
||||
核心规则:
|
||||
|
||||
1. 前端新增字段 `registration_type`,类型为 int,取值只能是 `0` 或 `1`。
|
||||
2. `registration_type=1` 时,后端按个人注册处理,内部租户使用 `default`,数据写入 `default` 租户业务库。
|
||||
3. `registration_type=0` 时,前端必须提交企业码,后端必须校验企业存在、启用且允许注册。
|
||||
4. 登录新增字段 `login_type`,类型为 int,取值同样是 `0` 企业、`1` 个人。
|
||||
5. 企业登录或企业注册都不允许使用 `default`。
|
||||
6. `default` 是系统保留的个人租户码,不允许作为企业码输入。
|
||||
|
||||
一句话规则:**注册类型由 `registration_type` 决定,登录类型由 `login_type` 决定;个人落 `default`,企业必须校验企业码。**
|
||||
|
||||
## 2. 前端注册字段
|
||||
|
||||
注册页提交发码和最终注册时都带同一组注册上下文字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `registration_type` | int | 是 | `0` 表示企业注册,`1` 表示个人注册 |
|
||||
| `tenant_code` | string | 企业注册必填 | 企业码;个人注册不填或传空字符串 |
|
||||
|
||||
前端规则:
|
||||
|
||||
| `registration_type` | `tenant_code` | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
| `1` | 空 | 允许提交,后端内部使用 `default` |
|
||||
| `1` | 非空 | 后端拒绝,避免误入库 |
|
||||
| `0` | 空 | 后端拒绝,提示请输入企业码 |
|
||||
| `0` | `default`、`Default`、`DEFAULT` | 后端拒绝 |
|
||||
| `0` | 其他值 | 后端在注册链路中校验 |
|
||||
|
||||
用户在企业注册模式输入 `default` 时,建议提示:
|
||||
|
||||
```text
|
||||
default 是系统保留企业码。个人注册请选择个人用户,企业注册请输入企业提供的企业码。
|
||||
```
|
||||
|
||||
## 3. 企业码校验
|
||||
|
||||
不新增单独的企业码校验接口,也不要求前端在注册前调用 `/v1/im/tenants/resolve`。
|
||||
|
||||
后端在注册发码和最终注册链路中校验:
|
||||
|
||||
1. `registration_type=1`
|
||||
- 不要求企业码。
|
||||
- 即使请求 Header 或内部路由使用 `default`,也属于服务端内部租户,不是用户输入企业码。
|
||||
- 创建用户时写入 `default` 租户业务库。
|
||||
|
||||
2. `registration_type=0`
|
||||
- `tenant_code` 必填。
|
||||
- `tenant_code` 大小写不敏感等于 `default` 时拒绝。
|
||||
- 查询企业是否存在、启用、允许注册。
|
||||
- 通过后在对应企业租户业务库创建用户。
|
||||
|
||||
企业不存在、停用或不允许注册时,客户端统一提示:
|
||||
|
||||
```text
|
||||
企业不存在或不可用
|
||||
```
|
||||
|
||||
客户端不要区分不存在、停用、禁用注册等内部原因。
|
||||
|
||||
`/v1/im/tenants/resolve` 只保留给服务发现、品牌配置、历史企业入口等场景使用;当前注册页不依赖它完成主流程。
|
||||
|
||||
## 4. 前端登录字段
|
||||
|
||||
登录页提交 WebSocket `{login}` 时带登录上下文字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `login_type` | int | basic 登录必填 | `0` 表示企业登录,`1` 表示个人登录 |
|
||||
| `tenant_code` | string | 企业登录必填 | 企业码;个人登录不填、传空字符串或内部传 `default` |
|
||||
|
||||
登录规则:
|
||||
|
||||
| `login_type` | `tenant_code` / `hi.tenant` | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
| `1` | `default` | 个人登录,允许 |
|
||||
| `1` | 非 `default` | 拒绝,避免个人登录误进企业租户 |
|
||||
| `0` | 空 | 拒绝,提示请输入企业码 |
|
||||
| `0` | `default`、`Default`、`DEFAULT` | 拒绝,统一提示“企业不存在或不可用” |
|
||||
| `0` | 其他值 | 企业登录,后端按该租户认证 |
|
||||
|
||||
企业用户如果在企业入口输入 `default`,后端返回策略错误,错误参数为 `what=tenant_code`、`reason=TENANT_CODE_FORBIDDEN`;客户端统一展示:
|
||||
|
||||
```text
|
||||
企业不存在或不可用
|
||||
```
|
||||
|
||||
个人登录 `{login}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"login": {
|
||||
"id": "login-1",
|
||||
"scheme": "basic",
|
||||
"login_type": 1,
|
||||
"tenant_code": "",
|
||||
"secret": "base64(account:password)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
企业登录 `{login}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"login": {
|
||||
"id": "login-1",
|
||||
"scheme": "basic",
|
||||
"login_type": 0,
|
||||
"tenant_code": "acme",
|
||||
"secret": "base64(account:password)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 个人注册流程
|
||||
|
||||
个人注册必须显式提交 `registration_type=1`。
|
||||
|
||||
1. 用户选择个人注册。
|
||||
2. 前端提交 `registration_type=1`,`tenant_code` 为空。
|
||||
3. 注册发码、验证码校验、WebSocket `hi`、`acc` 注册链路使用服务端内部租户 `default`。
|
||||
4. 服务端将用户创建到 `default` 租户对应的业务库。
|
||||
|
||||
发码示例:
|
||||
|
||||
```http
|
||||
POST /v1/im/auth/verification-codes
|
||||
X-MeetingIM-APIKey: <api_key>
|
||||
X-IM-Schema: 1
|
||||
X-Tenant-Code: default
|
||||
X-Request-ID: <uuid>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scene": "register",
|
||||
"registration_type": 1,
|
||||
"tenant_code": "",
|
||||
"phone": "19122421333"
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket 握手示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"hi": {
|
||||
"id": "hi-1",
|
||||
"ver": "0.25",
|
||||
"ua": "ios/1.0.0",
|
||||
"platf": "ios",
|
||||
"lang": "zh-CN",
|
||||
"tenant": "default"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最终注册 `{acc}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"acc": {
|
||||
"id": "acc-1",
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"registration_type": 1,
|
||||
"tenant_code": "",
|
||||
"username": "henry",
|
||||
"phone": "19122421333",
|
||||
"secret": "base64(henry:password)",
|
||||
"login": true,
|
||||
"cred": [
|
||||
{
|
||||
"meth": "tel",
|
||||
"val": "19122421333",
|
||||
"resp": "verification_token",
|
||||
"params": {"challenge_id": "challenge-id"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 企业注册流程
|
||||
|
||||
企业注册必须显式提交 `registration_type=0`。
|
||||
|
||||
1. 用户选择企业注册。
|
||||
2. 用户填写企业码,例如 `acme`。
|
||||
3. 前端直接提交用户输入的企业码,不做企业码存在性或保留字拦截。
|
||||
4. 获取验证码、提交注册时都带 `registration_type=0` 和 `tenant_code=acme`。
|
||||
5. 后端在注册链路中校验企业是否存在且可用。
|
||||
6. 校验通过后,服务端在该企业租户业务库中创建用户;校验不通过时返回“企业不存在或不可用”。
|
||||
|
||||
注册发码请求:
|
||||
|
||||
```http
|
||||
POST /v1/im/auth/verification-codes
|
||||
X-MeetingIM-APIKey: <api_key>
|
||||
X-IM-Schema: 1
|
||||
X-Tenant-Code: acme
|
||||
X-Request-ID: <uuid>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"scene": "register",
|
||||
"registration_type": 0,
|
||||
"tenant_code": "acme",
|
||||
"phone": "19122421333"
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket `hi`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hi": {
|
||||
"id": "hi-1",
|
||||
"ver": "0.25",
|
||||
"ua": "android/1.0.0",
|
||||
"platf": "android",
|
||||
"lang": "zh-CN",
|
||||
"tenant": "acme"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最终注册 `{acc}` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"acc": {
|
||||
"id": "acc-1",
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"registration_type": 0,
|
||||
"tenant_code": "acme",
|
||||
"username": "henry",
|
||||
"phone": "19122421333",
|
||||
"secret": "base64(henry:password)",
|
||||
"login": true,
|
||||
"cred": [
|
||||
{
|
||||
"meth": "tel",
|
||||
"val": "19122421333",
|
||||
"resp": "verification_token",
|
||||
"params": {"challenge_id": "challenge-id"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 后端校验规则
|
||||
|
||||
后端负责注册链路的最终校验,前端不做企业码拦截。
|
||||
|
||||
1. `registration_type` 缺失或不是 `0|1`
|
||||
- 拒绝注册。
|
||||
- 返回 `MALFORMED_REQUEST`,提示客户端升级或检查参数。
|
||||
|
||||
2. `registration_type=1`
|
||||
- 不从空企业码推断,而是由该字段明确进入个人注册。
|
||||
- 内部租户码设置为 `default`。
|
||||
- 拒绝用户提交的非空 `tenant_code`,避免误入库。
|
||||
- 注册时写入 `default` 租户业务库。
|
||||
|
||||
3. `registration_type=0`
|
||||
- `tenant_code` 必填。
|
||||
- 先 trim 首尾空格。
|
||||
- 如果大小写不敏感等于 `default`,拒绝注册。
|
||||
- 否则查询租户是否存在、是否启用、是否允许注册。
|
||||
- 校验通过后绑定对应租户。
|
||||
|
||||
建议错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 30000,
|
||||
"reason": "MALFORMED_REQUEST",
|
||||
"message": "invalid registration_type or tenant_code"
|
||||
}
|
||||
```
|
||||
|
||||
企业不可用时,客户端展示:
|
||||
|
||||
```text
|
||||
企业不存在或不可用
|
||||
```
|
||||
|
||||
## 8. 缓存和本地数据隔离
|
||||
|
||||
客户端本地缓存、Token、会话、草稿、附件缓存必须继续按 `tenant_code + user_id` 隔离。
|
||||
|
||||
个人注册用户登录后,缓存键中的 `tenant_code` 使用 `default`。企业用户登录后,使用对应企业码。切换个人/企业身份时必须清理当前连接并重新 `hi`,不能在同一个 WebSocket 中切换租户。
|
||||
|
||||
## 9. 验收用例
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| `login_type=1`,`hi.tenant=default` | 个人登录成功进入 `default` 租户认证 |
|
||||
| `login_type=1`,`hi.tenant=acme` | 拒绝登录 |
|
||||
| `login_type=0`,企业码空 | 拒绝登录,提示请输入企业码 |
|
||||
| `login_type=0`,企业码为 `default` | 拒绝登录,提示企业不存在或不可用 |
|
||||
| `login_type=0`,企业码有效 | 在该企业租户认证 |
|
||||
| `registration_type=1`,企业码空,手机号注册 | 成功进入 `default` 租户注册链路 |
|
||||
| `registration_type` 缺失 | 拒绝注册,返回参数错误 |
|
||||
| `registration_type=1`,企业码非空 | 拒绝或忽略,建议拒绝,避免误入库 |
|
||||
| `registration_type=0`,企业码空 | 拒绝注册,提示请输入企业码 |
|
||||
| `registration_type=0`,企业码为 `default` | 拒绝注册 |
|
||||
| `registration_type=0`,企业码不存在 | 发码或注册失败,提示企业不存在或不可用 |
|
||||
| `registration_type=0`,企业码已停用 | 统一提示企业不存在或不可用 |
|
||||
| `registration_type=0`,企业码有效 | 注册写入该企业租户库 |
|
||||
| 个人用户注册成功后登录 | `tenant_code=default`,可正常登录、收发消息 |
|
||||
|
||||
## 10. 对客户端的明确要求
|
||||
|
||||
1. 登录页和注册页都必须明确区分个人和企业。
|
||||
2. basic 登录必须提交 `login_type`。
|
||||
3. 发码和最终注册都必须提交 `registration_type`。
|
||||
4. 个人登录/注册提交类型 `1`,不要让用户输入或看到 `default`。
|
||||
5. 企业登录/注册提交类型 `0` 和用户输入的企业码。
|
||||
6. 企业用户手动输入 `default` 也直接提交,由后端返回错误,客户端按后端错误展示提示。
|
||||
7. 不需要为了注册主流程调用 `/v1/im/tenants/resolve`。
|
||||
|
||||
## 11. 对后端的明确要求
|
||||
|
||||
1. basic 登录链路必须以 `login_type` 作为个人/企业登录的判定来源。
|
||||
2. 注册链路必须以 `registration_type` 作为个人/企业注册的判定来源。
|
||||
3. 不允许仅根据企业码是否为空推断个人注册或个人登录。
|
||||
4. 登录、注册、发码、验证码校验、WebSocket `hi/login/acc` 链路保持同一租户上下文。
|
||||
5. 显式企业码 `default` 必须拒绝,防止用户绕过企业码校验。
|
||||
6. 企业登录/注册的企业码必须校验存在、启用、允许登录/注册。
|
||||
7. 日志中要能看出来源是 `personal_default` 还是 `enterprise_code`,方便排查误入库问题。
|
||||
@@ -1018,7 +1018,7 @@
|
||||
|
||||
功能说明:
|
||||
|
||||
工作台聚合组织内部的常用办公应用和业务入口,包括组织架构、日报/月报、会议通知、总经理信箱等模块。应用入口应根据企业配置和用户权限动态展示,并支持未读数量提示。
|
||||
工作台聚合组织内部的常用办公应用和业务入口,包括组织架构、日报/月报、会议通知、会议笔记、总经理信箱等模块。应用入口应根据企业配置和用户权限动态展示,并支持未读数量提示。
|
||||
|
||||
### 9.2 部门组织框架.png
|
||||
|
||||
@@ -1116,6 +1116,144 @@
|
||||
|
||||
页面通过提示文案说明信箱用途、处理方式及隐私边界。提示内容应由企业后台可配置,并在用户提交前清晰告知实名/匿名规则和信息接收方。
|
||||
|
||||
### 9.14 会议笔记
|
||||
|
||||
会议笔记是企业工作台下的办公协同模块,用于围绕会议记录会议资料、纪要文件和相关附件。功能需要和会议通知、参会人、会议状态及文件能力联动,保证发起人、会议记录员、参会人和普通成员在不同权限下看到合适的入口、文件列表和操作按钮。
|
||||
|
||||
通知与刷新规则:
|
||||
|
||||
1. 工作台底部 Tab 的红点数字需要与工作台内可接收通知的模块上下对应。当前原型中的红点 `3` 表示工作台下共有 3 条未读通知,这 3 条通知只归属到“会议通知”模块。
|
||||
2. 当前只有“会议通知”模块接收移动端通知,并承担会议创建、会议变更、会议提醒、参会反馈等通知展示。
|
||||
3. 打卡、月报、日报、部门框架、举报信箱、会议笔记等其他工作台模块不做移动端通知,也不产生独立通知红点。
|
||||
4. 除会议通知外,其他模块在用户进入模块时调用各自接口获取最新数据并刷新页面;客户端不得依赖通知事件来更新这些模块。
|
||||
5. 会议笔记的文件新增、预览、下载状态只在会议笔记列表/详情页内刷新展示,不向工作台 Tab 追加通知数。
|
||||
|
||||
### 9.14.1 会议笔记-工作台入口.png
|
||||
|
||||
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。
|
||||
2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。
|
||||
3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。
|
||||
|
||||
### 9.14.2 会议笔记-列表.png
|
||||
|
||||
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。
|
||||
2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。
|
||||
3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。
|
||||
4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。
|
||||
5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。
|
||||
6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。
|
||||
|
||||
### 9.14.3 会议笔记-发布页面.png
|
||||
|
||||
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。
|
||||
2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。
|
||||
3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。
|
||||
4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。
|
||||
5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。
|
||||
6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。
|
||||
|
||||
### 9.14.4 会议笔记-详情页.png
|
||||
|
||||
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。
|
||||
2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。
|
||||
3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。
|
||||
4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。
|
||||
5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。
|
||||
6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。
|
||||
|
||||
### 9.14.5 会议笔记-详情页(普通成员).png
|
||||
|
||||
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。
|
||||
2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。
|
||||
3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。
|
||||
4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。
|
||||
|
||||
### 9.14.6 会议笔记-详情页-在线预览.png
|
||||
|
||||
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。
|
||||
2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。
|
||||
3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。
|
||||
4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。
|
||||
|
||||
### 9.14.7 会议笔记-详情页-文件下载中.png
|
||||
|
||||
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 每个文件需独立展示下载进度、成功、失败和取消状态。
|
||||
2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。
|
||||
3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。
|
||||
4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。
|
||||
|
||||
### 9.14.8 会议笔记-文件预览.png
|
||||
|
||||
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `1/213`;更多菜单包含举报和分享操作。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 文件预览页应支持多页文档翻页、缩放、滑动浏览和加载失败重试。
|
||||
2. 页码、加载状态和错误状态必须与当前文件一致,切换文件或返回详情页后应清理旧状态。
|
||||
3. 分享权限由会议和文件权限共同决定;无分享权限时隐藏分享入口或提示无权限。
|
||||
4. 举报入口用于举报文件内容或会议资料,提交后应关联会议 ID、文件 ID、上传人和当前查看人。
|
||||
5. 预览页不得缓存超出权限有效期的文件内容;退出登录、切换企业或权限被收回后必须清理本地可访问副本。
|
||||
|
||||
## 10. 个人资料、通用设置、隐私与青少年模式
|
||||
|
||||
### 10.1 我的.png
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# IM 会议笔记产品原型需求文档
|
||||
|
||||
> 本文档根据 `docs/note/` 中的 8 张会议笔记产品原型图整理。文档格式与 `IM-产品原型需求文档.md` 保持一致,后续可合并到第 9 章“企业工作台与办公协同”中,建议插入在总经理信箱之后、个人资料与设置之前。
|
||||
|
||||
## 9.14 会议笔记
|
||||
|
||||
会议笔记是企业工作台下的办公协同模块,用于围绕会议记录会议资料、纪要文件和相关附件。功能需要和会议通知、参会人、会议状态及文件能力联动,保证发起人、会议记录员、参会人和普通成员在不同权限下看到合适的入口、文件列表和操作按钮。
|
||||
|
||||
通知与刷新规则:
|
||||
|
||||
1. 工作台底部 Tab 的红点数字需要与工作台内可接收通知的模块上下对应。当前原型中的红点 `3` 表示工作台下共有 3 条未读通知,这 3 条通知只归属到“会议通知”模块。
|
||||
2. 当前只有“会议通知”模块接收移动端通知,并承担会议创建、会议变更、会议提醒、参会反馈等通知展示。
|
||||
3. 打卡、月报、日报、部门框架、举报信箱、会议笔记等其他工作台模块不做移动端通知,也不产生独立通知红点。
|
||||
4. 除会议通知外,其他模块在用户进入模块时调用各自接口获取最新数据并刷新页面;客户端不得依赖通知事件来更新这些模块。
|
||||
5. 会议笔记的文件新增、预览、下载状态只在会议笔记列表/详情页内刷新展示,不向工作台 Tab 追加通知数。
|
||||
|
||||
### 9.14.1 会议笔记-工作台入口.png
|
||||
|
||||
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。
|
||||
2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。
|
||||
3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。
|
||||
|
||||
### 9.14.2 会议笔记-列表.png
|
||||
|
||||
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。
|
||||
2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。
|
||||
3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。
|
||||
4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。
|
||||
5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。
|
||||
6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。
|
||||
|
||||
### 9.14.3 会议笔记-发布页面.png
|
||||
|
||||
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。
|
||||
2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。
|
||||
3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。
|
||||
4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。
|
||||
5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。
|
||||
6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。
|
||||
|
||||
### 9.14.4 会议笔记-详情页.png
|
||||
|
||||
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。
|
||||
2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。
|
||||
3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。
|
||||
4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。
|
||||
5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。
|
||||
6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。
|
||||
|
||||
### 9.14.5 会议笔记-详情页(普通成员).png
|
||||
|
||||
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。
|
||||
2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。
|
||||
3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。
|
||||
4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。
|
||||
|
||||
### 9.14.6 会议笔记-详情页-在线预览.png
|
||||
|
||||
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。
|
||||
2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。
|
||||
3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。
|
||||
4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。
|
||||
|
||||
### 9.14.7 会议笔记-详情页-文件下载中.png
|
||||
|
||||
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 每个文件需独立展示下载进度、成功、失败和取消状态。
|
||||
2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。
|
||||
3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。
|
||||
4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。
|
||||
|
||||
### 9.14.8 会议笔记-文件预览.png
|
||||
|
||||
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
|
||||
|
||||
功能说明:
|
||||
|
||||
文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `1/213`;更多菜单包含举报和分享操作。
|
||||
|
||||
业务规则:
|
||||
|
||||
1. 文件预览页应支持多页文档翻页、缩放、滑动浏览和加载失败重试。
|
||||
2. 页码、加载状态和错误状态必须与当前文件一致,切换文件或返回详情页后应清理旧状态。
|
||||
3. 分享权限由会议和文件权限共同决定;无分享权限时隐藏分享入口或提示无权限。
|
||||
4. 举报入口用于举报文件内容或会议资料,提交后应关联会议 ID、文件 ID、上传人和当前查看人。
|
||||
5. 预览页不得缓存超出权限有效期的文件内容;退出登录、切换企业或权限被收回后必须清理本地可访问副本。
|
||||
|
||||
## 合并建议
|
||||
|
||||
1. 合并到 `IM-产品原型需求文档.md` 时,建议将本章插入到第 9 章“企业工作台与办公协同”的 `9.13 总经理信箱-文案提示.png` 之后。
|
||||
2. 如果主文档继续按图片顺序编号,可将本章编号调整为 `9.14` 至 `9.21`;如果按业务模块编号,可保留 `9.14.x` 子编号。
|
||||
3. 本文档引用的图片已经复制到 `docs/images/`,文件名均以 `会议笔记-` 开头,避免与现有 `工作台.png` 等图片重名。
|
||||
@@ -0,0 +1,181 @@
|
||||
# IM 会议笔记前后端逐图对接协议
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-09-05
|
||||
> 需求依据:[IM 会议笔记产品原型需求文档](./IM-会议笔记产品原型需求文档.md)
|
||||
> 公共协议依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
|
||||
> 说明:本文档的图片标题和顺序与会议笔记产品原型完全一致。后续合并时建议作为第 9 章的 `9.14 会议笔记`,原主文档 `9.14 接口示例` 顺延为 `9.15 接口示例`。
|
||||
|
||||
## 9.14 会议笔记
|
||||
|
||||
会议笔记属于企业工作台模块,复用会议主数据和统一文件能力。服务端负责判断会议是否结束、当前用户是否与会议有关、是否为会议记录员,以及文件查看和操作权限;客户端只根据响应中的状态与 `permissions` 展示页面,不自行推导权限。
|
||||
|
||||
本模块遵循主文档第 0 章的公共约定:产品 HTTP 使用 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Authorization: Bearer <token>`;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。
|
||||
|
||||
会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。
|
||||
|
||||
### 9.14.1 会议笔记-工作台入口.png
|
||||
|
||||
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
|
||||
|
||||
**状态:工作台接口需改造。**
|
||||
|
||||
- `GET /v1/im/workbench` 的 `modules` 增加 `code="meeting_notes"`、`route="/meeting-notes"`。服务端按租户功能开关和当前用户权限决定是否返回该入口。
|
||||
- 会议笔记模块固定返回 `unread_count=0`,不参与 `GET /v1/im/bootstrap` 的 `entry_badges.workbench_total_unread` 汇总。
|
||||
- 当前工作台红点只对应“会议通知”模块的未读通知。日报、月报、打卡、部门框架、举报信箱和会议笔记均不产生工作台通知数。
|
||||
- 客户端点击入口后请求 `GET /v1/im/meeting-notes`。切换企业后必须清空原租户的会议笔记缓存并重新加载。
|
||||
|
||||
### 9.14.2 会议笔记-列表.png
|
||||
|
||||
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
|
||||
|
||||
**状态:新增。**
|
||||
|
||||
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20`。接口只返回当前用户作为发起人、会议记录员或参会人的已结束会议,不返回未开始、进行中或已取消会议。
|
||||
- `query` 支持匹配会议主题、发起人、会议记录员、参会人和地点;搜索范围仍受当前用户会议可见权限限制。
|
||||
- 列表按 `end_at` 倒序排列,分页使用不透明 `cursor`。客户端不得用本地时间过滤会议状态或自行扩大搜索范围。
|
||||
- 已结束但尚未上传笔记的会议仍返回,使用 `note_status="empty"`、`file_count=0`;客户端展示会议基础信息,进入详情后显示“暂无笔记”。
|
||||
- `permissions.can_view=true` 决定是否允许进入详情;`permissions.can_upload` 只对当前会议记录员为 `true`。普通参会人可以进入详情,但不显示上传按钮。
|
||||
- `state="ended"` 是服务端按会议状态及服务端当前时间计算或持久化的有效状态。服务端必须以 `end_at` 和取消状态为准,不能信任客户端传入的“已结束”条件。
|
||||
|
||||
### 9.14.3 会议笔记-发布页面.png
|
||||
|
||||
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
|
||||
|
||||
**状态:现有会议创建接口需改造。**
|
||||
|
||||
- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。
|
||||
- 创建仍使用 `POST /v1/im/meetings`,请求新增 `recorder_user_id`。客户端未传或传空字符串时,服务端默认使用当前登录用户;客户端不能通过 `organizer_id` 伪造发起人。
|
||||
- 服务端校验会议记录员属于当前租户且账号有效。首版允许会议记录员是发起人或参会人;若选择了非参会人,服务端自动加入参会人列表并在响应中返回最终名单。
|
||||
- 服务端保存的会议必须有 `recorder_user_id`;创建请求可省略该字段以使用发起人默认值。`attendee_user_ids`、`start_at`、`end_at` 和 `title` 为必填业务数据,`location` 可为空,且 `end_at` 必须晚于 `start_at`。
|
||||
- 创建成功后的会议通知仍统一归属“会议通知”模块;会议笔记模块不额外生成通知或未读数。
|
||||
- `PATCH /v1/im/meetings/{meeting_id}` 支持在会议结束前修改 `recorder_user_id`,并使用 `version` 做乐观锁。会议结束后返回 `MEETING_CLOSED`。
|
||||
|
||||
### 9.14.4 会议笔记-详情页.png
|
||||
|
||||
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
|
||||
|
||||
**状态:新增。**
|
||||
|
||||
- 详情:`GET /v1/im/meetings/{meeting_id}/notes`,一次返回会议基础信息、发起人、会议记录员、参会人、文件列表、笔记状态和当前用户权限。
|
||||
- 会议基础信息与会议通知页面共用 `im_meetings` 和 `im_meeting_participants` 数据,不复制会议标题、时间和成员快照作为另一份可编辑主数据。
|
||||
- 当前用户为会议记录员且会议已结束时,`permissions.can_upload=true`,客户端展示“上传文件”;其他用户不展示该按钮。
|
||||
- 上传文件先调用 `POST /v0/file/u/`,Multipart 携带 `purpose=meeting_note` 和 `meeting_id`;上传成功取得 `file_id` 后,再调用 `POST /v1/im/meetings/{meeting_id}/notes/files` 建立会议笔记文件记录。
|
||||
- 文件登记成功后才进入详情列表。上传成功但登记失败的临时文件不得展示,并由服务端按临时文件 TTL 回收。
|
||||
- 当前会议记录员可替换或删除自己登记的文件;所有写操作均需 `Idempotency-Key`,服务端记录操作审计。
|
||||
|
||||
### 9.14.5 会议笔记-详情页(普通成员).png
|
||||
|
||||
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
|
||||
|
||||
**状态:新增权限响应。**
|
||||
|
||||
- 普通参会人调用与 9.14.4 相同的详情接口。响应中 `permissions.can_view=true`、`can_upload=false`、`can_replace=false`、`can_delete=false`。
|
||||
- 没有已上传文件时返回 `note_status="empty"` 和空数组 `files=[]`,客户端展示“暂无笔记”,不把空状态当作请求失败。
|
||||
- 有文件时普通参会人只展示文件名、大小、类型、上传人、上传时间和预览入口,不展示上传、替换、删除按钮。
|
||||
- 服务端必须在文件登记、替换、删除、预览和下载接口逐次校验会议关系及角色。隐藏客户端按钮不能代替服务端鉴权。
|
||||
- 与会议无关、已离开可见范围或跨租户的用户统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得通过 `meeting_id`、`note_file_id` 或 `file_id` 探测资源。
|
||||
|
||||
### 9.14.6 会议笔记-详情页-在线预览.png
|
||||
|
||||
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
|
||||
|
||||
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
|
||||
|
||||
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。
|
||||
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。两个接口都必须校验当前用户仍有会议笔记查看权限。
|
||||
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
|
||||
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
|
||||
|
||||
### 9.14.7 会议笔记-详情页-文件下载中.png
|
||||
|
||||
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
|
||||
|
||||
**状态:复用统一下载任务;会议笔记 ACL 新增。**
|
||||
|
||||
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传。
|
||||
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
|
||||
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
|
||||
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
|
||||
|
||||
### 9.14.8 会议笔记-文件预览.png
|
||||
|
||||
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
|
||||
|
||||
**状态:客户端预览页;服务端复用 9.14.6 文件读取协议。**
|
||||
|
||||
- 预览页展示服务端返回的安全文件名和实际内容;标题不得直接信任未经清理的上传文件名。
|
||||
- 图片、PDF、文本等受支持类型在应用内预览;Office 等类型优先使用受控转换文件,无法转换时按 `preview.mode="download_only"` 提示下载后打开。
|
||||
- 文件被删除、审核拒绝、转换失败或权限撤销时,服务端返回稳定 `reason`,客户端关闭预览并刷新详情,不能继续显示旧的授权链接。
|
||||
- 从预览页返回详情后调用 `GET /v1/im/meetings/{meeting_id}/notes` 获取最新文件状态。
|
||||
|
||||
### 9.14.9 接口示例
|
||||
|
||||
以下示例用于会议笔记 HTTP 联调快速查阅。成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`,下表只展示 `data`。
|
||||
|
||||
#### 9.14.9.1 工作台、会议记录员与会议笔记
|
||||
|
||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/im/workbench` 需改造 | Query only | `{"layout_version":2,"modules":[{"code":"meeting_notifications","name":"会议通知","route":"/meetings","unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","route":"/meeting-notes","unread_count":0,"enabled":true,"permission":"enabled"}]}` |
|
||||
| `POST /v1/im/meetings` 需改造 | Header `Idempotency-Key`;Body `{"title":"项目周会","start_at":"2026-09-05T10:00:00+08:00","end_at":"2026-09-05T10:30:00+08:00","location":"会议室 A","recorder_user_id":"usr_me","attendee_user_ids":["usr_2"],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","state":"scheduled","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr_2","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true},"version":1}` |
|
||||
| `PATCH /v1/im/meetings/{meeting_id}` 需改造 | Header `Idempotency-Key`;Body `{"recorder_user_id":"usr_2","version":1}` | `{"meeting_id":"mtg_1","recorder":{"user_id":"usr_2","display_name":"李四"},"version":2,"updated_at":"2026-09-05T01:30:00Z"}` |
|
||||
| `GET /v1/im/meeting-notes?query=&cursor=&limit=20` 新增 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participant_count":2,"participant_names":["张三","李四"],"note_status":"empty","file_count":0,"permissions":{"can_view":true,"can_upload":true}}],"next_cursor":"","has_more":false}` |
|
||||
| `GET /v1/im/meetings/{meeting_id}/notes` 新增 | Path meeting ID | `{"meeting":{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participants":[{"user_id":"usr_me","display_name":"张三"},{"user_id":"usr_2","display_name":"李四"}]},"note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","uploader":{"user_id":"usr_me","display_name":"张三"},"created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"ready","mode":"native","preview_file_id":"file_1"},"permissions":{"can_preview":true,"can_download":true,"can_replace":true,"can_delete":true}}],"permissions":{"can_view":true,"can_upload":true},"version":1}` |
|
||||
| `POST /v0/file/u/` | Multipart 文件;字段 `purpose=meeting_note`、`meeting_id=mtg_1`;Header `X-MeetingIM-Auth` | `{"ctrl":{"code":200,"params":{"file_id":"file_1","url":"/v0/file/s/file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf"}}}` |
|
||||
| `POST /v1/im/meetings/{meeting_id}/notes/files` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"processing","mode":"converted"}}` |
|
||||
| `PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_2","version":1}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_2","name":"会议纪要-v2.pdf","version":2,"updated_at":"2026-09-05T03:10:00Z"}` |
|
||||
| `DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Query `version=2` | `{"note_file_id":"mnote_1","deleted":true,"deleted_at":"2026-09-05T03:20:00Z"}` |
|
||||
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 返回文件字节流及 `Content-Type`、`Content-Length`、`ETag`、`Accept-Ranges`;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
|
||||
|
||||
### 9.14.10 字段与状态约定
|
||||
|
||||
| 字段 | 取值 | 前端处理 |
|
||||
| --- | --- | --- |
|
||||
| `meeting.state` | `scheduled`、`in_progress`、`ended`、`canceled` | 会议笔记列表只展示 `ended` |
|
||||
| `note_status` | `empty`、`available` | `empty` 展示“暂无笔记”,`available` 展示文件列表 |
|
||||
| `preview.status` | `ready`、`processing`、`unsupported`、`failed` | 分别进入预览、短时刷新、仅下载提示、失败提示 |
|
||||
| `preview.mode` | `native`、`converted`、`download_only` | 使用原文件、转换文件或仅提供下载 |
|
||||
| `permissions.can_view` | Boolean | 是否可以查看当前会议笔记 |
|
||||
| `permissions.can_upload` | Boolean | 是否显示并允许使用上传按钮,仅会议记录员为 `true` |
|
||||
| `permissions.can_replace` | Boolean | 是否允许替换当前笔记文件 |
|
||||
| `permissions.can_delete` | Boolean | 是否允许删除当前笔记文件 |
|
||||
| `permissions.can_preview` | Boolean | 是否允许预览当前文件 |
|
||||
| `permissions.can_download` | Boolean | 是否允许下载当前文件;首版普通参会人若产品要求“只能预览”,该值返回 `false` |
|
||||
|
||||
客户端必须以服务端返回的 `permissions` 为准。特别是普通参会人“只能预览”的要求,由文件项 `can_preview=true,can_download=false` 表达;即使预览底层使用文件读取接口,客户端也不得提供导出、系统分享或保存到相册入口。
|
||||
|
||||
### 9.14.11 错误码与客户端处理
|
||||
|
||||
| `reason` | 典型 HTTP 状态 | 场景 | 客户端处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MEETING_NOT_ENDED` | 409 | 未结束会议上传或登记笔记 | 提示“会议结束后可上传笔记”,刷新会议详情 |
|
||||
| `MEETING_CLOSED` | 409 | 会议结束后修改记录员或会议数据 | 切换为只读并刷新详情 |
|
||||
| `NOT_MEETING_RECORDER` | 403 | 非记录员上传、替换或删除文件 | 隐藏操作入口并刷新权限 |
|
||||
| `MEETING_NOTE_VERSION_CONFLICT` | 409 | 替换或删除时版本过期 | 拉取最新详情,不自动覆盖 |
|
||||
| `MEETING_NOTE_FILE_INVALID` | 400 | 文件未上传完成、用途不符或不属于当前租户 | 提示重新上传 |
|
||||
| `FILE_PREVIEW_PROCESSING` | 409 | 转换预览尚未完成 | 保持当前页并按退避策略刷新 |
|
||||
| `FILE_PREVIEW_UNSUPPORTED` | 422 | 文件类型不支持在线预览 | 根据 `can_download` 决定是否提供下载 |
|
||||
| `FILE_FORBIDDEN` | 403 | 当前用户无文件访问权限 | 关闭预览或停止下载并刷新详情 |
|
||||
| `FILE_UNAVAILABLE` | 404/410 | 文件被删除、失效或审核拒绝 | 标记不可用并刷新文件列表 |
|
||||
| `RESOURCE_NOT_FOUND` | 404 | 会议或笔记不可见、跨租户访问 | 返回列表,不提示资源是否真实存在 |
|
||||
|
||||
### 9.14.12 前后端联调与验收要点
|
||||
|
||||
1. 发起会议时不传 `recorder_user_id`,响应中的记录员必须默认为当前发起人;指定其他记录员时,响应返回最终记录员和参会人名单。
|
||||
2. 会议笔记列表只包含与当前用户有关的已结束会议;已结束但无文件的会议仍展示,详情为空状态。
|
||||
3. 记录员进入详情显示上传按钮,普通参会人进入同一会议不显示上传按钮;直接调用写接口仍必须被服务端拒绝。
|
||||
4. 普通参会人只允许在线预览时,响应为 `can_preview=true,can_download=false`,客户端不展示下载、分享或导出入口。
|
||||
5. 文件上传成功但会议笔记登记失败时,详情列表不出现该文件;重复登记同一幂等键返回首次结果。
|
||||
6. 文件替换和删除必须校验 `version`;旧版本请求返回冲突,不能覆盖新文件。
|
||||
7. 进入列表、进入详情以及从上传/预览/下载页面返回时均主动刷新;断开 WebSocket 不影响会议笔记数据获取。
|
||||
8. 会议笔记新增、替换、删除均不产生 Push、会议通知未读或工作台红点;工作台红点只随会议通知模块变化。
|
||||
9. 跨租户、无关用户和已撤销权限用户无法通过会议 ID 或文件 ID 查看、预览、下载会议笔记。
|
||||
10. 切换企业或退出登录后,会议笔记列表、详情、预览文件和下载临时数据按租户及账号隔离清理。
|
||||
|
||||
## 合并建议
|
||||
|
||||
1. 将本文 `9.14 会议笔记` 整段插入现有《IM 前后端逐图对接协议》第 9 章的 `9.13 总经理信箱-文案提示.png` 之后。
|
||||
2. 将现有 `9.14 接口示例` 及其子章节顺延为 `9.15`,并把本文 `9.14.9.1` 的接口行合并到新的第 9 章接口示例表中。
|
||||
3. 同步扩展会议数据模型:`im_meetings` 增加会议记录员字段,并新增会议笔记文件关联表;具体表结构在系统设计和数据库变更文档中统一维护。
|
||||
4. 主文档第 9 章现有会议创建示例需要补充 `recorder_user_id`,工作台示例需要增加 `meeting_notes` 模块并明确其 `unread_count=0`。
|
||||
@@ -0,0 +1,227 @@
|
||||
# IM 剩余问题修复方案
|
||||
|
||||
整理时间:2026-09-18
|
||||
|
||||
本文记录当前暂未完成的三个后端问题:青少年模式业务限制、Web 平台版本检查、消息搜索召回稳定性。
|
||||
|
||||
更新记录:
|
||||
|
||||
- 2026-09-18:P1 第一阶段已处理 Web 平台版本检查与消息搜索召回兜底;青少年模式已完成第一阶段策略层、夜间通话限制和消息内容过滤接入,剩余支付守护、好友/群邀请、Push 等全链路限制继续按 P2 增强。
|
||||
|
||||
## 1. 青少年模式业务限制未强制执行
|
||||
|
||||
### 现状
|
||||
|
||||
当前后端已经具备青少年模式的基础状态能力:
|
||||
|
||||
- `GET /v1/im/settings/youth-mode` 可返回 `enabled`、`policy_version`、`restrictions`、`restriction_items`。
|
||||
- `POST /v1/im/settings/youth-mode/enable` 和 `disable` 已完成开启、关闭和独立密码校验链路。
|
||||
- `im_youth_mode_settings` 已保存用户维度的启用状态、策略版本、限制项和密码状态。
|
||||
|
||||
第一阶段已将 `night_quiet` 接入音视频呼叫发起限制,将 `content_filter` 接入消息发送前内容安全审核,并把这两项 `restriction_items[].enforced` 改为 `true`。`payment_guard` 以及好友申请、群邀请、系统通知/Push 等更大范围入口仍未完整接入。
|
||||
|
||||
### 问题影响
|
||||
|
||||
如果只保存开关、不做服务端限制,客户端隐藏入口不能形成安全边界。用户可以通过旧客户端、重新安装、直接调用接口或构造 WebSocket 消息绕过限制。
|
||||
|
||||
### 建议修复方案
|
||||
|
||||
建议将青少年模式拆成一个统一策略检查层,例如 `imYouthPolicy` 或 `impolicy/youth`,所有关键入口调用同一套判断逻辑。
|
||||
|
||||
第一阶段建议先落这几个限制:
|
||||
|
||||
| 限制项 | 建议拦截点 | 修复方式 |
|
||||
| --- | --- | --- |
|
||||
| `night_quiet` 深夜免打扰 | 发消息、音视频呼叫、系统通知/Push | 服务端读取用户青少年状态和当前时间段,限制指定时段内发起呼叫或降低通知等级。 |
|
||||
| `content_filter` 内容过滤 | 发文本、发链接、上传后发布文件、资料/群资料修改 | 复用现有内容安全/风险审核能力;命中高风险时拒绝或进入审核状态。 |
|
||||
| `payment_guard` 支付守护 | 充值、购买、付费功能入口 | 当前 IM 暂无支付主链路,先预留统一错误码和策略接口,后续支付接口接入时强制调用。 |
|
||||
|
||||
建议新增统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 30005,
|
||||
"reason": "YOUTH_MODE_RESTRICTED",
|
||||
"message": "restricted by youth mode",
|
||||
"details": {
|
||||
"restriction": "night_quiet",
|
||||
"policy_version": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 建议改造点
|
||||
|
||||
- `server/im_http.go`
|
||||
- HTTP 发消息、转发、收藏转发、好友申请、群邀请、音视频相关接口前置检查。
|
||||
- `youthModeRestrictionItems` 中已真正接入的规则将 `enforced` 改为 `true`。
|
||||
- `server/topic.go`
|
||||
- 原生 WebSocket `{pub}` 保存消息前执行相同策略,避免绕过 HTTP。
|
||||
- `server/store/store.go`
|
||||
- 增加按用户读取青少年模式的轻量缓存,短 TTL 即可。
|
||||
- `docs/IM-青少年模式前后端对接文档.md`
|
||||
- 补充哪些接口会返回 `YOUTH_MODE_RESTRICTED`。
|
||||
|
||||
### 验收建议
|
||||
|
||||
- 开启青少年模式后,旧客户端直接发消息不能绕过限制。
|
||||
- HTTP 和 WebSocket 两条链路限制结果一致。
|
||||
- 关闭青少年模式后限制即时解除。
|
||||
- `restriction_items[].enforced` 只对已经服务端强制执行的规则返回 `true`。
|
||||
|
||||
### 当前处理状态
|
||||
|
||||
已新增统一策略判断 `imYouthModePolicyDecision`。当前行为:
|
||||
|
||||
- `night_quiet`:青少年模式开启且带该限制时,22:00-06:00 发起音视频呼叫返回 `YOUTH_MODE_RESTRICTED`。
|
||||
- `content_filter`:普通 WS 消息发送前进入内容安全审核,文本、文件名、链接 URL 等可展示内容会参与提取。
|
||||
- `payment_guard`:仍为预留项,返回 `enforced=false`,后续支付或付费功能接入时再强制调用。
|
||||
|
||||
## 2. Web 平台版本检查接口缺失
|
||||
|
||||
### 现状
|
||||
|
||||
当前版本检查接口为:
|
||||
|
||||
```http
|
||||
GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable
|
||||
```
|
||||
|
||||
当前实现和文档主要覆盖 `ios`、`android`。Web 客户端如果调用 `platform=web`,会因为平台枚举未支持而无法得到可用版本配置。
|
||||
|
||||
### 问题影响
|
||||
|
||||
Web 端无法统一读取版本策略、升级提示、最低支持版本和发布说明。后续如果 Web 端需要灰度、强刷、维护提示或版本兼容控制,只能靠前端硬编码。
|
||||
|
||||
### 建议修复方案
|
||||
|
||||
将 `web` 加入 App Release 平台枚举。平台建议统一为:
|
||||
|
||||
```text
|
||||
ios, android, web
|
||||
```
|
||||
|
||||
Web 响应建议保持与移动端同结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"current_version": "1.0.0",
|
||||
"has_update": false,
|
||||
"force_update": false,
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
Web 没有安装包时,`package_url` 和 `store_url` 可以为空;如果需要强刷或维护提示,可通过 `update_type=required` 和 `release_notes` 传达。
|
||||
|
||||
### 建议改造点
|
||||
|
||||
- `server/im_http.go`
|
||||
- 放开 `platform=web` 参数校验。
|
||||
- 保持 `current_version` 必传。
|
||||
- `server/db/mysql/adapter.go` 或 App Release 相关存储实现
|
||||
- 确认 `im_app_releases.platform` 没有限制 `web`。
|
||||
- 如数据库通过字典/配置初始化版本记录,需要补默认 Web 记录。
|
||||
- `docs/IM-App版本更新前后端对接文档.md`
|
||||
- 补充 Web 平台示例。
|
||||
- `docs/IM-前后端对接文档.md`
|
||||
- 将 `platform=ios|android` 更新为 `ios|android|web`。
|
||||
|
||||
### 验收建议
|
||||
|
||||
- `platform=web¤t_version=1.0.0&channel=stable` 返回 200。
|
||||
- `platform=invalid` 仍返回参数错误。
|
||||
- Web 无更新、推荐更新、强制更新三类数据都能按同一结构返回。
|
||||
|
||||
### 当前处理状态
|
||||
|
||||
已在 `serveIMAppReleaseLatest` 放开 `platform=web`,并补充 Web 平台单元测试。对接文档已同步更新为 `ios|android|web`。
|
||||
|
||||
## 3. 消息搜索召回稳定性待复核
|
||||
|
||||
### 现状
|
||||
|
||||
当前接口为:
|
||||
|
||||
```http
|
||||
GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=
|
||||
GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=
|
||||
```
|
||||
|
||||
现有后端已经能返回 `seq`、`from`、`ts`、`snippet`、`matches`、`snapshot`、`availability` 等字段。但问题清单中仍记录了“部分明显应该命中的关键词搜索返回空结果”的现象。该问题目前属于待复核项,可能来源包括:
|
||||
|
||||
- 搜索范围只覆盖部分消息内容字段,没有覆盖所有文本来源。
|
||||
- 结构化消息中正文可能存在于 `content.text`、`content.txt`、`content.title`、`content.summary`、Drafty 或文件快照字段中,索引/过滤口径不一致。
|
||||
- 中文分词、大小写、空格、标点归一化不一致。
|
||||
- 分页游标与过滤顺序不一致,导致命中项被跳过。
|
||||
- 热消息、分表消息和历史消息的查询路径不一致。
|
||||
|
||||
### 问题影响
|
||||
|
||||
搜索结果不稳定会直接影响聊天记录检索、文件检索、收藏定位和问题复盘。用户明知道某条消息存在,但搜索不到,会被认为是数据丢失。
|
||||
|
||||
### 建议修复方案
|
||||
|
||||
第一阶段先做“确定性兜底”,不急于引入复杂搜索引擎:
|
||||
|
||||
1. 明确搜索字段规范。
|
||||
- 文本消息:统一提取 `content.text`、`content.txt`、`content.content`、`content.title`、`content.summary`、字符串 content。
|
||||
- 文件消息:统一提取文件名、mime、文件说明、消息文本。
|
||||
- 链接/名片/合并转发:提取标题、摘要、展示文本。
|
||||
2. 增加 `message_search_text` 生成函数。
|
||||
- 写入消息时生成可搜索文本。
|
||||
- 老数据可查询时动态 fallback。
|
||||
3. 查询顺序固定。
|
||||
- 先按用户权限和 topic 过滤可见消息。
|
||||
- 再按搜索文本匹配。
|
||||
- 最后按 `seq desc` 或 `created_at desc` 分页。
|
||||
4. 中文和符号归一化。
|
||||
- 至少做大小写归一、前后空格、连续空白压缩、全角半角基础处理。
|
||||
5. 增加回归测试。
|
||||
- 普通文本、中文关键词、文件名、链接标题、转发快照、撤回/删除消息、分页边界都要覆盖。
|
||||
|
||||
第二阶段如果数据量和体验要求更高,再考虑引入专用搜索索引:
|
||||
|
||||
- MySQL FULLTEXT/ngram。
|
||||
- OpenSearch/Elasticsearch。
|
||||
- 独立 `im_message_search_index` 表,按 topic、seq、search_text 存储轻量索引。
|
||||
|
||||
### 建议改造点
|
||||
|
||||
- `server/im_http.go`
|
||||
- `messageText` 和 `messageSearchPayload` 的提取逻辑继续收口,保证响应与查询使用同一文本口径。
|
||||
- `server/db/mysql/adapter.go` 或消息查询实现
|
||||
- 检查 `TenantMessageSearch` 的 SQL 过滤条件和分页顺序。
|
||||
- 确认群消息分表 `im_group_messages_YYYYMM` 与私聊消息表搜索路径一致。
|
||||
- `server/store/types`
|
||||
- 如新增搜索索引表,补结构体和迁移 SQL。
|
||||
- `docs/IM-前后端对接文档.md`
|
||||
- 明确搜索匹配字段、排序规则和 `availability` 行为。
|
||||
|
||||
### 验收建议
|
||||
|
||||
- 同一 topic 下创建 20 条混合消息,逐条用关键词可搜到。
|
||||
- 搜索中文短词、英文大小写、文件名、链接标题均有稳定结果。
|
||||
- 翻页时不丢边界记录、不重复。
|
||||
- 被删除/撤回消息按 `availability` 返回或按产品规则隐藏,行为一致。
|
||||
- 私聊和群聊搜索结果字段一致。
|
||||
|
||||
### 当前处理状态
|
||||
|
||||
已完成第一阶段确定性兜底:私聊和群聊搜索不再只依赖 SQL `LIKE` 命中消息 JSON 原文,改为先按权限、Topic、类型和游标读取固定窗口,再对结构化文本做统一提取和归一化匹配。当前覆盖文本、文件名、标题、摘要、链接、卡片和转发快照等字段,并补充结构化内容召回测试。
|
||||
|
||||
## 建议排期
|
||||
|
||||
| 优先级 | 项目 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| P1 | Web 平台版本检查 | 改动小,边界清楚,适合快速补齐。 |
|
||||
| P1 | 消息搜索召回复核 | 影响用户感知,需要先补测试锁定问题。 |
|
||||
| P2 | 青少年模式业务限制 | 涉及多个入口和策略口径,需要独立设计和联调,不能只改一个接口。 |
|
||||
@@ -0,0 +1,256 @@
|
||||
# IM 原生 Cluster 与 Redis Fanout 职责边界
|
||||
|
||||
## 1. 背景
|
||||
|
||||
IM Server 当前同时存在两套跨 Pod 能力:
|
||||
|
||||
- Tinode 原生 Cluster:以 Topic master 为中心,负责把 Topic 请求路由到唯一 master 节点,由 master 统一处理 Topic 状态机。
|
||||
- Redis Fanout:以 Redis Pub/Sub 为跨 Pod 实时总线,负责把服务端生成的实时事件广播到其他 Pod,再由各 Pod 投递给本机 WebSocket session。
|
||||
|
||||
测试环境目前是多 Pod standalone 部署,日志表现为 `Cluster: running as a standalone server.`,因此当前实际跨 Pod 主链路是 Redis Fanout。目标架构需要启用原生 Cluster,把 Topic 热路径、强顺序状态机和实时通话状态迁回 Topic master;Redis Fanout 保留为大数据量、缓存、补偿和用户级控制能力。
|
||||
|
||||
本文定义两套机制的职责边界,避免后续同时启用后出现重复投递、重复状态变更或状态源不一致。
|
||||
|
||||
## 2. 总原则
|
||||
|
||||
1. 同一类 Topic 事件只能有一个实时主链路。
|
||||
2. 原生 Cluster 启用时,Topic 状态机相关事件优先由 Cluster 处理。
|
||||
3. Redis Fanout 不作为 Topic master 的替代品;它是跨 Pod 投递、通知和 session control 总线。
|
||||
4. 关键业务状态必须以 MySQL、Redis 原子状态或 Kafka 顺序消费作为权威,WebSocket fanout 只负责实时通知。
|
||||
5. 可丢的临时状态可以只走 fanout;不可丢的状态必须有历史补拉或条件更新兜底。
|
||||
6. 能利用原生 Cluster 内存态、高性能、强一致和强顺序的 Topic 热路径,应优先交给原生 Cluster。
|
||||
7. 数据量大、需要广播、缓存、聚合、补偿或跨业务模块共享的数据,应优先交给 Redis/MySQL/Kafka。
|
||||
|
||||
## 3. 分工原则
|
||||
|
||||
两套机制不是互斥关系,而是按数据特征分工:
|
||||
|
||||
```text
|
||||
强状态、强顺序、小范围热路径 -> 原生 Cluster
|
||||
大数据量、跨节点广播、缓存、补偿 -> Redis / MySQL / Kafka
|
||||
```
|
||||
|
||||
### 3.1 优先使用原生 Cluster 的场景
|
||||
|
||||
以下场景应尽量使用原生 Cluster,发挥 Topic master 的内存态和串行状态机能力:
|
||||
|
||||
- 单个 Topic 内的订阅、离开、权限变更和在线 session 状态。
|
||||
- 单聊/群聊 Topic 内的 `pub`、`read`、`recv`、`get/set meta`。
|
||||
- 通话实时状态机:`ringing`、`accept`、`hang-up`、`offer/answer/ice-candidate`。
|
||||
- 对顺序敏感的 Topic 内临时事件,例如正在输入、同 Topic 内 presence 派发。
|
||||
- 需要立即反馈客户端、且依赖 Topic 当前内存状态的操作。
|
||||
|
||||
这些能力的共同特点是:
|
||||
|
||||
- 数据范围集中在单个 Topic。
|
||||
- 需要强顺序或强一致。
|
||||
- 更适合由唯一 Topic master 串行处理。
|
||||
- 不适合广播给所有 Pod 后各自判断。
|
||||
|
||||
### 3.2 优先使用 Redis / MySQL / Kafka 的场景
|
||||
|
||||
以下场景应优先使用 Redis、MySQL 或 Kafka,不强行塞进原生 Cluster:
|
||||
|
||||
- 全局用户在线状态查询、好友列表在线状态展示。
|
||||
- 群成员在线节点集合、大群在线成员筛选。
|
||||
- 大群 fanout、热点消息缓存、未读聚合、离线补偿。
|
||||
- 用户级 session control:踢端、全端下线、同平台挤旧端、密码重置清退。
|
||||
- 限流、短状态、幂等、分布式去重。
|
||||
- 历史消息、通话记录、审计、后台统计。
|
||||
- Push、文件安全、后台管理命令等异步任务。
|
||||
|
||||
这些能力的共同特点是:
|
||||
|
||||
- 数据量大或跨 Topic。
|
||||
- 需要被 HTTP、Kafka、后台任务等多入口共享。
|
||||
- 可以最终一致,或可以通过历史补拉/条件更新补偿。
|
||||
- 不适合放在某个 Topic master 的内存里长期维护。
|
||||
|
||||
## 4. 目标启用模式
|
||||
|
||||
目标模式为:
|
||||
|
||||
```text
|
||||
原生 Cluster Topic master + Redis 大数据量支撑 + MySQL/Kafka 权威状态
|
||||
```
|
||||
|
||||
目标分工:
|
||||
|
||||
- 原生 Cluster 负责单个 Topic 内的实时请求路由、内存状态机、强顺序处理和低延迟下发。
|
||||
- Redis 负责全局在线状态、大群在线成员集合、热点缓存、大数据量 fanout、限流、幂等和 session control。
|
||||
- MySQL 负责最终历史、通话记录、关系状态和可补偿查询。
|
||||
- Kafka 负责后台异步任务、Push、审计、文件安全和跨服务事件。
|
||||
|
||||
启用原生 Cluster 后的核心原则:
|
||||
|
||||
```text
|
||||
Topic 内强状态、强顺序、小范围热路径,必须优先走原生 Cluster
|
||||
跨 Topic、大数据量、可最终一致或需补偿的数据,继续走 Redis/MySQL/Kafka
|
||||
```
|
||||
|
||||
当前 standalone + Redis Fanout 是过渡状态。切换到原生 Cluster 前,需要先完成部署形态、职责收敛、重复链路关闭和压测验收。
|
||||
|
||||
## 5. 职责边界表
|
||||
|
||||
| 能力 | 当前 standalone + Redis Fanout 过渡模式 | 目标原生 Cluster 模式 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Topic master 路由 | 不启用 | Cluster 负责 | 原生 Cluster 通过一致性哈希确定 Topic master。 |
|
||||
| `{sub}` 订阅 | 本 Pod Hub/Topic 处理,跨 Pod 投递依赖 Redis | Cluster `ProxyReqJoin` | 启 Cluster 后不要再用 Redis 模拟订阅路由。 |
|
||||
| `{leave}` 离开 | 本 Pod 处理 | Cluster `ProxyReqLeave` | 与订阅同源。 |
|
||||
| `{pub}` 消息写入 | 本 Pod 处理,生成 data 后 Redis fanout | Cluster `ProxyReqBroadcast` 到 Topic master | 启 Cluster 后 Topic data 不应再额外 Redis topic fanout。 |
|
||||
| `{get/set}` meta | 本 Pod 处理 | Cluster `ProxyReqMeta` | 强依赖 Topic 状态时应由 master 处理。 |
|
||||
| `read/recv` 回执 | Redis topic/user fanout 下发结果 | Cluster 路由 note 到 master | 启 Cluster 后回执状态由 master 生成,避免双份 info。 |
|
||||
| 通话 `ringing/accept/hang-up` | Redis `call_event` fanout + DB 条件更新兜底 | Cluster `ProxyReqCall` | 启 Cluster 后 Redis `call_event` 必须跳过。 |
|
||||
| `offer/answer/ice-candidate` | 当前可由现有通话状态机处理;声网模式下不走 SDP/ICE | Cluster 由 Topic master 转发 | 首版声网主要关注 accept/hang-up。 |
|
||||
| Topic 内 data/info/pres 下发 | Redis realtime fanout | Cluster proxy response | 两者不能同时作为主链路。 |
|
||||
| 用户在线状态查询 | Redis presence | 可继续 Redis presence | 即使启 Cluster,也建议 Redis 保持全局在线查询缓存。 |
|
||||
| 群在线成员缓存 | Redis topic member presence | 可继续 Redis 缓存 | 用于大群本节点在线成员查找和展示。 |
|
||||
| 踢端/全端下线 | Redis session control | Redis session control | 用户级控制不属于单个 Topic,建议继续 Redis。 |
|
||||
| 密码重置清退 | Redis session control | Redis session control | 与 Cluster 不冲突。 |
|
||||
| 同平台新登录挤旧端 | 本 Pod + Redis platform evict | 本 Pod + Redis platform evict | 继续用 Redis 保证跨 Pod 生效。 |
|
||||
| 好友/群业务事件 | 本地投递 + Redis user/topic fanout | 可继续 Redis user fanout | 业务事件通常不是 Topic 状态机请求。 |
|
||||
| 系统消息通知 | 本地投递 + Redis fanout | 视消息写入入口决定 | 如果写入会话 Topic,应避免和 Cluster topic 下发重复。 |
|
||||
| 群消息 seq/热消息缓存 | Redis / MySQL | 可继续 Redis / MySQL | 大数据量缓存和历史权威不依赖原生 Cluster。 |
|
||||
| Kafka 后台事件 | Kafka 负责异步持久化、Push、审计 | Kafka 继续负责 | Kafka 不做实时 WS fanout 主链路。 |
|
||||
|
||||
## 6. Redis Fanout 主链路范围
|
||||
|
||||
在当前 standalone 过渡模式下,Redis Fanout 是跨 Pod 实时投递主链路。启用原生 Cluster 后,Redis Fanout 需要收敛为配套能力,负责:
|
||||
|
||||
1. `message/user`:用户维度的系统事件、业务事件、me presence 下发。
|
||||
2. `session_control/user|device|platform`:踢端、清退、同平台挤旧端。
|
||||
3. presence 缓存:用户在线状态、节点 session 信息、群成员在线节点信息。
|
||||
4. 大群在线成员筛选、热点消息缓存、未读聚合和历史补偿辅助。
|
||||
5. 限流、短状态、幂等、分布式去重。
|
||||
|
||||
仅在 standalone 过渡模式下,Redis Fanout 额外负责:
|
||||
|
||||
1. `message/topic`:Topic 维度的 `data`、`info`、`pres` 跨 Pod下发。
|
||||
2. `call_event/topic`:通话 `ringing`、`accept`、`hang-up` 进入 Topic 状态机。
|
||||
|
||||
这些 Topic 状态机相关 fanout 在原生 Cluster 启用后必须关闭或跳过。
|
||||
|
||||
Redis Fanout 不负责:
|
||||
|
||||
- 作为消息历史权威。
|
||||
- 作为通话最终状态权威。
|
||||
- 保证同一 Topic 的全局严格顺序。
|
||||
- 替代 MySQL/Kafka 的审计、补偿和持久化。
|
||||
|
||||
## 7. 原生 Cluster 主链路范围
|
||||
|
||||
启用原生 Cluster 后,它应负责:
|
||||
|
||||
1. Topic owner/master 分配。
|
||||
2. Topic 请求路由:`sub`、`leave`、`pub`、`get/set`、`note`。
|
||||
3. Topic 内状态机:订阅、权限、回执、通话、presence 派发。
|
||||
4. Topic master 到 proxy session 的响应和广播。
|
||||
5. 节点故障后的 proxy session 清理与订阅失效通知。
|
||||
|
||||
原生 Cluster 不建议负责:
|
||||
|
||||
- 用户级 session control。
|
||||
- 全局在线状态查询缓存。
|
||||
- 业务后台事件可靠投递。
|
||||
- Kafka 异步消费、Push、审计。
|
||||
- Redis 热消息缓存、限流、短状态。
|
||||
- 大群全量成员 fanout、全局未读聚合、历史数据扫描等大数据量任务。
|
||||
|
||||
## 8. 双开时必须避免的重复链路
|
||||
|
||||
如果 `globals.cluster != nil`,以下 Redis Fanout 应跳过或降级:
|
||||
|
||||
| Redis 入口 | Cluster 启用时处理 |
|
||||
| --- | --- |
|
||||
| `publishRealtimeTopicMessage` | 跳过。Topic data/info/pres 由 Cluster proxy response 下发。 |
|
||||
| `publishRealtimeCallEvent` | 跳过。通话 note 由 `ProxyReqCall` 路由到 Topic master。 |
|
||||
| 群消息在线投递 fanout | 需要专项确认;如果同一 data 已由 Cluster 下发,不得再广播。 |
|
||||
| 会话 Topic 系统消息 fanout | 如果消息进入 Topic 状态机,应避免再次 topic fanout。 |
|
||||
|
||||
以下 Redis Fanout 可继续保留:
|
||||
|
||||
| Redis 入口 | 保留原因 |
|
||||
| --- | --- |
|
||||
| `publishRealtimeEvictUser` | 用户级清退,不归属单一 Topic。 |
|
||||
| `publishRealtimeEvictDevice` | 设备级清退,不归属单一 Topic。 |
|
||||
| `publishRealtimeEvictUserPlatform` | 同平台挤旧端,需要跨 Pod 控制 session。 |
|
||||
| 用户 presence 缓存 | 全局在线查询和好友列表展示仍需要。 |
|
||||
| 业务 user event | 多数业务事件不是 Topic 状态机请求。 |
|
||||
|
||||
## 9. 状态权威与顺序规则
|
||||
|
||||
### 9.1 消息顺序
|
||||
|
||||
- 权威顺序来自 `topic + seq`。
|
||||
- Redis Fanout 到达乱序时,客户端必须按 seq 去重和重排。
|
||||
- 服务端历史读取按 seq 返回,作为重连补偿来源。
|
||||
|
||||
### 9.2 通话状态
|
||||
|
||||
通话状态必须使用条件更新保证单向流转:
|
||||
|
||||
```text
|
||||
started -> accepted -> finished
|
||||
started -> missed
|
||||
started -> declined
|
||||
accepted -> disconnected
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `accept` 只能从 `started` 写入 `accepted`。
|
||||
- `missed` 只能从 `started` 且 `accepted_at IS NULL` 写入。
|
||||
- `finished` 只能结束未结束通话。
|
||||
- 已 `accepted` 的通话不得被 `missed` 或 `declined` 覆盖。
|
||||
- WebSocket 下发失败不能改变最终状态,客户端可通过通话记录或会话消息补偿。
|
||||
|
||||
### 9.3 Session Control
|
||||
|
||||
session control 以 Redis 短状态和 token/auth_version 为兜底:
|
||||
|
||||
- Redis 实时踢端用于立即关闭在线 WS。
|
||||
- token/auth_version 用于防止旧 token 重新登录。
|
||||
- stale session-control fanout 必须丢弃,避免旧事件踢掉新 session。
|
||||
|
||||
## 10. 启用原生 Cluster 的前置条件
|
||||
|
||||
启用原生 Cluster 前必须满足:
|
||||
|
||||
1. K8s 部署从普通 Deployment 改为适合稳定网络身份的形态,例如 StatefulSet 或稳定 Pod DNS。
|
||||
2. 每个 Pod 配置唯一且稳定的 `cluster_self`。
|
||||
3. 配置完整 `cluster_config.nodes`,包括节点名和可达地址。
|
||||
4. 开放并验证 gRPC/cluster 节点间通信。
|
||||
5. 明确 Redis Fanout 哪些保留、哪些关闭。
|
||||
6. 压测 `sub/pub/read/recv/call/group` 跨节点链路。
|
||||
7. 验证节点重启、扩缩容、Topic rehash、proxy subscription 失效和客户端重订阅。
|
||||
8. 验证不会重复投递 data/info/pres/call event。
|
||||
|
||||
## 11. 观测与验收
|
||||
|
||||
必须保留以下观测指标:
|
||||
|
||||
- Redis fanout publish/receive/deliver 计数。
|
||||
- Redis fanout `skip_native_cluster` 计数。
|
||||
- Redis fanout `no_local_topic`、`no_local_session`、`hub_full` 计数。
|
||||
- 通话状态转换计数:started、accepted、finished、missed、declined。
|
||||
- DB 条件更新失败计数,用于识别迟到事件和乱序事件。
|
||||
- Cluster 模式下 proxy queue、TopicMaster RPC 失败、cluster unreachable、rehash 次数。
|
||||
|
||||
验收用例至少覆盖:
|
||||
|
||||
1. A、B 连接到不同 Pod,P2P 消息实时到达。
|
||||
2. 接收方未订阅当前会话,仅在线 me,能收到未读提醒或补偿。
|
||||
3. 通话 accept/hang-up 跨 Pod 到达,且不会被 missed 覆盖。
|
||||
4. 多端登录同一账号,所有端收到业务事件。
|
||||
5. 密码重置或后台踢端能清退所有 Pod 上的 session。
|
||||
6. 大群在线成员分布在多 Pod,消息只投递在线成员,离线靠 seq 补拉。
|
||||
7. Redis 短暂不可用时,关键状态不写坏,客户端可补偿。
|
||||
|
||||
## 12. 当前结论
|
||||
|
||||
当前阶段职责划分如下:
|
||||
|
||||
- 当前测试环境仍是 standalone + Redis Fanout,这是过渡状态。
|
||||
- 目标是启用原生 Cluster,让 Topic master 承担内存高性能、强一致、强顺序的实时状态机。
|
||||
- Redis 不退出架构,而是转为大数据量、缓存、补偿、全局 presence、session control 和异步支撑能力。
|
||||
- 启用原生 Cluster 时,Topic 状态机相关的 Redis topic/call fanout 必须关闭或降级,避免 data/info/pres/call event 双路投递。
|
||||
@@ -0,0 +1,458 @@
|
||||
# IM 原生 Cluster 运维部署说明
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文给运维说明 IM Server 如何部署才能真正启用 Tinode 原生 Cluster。
|
||||
|
||||
目标效果:
|
||||
|
||||
- 多个 IM Pod 组成同一个 Tinode Cluster,不再是 standalone。
|
||||
- 每个 Pod 拥有稳定、唯一的节点名。
|
||||
- 节点之间通过 `cluster_config.nodes.addr` 建立内部 TCP RPC 通信。
|
||||
- Topic 请求可以路由到唯一 Topic master,由 master 处理 Topic 内状态机、顺序和实时下发。
|
||||
|
||||
如果部署后日志仍出现:
|
||||
|
||||
```text
|
||||
Cluster: running as a standalone server.
|
||||
```
|
||||
|
||||
说明原生 Cluster 没有启用,跨 Pod Topic 状态机不会生效。
|
||||
|
||||
## 2. 原生 Cluster 启用条件
|
||||
|
||||
必须同时满足以下条件:
|
||||
|
||||
1. `cluster_self` 非空。
|
||||
2. `cluster_self` 的值必须等于 `cluster_config.nodes` 中某个节点的 `name`。
|
||||
3. `cluster_config.nodes` 必须包含所有 IM 节点,包括当前节点。
|
||||
4. 每个节点的 `addr` 必须是其他 Pod 可访问的内部地址。
|
||||
5. 每个节点必须监听自己的 `addr` 端口。
|
||||
6. 节点之间网络策略、安全组、Service 必须放通 cluster 内部端口。
|
||||
7. 至少 2 个节点才算有效 Cluster,生产建议 3 个节点起。
|
||||
|
||||
当前代码启动参数由 `docker/tinode/entrypoint.sh` 传入:
|
||||
|
||||
```bash
|
||||
./tinode --config=${CONFIG} --static_data=$STATIC_DIR --cluster_self=$CLUSTER_SELF --pprof_url=$PPROF_URL
|
||||
```
|
||||
|
||||
所以运维侧必须确保每个 Pod 的环境变量 `CLUSTER_SELF` 有值,并且值稳定。
|
||||
|
||||
## 3. 推荐部署形态
|
||||
|
||||
推荐使用 Kubernetes `StatefulSet`,不要用普通 `Deployment` 直接滚动多个副本。
|
||||
|
||||
原因:
|
||||
|
||||
- 原生 Cluster 依赖稳定节点名,例如 `im-server-0`、`im-server-1`、`im-server-2`。
|
||||
- 原生 Cluster 依赖稳定 DNS,例如 `im-server-0.im-server-cluster.default.svc.cluster.local`。
|
||||
- 普通 Deployment 的 Pod 名会变化,不适合作为 `cluster_self`。
|
||||
- StatefulSet 配合 Headless Service 可以给每个 Pod 提供固定网络身份。
|
||||
|
||||
推荐结构:
|
||||
|
||||
```text
|
||||
Headless Service: im-server-cluster
|
||||
StatefulSet: im-server
|
||||
Pod DNS:
|
||||
im-server-0.im-server-cluster.<namespace>.svc.cluster.local
|
||||
im-server-1.im-server-cluster.<namespace>.svc.cluster.local
|
||||
im-server-2.im-server-cluster.<namespace>.svc.cluster.local
|
||||
```
|
||||
|
||||
## 4. 端口规划
|
||||
|
||||
至少需要区分三类端口:
|
||||
|
||||
| 端口 | 用途 | 是否对公网暴露 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `6060` | HTTP/WebSocket/API | 通过 Ingress/SLB 暴露 | 客户端连接入口。 |
|
||||
| `16060` | Tinode gRPC 客户端端口 | 通常不对公网暴露 | 管理或内部 gRPC 客户端使用。 |
|
||||
| `12000` | 原生 Cluster 内部 RPC | 不对公网暴露,只在 Pod 间放通 | `cluster_config.nodes.addr` 使用。 |
|
||||
|
||||
注意:`cluster_config.nodes.addr` 使用的是原生 Cluster 内部 TCP RPC 地址,不是 `grpc_listen` 的 `16060`。
|
||||
|
||||
建议统一所有 Pod 的 cluster 监听端口为 `12000`,通过 Pod DNS 区分节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "im-server-0",
|
||||
"addr": "im-server-0.im-server-cluster.default.svc.cluster.local:12000"
|
||||
}
|
||||
```
|
||||
|
||||
不要沿用 docker compose 示例里的 `tinode-0:12000`、`tinode-1:12001`、`tinode-2:12002`,除非是在同一主机或 docker compose 网络里部署。
|
||||
|
||||
## 5. 配置文件要求
|
||||
|
||||
`server/tinode.conf` 默认配置是:
|
||||
|
||||
```json
|
||||
"cluster_config": {
|
||||
"self": "",
|
||||
"nodes": [],
|
||||
"failover": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个配置不会启用 Cluster。
|
||||
|
||||
生产或测试环境需要提供完整配置。建议 3 节点配置如下:
|
||||
|
||||
```json
|
||||
"cluster_config": {
|
||||
"self": "",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "im-server-0",
|
||||
"addr": "im-server-0.im-server-cluster.default.svc.cluster.local:12000"
|
||||
},
|
||||
{
|
||||
"name": "im-server-1",
|
||||
"addr": "im-server-1.im-server-cluster.default.svc.cluster.local:12000"
|
||||
},
|
||||
{
|
||||
"name": "im-server-2",
|
||||
"addr": "im-server-2.im-server-cluster.default.svc.cluster.local:12000"
|
||||
}
|
||||
],
|
||||
"failover": {
|
||||
"enabled": true,
|
||||
"heartbeat": 100,
|
||||
"vote_after": 8,
|
||||
"node_fail_after": 16
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `self` 可以保持为空,由启动参数 `--cluster_self=$CLUSTER_SELF` 覆盖。
|
||||
- `CLUSTER_SELF` 必须分别为 `im-server-0`、`im-server-1`、`im-server-2`。
|
||||
- `nodes` 必须所有 Pod 完全一致。
|
||||
- `addr` 必须能被其他 Pod 访问。
|
||||
- 3 节点建议开启 failover;2 节点不建议开启 failover。
|
||||
|
||||
## 6. Kubernetes 示例
|
||||
|
||||
以下示例只展示 Cluster 相关关键配置,实际部署时需要合并现有镜像、环境变量、Secret、资源限制、探针、Ingress 和业务配置。
|
||||
|
||||
### 6.1 Headless Service
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: im-server-cluster
|
||||
namespace: default
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: im-server
|
||||
ports:
|
||||
- name: cluster
|
||||
port: 12000
|
||||
targetPort: 12000
|
||||
- name: http
|
||||
port: 6060
|
||||
targetPort: 6060
|
||||
- name: grpc
|
||||
port: 16060
|
||||
targetPort: 16060
|
||||
```
|
||||
|
||||
### 6.2 客户端入口 Service
|
||||
|
||||
客户端入口可以继续使用普通 Service 或 Ingress,负载到所有 Pod:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: im-server
|
||||
namespace: default
|
||||
spec:
|
||||
sessionAffinity: None
|
||||
selector:
|
||||
app: im-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 6060
|
||||
targetPort: 6060
|
||||
```
|
||||
|
||||
### 6.3 StatefulSet
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: im-server
|
||||
namespace: default
|
||||
spec:
|
||||
serviceName: im-server-cluster
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: im-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: im-server
|
||||
spec:
|
||||
containers:
|
||||
- name: im-server
|
||||
image: <im-server-image>
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 6060
|
||||
- name: grpc
|
||||
containerPort: 16060
|
||||
- name: cluster
|
||||
containerPort: 12000
|
||||
env:
|
||||
- name: CLUSTER_SELF
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: EXT_CONFIG
|
||||
value: /etc/tinode/tinode.conf
|
||||
- name: CLUSTER_PROXY_TO_MASTER_BUFFER
|
||||
value: "4096"
|
||||
- name: CLUSTER_PROXY_TO_MASTER_WORKERS
|
||||
value: "16"
|
||||
- name: CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS
|
||||
value: "300"
|
||||
- name: CLUSTER_ROUTE_RETRIES
|
||||
value: "3"
|
||||
- name: CLUSTER_ROUTE_RETRY_BACKOFF_MS
|
||||
value: "25"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 6060
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: 6060
|
||||
timeoutSeconds: 3
|
||||
volumeMounts:
|
||||
- name: tinode-config
|
||||
mountPath: /etc/tinode
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: tinode-config
|
||||
configMap:
|
||||
name: im-server-tinode-conf
|
||||
```
|
||||
|
||||
重点:
|
||||
|
||||
- `serviceName` 必须指向 Headless Service。
|
||||
- `CLUSTER_SELF` 直接取 Pod 名,StatefulSet 下会稳定为 `im-server-0`、`im-server-1`、`im-server-2`。
|
||||
- ConfigMap 中的 `cluster_config.nodes.name` 必须和 Pod 名一致。
|
||||
- ConfigMap 中的 `cluster_config.nodes.addr` 建议使用 StatefulSet Pod DNS。
|
||||
|
||||
## 7. NetworkPolicy / 安全组要求
|
||||
|
||||
如果集群启用了 NetworkPolicy,需要允许 IM Pod 之间访问 cluster 端口:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: im-server-cluster-allow
|
||||
namespace: default
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: im-server
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: im-server
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 12000
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: im-server
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 12000
|
||||
```
|
||||
|
||||
如果没有 NetworkPolicy,可以跳过此项,但仍要确认云厂商安全组、服务网格策略没有拦截 Pod 间 TCP `12000`。
|
||||
|
||||
## 8. 启动顺序
|
||||
|
||||
建议:
|
||||
|
||||
1. 先部署 ConfigMap、Headless Service、普通入口 Service。
|
||||
2. 再部署 StatefulSet。
|
||||
3. 第一个 Pod 可以执行数据库初始化和升级。
|
||||
4. 其他 Pod 设置 `NO_DB_INIT=true`,避免多 Pod 同时初始化数据库。
|
||||
5. 等所有 Pod Ready 后再接入客户端流量。
|
||||
|
||||
如果当前镜像沿用 `docker/tinode/entrypoint.sh` 的逻辑:
|
||||
|
||||
- 第一个 Pod 可以保留 `NO_DB_INIT=false`。
|
||||
- 其他 Pod 建议设置 `NO_DB_INIT=true`。
|
||||
- 如果所有 Pod 同时启动,要确认数据库初始化脚本具备幂等能力,否则建议改成 init Job 单独执行。
|
||||
|
||||
## 9. 发布步骤建议
|
||||
|
||||
### 9.1 灰度前检查
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
kubectl -n default get pod -l app=im-server -o wide
|
||||
kubectl -n default get svc im-server-cluster
|
||||
kubectl -n default exec im-server-0 -- sh -c 'echo $CLUSTER_SELF'
|
||||
kubectl -n default exec im-server-1 -- sh -c 'echo $CLUSTER_SELF'
|
||||
kubectl -n default exec im-server-2 -- sh -c 'echo $CLUSTER_SELF'
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
im-server-0
|
||||
im-server-1
|
||||
im-server-2
|
||||
```
|
||||
|
||||
验证节点 DNS:
|
||||
|
||||
```bash
|
||||
kubectl -n default exec im-server-0 -- sh -c 'nc -vz im-server-1.im-server-cluster.default.svc.cluster.local 12000'
|
||||
kubectl -n default exec im-server-0 -- sh -c 'nc -vz im-server-2.im-server-cluster.default.svc.cluster.local 12000'
|
||||
```
|
||||
|
||||
### 9.2 启动后日志检查
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
kubectl -n default logs im-server-0 | grep -i cluster
|
||||
kubectl -n default logs im-server-1 | grep -i cluster
|
||||
kubectl -n default logs im-server-2 | grep -i cluster
|
||||
```
|
||||
|
||||
不能出现:
|
||||
|
||||
```text
|
||||
Cluster: running as a standalone server.
|
||||
```
|
||||
|
||||
如果出现该日志,优先检查:
|
||||
|
||||
- `CLUSTER_SELF` 是否为空。
|
||||
- `CLUSTER_SELF` 是否不在 `cluster_config.nodes.name` 中。
|
||||
- 配置文件是否确实被容器读取。
|
||||
- `EXT_CONFIG` 是否指向了正确文件。
|
||||
- `cluster_config.nodes` 是否为空。
|
||||
|
||||
如果出现节点连接失败,优先检查:
|
||||
|
||||
- `cluster_config.nodes.addr` DNS 是否能解析。
|
||||
- 端口 `12000` 是否监听。
|
||||
- Pod 间 NetworkPolicy 是否放通。
|
||||
- Service `targetPort` 是否正确。
|
||||
|
||||
### 9.3 功能验收
|
||||
|
||||
至少验证:
|
||||
|
||||
1. A、B 两个账号分别连到不同 Pod,P2P 消息实时互达。
|
||||
2. A、B 分别连到不同 Pod,语音通话 `ringing`、`accept`、`hang-up` 状态实时互达。
|
||||
3. 同一个账号多端连接不同 Pod,`recv/read` 回执正常。
|
||||
4. 群聊成员分布在不同 Pod,群消息实时下发正常。
|
||||
5. 重启任意一个 Pod 后,客户端能重连,Topic 可重新订阅。
|
||||
6. 日志无持续 cluster unreachable、proxy timeout、dial timeout。
|
||||
|
||||
## 10. 与 Redis Fanout 的关系
|
||||
|
||||
启用原生 Cluster 后,Redis 仍然保留,但职责要收敛:
|
||||
|
||||
- 保留:全局 presence、session control、踢端、同平台挤旧端、大群在线缓存、热点缓存、限流、幂等、补偿。
|
||||
- 收敛或关闭:Topic data/info/pres 的 Redis topic fanout。
|
||||
- 收敛或关闭:通话 `call_event/topic` 的 Redis fanout。
|
||||
|
||||
否则会出现同一个 Topic 事件既由 Cluster 下发、又由 Redis Fanout 下发,导致重复 data/info/pres/call event。
|
||||
|
||||
对应职责边界参考:
|
||||
|
||||
- `docs/IM-原生Cluster与RedisFanout职责边界.md`
|
||||
|
||||
## 11. 常见问题
|
||||
|
||||
### 11.1 日志仍然是 standalone
|
||||
|
||||
原因通常是 `cluster_self` 为空或配置文件里的 `nodes` 为空。
|
||||
|
||||
处理:
|
||||
|
||||
1. 确认 Pod 环境变量 `CLUSTER_SELF`。
|
||||
2. 确认启动参数包含 `--cluster_self=$CLUSTER_SELF`。
|
||||
3. 确认容器读取的是带完整 `cluster_config.nodes` 的配置。
|
||||
|
||||
### 11.2 Pod 能启动,但跨 Pod 消息还是不通
|
||||
|
||||
原因通常是 cluster 内部端口不通,或节点地址写错。
|
||||
|
||||
处理:
|
||||
|
||||
1. 从任意 Pod `nc -vz <peer-pod-dns> 12000`。
|
||||
2. 检查 Headless Service selector 是否匹配 Pod label。
|
||||
3. 检查 NetworkPolicy 是否放通 egress/ingress。
|
||||
4. 检查 `cluster_config.nodes.addr` 是否写成了只在 docker compose 里可用的短域名。
|
||||
|
||||
### 11.3 扩缩容后部分 Topic 异常
|
||||
|
||||
原生 Cluster 的 Topic master 基于节点集合计算。扩缩容会改变节点集合,可能触发 Topic 重新路由。
|
||||
|
||||
建议:
|
||||
|
||||
- 生产先固定 3 节点。
|
||||
- 不要频繁自动扩缩容 IM Server。
|
||||
- 扩缩容必须配合完整压测和重连验证。
|
||||
- 如果要从 3 节点扩到 5 节点,先更新所有 Pod 的 `cluster_config.nodes`,再按发布计划滚动。
|
||||
|
||||
### 11.4 2 节点能不能用
|
||||
|
||||
2 节点可以启用基础 Cluster,但不建议开启 failover,也不建议生产使用。
|
||||
|
||||
生产推荐 3 节点起,便于 leader 心跳和故障判断。
|
||||
|
||||
## 12. 运维上线检查清单
|
||||
|
||||
- [ ] 使用 StatefulSet,不使用普通 Deployment 承载 Cluster 节点身份。
|
||||
- [ ] 创建 Headless Service。
|
||||
- [ ] 每个 Pod 的 `CLUSTER_SELF` 等于 Pod 名。
|
||||
- [ ] `cluster_config.nodes` 包含所有 Pod。
|
||||
- [ ] 所有 Pod 使用同一份 `cluster_config.nodes`。
|
||||
- [ ] 每个 `nodes.addr` 可从其他 Pod 访问。
|
||||
- [ ] 放通 cluster 内部 TCP 端口 `12000`。
|
||||
- [ ] 普通 HTTP/WebSocket 入口仍通过 Service/Ingress 负载到所有 Pod。
|
||||
- [ ] 只有一个入口负责数据库初始化,或数据库初始化已改为独立 Job。
|
||||
- [ ] 启动日志不再出现 `Cluster: running as a standalone server.`。
|
||||
- [ ] 跨 Pod P2P、群聊、read/recv、通话状态已验收。
|
||||
- [ ] 原生 Cluster 启用后,Topic 相关 Redis Fanout 已按职责边界关闭或降级。
|
||||
@@ -0,0 +1,162 @@
|
||||
# IM 多开跨 Pod 实时投递压测方案
|
||||
|
||||
日期:2026-09-13
|
||||
|
||||
> 006 商户、2000 账号、两台压测机的可执行场景与统计口径见
|
||||
> [IM-006商户双机消息压力测试方案](./IM-006商户双机消息压力测试方案.md)。本文保留为通用阶梯与指标说明。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
验证 IM Server 使用 Redis realtime fanout 后,在多 Pod、多端登录、群消息和群业务事件场景下的实时投递能力。
|
||||
|
||||
本次压测重点观察:
|
||||
|
||||
1. 跨 Pod 在线消息是否能投递到所有目标 WS 连接。
|
||||
2. 群消息在多 Pod 下的端到端延迟。
|
||||
3. Redis Pub/Sub 发布、接收、投递失败和本地无连接的比例。
|
||||
4. Hub 队列满、WS 写队列满、Redis 发布失败等异常是否可观测。
|
||||
5. Pod 扩缩容、单 Pod 重启时是否出现明显丢消息或延迟尖刺。
|
||||
|
||||
## 2. 前置环境
|
||||
|
||||
建议至少准备:
|
||||
|
||||
- IM Server:3 个 Pod。
|
||||
- Redis:独立实例或 Redis Cluster,开启 `group_fanout_enabled`。
|
||||
- MySQL:使用压测租户库。
|
||||
- 监控:开启 `/metrics`,采集 Prometheus 指标。
|
||||
- 账号:准备足够多的群成员账号 CSV,格式为 `username,password[,token]`。
|
||||
- 群 topic:提前创建一个大群,并把压测账号加入该群。
|
||||
|
||||
每个 Pod 必须配置不同的节点标识:
|
||||
|
||||
```json
|
||||
"node_id": "im-pod-1"
|
||||
```
|
||||
|
||||
或通过环境变量:
|
||||
|
||||
```shell
|
||||
IM_NODE_ID=im-pod-1
|
||||
```
|
||||
|
||||
## 3. 新增服务端指标
|
||||
|
||||
本次改造新增 Prometheus 指标:
|
||||
|
||||
```text
|
||||
realtime_fanout_events_total{stage,kind,target_type,status}
|
||||
realtime_fanout_delivery_duration_seconds_bucket{kind,target_type}
|
||||
```
|
||||
|
||||
常用查询:
|
||||
|
||||
```promql
|
||||
sum by (stage, kind, target_type, status) (rate(realtime_fanout_events_total[1m]))
|
||||
```
|
||||
|
||||
```promql
|
||||
histogram_quantile(
|
||||
0.95,
|
||||
sum by (le, kind, target_type) (rate(realtime_fanout_delivery_duration_seconds_bucket[5m]))
|
||||
)
|
||||
```
|
||||
|
||||
重点关注 status:
|
||||
|
||||
- `publish/ok`:本 Pod 成功发布到 Redis。
|
||||
- `publish/error`:Redis 发布失败。
|
||||
- `publish/skip_native_cluster`:启用了 Tinode 原生 cluster,跳过 Redis topic fanout。
|
||||
- `receive/ok`:其他 Pod 收到 Redis fanout。
|
||||
- `receive/malformed`:收到无法解析的消息。
|
||||
- `deliver/local_delivered`:本 Pod 找到本地 WS 并投递。
|
||||
- `deliver/no_local_session`:本 Pod 没有目标本地连接,属于正常情况。
|
||||
- `deliver/hub_full`:Hub routeSrv 队列满,需要重点告警。
|
||||
- `deliver/skip_no_session_store`:session store 未初始化。
|
||||
|
||||
## 4. 压测脚本
|
||||
|
||||
脚本位置:
|
||||
|
||||
```shell
|
||||
loadtest/realtime_fanout_probe.go
|
||||
```
|
||||
|
||||
运行示例:
|
||||
|
||||
```shell
|
||||
go run ./loadtest/realtime_fanout_probe.go \
|
||||
-ws "ws://im-pod-1.example.com/v0/channels,ws://im-pod-2.example.com/v0/channels,ws://im-pod-3.example.com/v0/channels" \
|
||||
-apikey "$IM_API_KEY" \
|
||||
-accounts loadtest/users.csv \
|
||||
-topic grpXXXXXXXXXXXX \
|
||||
-subscribers 3000 \
|
||||
-publishers 10 \
|
||||
-rate 200 \
|
||||
-duration 10m \
|
||||
-ramp 2m
|
||||
```
|
||||
|
||||
脚本行为:
|
||||
|
||||
1. 按 CSV 账号建立 WS 连接。
|
||||
2. 将连接均匀分布到 `-ws` 提供的多个 Pod 地址。
|
||||
3. 每个连接登录并订阅同一个群 topic。
|
||||
4. 从前 `-publishers` 个连接中选择发布者。
|
||||
5. 按 `-rate` 总速率发布带 `probe_id` 和 `sent_at_unix_nano` 的群消息。
|
||||
6. 所有订阅连接统计收到的 `{data}`,输出接收数、丢失率、p50/p95/p99/max 延迟。
|
||||
|
||||
输出示例:
|
||||
|
||||
```text
|
||||
==== realtime fanout probe summary ====
|
||||
published=120000 active_subscribers=3000 expected_deliveries=360000000 received=359998200 lost=1800 loss_rate=0.0005% unique_messages_seen=120000 errors=0 ctrl_acks=120000
|
||||
latency p50=18ms p95=65ms p99=140ms max=900ms samples=359998200
|
||||
```
|
||||
|
||||
## 5. 压测阶梯
|
||||
|
||||
建议按下面阶梯逐步推进,每个阶梯至少跑 10 分钟:
|
||||
|
||||
| 阶段 | Pod | 在线连接 | 发布者 | 总 QPS | 目标 |
|
||||
| --- | --- | ---: | ---: | ---: | --- |
|
||||
| S1 | 3 | 300 | 1 | 10 | 验证链路正确性 |
|
||||
| S2 | 3 | 3000 | 10 | 100 | 验证常规群聊压力 |
|
||||
| S3 | 3 | 10000 | 20 | 300 | 验证大群在线 fanout |
|
||||
| S4 | 6 | 30000 | 50 | 500 | 验证扩容后的 Redis fanout |
|
||||
| S5 | 6 | 30000 | 50 | 1000 | 探测上限和队列瓶颈 |
|
||||
|
||||
## 6. 通过标准
|
||||
|
||||
建议初始通过标准:
|
||||
|
||||
- `publish/error` 为 0。
|
||||
- `receive/malformed` 为 0。
|
||||
- `deliver/hub_full` 为 0。
|
||||
- 压测脚本统计丢失率低于 0.01%。
|
||||
- p95 端到端延迟低于 200ms。
|
||||
- p99 端到端延迟低于 500ms。
|
||||
- Pod CPU、内存、GC 无持续上升趋势。
|
||||
- Redis CPU 不超过 70%,网络出入带宽有余量。
|
||||
|
||||
## 7. 故障注入
|
||||
|
||||
基础压测通过后执行:
|
||||
|
||||
1. 压测中重启一个 IM Pod,观察其他 Pod 是否继续投递。
|
||||
2. 压测中扩容 Pod 数,观察 `receive/ok` 是否按 Pod 数增加。
|
||||
3. 临时降低 Hub/WS 队列容量或制造慢客户端,确认 `hub_full`、WS 写失败日志可见。
|
||||
4. Redis 短暂不可用,确认 `publish/error` 增加,恢复后新消息继续投递。
|
||||
|
||||
## 8. 结果记录
|
||||
|
||||
每轮记录:
|
||||
|
||||
- Git commit。
|
||||
- IM Pod 数量和规格。
|
||||
- Redis 规格。
|
||||
- 在线连接数、发布 QPS、消息大小。
|
||||
- 脚本输出摘要。
|
||||
- Prometheus p95/p99。
|
||||
- `publish/error`、`deliver/hub_full`、WS 写失败数量。
|
||||
- Pod 重启/扩容期间的最大延迟和丢失率。
|
||||
@@ -0,0 +1,488 @@
|
||||
# IM 多开与跨 Pod 消息投递改造方案
|
||||
|
||||
> 日期:2026-09-12
|
||||
> 适用范围:IMServer、Admin Server / HotGo、Redis、Kafka、JPush
|
||||
> 关联文档:`docs/对接文档最新/IM-前后端对接文档.md`、`docs/IM-服务端架构与新功能开发指南.md`、`docs/IM-Redis三层数据分层改造方案.md`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
多开指同一用户在多设备、多 App 实例、多浏览器窗口或同设备多连接同时在线。服务端必须保证任意一个 Pod 收到的实时消息、业务通知、在线状态变化和账号治理命令,都能正确投递到同一租户内该用户在所有相关 Pod 上的 WebSocket / Long Polling 连接。
|
||||
|
||||
当前代码已有以下基础:
|
||||
|
||||
- `SessionStore` 管理本 Pod 内的实时连接。
|
||||
- Redis presence 记录用户在线状态、session 集合和节点维度在线用户。
|
||||
- 群消息已有 `redis_fanout.go`,通过 Redis Pub/Sub 通知其他 Pod。
|
||||
- 群消息已拆到 `im_group_messages`,并具备 Redis seq、`client_mid` 幂等和 Kafka 异步持久化基础。
|
||||
- `im_business_events.go` 仍使用租户库 business event outbox 做本地轮询投递。
|
||||
|
||||
本功能仍处于开发完善阶段,尚未上线。本次改动按“完整补齐多开跨 Pod 能力”处理,不按线上热修补或灰度兼容旧缺陷的思路处理。
|
||||
|
||||
本次改造目标:
|
||||
|
||||
1. 不再依赖租户库 business event outbox 作为 C 端实时通知主链路。
|
||||
2. 建立统一的跨 Pod 实时投递总线,覆盖个人消息、群消息、系统消息、好友申请、好友状态、群治理、账号治理等需要多开同步的数据。
|
||||
3. IMServer 只把本 Pod 连接保存在内存,全局在线、节点路由和跨 Pod 投递统一依赖 Redis。
|
||||
4. Admin Server 不直连 IM 租户库,不直接操作用户 WS;后台写操作通过 Kafka Command 到 IMServer,由 IMServer 更新租户库并通过实时投递总线通知在线端。
|
||||
5. 离线 Push 只做提醒和唤醒,不作为消息正文或业务状态真源。
|
||||
|
||||
## 1.1 前置确认
|
||||
|
||||
实施前必须确认生产部署是否启用 Tinode 原生 `cluster_config`:
|
||||
|
||||
- 如果未启用,Redis realtime dispatcher 是跨 Pod 实时投递唯一主链路。
|
||||
- 如果启用,Topic 消息不要同时走 Tinode 原生 Topic master/proxy 和 Redis 广播式 dispatcher,否则可能重复投递。当前代码实现策略是:`globals.cluster != nil` 时,Topic 消息 fanout 不再额外发布 Redis realtime;session control 仍可通过 Redis 做幂等控制补充。
|
||||
|
||||
未启用原生 cluster 时,每个 Pod 必须配置唯一节点 ID。优先级为:
|
||||
|
||||
1. `node_id` 配置项。
|
||||
2. `IM_NODE_ID` 环境变量。
|
||||
3. `HOSTNAME` 环境变量。
|
||||
4. 本地开发兜底值 `local`。
|
||||
|
||||
多 Pod 环境不能让所有实例都使用 `local`,否则 Redis fanout 会误认为其他 Pod 是本节点并跳过投递。
|
||||
|
||||
## 2. 核心结论
|
||||
|
||||
跨 Pod 实时投递使用 Redis Pub/Sub 或 Redis Stream,不使用数据库 outbox,也不使用 Kafka 作为 WebSocket fanout 主链路。
|
||||
|
||||
Kafka 仍保留以下职责:
|
||||
|
||||
- Admin Server 到 IMServer 的后台写命令。
|
||||
- IMServer 到 Admin Server 的命令结果、审核结果、摘要同步。
|
||||
- 私聊/群聊消息异步持久化。
|
||||
- 文件安全检测、离线 Push 任务、审计、死信。
|
||||
|
||||
Redis 实时投递总线负责:
|
||||
|
||||
- 这个 Pod 收到消息后,通知其他 Pod。
|
||||
- 其他 Pod 根据本地 `SessionStore` 找到真实 WS 连接并投递。
|
||||
- 通过事件 ID / 消息 ID / `topic+seq` 做短期去重,优先复用已有 `ClaimShortState` / `ClaimShortStateValue` Redis 原语。
|
||||
|
||||
MySQL 负责:
|
||||
|
||||
- 最终状态和历史补偿。
|
||||
- 用户、好友、群、系统通知、消息、文件、设备会话等可追溯数据。
|
||||
- 客户端重连后按 `topic+seq`、通知 ID 或业务 ID 拉取缺口。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```text
|
||||
Client A
|
||||
-> Pod-1 WebSocket
|
||||
-> Session / Topic / HTTP handler
|
||||
-> 权限、幂等、seq、状态写入
|
||||
-> 投递本 Pod 本地 sessions
|
||||
-> Redis realtime bus
|
||||
channel/stream: im:{tenant_id}:rt:{kind}
|
||||
payload: envelope + server message
|
||||
-> Pod-2 / Pod-3 subscriber
|
||||
-> 按 tenant + target user/topic 找本 Pod sessions
|
||||
-> queueOut 到对应 WS / Long Polling
|
||||
|
||||
Client B reconnect
|
||||
-> hi -> token login -> sub me/topics
|
||||
-> 按 seq/read/recv 或 notification cursor 从 MySQL/Redis 热缓存补齐
|
||||
```
|
||||
|
||||
Admin Server 写操作:
|
||||
|
||||
```text
|
||||
Admin Server
|
||||
-> 校验后台权限
|
||||
-> 写 hotgo 命令状态
|
||||
-> Kafka im.admin.command
|
||||
-> IMServer 任一 Pod 消费
|
||||
-> 更新租户 MySQL / Redis / Topic 权限快照
|
||||
-> Redis realtime bus 通知在线 C 端
|
||||
-> Kafka im.admin.command.result 回写 Admin
|
||||
```
|
||||
|
||||
## 4. 跨 Pod 实时投递总线设计
|
||||
|
||||
### 4.1 统一 Envelope
|
||||
|
||||
所有需要投递到其他 Pod 的实时事件统一包装:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "rt_01K...",
|
||||
"tenant_id": "1",
|
||||
"tenant_code": "default",
|
||||
"kind": "message|topic_event|user_event|presence|session_control",
|
||||
"target_type": "user|topic|device|session",
|
||||
"target": "usr_xxx 或 grp_xxx 或 p2p_xxx",
|
||||
"from": "usr_sender",
|
||||
"source_node": "im-pod-1",
|
||||
"skip_sid": "sid_sender",
|
||||
"dedup_key": "message:grp_xxx:123",
|
||||
"occurred_at": "2026-09-08T10:00:00.000Z",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
字段约定:
|
||||
|
||||
- `event_id`:全局唯一,消费者短期去重。
|
||||
- `tenant_id`:必填,Redis key 和投递过滤必须带租户。
|
||||
- `kind`:决定消费者如何解释 payload。
|
||||
- `target_type=user`:投递给某用户在本 Pod 的所有连接。
|
||||
- `target_type=topic`:投递给本 Pod 已订阅该 Topic 的连接。
|
||||
- `target_type=device`:投递或控制某用户指定设备连接。
|
||||
- `target_type=session`:精确控制某个 Session,主要用于本 Pod 本地,跨 Pod 可选。
|
||||
- `skip_sid`:避免给发起连接重复投递,除非业务要求多端含当前端也收到。
|
||||
- `dedup_key`:短期幂等键,例如 `msg:topic:seq`、`friend_request:id:version`。
|
||||
- `payload`:可以是 Tinode `ServerComMessage` 的 JSON,也可以是业务刷新事件。
|
||||
|
||||
### 4.2 Redis Channel 建议
|
||||
|
||||
首版可以使用 Pub/Sub,要求客户端重连补偿兜底:
|
||||
|
||||
```text
|
||||
im:{tenant_id}:rt:user
|
||||
im:{tenant_id}:rt:topic
|
||||
im:{tenant_id}:rt:presence
|
||||
im:{tenant_id}:rt:session_control
|
||||
```
|
||||
|
||||
如果需要更强的可观测和短暂重放,可升级为 Redis Stream:
|
||||
|
||||
```text
|
||||
im:{tenant_id}:stream:rt
|
||||
consumer_group = imserver:{node_id}
|
||||
```
|
||||
|
||||
选择建议:
|
||||
|
||||
- 在线消息 fanout:Pub/Sub 足够,丢失后客户端按 seq 补偿。
|
||||
- 踢设备、封禁、强制退出:建议 Pub/Sub + Redis control key 双写,避免订阅瞬断导致控制命令遗漏。
|
||||
- Admin 重要业务变更:状态已落 MySQL,实时事件可以 Pub/Sub;客户端重进页面重新 GET。
|
||||
|
||||
### 4.3 本 Pod 投递规则
|
||||
|
||||
收到跨 Pod envelope 后:
|
||||
|
||||
1. 丢弃 `source_node == currentNodeID()` 的回环事件,除非显式需要自消费。
|
||||
2. 使用 Redis `rt:dedup:{event_id}` 或本地 LRU 做短期去重。
|
||||
3. 校验 `tenant_id`,不得跨租户投递。
|
||||
4. 根据 `target_type` 选择本地 sessions:
|
||||
- `user`:`session.uid == target && session.tenantID == tenantID`
|
||||
- `topic`:`session.hasSub(target) && session.tenantID == tenantID`
|
||||
- `device`:`session.uid == uid && session.deviceID == device_id`
|
||||
5. 对每个匹配 Session 调用 `queueOut`。
|
||||
6. 队列满时记录指标,不阻塞 Redis 订阅协程;客户端靠重连补偿。
|
||||
|
||||
## 5. 哪些数据需要多开处理
|
||||
|
||||
| 类别 | 场景 | 是否需要跨 Pod 实时投递 | 目标 | 最终补偿来源 | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 个人消息 | P2P 文本、图片、语音、文件、名片、通话邀请消息 | 是 | P2P Topic 在线成员、发送人其他端 | 私聊消息表 / Topic seq | Pod-1 收到发送后,本 Pod 投递并通过 Redis 投递到接收人和发送人其他端所在 Pod。 |
|
||||
| 群消息 | 群文本、媒体、文件、@、合并转发 | 是 | 群 Topic 在线成员 | `im_group_messages` / Redis 热消息 | 已有群 fanout 雏形,需要纳入统一 envelope 和节点去重。 |
|
||||
| 发送回执 | `pub` ack、消息发送成功 seq | 当前连接本地为主,发送人其他端可选 | 发送人其他端 | 消息表 / seq | 当前连接必须收到 `ctrl 202`;其他端同步消息用 `{data}`,不依赖 ctrl。 |
|
||||
| 送达/已读 | `note recv/read` | 是 | 对端所有在线端、本人其他端 | subscription 游标 | 多端 read/recv 取最大值,所有端刷新气泡状态和未读数。 |
|
||||
| 正在输入 | `kp/kpa` 临时状态 | 是,但 TTL 短 | 对端在线端或群内订阅端 | 无 | 不落库,允许丢失,限频。 |
|
||||
| 消息撤回/编辑 | recall、revision | 是 | Topic 在线成员 | 消息版本表 / 消息状态 | HTTP 成功后发 topic 事件,在线端刷新 `topic+seq+version`。 |
|
||||
| 删除/清空记录 | 当前用户视图删除 | 是,用户维度 | 本人所有端 | 删除游标 / del 记录 | 只影响操作者多端,不影响对方。 |
|
||||
| 系统消息 | 系统通知列表新增、已读 | 是 | 目标用户所有端 | `im_system_notifications` | 新增需要提醒所有端;已读要同步清红点。 |
|
||||
| 好友申请 | 创建、同意、拒绝 | 是 | 申请人、被申请人所有端 | 好友申请表、系统通知表 | 刷新新朋友列表、bootstrap badge、会话 context。 |
|
||||
| 好友关系 | 添加、删除好友、拉黑/取消拉黑、备注 | 是 | 双方或操作者所有端 | 好友/黑名单表 | 输入区权限、会话标题、通讯录和黑名单需要同步。 |
|
||||
| 好友在线状态 | 上线、离线、最近活跃 | 是 | 订阅相关 P2P/me Topic 的在线端 | Redis presence | 不应扫库;presence 以 Redis TTL 为准。 |
|
||||
| 群成员 | 入群、退群、踢人、邀请审批 | 是 | 群 Topic 在线成员、相关用户所有端 | 群成员表、订阅表 | 同步群人数、成员列表、会话权限和系统提示。 |
|
||||
| 群角色 | 群主转让、管理员增删 | 是 | 群 Topic 在线成员、目标用户所有端 | 群成员角色表 | 必须刷新 Topic `perUser` 权限快照和 Redis 成员缓存。 |
|
||||
| 群禁言 | 全员禁言、成员禁言/解禁 | 是 | 群 Topic 在线成员、被禁言用户所有端 | 群治理/禁言表 | 输入框权限不能只靠前端本地状态。 |
|
||||
| 群公告 | 发布、更新、已读 | 是 | 群 Topic 在线成员;已读为本人所有端 | 群公告表、公告已读表 | 顶部入口、公告详情和红点同步。 |
|
||||
| 群资料 | 名称、头像、简介 | 是 | 群 Topic 在线成员 | 群表 / Topic desc | 会话标题、列表摘要、成员页都要刷新。 |
|
||||
| 群文件 | 上传登记、删除、审核结果 | 是 | 群 Topic 在线成员 | 群文件表、文件引用表 | 文件正文不走 Redis,只投递文件项变化事件。 |
|
||||
| 会话设置 | 置顶、免打扰、群昵称、草稿 | 是,用户维度 | 本人所有端 | subscription/private 设置表 | 不影响其他用户。 |
|
||||
| 未读数/入口红点 | bootstrap badge、会话 unread、系统通知 unread | 是 | 本人所有端 | seq/read、系统通知表、业务表 | 实时事件只触发刷新;服务端 GET 返回最终值。 |
|
||||
| 设备会话 | 新设备登录、退出当前设备、踢设备、全端退出 | 是 | 被控制用户所有端或指定设备 | 设备会话表、Token auth_version、Redis control key | 需要跨 Pod 关闭 WS。 |
|
||||
| 账号治理 | 封禁、解封、冻结、注销、密码重置 | 是 | 目标用户所有端 | 用户状态、auth_version | Admin command 触发,IM 执行后踢在线并清 Token。 |
|
||||
| 安全风控 | 风险弹窗确认/取消 | 是,用户维度 | 操作者所有端 | 风控挑战表 / Redis action token | 防止多端同时继续敏感操作。 |
|
||||
| 会议/工作台 | 会议通知、日报月报提醒、工作台红点 | 是 | 目标用户所有端 | 对应业务表 | 会议笔记不产生通知,但页面返回后主动刷新。 |
|
||||
| 文件安全 | 审核通过/拒绝、文件失效 | 是 | 文件引用可见用户 | 文件元数据/引用表 | 通知在线端刷新消息卡片或文件列表。 |
|
||||
| 离线 Push | 用户离线、后台或不可实时投递 | 不属于 WS 多开,但需去重 | JPush alias / registration_id | Push 任务表或 Kafka | 多设备允许同 alias,payload 只带定位字段。 |
|
||||
|
||||
## 6. IMServer 改造方案
|
||||
|
||||
### 6.1 新增 Realtime Dispatcher
|
||||
|
||||
建议新增模块:
|
||||
|
||||
```text
|
||||
server/realtime_dispatcher.go
|
||||
server/store/realtime_runtime.go
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- 发布跨 Pod envelope 到 Redis。
|
||||
- 订阅 Redis envelope 并投递到本 Pod `SessionStore`。
|
||||
- 提供 `PublishToUser`、`PublishToTopic`、`PublishSessionControl` 等方法。
|
||||
- 统一 `event_id`、`dedup_key`、`skip_sid`、租户校验和指标。
|
||||
|
||||
接口示意:
|
||||
|
||||
```go
|
||||
type RealtimeTarget struct {
|
||||
TenantID types.TenantID
|
||||
Type string // user/topic/device/session
|
||||
Target string
|
||||
SkipSid string
|
||||
}
|
||||
|
||||
func PublishRealtime(target RealtimeTarget, msg *ServerComMessage) error
|
||||
func PublishRealtimeEvent(target RealtimeTarget, event IMRealtimeEvent) error
|
||||
```
|
||||
|
||||
现有 `redis_fanout.go` 后续可以改为调用统一 dispatcher,保留兼容封装。
|
||||
|
||||
### 6.2 个人消息跨 Pod
|
||||
|
||||
当前群消息已有专用 fanout,个人消息也需要同等级处理。
|
||||
|
||||
发送流程:
|
||||
|
||||
1. WS 收到 P2P `pub`。
|
||||
2. Session 完成租户、登录、Topic 归一化。
|
||||
3. Topic 校验好友关系、黑名单、风控、文件 ACL。
|
||||
4. 使用 `client_mid` 做幂等,生成 Topic seq。
|
||||
5. 本 Pod 投递:
|
||||
- 当前连接返回 `ctrl 202`。
|
||||
- 发送人其他端收到同一 `{data}`。
|
||||
- 接收人在本 Pod 的端收到 `{data}`。
|
||||
6. Redis realtime bus 投递到其他 Pod:
|
||||
- `target_type=topic`,`target=p2p...`
|
||||
- 或拆成 `target_type=user` 给双方用户。
|
||||
7. 异步持久化到 MySQL。
|
||||
8. 不在线设备只生成 Push 任务,客户端重连按 seq 补齐。
|
||||
|
||||
关键点:
|
||||
|
||||
- `noecho=false` 时发送人多端应同步消息。
|
||||
- `skip_sid` 只能跳过当前连接的重复 `{data}`,不能跳过发送人其他端。
|
||||
- P2P 传入 `usr...` 时必须先归一化为真实 P2P Topic。
|
||||
|
||||
### 6.3 群消息跨 Pod
|
||||
|
||||
保留当前 Redis seq、`client_mid` 幂等、热消息和 `im_group_messages` 最终落库模型。
|
||||
|
||||
需要补强:
|
||||
|
||||
- `redis_fanout.go` 使用统一 envelope。
|
||||
- Redis channel 从全局 `im:0:group_fanout` 调整为带租户或 envelope 必带 `tenant_id`。
|
||||
- 投递时优先使用 `topic:{topic}:node:{node_id}:online_members`,避免所有 Pod 都扫描全部 session。
|
||||
- 消费端支持 topic 已加载和未加载两种路径:
|
||||
- Topic 已加载:投递到 `hub.routeSrv`,由 Topic 统一处理。
|
||||
- Topic 未加载:直接扫描本 Pod 已订阅该 Topic 的 session 投递。
|
||||
- Redis Pub/Sub 丢失不补发,客户端重连通过 `since=last_seq+1` 补齐。
|
||||
|
||||
### 6.4 业务状态事件跨 Pod
|
||||
|
||||
替换 `im_business_events.go` 的数据库 outbox 轮询模式。
|
||||
|
||||
HTTP handler 或 Store 写成功后:
|
||||
|
||||
1. 同步写 MySQL 最终状态。
|
||||
2. 失效 Redis 缓存和 Topic 权限快照。
|
||||
3. 调用 realtime dispatcher 发布事件。
|
||||
4. 如需通知离线用户,投 Kafka Push 任务。
|
||||
|
||||
业务事件 payload 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"topic": "usr_target",
|
||||
"from": "usr_actor",
|
||||
"what": "im",
|
||||
"event": "friend.request.created",
|
||||
"payload": {"request_id": "frq_1"},
|
||||
"ts": "2026-09-08T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
群事件:
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"topic": "grp_xxx",
|
||||
"from": "usr_actor",
|
||||
"what": "group",
|
||||
"event": "group.role.updated",
|
||||
"payload": {"topic": "grp_xxx", "user_id": "usr_2", "role": "admin"},
|
||||
"ts": "2026-09-08T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 Session Control 跨 Pod
|
||||
|
||||
设备退出、踢设备、全端退出、密码重置、账号封禁必须跨 Pod 生效。
|
||||
|
||||
建议 Redis key:
|
||||
|
||||
```text
|
||||
im:{tenant_id}:session_control:user:{uid}:version
|
||||
im:{tenant_id}:session_control:device:{uid}:{device_id}:version
|
||||
im:{tenant_id}:session_control:event:{event_id}
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. IMServer 或 Admin command 更新 MySQL 用户状态、设备会话状态或 `auth_version`。
|
||||
2. 写 Redis control version,TTL 可 1 到 7 天。
|
||||
3. 发布 `target_type=user/device` 的 `session_control` envelope。
|
||||
4. 各 Pod 收到后调用 `SessionStore.EvictUser` 或 `EvictUserDevice`。
|
||||
5. Session 心跳或上行业务包也检查 Redis control version,防止 Pub/Sub 丢失。
|
||||
|
||||
### 6.6 Presence 多开规则
|
||||
|
||||
Redis presence 是全局在线真源:
|
||||
|
||||
- 用户登录成功:增加 session,写 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。
|
||||
- 订阅群 Topic:写 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`。
|
||||
- 心跳:刷新 session TTL、user TTL、已订阅群成员 TTL。
|
||||
- 断线:移除 session;最后一个 session 离线时标记 `online=0`。
|
||||
- 节点异常:依赖 TTL 自动过期。
|
||||
|
||||
好友在线状态变化:
|
||||
|
||||
- 不扫描所有用户。
|
||||
- 只通知与该用户有订阅关系或正在会话的在线端。
|
||||
- 客户端展示最近在线状态时,HTTP/WS 查询均以 Redis presence 为准。
|
||||
|
||||
## 7. Admin Server 改造方案
|
||||
|
||||
### 7.1 Admin 不直接做 WS 投递
|
||||
|
||||
Admin Server / HotGo 只负责后台权限、配置、审核和命令生产,不直接连接 IMServer 内存态,也不直接写租户库。
|
||||
|
||||
Admin 写操作统一:
|
||||
|
||||
1. 校验后台管理员权限。
|
||||
2. 写 hotgo 命令表,记录 `command_id/event_id/idempotency_key/actor/request_id`。
|
||||
3. 发送 Kafka `im.admin.command`。
|
||||
4. 等待 IMServer `im.admin.command.result` 更新命令状态。
|
||||
|
||||
### 7.2 Admin 命令触发的多开场景
|
||||
|
||||
| Admin 操作 | Kafka Command | IMServer 动作 | C 端实时投递 |
|
||||
| --- | --- | --- | --- |
|
||||
| 封禁用户 | `user.disable` | 更新用户状态、递增 `auth_version`、踢在线 | `session_control user.disabled` 给目标用户所有端 |
|
||||
| 解封用户 | `user.enable` | 更新用户状态、失效缓存 | 可选 `user.enabled` 给本人和相关管理视图 |
|
||||
| 重置密码 | `user.password.reset` | 更新密码、递增 `auth_version`、撤销 Token | `session_control security.password_reset` |
|
||||
| 踢设备 | `user.device.revoke` | 撤销设备会话 | `session_control device.revoked` |
|
||||
| 解散群 | `group.delete` | 解散 Topic、撤销订阅和二维码 | `group.deleted` 给群在线成员 |
|
||||
| 踢群成员 | `group.member.remove` | 更新成员状态和 Topic 订阅 | `group.member.removed` 给群成员和被踢用户多端 |
|
||||
| 设置管理员 | `group.admin.set` | 更新角色、刷新权限快照 | `group.role.updated` |
|
||||
| 删除管理员 | `group.admin.remove` | 降级角色、刷新权限快照 | `group.role.updated` |
|
||||
| 禁言成员 | `group.member.mute` | 写禁言表、刷新权限 | `group.mute.updated` |
|
||||
| 撤回/处置消息 | `message.moderate` | 更新消息状态、审核记录 | `message.recalled` 或 `message.moderated` |
|
||||
| 文件审核处置 | `file.audit.update` | 更新文件状态和引用可见性 | `file.unavailable` 给引用可见用户 |
|
||||
| 举报处理结果 | `abuse.report.resolve` | 更新举报状态、生成系统通知 | `system_notification.created` 给举报人 |
|
||||
|
||||
### 7.3 Admin 摘要同步
|
||||
|
||||
Admin 页面需要列表、统计和审核台数据,不应通过实时总线获得。
|
||||
|
||||
推荐:
|
||||
|
||||
- Admin 查询租户详情:调用 IM 内部只读 API。
|
||||
- Admin 高频列表:维护 hotgo 摘要表。
|
||||
- IM 状态变更后通过 Kafka result/event 同步摘要给 Admin。
|
||||
|
||||
这些摘要同步不属于 C 端多开实时投递,不要求发到用户 WS。
|
||||
|
||||
## 8. Redis、Kafka、MySQL 边界
|
||||
|
||||
| 组件 | 用途 | 不应用于 |
|
||||
| --- | --- | --- |
|
||||
| Redis Pub/Sub / Stream | 在线跨 Pod WS 投递、session control、presence 变化、短期去重 | 最终历史、长事务、后台命令可靠执行 |
|
||||
| Redis KV/Set/Hash | presence、节点在线用户、Topic 在线成员、短期幂等、热消息 | 长期业务主数据 |
|
||||
| Kafka | Admin command/result、消息持久化、Push、文件安全、审计、死信 | WebSocket 实时 fanout、验证码、在线状态 |
|
||||
| MySQL 租户库 | 最终状态、历史查询、重连补偿、审计 | 在线连接表、实时 fanout |
|
||||
| hotgo | Admin 后台主数据、配置、命令状态、摘要 | C 端 IM 消息和租户业务最终状态 |
|
||||
|
||||
## 9. 渐进实施计划
|
||||
|
||||
### 阶段一:统一跨 Pod 投递
|
||||
|
||||
1. 新增 realtime dispatcher。
|
||||
2. 先增加 P2P 个人消息跨 Pod 投递。当前 P2P 是完全空白,应优先于群消息迁移。
|
||||
3. 将现有群消息 Redis fanout 改为 dispatcher,并修复 `ServerComMessage` 内部路由字段 `json:"-"` 导致跨 Pod 丢 `TenantID/RcptTo/SkipSid` 的问题。
|
||||
4. 增加用户维度事件投递,先覆盖好友申请、系统通知、已读同步。
|
||||
5. 增加 session control 跨 Pod 踢设备/全端退出。
|
||||
6. 同步上线 Redis 订阅健康检查、发布/消费/队列满指标和基础告警。
|
||||
|
||||
### 阶段二:替换 business event outbox
|
||||
|
||||
1. 已停止把 C 端在线通知写入 `im_business_events_outbox`,本次改造直接替换为 realtime dispatcher。
|
||||
2. `publishIMRelationEvent`、`publishIMMeetingEvent` 等改为:
|
||||
- 需要历史展示的系统通知写 `im_system_notifications`。
|
||||
- 在线刷新直接调用 realtime dispatcher。
|
||||
- 离线提醒投 Kafka Push。
|
||||
3. 已移除 `startIMExternalBusinessEventPoller`。
|
||||
4. `registration.invite_used` 改为注册提交后直接同步邀请码使用聚合;`security.password_reset` 改为密码重置后直接跨 Pod 踢线。
|
||||
|
||||
### 阶段三:Admin 联动
|
||||
|
||||
1. Admin Server 后台写操作全部通过 Kafka command。
|
||||
2. IMServer command consumer 执行业务状态更新。
|
||||
3. IMServer 执行成功后通过 realtime dispatcher 通知 C 端。
|
||||
4. IMServer 通过 Kafka result/event 回写 Admin 命令状态和摘要。
|
||||
|
||||
### 阶段四:可观测和压测
|
||||
|
||||
1. 完善跨 Pod 投递指标:发布数、消费数、投递 session 数、丢弃数、队列满数、延迟。
|
||||
2. 完善 Redis 订阅健康检查和告警。
|
||||
3. 压测同一用户 10 端、多 Pod P2P 消息、千人群/万人群消息。
|
||||
4. 验证 Pub/Sub 丢失后重连补偿路径。
|
||||
|
||||
## 10. 验收用例
|
||||
|
||||
| 用例 | 步骤 | 预期 |
|
||||
| --- | --- | --- |
|
||||
| 同用户多端收个人消息 | 用户 A 两端分别连 Pod-1、Pod-2,用户 B 发 P2P 消息到 Pod-1 | A 两端都收到消息,B 其他端也同步,消息只落一条。 |
|
||||
| 群消息跨 Pod | 群成员分布在 Pod-1、Pod-2、Pod-3,Pod-1 收到群消息 | 所有在线订阅成员实时收到;离线成员重连按 seq 补齐。 |
|
||||
| 重复发送幂等 | 断线后使用同一 `client_mid` 重发 | 返回同一 `seq`,不重复落库,不重复 Push。 |
|
||||
| 已读多端同步 | A 在手机读到 seq=100 | A 其他端未读清除,B 多端看到 read 更新。 |
|
||||
| 好友申请多端同步 | B 向 A 发好友申请 | A 所有在线端刷新新朋友红点和申请列表。 |
|
||||
| 系统通知已读同步 | A 在 Pod-1 上把系统通知标记已读 | A 在 Pod-2 的端红点同步消失。 |
|
||||
| 群角色变更 | Admin 将 A 设为管理员 | A 所有端群管理入口出现;群在线成员看到角色标识刷新。 |
|
||||
| 禁言即时生效 | Admin 禁言 A,A 另一个端在 Pod-2 继续发消息 | 服务端返回 `GROUP_MUTED`,不能成功发送。 |
|
||||
| 踢设备 | Admin 踢 A 的 device-1 | device-1 在任意 Pod 的连接被关闭,其他设备保持在线。 |
|
||||
| 全端退出 | 密码重置或封禁 A | A 所有 Pod 上的连接关闭,旧 Token 后续登录失败。 |
|
||||
| Pub/Sub 瞬断补偿 | 人为断开 Pod-2 Redis 订阅后发送消息,再恢复并重连客户端 | 实时期间可能漏收,但重连后按 seq 补齐,最终状态正确。 |
|
||||
|
||||
## 11. 代码落点建议
|
||||
|
||||
IMServer:
|
||||
|
||||
- `server/realtime_dispatcher.go`:统一跨 Pod 发布与订阅。
|
||||
- `server/redis_fanout.go`:改造成群消息 dispatcher 适配层。
|
||||
- `server/sessionstore.go`:补充按租户、用户、设备、Topic 的本地投递辅助方法。
|
||||
- `server/session.go`:登录、心跳、断线时刷新 Redis presence 和 control version 检查。
|
||||
- `server/topic.go`:个人/群消息发布成功后调用 dispatcher。
|
||||
- `server/im_http.go`:好友、系统通知、群治理、会话设置等 HTTP 写成功后调用 dispatcher。
|
||||
- `server/kafka_admin_commands.go`:Admin command 执行成功后调用 dispatcher。
|
||||
- `server/store/group_message_runtime.go`:补充 realtime bus Redis 发布、订阅、去重 key。
|
||||
|
||||
Admin Server / HotGo:
|
||||
|
||||
- 后台写操作保留 command/outbox 到 Kafka 的可靠链路。
|
||||
- 不新增任何直接调用 IM Pod WS 内存态的接口。
|
||||
- 命令结果和摘要通过 Kafka result/event 回写 hotgo。
|
||||
- 后台实时页面如需刷新,使用 Admin 自己的 WebSocket/SSE,从 hotgo 命令结果或摘要表驱动,不复用 C 端 WS。
|
||||
|
||||
## 12. 风险与注意事项
|
||||
|
||||
1. Pub/Sub 不保证离线重放,因此所有实时投递都必须有最终补偿来源。
|
||||
2. `event_id` 去重不能代替消息 `client_mid` 幂等;二者层级不同。
|
||||
3. 群消息 seq 只能有一个发号源,Redis 不可用时不能临时改用 MySQL 发号。
|
||||
4. Session control 不能只靠 Pub/Sub,必须在心跳/上行业务包检查版本兜底。
|
||||
5. Topic 权限变化后必须刷新内存快照和 Redis 缓存,否则多开端会出现权限漂移。
|
||||
6. Push payload 不得带敏感正文,只带 `tenant_code/topic/seq/notification_id` 等定位字段。
|
||||
7. Admin command 成功但实时投递失败时,不回滚业务状态;客户端通过重连或重新 GET 补偿。
|
||||
8. 跨租户 Redis key、channel、payload 都必须带 `tenant_id`,不允许使用裸 `topic` 做全局路由。
|
||||
@@ -0,0 +1,484 @@
|
||||
# IM 工作台服务拆分迁移稳定方案
|
||||
|
||||
> 目标:将 `im_server` 中的工作台业务拆到独立仓库 `/Users/youshunlai/go_workspace/im_workbench`,先形成独立服务和独立发布边界;数据库暂不迁移,等接口全部稳定后再单独做工作台库迁移。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
建议采用“独立仓库 + 分阶段迁移 + Nginx 分流 + Redis 统一 Token 鉴权”的方式:
|
||||
|
||||
1. `im_server` 保留 IM 核心能力:登录态校验、聊天协议、单聊/群聊、消息存储、长连接、在线状态、Redis 实时 fanout、Kafka 消息消费。
|
||||
2. `im_workbench` 承接工作台能力:工作台入口、组织架构、考勤、会议、会议笔记、日报、月报、总经理信箱、工作台红点。
|
||||
3. 当前阶段不迁移数据库,`im_workbench` 先连接现有租户库读取和写入工作台相关表。
|
||||
4. 等 `im_workbench` 接口、鉴权、Kafka、监控全部稳定后,再单独启动数据库迁移专项。
|
||||
5. 工作台接口直接切换为 `/v1/workbench/*`,由 Nginx/API Gateway 按路径分发到 `im_workbench`;`im_server` 不再代理工作台请求。
|
||||
6. 迁移采用“先复制迁出、旧代码保留”的方式;只有 `im_workbench` 全部接口、数据库、Kafka、监控联调通过后,才删除 `im_server` 中的工作台旧代码。
|
||||
|
||||
一句话:网关分流,Redis 鉴权,先拆服务和接口,数据库后迁,最后清理 `im_server` 中的工作台旧代码。
|
||||
|
||||
## 2. 拆分范围
|
||||
|
||||
### 2.1 迁出到 im_workbench
|
||||
|
||||
以下业务从 `im_server` 迁移到 `im_workbench`:
|
||||
|
||||
| 模块 | 当前接口示例 | 迁移归属 |
|
||||
|---|---|---|
|
||||
| 工作台入口 | `GET /v1/workbench` | im_workbench |
|
||||
| 组织架构 | `/v1/workbench/organization/*` | im_workbench |
|
||||
| 考勤打卡 | `/v1/workbench/attendance/*` | im_workbench |
|
||||
| 会议通知 | `/v1/workbench/meetings*` | im_workbench |
|
||||
| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` | im_workbench |
|
||||
| 日报 | `/v1/workbench/reports/daily*` | im_workbench |
|
||||
| 月报 | `/v1/workbench/reports/monthly*` | im_workbench |
|
||||
| 总经理信箱 | `/v1/workbench/mailbox*` | im_workbench |
|
||||
| 工作台红点 | `workbench_total_unread`、模块未读 | im_workbench 计算,im_server 聚合透出 |
|
||||
|
||||
### 2.2 留在 im_server
|
||||
|
||||
以下能力继续留在 `im_server`:
|
||||
|
||||
| 模块 | 说明 |
|
||||
|---|---|
|
||||
| 登录与 Token 签发 | C 端登录仍由 IM 负责,登录成功后把 Token 会话写入 Redis。 |
|
||||
| 聊天消息 | 单聊、群聊、消息 seq、消息落库、Redis fanout、WS 下发。 |
|
||||
| 群治理 | 群资料、群成员、禁言、进群审批、群公告、群文件。 |
|
||||
| 文件上传入口 | `POST /v0/file/u/` 暂留 im_server,工作台只保存业务引用;后续可单独拆文件服务。 |
|
||||
| 系统通知投递 | 最终给 C 端的在线通知仍由 im_server 通过 WS 下发。 |
|
||||
| Kafka 消费到 C 端 | Admin 或 Workbench 产生的业务事件,由 im_server 消费后找在线用户下发。 |
|
||||
|
||||
## 3. 目标架构
|
||||
|
||||
```text
|
||||
C 端 App
|
||||
|
|
||||
| HTTPS
|
||||
v
|
||||
Nginx / API Gateway
|
||||
|-- /v1/im/*、/v0/channels、/v0/file/u/ -> im_server
|
||||
|-- /v1/workbench/* -> im_workbench
|
||||
|
||||
im_workbench
|
||||
|
|
||||
| MySQL
|
||||
v
|
||||
现有租户库中的工作台表
|
||||
|
||||
im_workbench
|
||||
|
|
||||
| Kafka: workbench 业务事件
|
||||
v
|
||||
im_server
|
||||
|
|
||||
| WebSocket
|
||||
v
|
||||
C 端在线用户
|
||||
```
|
||||
|
||||
稳定边界:
|
||||
|
||||
1. `im_workbench` 不直接操作 IM 消息表、在线连接、WebSocket。
|
||||
2. `im_server` 不直接写工作台业务表,也不代理工作台 HTTP 请求。
|
||||
3. `im_server` 登录成功后将 Token 会话写入 Redis,`im_workbench` 每次请求从 Redis 校验 Token。
|
||||
4. 两边共享用户 ID 口径:内部管理和工作台接口使用数据库 `BIGINT/int64`;聊天协议层仍可使用 `usr...`。
|
||||
5. 工作台需要通知 C 端时投 Kafka 事件,由 `im_server` 统一下发。
|
||||
|
||||
## 4. 数据库处理方案
|
||||
|
||||
### 4.0 当前阶段原则
|
||||
|
||||
当前阶段数据库先不迁移。`im_workbench` 独立服务启动后,仍访问现有租户库中的工作台相关表,先保证新服务接口、Redis Token 鉴权、Nginx 路由、Kafka 事件和监控全部跑通。
|
||||
|
||||
代码层面禁止服务启动时自动建库建表。`im_server` 和 `im_workbench` 都只负责连接已有数据库和执行正常业务 SQL;所有 `CREATE DATABASE`、`CREATE TABLE`、`ALTER TABLE`、索引变更、初始化字典和种子数据,统一整理为 SQL 文件,经审核后由人工或运维发布流程执行。
|
||||
|
||||
当前 MySQL adapter 已禁用 `CreateDb` 自动建库建表和 `UpgradeDb` 自动升级 DDL。服务启动时如果数据库不存在会直接连接失败;数据库版本不一致时只报错提示执行人工 SQL,不再自动补表、补字段或补索引。
|
||||
|
||||
数据库迁移作为后置专项处理,触发条件是:
|
||||
|
||||
1. `/v1/workbench/*` 全部接口在本地、测试环境、前端联调中稳定。
|
||||
2. `im_server` 中工作台旧接口确认没有客户端继续访问。
|
||||
3. 工作台事件、通知、红点、文件引用链路全部验收通过。
|
||||
4. 运维确认新库命名、备份、监控、账号权限和迁移窗口。
|
||||
|
||||
### 4.1 当前工作台相关表
|
||||
|
||||
当前这些表仍保留在租户库 `tinode` 中,暂不迁移;后续数据库专项时再纳入迁移范围:
|
||||
|
||||
| 表 | 用途 | 迁移建议 |
|
||||
|---|---|---|
|
||||
| `im_workbench_apps` | 工作台模块配置、排序、入口 | 暂留租户库;后续迁到工作台库或由 Admin 配置后同步。 |
|
||||
| `im_org_departments` | 组织部门树 | 暂留租户库;后续迁到工作台库。 |
|
||||
| `im_org_members` | 组织成员、岗位、联系方式 | 暂留租户库;保留 `user_id BIGINT` 关联 IM 用户。 |
|
||||
| `im_org_visibility_overrides` | 组织可见范围覆盖 | 暂留租户库。 |
|
||||
| `im_attendance_rules` | 考勤规则 | 暂留租户库。 |
|
||||
| `im_attendance_shifts` | 考勤班次 | 暂留租户库。 |
|
||||
| `im_attendance_records` | 考勤日记录 | 暂留租户库。 |
|
||||
| `im_attendance_checkins` | 打卡流水 | 暂留租户库。 |
|
||||
| `im_attendance_appeals` | 补卡/异常申诉 | 暂留租户库。 |
|
||||
| `im_meetings` | 会议主表 | 暂留租户库。 |
|
||||
| `im_meeting_participants` | 会议参与人 | 暂留租户库。 |
|
||||
| `im_meeting_note_files` | 会议笔记文件引用 | 暂留租户库;文件实体仍在文件表/OSS。 |
|
||||
| `im_daily_reports` | 日报 | 暂留租户库。 |
|
||||
| `im_monthly_reports` | 月报 | 暂留租户库。 |
|
||||
| `im_mailbox_config` | 总经理信箱配置 | 暂留租户库;后续可迁到工作台库或 Admin 公共库配置后同步。 |
|
||||
| `im_mailbox_submissions` | 总经理信箱提交 | 暂留租户库。 |
|
||||
|
||||
### 4.2 不迁出的表
|
||||
|
||||
| 表/数据 | 原因 |
|
||||
|---|---|
|
||||
| `users`、认证、会话、设备 | 属于 IM 登录和账号基础能力。 |
|
||||
| `topics`、`subscriptions`、`messages`、`im_group_messages` | 属于聊天主链路。 |
|
||||
| 好友、黑名单、群成员、群治理表 | 属于 IM 社交关系和群聊能力。 |
|
||||
| 文件实体表、OSS 元数据 | 暂不迁移,工作台只保存 `file_id` 引用。 |
|
||||
| `im_system_notifications` | 建议先留在 im_server,作为统一 C 端通知中心。后续如要拆,可单独设计通知服务。 |
|
||||
| `im_business_events_outbox` | 已废弃,不纳入迁移;当前代码不再自动建表,工作台拆分后事件统一走 Kafka 或实时投递链路。 |
|
||||
|
||||
### 4.3 后续目标库建议
|
||||
|
||||
后续接口稳定后,再给工作台单独库,命名按环境区分:
|
||||
|
||||
```text
|
||||
本地:im_workbench
|
||||
测试:im_workbench_test 或按运维规范命名
|
||||
生产:im_workbench
|
||||
```
|
||||
|
||||
如果继续保持“一个租户一个库”,则工作台也应支持租户库路由:
|
||||
|
||||
```text
|
||||
hotgo.im_tenant
|
||||
- code
|
||||
- im_db_name
|
||||
- workbench_db_name
|
||||
- im_internal_base_url
|
||||
- workbench_internal_base_url
|
||||
```
|
||||
|
||||
正式方向建议在 `hotgo.im_tenant` 或租户配置表里增加工作台库配置,而不是让 `im_server` 读取工作台库 DSN。
|
||||
|
||||
### 4.4 后续数据迁移顺序
|
||||
|
||||
迁库等接口全部稳定后再做,不要和本轮服务拆分混在一起。后续建议分四步:
|
||||
|
||||
1. 建新库新表:在 `im_workbench` 库手动执行审核后的 SQL,创建同结构表,先不接线上流量。
|
||||
2. 全量复制:从租户库复制历史工作台表数据到工作台库。
|
||||
3. 增量同步:迁移窗口内对工作台写操作做双写,或由旧库 binlog/脚本同步到新库。
|
||||
4. 切读切写:先灰度读新库,再切写新库,最后关闭旧库工作台写入。
|
||||
|
||||
迁移校验至少包括:
|
||||
|
||||
| 校验项 | 说明 |
|
||||
|---|---|
|
||||
| 行数校验 | 每张表按租户统计行数。 |
|
||||
| 主键/业务唯一键校验 | `meeting_id`、`report_id`、`submission_id`、`department_id` 等必须一致。 |
|
||||
| 更新时间校验 | 对比 `updated_at` 最大值,确认增量无遗漏。 |
|
||||
| 抽样内容校验 | 抽样会议、日报、月报、考勤记录详情。 |
|
||||
| 外键引用校验 | 会议参与人、会议笔记文件、组织成员部门引用不能断。 |
|
||||
|
||||
### 4.5 回滚原则
|
||||
|
||||
当前阶段没有数据库迁移,回滚以服务和路由为主。后续真正迁库时,旧库数据至少保留一个发版周期,不删除旧表。
|
||||
|
||||
回滚策略:
|
||||
|
||||
1. 如果服务拆分异常:关闭 `/v1/workbench/*` 路由,前端回退旧路径或旧版本。
|
||||
2. 如果 `im_workbench` 业务异常:保留 `im_server` 旧工作台代码作为临时回退能力。
|
||||
3. 如果 Redis 鉴权异常:停止工作台新路由,优先恢复登录态校验。
|
||||
4. 如果 Kafka 通知异常:业务写入不回滚,只降级为客户端主动拉取和后台补偿推送。
|
||||
5. 后续如果新库读写异常:再按迁库专项方案切回旧库。
|
||||
|
||||
## 5. 路由与鉴权迁移方案
|
||||
|
||||
### 5.1 Nginx 分流规则
|
||||
|
||||
工作台接口直接使用新路径 `/v1/workbench/*`,Nginx/API Gateway 按路径分流:
|
||||
|
||||
```text
|
||||
/v1/im/* -> im_server
|
||||
/v0/channels -> im_server
|
||||
/v0/file/u/ -> im_server
|
||||
/v1/workbench/* -> im_workbench
|
||||
```
|
||||
|
||||
建议工作台目标路由:
|
||||
|
||||
| 原 IM 路径 | 新 Workbench 路径 |
|
||||
|---|---|
|
||||
| `GET /v1/im/workbench` | `GET /v1/workbench` |
|
||||
| `/v1/im/organization/*` | `/v1/workbench/organization/*` |
|
||||
| `/v1/im/attendance/*` | `/v1/workbench/attendance/*` |
|
||||
| `/v1/im/meetings*` | `/v1/workbench/meetings*` |
|
||||
| `/v1/im/meeting-notes*` | `/v1/workbench/meeting-notes*` |
|
||||
| `/v1/im/meetings/{id}/notes*` | `/v1/workbench/meetings/{id}/notes*` |
|
||||
| `/v1/im/reports/daily*` | `/v1/workbench/reports/daily*` |
|
||||
| `/v1/im/reports/monthly*` | `/v1/workbench/reports/monthly*` |
|
||||
| `/v1/im/mailbox*` | `/v1/workbench/mailbox*` |
|
||||
|
||||
不建议让 `im_server` 继续代理工作台请求。路由由 Nginx 分流,服务各自鉴权、各自访问自己的数据库。
|
||||
|
||||
### 5.2 Redis Token 鉴权
|
||||
|
||||
登录仍由 `im_server` 负责。登录成功后,`im_server` 将 Token 会话写入 Redis,`im_workbench` 直接从 Redis 校验登录态。
|
||||
|
||||
Redis Key 建议:
|
||||
|
||||
```text
|
||||
im:session:token:{token_hash}
|
||||
```
|
||||
|
||||
Value 建议使用 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": 42,
|
||||
"tenant_id": 1,
|
||||
"tenant_code": "default",
|
||||
"device_id": "ios-device-1",
|
||||
"auth_level": "auth",
|
||||
"state": "active",
|
||||
"expires_at": "2026-09-13T18:00:00+08:00",
|
||||
"issued_at": "2026-09-13T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
请求 Header 保持 C 端统一:
|
||||
|
||||
| Header | 说明 |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer {token}`,两个服务都从 Redis 校验。 |
|
||||
| `X-MeetingIM-APIKey` | 应用 API Key。 |
|
||||
| `X-Tenant-Code` | 租户编码。 |
|
||||
| `X-IM-Schema` | 协议版本。 |
|
||||
| `X-Request-ID` | 链路追踪 ID。 |
|
||||
|
||||
校验流程:
|
||||
|
||||
1. 从 `Authorization` 取 Bearer Token,先做格式校验。
|
||||
2. 对 Token 做 hash 后查 Redis,避免 Redis 明文 Key 暴露原始 Token。
|
||||
3. 校验会话存在、未过期、`state=active`。
|
||||
4. 校验 `X-Tenant-Code` 与 Redis 会话中的 `tenant_code` 一致。
|
||||
5. 校验 API Key 是否可用于当前租户和客户端类型。
|
||||
6. 将 `tenant_id`、`tenant_code`、`user_id`、`device_id` 写入请求上下文。
|
||||
|
||||
Token 失效场景:
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| 用户主动退出 | 删除当前 Token Key。 |
|
||||
| 踢设备 | 删除指定 `device_id` 对应 Token Key。 |
|
||||
| 冻结/封禁账号 | 删除用户全部 Token Key,并通过 Kafka 通知在线连接断开。 |
|
||||
| 修改密码 | 删除用户全部 Token Key。 |
|
||||
| Token 过期 | Redis TTL 自动过期。 |
|
||||
|
||||
### 5.3 共享鉴权代码
|
||||
|
||||
建议抽一个轻量公共包,两个服务复用:
|
||||
|
||||
```text
|
||||
meetingim_common/auth/session
|
||||
```
|
||||
|
||||
公共包只放稳定协议:
|
||||
|
||||
1. Redis Key 生成。
|
||||
2. Token hash 规则。
|
||||
3. Session JSON 结构。
|
||||
4. Header 解析。
|
||||
5. 错误码定义。
|
||||
|
||||
不要把数据库访问、HTTP handler、业务权限放进公共包,避免公共包变成新的耦合点。
|
||||
|
||||
## 6. 事件与通知
|
||||
|
||||
工作台拆出后,工作台业务变更不再直接调用 `im_server` 代码函数,而是投 Kafka。
|
||||
|
||||
建议 Topic:
|
||||
|
||||
| Topic | 生产者 | 消费者 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `im.workbench.event` | im_workbench | im_server | 工作台事件、红点、系统通知、WS 下发。 |
|
||||
| `im.cache.invalidate` | im_workbench/Admin | im_server/im_workbench | 组织、工作台模块、配置变更后的缓存失效。 |
|
||||
| `im.audit.event` | im_workbench | Admin/审计消费者 | 工作台敏感操作审计。 |
|
||||
|
||||
`im.workbench.event` 消息建议字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "wb_default_meeting_mtg_1_created_v1",
|
||||
"tenant_id": 1,
|
||||
"tenant_code": "default",
|
||||
"actor_user_id": 42,
|
||||
"module": "meeting",
|
||||
"action": "meeting.created",
|
||||
"resource_id": "mtg_1",
|
||||
"target_user_ids": [42, 43],
|
||||
"title": "会议通知",
|
||||
"summary": "项目周会",
|
||||
"data": {
|
||||
"meeting_id": "mtg_1",
|
||||
"badge_delta": 1,
|
||||
"version": 1
|
||||
},
|
||||
"occurred_at": "2026-09-13T13:30:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
消费原则:
|
||||
|
||||
1. `event_id` 全局幂等,消费端记录处理日志。
|
||||
2. 系统通知写入失败可重试,超过次数进入死信。
|
||||
3. WS 下发失败不影响业务写入,客户端重连后通过工作台接口和 bootstrap 补偿。
|
||||
4. 工作台红点以服务端查询为准,Kafka 只做实时增量提醒。
|
||||
|
||||
## 7. 代码迁移步骤
|
||||
|
||||
### 7.1 第一阶段:服务骨架
|
||||
|
||||
在 `/Users/youshunlai/go_workspace/im_workbench` 新建 Go 服务:
|
||||
|
||||
```text
|
||||
im_workbench/
|
||||
cmd/workbench/
|
||||
internal/http/
|
||||
internal/store/
|
||||
internal/kafka/
|
||||
internal/config/
|
||||
docs/
|
||||
sql/
|
||||
```
|
||||
|
||||
第一阶段只做:
|
||||
|
||||
1. 配置文件、日志、MySQL、Redis、Kafka 初始化。
|
||||
2. 健康检查:`GET /healthz`、`GET /readyz`。
|
||||
3. Redis Token 鉴权中间件。
|
||||
4. 租户上下文解析。
|
||||
5. 复制工作台接口 DTO 和响应结构。
|
||||
|
||||
### 7.2 第二阶段:迁移业务代码
|
||||
|
||||
从 `im_server` 迁出以下代码:
|
||||
|
||||
| 当前位置 | 迁移内容 |
|
||||
|---|---|
|
||||
| `server/im_http.go` | `serveIMWorkbench`、Organization、Attendance、Meetings、MeetingNotes、DailyReports、MonthlyReports、Mailbox 相关 handler。 |
|
||||
| `server/store/store.go` | 工作台相关 Persistence Interface 和 mapper。 |
|
||||
| `server/store/types/*.go` | `workbench.go`、`organization.go`、`attendance.go`、`meeting.go`、`reports.go`、`mailbox.go` 等类型。 |
|
||||
| `server/db/mysql/tenant_adapter.go` | 工作台相关 SQL 实现。 |
|
||||
| `server/im_business_events.go` | 工作台事件生产逻辑拆到 `im_workbench`,只保留 IM 消费下发。 |
|
||||
| `server/kafka_workbench_audit.go` | 工作台事件生产侧迁出,IM 侧保留消费或下发处理。 |
|
||||
|
||||
迁移时要保留测试:
|
||||
|
||||
1. 原 handler 单测迁到 `im_workbench`。
|
||||
2. `im_server` 原有工作台测试暂时保留,作为旧链路回归保护。
|
||||
3. `im_workbench` 新增同等接口测试,确认新路径行为一致。
|
||||
4. Kafka 事件用例拆为 Workbench 生产端测试和 IM 消费端测试。
|
||||
|
||||
### 7.3 第三阶段:Nginx 路由切换
|
||||
|
||||
`im_server` 中原工作台 handler 暂时不删除,不做主链路代理。新请求由 Nginx/API Gateway 直接分流到 `im_workbench`:
|
||||
|
||||
```text
|
||||
客户端请求 /v1/workbench
|
||||
-> Nginx/API Gateway
|
||||
-> im_workbench Redis Token 鉴权
|
||||
-> return response
|
||||
```
|
||||
|
||||
路由层必须支持:
|
||||
|
||||
1. `/v1/workbench/*` 全量转发到 `im_workbench`。
|
||||
2. `/v1/im/*`、`/v0/channels`、`/v0/file/u/` 继续转发到 `im_server`。
|
||||
3. 透传 `Authorization`、`X-MeetingIM-APIKey`、`X-Tenant-Code`、`X-IM-Schema`、`X-Request-ID`。
|
||||
4. 工作台服务超时建议 3 到 5 秒。
|
||||
5. 工作台不可用时返回明确 `WORKBENCH_UNAVAILABLE` 或网关 502/504,不影响聊天主链路。
|
||||
|
||||
旧代码保留策略:
|
||||
|
||||
1. `im_server` 工作台旧 handler、store、SQL 先保留。
|
||||
2. 前端切新路径后,测试环境持续观察一个发版周期。
|
||||
3. `im_workbench` 全部接口、数据库、Kafka、监控确认稳定后,再从 `im_server` 删除旧工作台代码。
|
||||
4. 删除前要确保没有客户端继续访问 `/v1/im/workbench`、`/v1/im/meetings*` 等旧路径。
|
||||
|
||||
### 7.4 第四阶段:接口稳定验收
|
||||
|
||||
当前阶段只做接口稳定验收,不做数据库切换。建议保留以下服务开关:
|
||||
|
||||
| 配置 | 说明 |
|
||||
|---|---|
|
||||
| `workbench.service.enabled` | 是否启用工作台独立服务。 |
|
||||
| `workbench.route.enabled` | Nginx/API Gateway 是否启用 `/v1/workbench/*` 路由。 |
|
||||
| `workbench.kafka.enabled` | 是否启用工作台事件 Kafka。 |
|
||||
|
||||
后续迁库专项再增加数据库切换开关:
|
||||
|
||||
```text
|
||||
workbench.db.read_from=old|new|shadow
|
||||
workbench.db.write_to=old|dual|new
|
||||
```
|
||||
|
||||
## 8. 发布与回归
|
||||
|
||||
### 8.1 发布前检查
|
||||
|
||||
1. `im_workbench` 健康检查通过。
|
||||
2. `im_workbench` 可连接现有租户库,并只访问工作台相关表。
|
||||
3. Redis Token 鉴权与 `im_server` 登录态一致。
|
||||
4. Nginx/API Gateway 工作台路由灰度开关默认关闭或仅测试环境开放。
|
||||
5. Kafka Topic、消费组、死信队列已建。
|
||||
6. 监控已覆盖 HTTP 成功率、耗时、Kafka 积压、DB 错误、网关转发失败。
|
||||
7. 本阶段不要求新工作台库建表和数据复制。
|
||||
|
||||
### 8.2 必测用例
|
||||
|
||||
| 模块 | 用例 |
|
||||
|---|---|
|
||||
| 工作台入口 | 登录后返回模块排序、开关、未读角标。 |
|
||||
| 组织架构 | 普通用户、手机号注册用户、邀请用户、测试账号可见范围正确。 |
|
||||
| 考勤 | 配置查询、打卡、重复幂等、记录列表、申诉创建。 |
|
||||
| 会议 | 创建、列表、详情、修改、取消、提醒、参会响应。 |
|
||||
| 会议笔记 | 上传文件、绑定文件、详情、替换、删除、权限校验。 |
|
||||
| 日报 | 创建草稿、保存、提交、列表、统计、提醒。 |
|
||||
| 月报 | 创建、保存、提交、reopen、详情、统计。 |
|
||||
| 总经理信箱 | 配置查询、匿名/实名提交、附件引用。 |
|
||||
| 通知 | 工作台事件生成系统通知,在线用户收到 WS,离线用户重登可补偿。 |
|
||||
| 回滚 | 关闭 `/v1/workbench/*` 路由后,前端回退旧版本或网关明确降级;旧代码未删前可切回旧路径。 |
|
||||
|
||||
## 9. 风险点
|
||||
|
||||
| 风险 | 处理 |
|
||||
|---|---|
|
||||
| 一次性拆代码和迁库导致回归面过大 | 本阶段不迁库,只拆服务和接口;数据库等接口稳定后单独迁移。 |
|
||||
| 工作台依赖 IM 用户资料 | 通过 IM 内部查询 API 获取用户摘要,禁止直连 IM 库。 |
|
||||
| IM 需要工作台红点 | 通过工作台内部接口或 Kafka 事件聚合,禁止 im_server 查工作台库。 |
|
||||
| 文件上传仍在 im_server | 工作台保存 `file_id` 引用,文件服务后续再独立拆。 |
|
||||
| Kafka 事件丢失 | 生产端直接投 Kafka,必要时使用业务失败表补偿;消费端使用幂等和死信队列。 |
|
||||
| 前端路径切换风险 | 前端切到 `/v1/workbench/*` 前必须完成接口对照、灰度和回归。 |
|
||||
| 数据不一致 | 当前阶段继续读写同一套租户库,避免双写不一致;后续迁库再做影子读和校验。 |
|
||||
|
||||
## 10. 推荐落地顺序
|
||||
|
||||
1. 在 `im_workbench` 建服务骨架、配置、健康检查、Redis Token 鉴权。
|
||||
2. 迁移工作台类型、store interface、MySQL 实现和单测。
|
||||
3. 迁移 `/v1/workbench`、组织架构、考勤接口。
|
||||
4. 迁移会议、会议笔记、日报、月报、总经理信箱接口。
|
||||
5. Nginx/API Gateway 增加 `/v1/workbench/*` 路由,默认关闭或只对测试环境开放。
|
||||
6. 本地环境用新路径跑通全部工作台接口。
|
||||
7. 测试环境启用新路由灰度,只切少量测试账号。
|
||||
8. 全量接口稳定后,确认没有客户端访问旧 `/v1/im/*` 工作台路径。
|
||||
9. 清理 `im_server` 内工作台业务实现,只保留 IM 主链路。
|
||||
10. 单独启动数据库迁移专项,新建工作台库、复制数据、影子读、切读写。
|
||||
|
||||
## 11. 当前建议先做的第一步
|
||||
|
||||
第一步不要急着删 `im_server` 代码,建议先在 `im_workbench` 完成最小可运行服务:
|
||||
|
||||
1. 监听端口建议:`7070`。
|
||||
2. 支持 `GET /healthz`、`GET /readyz`。
|
||||
3. 支持 Redis Token 鉴权和统一 Header 校验。
|
||||
4. 接入本地 MySQL、Redis、Kafka 配置。
|
||||
5. 先迁 `GET /v1/workbench` 一个读接口做样板。
|
||||
6. Nginx/API Gateway 加 `/v1/workbench` 测试路由,只开放样板接口。
|
||||
|
||||
这样第一步上线风险最低,也能验证服务拆分、Redis 鉴权、链路追踪、配置和部署是否顺畅。
|
||||
@@ -110,22 +110,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications (
|
||||
INDEX idx_im_realname_id_hash (id_number_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_action_tokens (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_id VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
target VARCHAR(128) NOT NULL DEFAULT '',
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
state SMALLINT NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
consumed_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_action_tokens_token (token_id),
|
||||
INDEX idx_im_action_tokens_user_action (user_id, action, target, state, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
DROP TABLE IF EXISTS tinode.im_action_tokens;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
|
||||
id BIGINT NOT NULL,
|
||||
@@ -164,43 +149,9 @@ CREATE TABLE IF NOT EXISTS tinode.im_system_notifications (
|
||||
INDEX idx_im_system_notifications_inbox (user_id, id DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_http_idempotency (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
method VARCHAR(8) NOT NULL,
|
||||
route VARCHAR(512) NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
body_hash CHAR(64) NOT NULL,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'processing',
|
||||
response_status INT NOT NULL DEFAULT 0,
|
||||
response_body MEDIUMBLOB,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_http_idempotency_scope (user_id, method, route, idempotency_key),
|
||||
INDEX idx_im_http_idempotency_expiry (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_business_events_outbox (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
event_id VARCHAR(64) NOT NULL,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
target VARCHAR(128) NOT NULL,
|
||||
actor_user_id BIGINT NOT NULL DEFAULT 0,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload JSON,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
next_retry_at DATETIME(3),
|
||||
delivered_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_business_events_outbox_event (event_id),
|
||||
INDEX idx_im_business_events_outbox_pending (status, next_retry_at, id),
|
||||
INDEX idx_im_business_events_outbox_target (target_type, target, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
-- HTTP write idempotency is Redis-backed in the latest code.
|
||||
DROP TABLE IF EXISTS meeting_im.im_http_idempotency;
|
||||
DROP TABLE IF EXISTS tinode.im_http_idempotency;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
@@ -732,6 +683,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
|
||||
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
|
||||
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
|
||||
read_receipts TINYINT NOT NULL DEFAULT 1,
|
||||
friend_request_verification TINYINT NOT NULL DEFAULT 1,
|
||||
group_invite_verification TINYINT NOT NULL DEFAULT 0,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
@@ -1050,5 +1002,37 @@ INSERT INTO tinode.im_feedback_config(id, categories, max_length, attachment_pol
|
||||
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。')
|
||||
ON DUPLICATE KEY UPDATE updated_at = updated_at;
|
||||
|
||||
-- ============================================================
|
||||
-- Shared database runtime fixes for meeting_im.
|
||||
-- These are required after moving default/system tenants into the shared DB.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meeting_im.im_user_account_sequences (
|
||||
tenant_id BIGINT NOT NULL,
|
||||
id TINYINT UNSIGNED NOT NULL,
|
||||
next_value INT UNSIGNED NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (tenant_id, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
ALTER TABLE meeting_im.im_user_account_sequences
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (tenant_id, id);
|
||||
|
||||
INSERT INTO meeting_im.im_user_account_sequences(tenant_id, id, next_value)
|
||||
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
|
||||
FROM meeting_im.im_users
|
||||
WHERE tenant_id > 0
|
||||
GROUP BY tenant_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
next_value = GREATEST(next_value, VALUES(next_value));
|
||||
|
||||
INSERT IGNORE INTO meeting_im.im_user_account_sequences(tenant_id, id, next_value)
|
||||
SELECT id, 1, 999
|
||||
FROM hotgo.im_tenant
|
||||
WHERE state = 1;
|
||||
|
||||
DROP TABLE IF EXISTS meeting_im.im_login_lockouts;
|
||||
|
||||
-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go.
|
||||
UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version';
|
||||
|
||||
@@ -121,19 +121,21 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 创建群 | 群成员 | `info.what="group"`, `event="group.created"` | `topic`、群基础信息 | 除操作人外写入 |
|
||||
| 更新群设置 | 群成员 | `event="group.settings.updated"` | `topic` | 除操作人外写入 |
|
||||
| 成员资料/权限更新 | 群成员 | `event="group.member.updated"` | `user_id` | 除操作人外写入 |
|
||||
| 成员加入 | 群成员 | `event="group.member.joined"` | `user_id`、`topic` | 除操作人外写入 |
|
||||
| 入群申请创建 | 群成员/管理员可见链路 | `event="group.join_request.created"` | `request_id`、`applicant_user_id`、`topic` | 除操作人外写入 |
|
||||
| 入群申请处理 | 群成员 | `event="group.join_request.accepted/rejected"` | `request_id`、`status`、`topic` | 除操作人外写入 |
|
||||
| 成员资料/权限更新 | 群主、管理员、本人 | `event="group.member.updated"` | `user_id`、`member_sync=single` | 不写群系统消息 |
|
||||
| 成员加入 | 群成员 | `event="group.member.joined"` | `user_id`、`topic`、`member_sync=single` | 写入群消息并参与个人未读与离线 Push |
|
||||
| 入群申请创建 | 群主、管理员、申请人 | `event="group.join_request.created"` | `request_id`、`applicant_user_id`、`topic` | 普通成员不可见 |
|
||||
| 入群申请处理 | 群主、管理员、申请人 | `event="group.join_request.accepted/rejected"` | `request_id`、`status`、`topic` | 普通成员不可见;接受时另发全群加入消息 |
|
||||
| 群公告更新 | 群成员 | `event="group.announcement.updated"` | `topic`、公告信息 | 除操作人外写入 |
|
||||
| 群禁言更新 | 群成员 | `event="group.mute.updated"` | `topic`、禁言信息 | 除操作人外写入 |
|
||||
| 成员离群 | 群成员 | `event="group.member.left"` | `user_id` | 除操作人外写入 |
|
||||
| 群角色更新 | 群成员 | `event="group.role.updated"` | `user_id`、角色信息 | 除操作人外写入 |
|
||||
| 群禁言更新 | 群主、管理员、被操作人 | `event="group.mute.updated"` | `topic`、禁言信息、`member_sync` | 普通成员不可见 |
|
||||
| 成员离群/移除 | 群主、管理员、离群成员 | `event="group.member.left"` | `user_id`、`member_sync=remove` | 普通成员不可见 |
|
||||
| 群角色更新 | 群主、管理员、被操作人 | `event="group.role.updated"` | `user_id`、角色信息、`member_sync` | 普通成员不可见 |
|
||||
| 群主转让 | 群成员 | `event="group.owner.transferred"` | `previous_owner`、`new_owner` | 除操作人外写入 |
|
||||
|
||||
前端建议动作:
|
||||
|
||||
- 群成员、角色、禁言、群主变化:刷新群详情、成员列表、当前用户群权限。
|
||||
- 首次进入、断线重连、本地无快照或收到 `member_sync=full`:分页拉取完整群成员。
|
||||
- 收到 `member_sync=single`:调用 `GET /v1/im/groups/{topic}/members/{user_id}` 只刷新一个成员。
|
||||
- 收到 `member_sync=remove`:从本地成员快照删除 `user_id`;若本地状态无法确认,再全量分页同步。
|
||||
- 入群申请变化:管理员刷新入群申请列表和群 summary 待处理数。
|
||||
- 群公告变化:刷新公告区。
|
||||
- 成员加入/离开:刷新群成员数、成员列表和会话系统提示。
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
# IM 服务端架构与新功能开发指南
|
||||
|
||||
> 文档状态:按 2026-09-08 代码实现整理
|
||||
> 适用项目:`im_server`
|
||||
> 文档目标:让新接手的开发者能够快速判断一个功能应放在哪里、如何调用、如何存储、如何做多节点一致性,以及如何验收。
|
||||
|
||||
## 1. 系统定位
|
||||
|
||||
`im_server` 是基于 Tinode 扩展的多租户 IM 业务服务,同时承担两类职责:
|
||||
|
||||
1. IM 实时核心:WebSocket 连接、登录会话、Topic 订阅、消息路由、在线状态、音视频信令。
|
||||
2. C 端产品业务:注册、安全、好友、群治理、收藏、文件、工作台、会议、日报、举报等 HTTP API。
|
||||
|
||||
系统不是传统的 `controller -> service -> repository` 三层单体。它的核心是“Topic Actor 实时模型 + HTTP 业务扩展层 + Store 统一持久化边界”。开发新功能时必须先判断它属于实时 Topic 链路,还是 HTTP 业务链路,或者同时涉及两者。
|
||||
|
||||
## 2. 系统边界
|
||||
|
||||
### 2.1 服务与数据库归属
|
||||
|
||||
| 服务 | 直连数据库 | 负责数据 |
|
||||
| --- | --- | --- |
|
||||
| IM Server | 租户业务库,默认库为 `tinode` | 用户、认证、Topic、订阅、私聊/群聊消息、群业务、好友关系、文件元数据、C 端业务数据 |
|
||||
| Admin / HotGo | `hotgo` | 租户注册表、系统配置、字典、后台账号、菜单权限、后台命令 Outbox、管理端摘要数据 |
|
||||
|
||||
原则:服务与库一一对应,Admin 不直连租户库,IM 不直连 `hotgo`。跨系统写操作通过 Kafka 命令/事件,必须同步返回的少量查询通过受控的内部 HTTP API。
|
||||
|
||||
### 2.2 租户隔离
|
||||
|
||||
目标模型是“一个租户一个业务库”:
|
||||
|
||||
- `tenant_id` 是请求上下文和路由键,不要因为租户库内没有 `tenant_id` 列就在代码中丢掉租户上下文。
|
||||
- 所有业务 Store 调用必须显式传递 `types.TenantID`。
|
||||
- `store.ForTenant` 和 `tenantBusinessAdapter` 负责验证租户与选择租户适配器。
|
||||
- MySQL 的 `TenantRoutedAdapter.TenantAdapterFor` 负责将租户路由到对应连接池。
|
||||
- 任何无租户上下文的业务查询都应失败关闭,不允许回退到旧的无租户查询。
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
| 领域 | 当前实现 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 语言 | Go 1.26 | 服务端主体 |
|
||||
| 实时协议 | Tinode JSON Protocol、WebSocket、Long Polling、gRPC | 长连接、Topic 订阅、消息和集群通信 |
|
||||
| HTTP | `net/http` + `http.ServeMux` | C 端 REST API、文件 API、内部 API |
|
||||
| 数据库 | MySQL + `database/sql` + `sqlx` | 租户业务最终状态 |
|
||||
| 热状态/缓存 | Redis 7,`go-redis/v9` | 在线状态、Seq、幂等、群热消息、对象缓存、关系 Set、限流、跨节点 fanout |
|
||||
| 异步事件 | Kafka,`segmentio/kafka-go` | 跨系统命令、消息异步持久化、审计、缓存失效、失败重试与死信 |
|
||||
| 文件 | 阿里云 OSS,保留 FS/S3 适配器 | 文件对象存储,业务库只存元数据和引用 |
|
||||
| 文件安全 | 阿里云内容安全 | 上传完成后的异步审核 |
|
||||
| 离线推送 | JPush,同时保留 FCM/TNPG 插件能力 | 离线提醒;客户端重连后按 Topic seq 补偿 |
|
||||
| 音视频 | Tinode 通话信令 + 声网 RTC Token | 呼叫邀请/接听/挂断与 RTC 加入 |
|
||||
| 认证 | basic/token/code/rest 可插拔认证器 | 密码、Token、验证码和外部认证 |
|
||||
| 可观测性 | Go `log`、expvar、Prometheus exporter、pprof | 日志、运行指标和性能诊断 |
|
||||
|
||||
## 4. 运行时架构
|
||||
|
||||
```text
|
||||
Client
|
||||
|-- WebSocket /v0/channels
|
||||
| -> Session -> Hub -> Topic actor -> Store/Redis
|
||||
| |-> local sessions
|
||||
| `-> Redis fanout -> other IM nodes -> WebSocket
|
||||
|
|
||||
|-- HTTP /v1/im/*
|
||||
| -> protocol middleware -> bearer/API key/tenant
|
||||
| -> handler -> Store mapper -> tenant adapter -> tenant MySQL
|
||||
| |-> Redis cache/short state
|
||||
| `-> business event -> online WebSocket/Kafka
|
||||
|
|
||||
`-- Upload/Download
|
||||
-> media handler -> OSS
|
||||
-> file metadata/link -> tenant MySQL
|
||||
`-> file security task -> Kafka -> Aliyun security
|
||||
|
||||
Admin / HotGo
|
||||
|-- Kafka admin command/config event -> IM consumer -> tenant business mutation
|
||||
`-- signed internal HTTP <-------> IM internal read API
|
||||
```
|
||||
|
||||
### 4.1 启动顺序
|
||||
|
||||
`server/main.go` 是服务入口,主要顺序为:
|
||||
|
||||
1. 读取 JSON with comments 格式的 `meetingim.conf`/`tinode.conf`。
|
||||
2. 初始化日志、expvar、pprof 和集群 worker ID。
|
||||
3. 打开 Store,校验数据库版本与租户适配能力,同时初始化 Redis runtime。
|
||||
4. 初始化 Kafka、IM 内部 API 和 Admin 内部配置 API 客户端。
|
||||
5. 初始化认证器、验证器、文件处理器、Push 和音视频。
|
||||
6. 创建 `SessionStore` 和 `Hub`,启动 Redis 群消息 fanout 订阅。
|
||||
7. 启动 Kafka Admin Command、Config Event、Cache Invalidate、Message Persist 和 Dead Letter 消费者。
|
||||
8. 启动集群、gRPC、HTTP/WebSocket 路由和优雅退出。
|
||||
|
||||
任何新增的基础组件都应明确放在这个顺序中,初始化失败时应快速终止启动,不要让必需依赖在不可用状态下继续运行。
|
||||
|
||||
## 5. 核心业务模型
|
||||
|
||||
### 5.1 Session
|
||||
|
||||
`Session` 表示一条客户端连接,保存租户、用户、设备、认证等级、已订阅 Topic 和输出队列。一个用户可以同时有多个 Session。
|
||||
|
||||
登录和断线时需同步 Redis 在线状态;心跳用于刷新 presence TTL。不应仅依赖本进程 Session 判断全局在线状态。
|
||||
|
||||
### 5.2 Hub
|
||||
|
||||
`Hub` 是 Topic 路由中心,核心通道包括:
|
||||
|
||||
| 通道 | 用途 |
|
||||
| --- | --- |
|
||||
| `join` | 加载/创建 Topic,将 Session 附加到 Topic |
|
||||
| `routeCli` | 客户端消息路由到 Topic |
|
||||
| `routeSrv` | 服务端业务消息路由到 Topic/Session |
|
||||
| `meta` | 未订阅 Topic 的元数据查询 |
|
||||
| `unreg` | 卸载或删除 Topic |
|
||||
| `userStatus` | 用户停用/恢复等状态变更 |
|
||||
|
||||
Topic 索引不是纯字符串,而是 `types.TopicKey{TenantID, Topic}`,以防止不同租户的同名 Topic 串数据。
|
||||
|
||||
### 5.3 Topic Actor
|
||||
|
||||
`Topic` 是会话和消息处理单元,通过 channel 串行处理加入、退出、客户端消息、服务端消息和元数据操作。主要分类包括:
|
||||
|
||||
| Topic | 含义 |
|
||||
| --- | --- |
|
||||
| `me` | 用户主 Topic,会话列表和用户级 presence |
|
||||
| `fnd` | 搜索/发现 Topic |
|
||||
| `usr...` | 客户端可见的 P2P 对端标识,入库前需归一化为真实 P2P Topic |
|
||||
| `grp...` | 群聊 Topic |
|
||||
| `chn...` | 频道 Topic |
|
||||
| `slf...` | 个人收藏/保存消息 Topic |
|
||||
| `sys...` | 系统通知 Topic |
|
||||
|
||||
涉及消息发送、已读、撤回、编辑、通话信令、在线投递时,优先复用 Topic/Session 链路,不要另造一套无状态 HTTP 投递逻辑。
|
||||
|
||||
## 6. 四类标准调用链路
|
||||
|
||||
### 6.1 WebSocket 实时消息
|
||||
|
||||
```text
|
||||
Client packet
|
||||
-> hdl_websock.go 解码
|
||||
-> Session 认证/租户上下文
|
||||
-> Hub.join / Hub.routeCli
|
||||
-> Topic 权限和业务校验
|
||||
-> Redis 原子 Seq + client_mid 幂等
|
||||
-> 本地 Session 实时投递
|
||||
-> Redis Pub/Sub 跨节点 fanout
|
||||
-> Kafka persist 异步落 MySQL
|
||||
```
|
||||
|
||||
群消息使用 `im_group_messages`,私聊仍使用私聊消息表。`SeqId` 全链路必须使用 `int64/BIGINT`,群 Topic seq 由 Redis 原子递增,数据库只作基线与最终持久化。
|
||||
|
||||
群热消息可在 Redis 保留 24 小时左右,历史补偿最终以 MySQL 为准。离线用户只接收 Push 提醒,重连后按 Topic seq 拉取缺口。
|
||||
|
||||
### 6.2 C 端 HTTP 业务请求
|
||||
|
||||
```text
|
||||
/v1/im/*
|
||||
-> withIMHTTPProtocol
|
||||
-> API key + X-IM-Schema + X-Request-ID
|
||||
-> Bearer token -> principal{tenant_id, uid}
|
||||
-> 写请求 Idempotency-Key 中间件
|
||||
-> serveIM... handler
|
||||
-> store.<Domain> mapper
|
||||
-> TenantBusinessAdapter
|
||||
-> tenant MySQL / Redis
|
||||
-> writeIMSuccess / writeIMError
|
||||
```
|
||||
|
||||
所有已认证的 `POST/PUT/PATCH/DELETE` 默认要求 `Idempotency-Key`,幂等结果保留 24 小时。同一 key 使用不同请求体必须返回冲突,已完成请求应重放原响应。
|
||||
|
||||
HTTP 统一响应包含 `code/data/reason/message/details/request_id/server_time`。业务错误应返回稳定 `reason`,不应将 SQL 或内部异常直接暴露给客户端。
|
||||
|
||||
### 6.3 Admin 后台写操作
|
||||
|
||||
```text
|
||||
Admin API
|
||||
-> hotgo 本地权限校验
|
||||
-> 同事务写 command + outbox
|
||||
-> Kafka im.admin.command
|
||||
-> IM consumer 校验 tenant/event/actor
|
||||
-> event_id + consumer_group 幂等抢占
|
||||
-> 租户库业务变更
|
||||
-> 缓存失效 + Topic/Session 同步
|
||||
-> Kafka im.admin.command.result
|
||||
-> Admin 更新命令结果
|
||||
```
|
||||
|
||||
不可重试的参数、权限、版本冲突应直接返回稳定失败原因。临时故障按退避策略重试,超过上限写 `im.dead.letter` 和租户失败表。人工重放必须生成新 `event_id`,并使用 `parent_event_id` 串起审计链。
|
||||
|
||||
### 6.4 文件链路
|
||||
|
||||
```text
|
||||
Client request upload
|
||||
-> IM 创建文件元数据
|
||||
-> OSS 上传/回调完成
|
||||
-> 租户库更新文件状态
|
||||
-> 绑定消息/群文件/业务引用
|
||||
-> Kafka im.file.security
|
||||
-> 阿里云安全检测
|
||||
-> 更新审核状态并通知用户
|
||||
```
|
||||
|
||||
文件字节流不经 Kafka,Kafka 只传递文件 ID、OSS 对象信息和审核任务。任何业务文件都必须建立引用记录,否则垃圾回收可能将它当作孤儿文件删除。
|
||||
|
||||
## 7. 数据分层与一致性
|
||||
|
||||
### 7.1 进程内缓存
|
||||
|
||||
适合低频变更、全局或租户级、可短时间最终一致的数据:
|
||||
|
||||
- 租户基本配置和状态。
|
||||
- 注册短信开关。
|
||||
- 字典和第三方配置(OSS、文件安全、短信、JPush、Agora)。
|
||||
|
||||
更新来源为定时刷新和 Kafka `im.config.event`/`im.cache.invalidate`。进程内缓存不应与 Redis 重复缓存同一份高频业务对象。
|
||||
|
||||
### 7.2 Redis
|
||||
|
||||
Redis 用于跨节点共享且高频的数据:
|
||||
|
||||
- 用户在线状态、Session 和群成员节点索引。
|
||||
- 群消息原子 `SeqId`、`client_mid` 唯一占位和状态。
|
||||
- 24 小时左右的群热消息。
|
||||
- 用户、Topic、Subscription、群设置、群成员、成员分页快照和未读数缓存。
|
||||
- 好友/黑名单 Set。
|
||||
- 验证码、Action Token、临时占位、限流等 TTL 状态。
|
||||
- 群消息 Redis Pub/Sub 跨节点实时 fanout。
|
||||
|
||||
Redis key 必须包含统一前缀和租户维度,不允许跨租户共用业务 key。写 MySQL 成功后必须失效或刷新对应 Redis 数据,多节点通过 Kafka 失效事件收敛。
|
||||
|
||||
### 7.3 MySQL
|
||||
|
||||
MySQL 是最终业务状态、历史查询和审计依据。以下数据不得仅存 Redis:
|
||||
|
||||
- 用户、凭据、好友、黑名单、Topic、订阅和群治理最终状态。
|
||||
- 私聊/群聊历史消息、撤回和编辑版本。
|
||||
- 文件元数据、业务引用和安全审核结果。
|
||||
- HTTP 幂等结果、Kafka 消费幂等、死信和审计数据。
|
||||
|
||||
### 7.4 一致性顺序
|
||||
|
||||
普通业务写的推荐顺序:
|
||||
|
||||
1. 校验租户、身份、权限、幂等键和业务版本。
|
||||
2. 在同一事务中更新 MySQL 业务状态及 outbox(需要可靠事件时)。
|
||||
3. 提交事务。
|
||||
4. 失效本节点 Redis/进程内缓存。
|
||||
5. 投递 Kafka 失效或业务事件,通知其他节点和客户端。
|
||||
|
||||
如果业务要求“先实时到达,再异步落库”,则必须先完成 Redis 幂等占位和 seq 分配,投递 Kafka 持久化事件,并为失败准备重试和死信补偿。
|
||||
|
||||
## 8. Kafka 责任边界
|
||||
|
||||
| Topic | 主要用途 |
|
||||
| --- | --- |
|
||||
| `im.admin.command` | Admin 到 IM 的业务写命令 |
|
||||
| `im.admin.command.result` | IM 回传命令结果 |
|
||||
| `im.config.event` | 租户、字典、第三方配置变更 |
|
||||
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 |
|
||||
| `im.message.persist` | 私聊消息异步落库 |
|
||||
| `im.group.message.persist` | 群聊消息异步落库 |
|
||||
| `im.message.push` | 离线 Push 任务,属增强链路 |
|
||||
| `im.file.security` | 文件安全检测 |
|
||||
| `im.cache.invalidate` | 多节点缓存失效 |
|
||||
| `im.audit.event` | 跨系统审计 |
|
||||
| `im.dead.letter` | 超过重试上限的事件 |
|
||||
|
||||
Kafka 不用于 WebSocket 建连、验证码、在线状态、实时群消息 fanout、OSS 字节流或必须当场返回结果的查询。
|
||||
|
||||
统一 Envelope 至少包含:`event_id`、`trace_id`、`tenant_id/tenant_code`、`source_service`、`target_service`、`event_type`、`biz_type`、`biz_id`、`idempotency_key`、`version`、`occurred_at`、`actor`、`payload`。
|
||||
|
||||
## 9. 代码组织
|
||||
|
||||
| 路径 | 职责 | 新功能常见修改 |
|
||||
| --- | --- | --- |
|
||||
| `server/main.go` | 进程入口、组件初始化、HTTP 路由注册 | 新增路由或新基础组件启动 |
|
||||
| `server/session.go` | 连接、登录、请求分发、设备和 Session 状态 | 账号登录或连接级功能 |
|
||||
| `server/hub.go` | Topic 加载与全局消息路由 | 路由、Topic 生命周期 |
|
||||
| `server/topic.go` / `init_topic.go` | Topic actor、权限、订阅和消息处理 | 消息、群权限、Topic 内实时行为 |
|
||||
| `server/datamodel.go` | WebSocket 客户端/服务端协议结构 | 协议字段或服务端推送类型 |
|
||||
| `server/im_http.go` | C 端产品 HTTP API 的路由处理与通用约定 | 大部分 C 端业务入口 |
|
||||
| `server/internal_api.go` | IM 向 Admin 提供的签名内部查询 API | 后台需要同步读取租户数据 |
|
||||
| `server/admin_internal_config.go` | IM 调用 Admin 内部配置 API | IM 需要 hotgo 配置兜底查询 |
|
||||
| `server/kafka_*.go` | Kafka runtime、生产、消费、重试、死信和各业务 handler | 跨系统或异步事件 |
|
||||
| `server/im_business_events.go` | 业务事件持久化与在线投递 | 需主动推给 C 端的状态变更 |
|
||||
| `server/redis_fanout.go` | 群消息跨节点实时投递 | 群大并发/未加载 Topic 投递 |
|
||||
| `server/store/types/` | 业务实体、值对象、查询参数和稳定错误 | 先定义领域数据结构 |
|
||||
| `server/store/store.go` | Store mapper、参数验证、租户边界、缓存编排 | 对 handler 提供统一持久化 API |
|
||||
| `server/store/infrastructure.go` | HTTP 幂等、业务事件、Kafka 幂等/死信 Store | 可靠基础链路 |
|
||||
| `server/store/group_message_runtime.go` | Redis seq、唯一 key、热消息、presence、缓存和限流 | Redis 新 key 或新的 TTL 状态 |
|
||||
| `server/db/adapter.go` | 持久化能力接口 | 新增 Store 数据能力 |
|
||||
| `server/db/mysql/tenant_adapter.go` | MySQL 租户业务 SQL 实现 | 业务 CRUD/事务 |
|
||||
| `server/db/mysql/*_schema.go` | 扩展业务表建表/升级逻辑 | 新表、列、索引 |
|
||||
| `server/media/` | FS/OSS/S3 文件处理插件 | 文件存储能力 |
|
||||
| `server/filesafety/` | 文件安全适配器 | 文件检测能力 |
|
||||
| `server/push/` | 离线 Push 插件 | 厂商 Push 接入 |
|
||||
| `server/auth/` / `server/validate/` | 认证和凭据验证插件 | 登录或验证方式 |
|
||||
| `server/*_test.go` | 单元与链路测试 | handler、Topic、Kafka、Redis 逻辑测试 |
|
||||
| `server/db/mysql/*_test.go` | MySQL schema 和 SQL 回归 | 数据库变更测试 |
|
||||
|
||||
## 10. 新功能放置决策
|
||||
|
||||
| 问题 | 选择 |
|
||||
| --- | --- |
|
||||
| 是否是客户端建连、订阅、发消息或通话实时信令? | WebSocket + Session/Hub/Topic |
|
||||
| 是否是 C 端业务 CRUD、列表、搜索、设置或审批? | `/v1/im/*` HTTP + Store |
|
||||
| 是否是 Admin 对租户业务的写操作? | Admin 本地 command/outbox + Kafka + IM consumer |
|
||||
| Admin 是否必须当场读取租户详情? | IM 受控内部 GET API |
|
||||
| IM 是否需要读取 hotgo 的低频配置? | 进程内缓存 + Kafka 刷新 + Admin 内部 API 兜底 |
|
||||
| 是否需要跨节点高频共享、TTL 或原子计数? | Redis |
|
||||
| 是否需要最终可追溯的业务状态? | 租户 MySQL |
|
||||
| 是否跨系统、可异步、需重试/削峰/审计? | Kafka |
|
||||
| 是否为在线群消息实时扩散? | Redis Pub/Sub + WS,不用 Kafka 做主链路 |
|
||||
| 是否为文件本体? | OSS;MySQL 只存元数据/引用 |
|
||||
|
||||
## 11. 新功能标准开发流程
|
||||
|
||||
### 11.1 需求分析
|
||||
|
||||
1. 从前后端对接文档提取请求、响应、权限、状态机、幂等、并发和通知要求。
|
||||
2. 标出数据归属:`hotgo` 公共数据或租户库业务数据。
|
||||
3. 选择入口:HTTP、WebSocket、Kafka Admin Command 或内部 API。
|
||||
4. 列出需要同步的状态:MySQL、Redis、Topic 内存、进程内缓存、C 端推送。
|
||||
5. 明确失败类型:可重试、不可重试、并发冲突、部分成功和补偿。
|
||||
|
||||
### 11.2 领域与数据库
|
||||
|
||||
1. 在 `server/store/types/` 按业务域定义实体、状态常量、查询参数和结果。
|
||||
2. 在 `server/db/adapter.go` 增加最小必要的租户持久化接口,方法必须携带 `TenantID`。
|
||||
3. 在 MySQL 适配器实现 SQL;复杂写操作使用事务,状态迁移在 SQL `WHERE` 中校验旧状态/版本。
|
||||
4. 在对应 `*_schema.go` 添加幂等建表/升级 SQL,为高频查询、唯一幂等键和状态扫描建索引。
|
||||
5. 在 `server/store/store.go` 增加 mapper,负责参数验证、调用 adapter 和缓存编排;handler 不应直接拼 SQL。
|
||||
|
||||
表命名遵循当前租户库风格,类型必须与 Go 全链路一致,尤其是 ID、seq、version 使用 `BIGINT/int64`。面向 JavaScript 客户端的 64 位 ID 优先以字符串输出,避免精度丢失。
|
||||
|
||||
### 11.3 API 或协议层
|
||||
|
||||
HTTP 功能:
|
||||
|
||||
1. 在 `server/im_http.go` 定义独立 request/response 结构,不要直接暴露数据库 row。
|
||||
2. 使用 `decodeIMJSONBody`、`authenticateIMHTTPBearer`、`requireIMGroupMember` 等公共函数做解码、认证和权限检查。
|
||||
3. 在 `server/main.go` 注册具体路由,路由顺序要避免通配前缀吞掉更具体的路径。
|
||||
4. 使用 `writeIMSuccess`/`writeIMError` 统一输出,新增稳定业务 `reason`。
|
||||
5. 资源更新需并发保护时使用 version/`If-Match`,不允许用 `version=0` 绕过乐观锁。
|
||||
|
||||
WebSocket 功能:
|
||||
|
||||
1. 在 `datamodel.go` 增加最小协议字段,考虑 JSON 和 protobuf 转换兼容。
|
||||
2. 在 Session 做连接级验证,在 Topic 做资源权限与业务状态验证。
|
||||
3. 所有可重发消息必须有 `client_mid`/业务幂等键。
|
||||
4. 成功后同时考虑本节点 Session、跨节点 fanout、离线 Push 与历史补偿。
|
||||
|
||||
### 11.4 缓存、事件与审计
|
||||
|
||||
1. 对高频读对象选择 cache-aside:先 Redis,miss 后读 MySQL 并回填。
|
||||
2. 所有写方法列出需失效的 key,包括对象 key、关系 Set、分页快照和未读数。
|
||||
3. 需可靠异步处理时同事务写 outbox,消费端使用 `event_id + consumer_group` 幂等。
|
||||
4. 影响管理权限、账号安全、消息内容、文件处置或人工重放的操作要写审计。
|
||||
5. 业务状态变更需立即告知 C 端时,创建业务事件并通过已认证服务端链路投递,不信任客户端伪造的系统消息。
|
||||
|
||||
### 11.5 测试与文档
|
||||
|
||||
每个功能至少覆盖:
|
||||
|
||||
1. 正常路径、无认证、跨租户、无权限、资源不存在和方法错误。
|
||||
2. 重复 `Idempotency-Key`/`client_mid` 不产生重复数据。
|
||||
3. 同一幂等键配不同 payload 返回冲突。
|
||||
4. version 冲突和并发状态迁移。
|
||||
5. MySQL 成功后 Redis/Topic 缓存及在线客户端状态正确刷新。
|
||||
6. Kafka 重复消费、临时失败重试、不可重试失败和死信重放。
|
||||
7. 64 位 ID/seq 在 JSON 编解码时不丢失精度。
|
||||
8. 更新前后端对接文档、SQL 变更集和未实现增强项。
|
||||
|
||||
## 12. 关键约定
|
||||
|
||||
### 12.1 ID 与时间
|
||||
|
||||
- 用户 ID 由 Snowflake UID 体系生成,对外使用 `usr...` 编码形式或协议规定的字符串。
|
||||
- 业务 ID 必须全局或租户内唯一,不能用秒级时间戳直接代替唯一 ID。
|
||||
- `SeqId`、数据库主键和 version 使用 `int64/BIGINT`。
|
||||
- 持久化与对外时间使用 UTC,HTTP 输出 RFC3339/RFC3339Nano。
|
||||
|
||||
### 12.2 Topic 归一化
|
||||
|
||||
客户端传入的 `usr...`、`slf` 等可见 Topic 不一定是数据库真实 Topic。历史查询、上下文、收藏、转发、媒体、回执等接口在查库前必须统一归一化,并在鉴权、查询和响应之间保持明确的 Topic 语义。
|
||||
|
||||
### 12.3 权限
|
||||
|
||||
- HTTP handler 负责验证主体和请求参数,Store/adapter 仍应使用租户和业务条件限制写入。
|
||||
- Topic 管理员、群主、普通成员权限以服务端为准,不接受客户端声明的角色。
|
||||
- 群主转让、设管理员、禁言、踢人等变更后,必须刷新 Topic 内 `perUser` 权限快照和 Redis 订阅/成员缓存,不能等待重连后生效。
|
||||
- 封禁、冻结、踢设备、全端退出和注销等账号治理必须同步处理已存在 Session。
|
||||
|
||||
### 12.4 可观测性
|
||||
|
||||
- 日志使用 `server/logs` 的 `Info/Warn/Err`,默认输出 stderr,由容器日志系统收集。
|
||||
- 日志行前缀为 `I/W/E` 加 Go log flags 生成的日期时间;敏感值、Token、密码、验证码、证件号和第三方密钥不得记录。
|
||||
- HTTP 日志和异步事件应携带 `request_id/trace_id`、租户、业务类型和业务 ID。
|
||||
- 新增核心链路应增加请求数、失败数、延迟、队列深度/积压和死信指标。
|
||||
|
||||
## 13. 禁止的实现方式
|
||||
|
||||
1. 在 HTTP handler 内直接打开 MySQL 或拼接 SQL。
|
||||
2. 因租户库表没有 `tenant_id` 列,就不向 Store 传递 `TenantID`。
|
||||
3. Admin 直连 `tinode` 或 IM 直连 `hotgo`。
|
||||
4. 将 Kafka 当作 HTTP RPC 或实时 WebSocket fanout 使用。
|
||||
5. 将业务最终状态只存在 Redis。
|
||||
6. 为同一份高频数据同时增加进程内缓存和 Redis 缓存。
|
||||
7. 业务写成功后不失效 Redis、Topic 权限快照和成员分页快照。
|
||||
8. 重试请求没有 `Idempotency-Key/client_mid/event_id`。
|
||||
9. 使用 `int` 或 JSON number 承载可能超过 JavaScript 安全范围的 ID/seq。
|
||||
10. 直接信任客户端构造的系统通知、管理员角色或审计信息。
|
||||
11. 文件上传成功后不创建业务引用和安全审核任务。
|
||||
12. 新增数据表只在开发者本机手工执行,不写幂等 schema SQL、升级逻辑和回归测试。
|
||||
|
||||
## 14. 开发与验收命令
|
||||
|
||||
MySQL 构建使用 build tag:
|
||||
|
||||
```bash
|
||||
go build -tags mysql -o bin/tinode ./server
|
||||
go build -tags mysql -o bin/init-db ./tinode-db
|
||||
```
|
||||
|
||||
建议测试顺序:
|
||||
|
||||
```bash
|
||||
# 先跑修改模块的定向测试
|
||||
go test ./server -run 'TestName'
|
||||
|
||||
# Store 与 MySQL schema/adapter
|
||||
go test -tags mysql ./server/store/... ./server/db/mysql/...
|
||||
|
||||
# 完整服务端回归
|
||||
go test -tags mysql ./server/...
|
||||
```
|
||||
|
||||
发版前至少检查:
|
||||
|
||||
- 配置文件可解析,MySQL、Redis、Kafka、OSS 等必需依赖可连通。
|
||||
- 数据库版本与代码一致,所有新表/列/索引已在目标环境幂等执行。
|
||||
- Kafka Topic、消费组、重试和死信链路可用。
|
||||
- 单节点和多节点下的在线状态、群消息、权限变更均一致。
|
||||
- 新增接口与前后端对接文档一致,无密钥、密码或测试 Token 进入 Git。
|
||||
|
||||
## 15. 新功能提交清单
|
||||
|
||||
开发者在提交 PR/代码前应逐项确认:
|
||||
|
||||
- [ ] 已确定功能入口是 HTTP、WebSocket、Kafka 还是内部 API。
|
||||
- [ ] 已确定数据归属 `hotgo` 还是租户库。
|
||||
- [ ] 所有 Store/adapter 调用都显式携带 `TenantID`。
|
||||
- [ ] 已设计幂等键、唯一索引、业务版本和状态机。
|
||||
- [ ] 已明确 MySQL、Redis、进程内缓存的使用和失效顺序。
|
||||
- [ ] 需跨系统可靠投递时使用 outbox + Kafka,消费端已幂等。
|
||||
- [ ] 已处理 Topic 内存权限快照和多节点同步。
|
||||
- [ ] 文件已存 OSS,业务引用和安全审核已建立。
|
||||
- [ ] `SeqId`/ID/version 已在 Go、JSON、Kafka 和 MySQL 全链路使用 64 位安全类型。
|
||||
- [ ] 已增加正常、权限、跨租户、幂等、并发、缓存和重试测试。
|
||||
- [ ] 已补充 API 对接文档、SQL 变更和未完成增强项。
|
||||
|
||||
## 16. 相关文档
|
||||
|
||||
- `docs/IM-前后端对接文档.md`:C 端业务 API 和交互协议。
|
||||
- `docs/IM-系统设计文档.md`:原始产品系统设计。
|
||||
- `docs/IM-Redis三层数据分层改造方案.md`:进程内、Redis、MySQL 分层细节。
|
||||
- `docs/IM-Kafka接入方案.md`:Admin/IM 边界、Topic、幂等、重试和死信。
|
||||
- `docs/IM-Kafka接入范围识别.md`:按业务场景判断 Kafka 接入范围。
|
||||
- `docs/IM-Kafka接入联调测试文档.md`:Admin 与 IM 联调用例。
|
||||
- `docs/third/IM-文件安全第三方接入记录.md`:OSS 和文件安全链路。
|
||||
- `docs/IM-未实现后续增强项.md`:已知未完成能力和深度优化项。
|
||||
@@ -1,5 +1,16 @@
|
||||
# IM 注册、邀请码与认证数据模型改造需求文档
|
||||
|
||||
## 0. 统一验证码输入变更(2026-09-16)
|
||||
|
||||
本节为最新规则,覆盖本文后续章节中“独立邀请码输入框”“短信开启时邀请码不能替代验证码”等旧描述:
|
||||
|
||||
1. 注册页面取消独立邀请码输入框,邀请码与短信验证码共用同一个输入框。
|
||||
2. 5 位大小写英文字母按邀请码处理,6 位数字按短信验证码处理,其他格式拒绝。
|
||||
3. 有效邀请码可以作为注册放行凭证;注册成功后写入 `im_registration_invite_uses`,建立邀请码与新用户的推荐使用关系。
|
||||
4. 短信验证码注册会建立已完成的 `tel credential`;邀请码注册只保存手机号,不把手机号标记为短信已验证。
|
||||
5. 新客户端不再提交独立 `invite_code`,而是在 `{acc}.cred[].resp` 中提交用户在统一输入框填写的 5 位邀请码。旧 `invite_code` 字段仅保留向后兼容。
|
||||
6. `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 输入邀请码时返回 `{"verification_mode":"invite_code","invite_accepted":true}`;输入短信验证码时继续返回一次性 `verification_token`。
|
||||
|
||||
> 版本:V1.0 评审稿
|
||||
> 编写日期:2026-08-18
|
||||
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# IM 监控运维对接文档
|
||||
|
||||
本文档用于 IM Server 与 IM Manager 发版、健康检查和 Prometheus 监控对接。
|
||||
|
||||
## 1. 接入范围
|
||||
|
||||
| 服务 | 端口 | 健康检查 | 指标接口 | 说明 |
|
||||
|---|---:|---|---|---|
|
||||
| im-server | 6060 | `GET /healthz` | `GET /metrics` | 对外 IM API 与 WebSocket 服务。 |
|
||||
| im-manager | 8000 | `GET /healthz` | 暂无 | 后台管理服务。 |
|
||||
|
||||
两个接口均不需要业务鉴权,不需要 `X-MeetingIM-APIKey`、`Authorization`、租户 Header。
|
||||
|
||||
## 2. 应用配置
|
||||
|
||||
本轮监控改造没有新增业务配置项,应用配置文件不需要额外增加开关。
|
||||
|
||||
需要运维侧配置的是:
|
||||
|
||||
| 配置位置 | 配置内容 |
|
||||
|---|---|
|
||||
| Docker / K8s 健康探针 | 调用 `/healthz`,不要再用 `nc -z` 只探测端口。 |
|
||||
| Prometheus | 抓取 `im-server:6060/metrics`。 |
|
||||
| 发版脚本 | 蓝绿切换前调用新实例 `/healthz`,返回 200 后再切流。 |
|
||||
| 网关/Nginx | `/healthz`、`/metrics` 建议只允许内网或监控网段访问。 |
|
||||
|
||||
## 3. 健康检查
|
||||
|
||||
### 3.1 im-server
|
||||
|
||||
请求:
|
||||
|
||||
```bash
|
||||
curl -fsS http://im-server:6060/healthz
|
||||
```
|
||||
|
||||
正常响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"mysql": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
异常响应 HTTP 状态码为 `503`,示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"checks": {
|
||||
"mysql": "dial tcp 127.0.0.1:3306: i/o timeout"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
检测内容:
|
||||
|
||||
| 依赖 | 检测方式 |
|
||||
|---|---|
|
||||
| MySQL | `PingContext`,包含租户注册库连接检查。 |
|
||||
|
||||
### 3.2 im-manager
|
||||
|
||||
请求:
|
||||
|
||||
```bash
|
||||
curl -fsS http://im-manager:8000/healthz
|
||||
```
|
||||
|
||||
正常响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"mysql": "ok",
|
||||
"redis": "ok",
|
||||
"kafka": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果 Kafka 未启用,响应为:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"mysql": "ok",
|
||||
"redis": "ok",
|
||||
"kafka": "disabled"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
检测内容:
|
||||
|
||||
| 依赖 | 检测方式 |
|
||||
|---|---|
|
||||
| MySQL | `SELECT 1`。 |
|
||||
| Redis | `PING`。 |
|
||||
| Kafka | 临时 Sarama client 拉取 broker metadata,可发现网络、认证、broker 不可用问题。 |
|
||||
|
||||
## 4. Docker 健康检查
|
||||
|
||||
镜像内已改为请求 `/healthz`:
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
|
||||
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
|
||||
```
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \
|
||||
CMD wget -q -O /dev/null http://localhost:8000/healthz || exit 1
|
||||
```
|
||||
|
||||
运维如果使用 K8s,建议:
|
||||
|
||||
| 探针 | 路径 | 初始延迟 | 周期 | 超时 | 失败次数 |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| im-server readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
|
||||
| im-server livenessProbe | `/livez` | 15s | 20s | 3s | 3 |
|
||||
| im-manager readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
|
||||
| im-manager livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
|
||||
|
||||
## 5. Prometheus 指标
|
||||
|
||||
目前只在 `im-server` 暴露:
|
||||
|
||||
```bash
|
||||
curl -fsS http://im-server:6060/metrics
|
||||
```
|
||||
|
||||
已支持指标:
|
||||
|
||||
| 指标 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ws_connections_active` | Gauge | 当前 WebSocket 连接数。 |
|
||||
| `http_requests_total{method,path,status}` | Counter | HTTP 请求量,按方法、归一化路径、状态码统计。 |
|
||||
| `http_request_duration_seconds{method,path,status}` | Histogram | HTTP 请求耗时分布。 |
|
||||
| `db_query_duration_seconds{driver,operation,status}` | Histogram | MySQL 操作耗时分布。 |
|
||||
|
||||
路径中的用户 ID、Topic ID、UUID 等动态片段会归一化为 `{id}`,避免 Prometheus label 过多。
|
||||
|
||||
Prometheus 示例配置:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: im-server
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 15s
|
||||
scrape_timeout: 5s
|
||||
static_configs:
|
||||
- targets:
|
||||
- im-server:6060
|
||||
```
|
||||
|
||||
## 6. 发版脚本建议
|
||||
|
||||
蓝绿部署或滚动发布时,切流前执行:
|
||||
|
||||
```bash
|
||||
curl -fsS --max-time 3 http://127.0.0.1:6060/healthz
|
||||
```
|
||||
|
||||
后台服务:
|
||||
|
||||
```bash
|
||||
curl -fsS --max-time 3 http://127.0.0.1:8000/healthz
|
||||
```
|
||||
|
||||
只有 HTTP 状态码为 `200` 才允许切流。返回 `503` 时应保留旧实例,并输出响应 body 方便定位具体依赖。
|
||||
|
||||
## 7. 告警建议
|
||||
|
||||
| 告警项 | 建议规则 |
|
||||
|---|---|
|
||||
| im-server 健康检查失败 | `/healthz` 连续 3 次非 200。 |
|
||||
| im-manager 健康检查失败 | `/healthz` 连续 3 次非 200。 |
|
||||
| WebSocket 连接数异常 | `ws_connections_active` 突降 50% 以上或接近 0。 |
|
||||
| HTTP 5xx | `rate(http_requests_total{status=~"5.."}[5m]) > 0`。 |
|
||||
| HTTP 延迟 | P95 超过业务阈值,例如 1s。 |
|
||||
| DB 延迟 | `db_query_duration_seconds` P95 超过 200ms 或 500ms。 |
|
||||
|
||||
## 8. 安全要求
|
||||
|
||||
`/healthz` 和 `/metrics` 不带业务鉴权,必须通过网络层控制访问范围。
|
||||
|
||||
建议:
|
||||
|
||||
1. 只允许内网、K8s 探针、Prometheus、运维跳板机访问。
|
||||
2. 不要直接暴露到公网。
|
||||
3. 如必须公网访问,需要网关加 IP 白名单或 Basic Auth。
|
||||
4. 不在日志中记录 `/metrics` 的完整响应内容。
|
||||
|
||||
## 9. 验收命令
|
||||
|
||||
im-server:
|
||||
|
||||
```bash
|
||||
curl -i http://im-server:6060/healthz
|
||||
curl -s http://im-server:6060/metrics | grep -E 'ws_connections_active|http_requests_total|http_request_duration_seconds|db_query_duration_seconds'
|
||||
```
|
||||
|
||||
im-manager:
|
||||
|
||||
```bash
|
||||
curl -i http://im-manager:8000/healthz
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
1. `/healthz` 正常返回 HTTP 200,`status=ok`。
|
||||
2. 任一依赖不可用时返回 HTTP 503,并在 `checks` 中标明具体原因。
|
||||
3. `/metrics` 返回 Prometheus 文本格式。
|
||||
4. Docker 或 K8s 健康状态不再只依赖端口探测。
|
||||
@@ -423,6 +423,16 @@ stateDiagram-v2
|
||||
| 转让群主 | 二次确认;同一事务更新 owner、双方角色、Topic Owner/ACL、审计和 Outbox |
|
||||
| 解散群聊 | 标记群状态、踢出成员、停用二维码和审批入口 |
|
||||
|
||||
### 10.3 群成员列表读取与缓存
|
||||
|
||||
群成员列表按需分页读取,不作为进入会话的必拉数据。普通聊天页只依赖群详情、当前用户权限和 `member_count`;成员页、候选人选择器和 `@` 搜索才调用成员列表接口。
|
||||
|
||||
服务端成员列表查询接口为 `GET /v1/im/groups/{topic}/members`。参数含义:`query` 用于搜索成员,空值表示读取当前筛选条件下的成员页;`cursor` 必须来自上一页 `next_cursor`;`limit` 默认 `30`,最大 `100`;`mentionable=true` 用于 `@` 选择器,只返回可被提及成员;`role=owner|admin|member` 用于按现有角色筛选;`eligible_role=admin|owner` 用于添加管理员和转让群主候选视图;`mute_eligible=true` 用于禁言候选视图。候选视图的 `eligible/ineligible_reason` 在 HTTP 响应阶段按当前登录人的权限和目标成员状态计算。
|
||||
|
||||
成员页按 `im_group_members.id` 游标分页,常规列表走 `(topic, member_status, id)` 索引,角色筛选走 `(topic, role, member_status, id)` 索引,搜索和候选列表必须复用分页查询,不得提供一次返回全群成员的接口。万人群场景中,客户端打开群聊不拉成员列表;打开成员页只拉第一页;搜索、`@`、管理员候选、禁言候选和转让群主候选都带 `query` 缩小结果集。
|
||||
|
||||
成员页缓存使用 Redis,缓存维度为 `topic + role + eligible_role + query + cursor + limit + mentionable`,缓存内容为成员页结果和 `next_cursor`。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人补齐。成员加入、退出、角色变更、群昵称变更、成员状态变化、资料投影刷新等写操作必须调用 `invalidateGroupMemberPages(topic)` 清理该群所有成员页缓存。
|
||||
|
||||
## 11. 收藏、转发与搜索设计
|
||||
|
||||
### 11.1 收藏
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
# IM 群消息按月分表改造方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前每个租户库使用单表 `im_group_messages` 保存全部群消息。随着群数量、消息量和保留时间增长,单表会出现索引膨胀、查询抖动、DDL 风险升高和归档困难等问题。
|
||||
|
||||
本方案将群消息正文按自然月拆分为物理表:
|
||||
|
||||
```text
|
||||
im_group_messages_202609
|
||||
im_group_messages_202610
|
||||
im_group_messages_202611
|
||||
```
|
||||
|
||||
目标:
|
||||
|
||||
1. 每个租户库独立分表,消息表不增加 `tenant_id`。
|
||||
2. Topic 的 `seqid` 继续由 Redis 原子递增,跨月不重置。
|
||||
3. Kafka 异步落库稳定路由到首次确定的月表。
|
||||
4. 客户端继续按 `topic + seq` 拉取,不感知月份。
|
||||
5. 支持跨月分页、补拉、指定消息、搜索、撤回、编辑和删除。
|
||||
6. 旧单表平滑迁移,不停服、不丢失、不重复。
|
||||
7. Redis 保留 24 小时热消息,MySQL 月表仍是最终数据源。
|
||||
|
||||
## 2. 核心原则
|
||||
|
||||
### 2.1 分表粒度
|
||||
|
||||
- 一个租户库每月一张群消息表,不按单个群建表。
|
||||
- 表名固定为 `im_group_messages_YYYYMM`。
|
||||
- 月份统一使用 UTC,但最终写入月份由 Redis 全局 `active_month` 决定,避免多节点在月初产生交叠。
|
||||
- Redis Lua 一次性返回 `seqid + shard_month`;生产端把结果固化到 Kafka,重试、补偿不得重算。
|
||||
|
||||
### 2.2 Seq 规则
|
||||
|
||||
- `seqid` 在 Topic 内全生命周期单调递增,跨月不归零。
|
||||
- Redis `topic:last_seq` 是唯一发号源。
|
||||
- 月表只保存已分配的 Seq,不通过 `MAX(seqid)` 发号。
|
||||
- 客户端游标继续使用 `before/since/seq`。
|
||||
|
||||
### 2.3 分层职责
|
||||
|
||||
| 层级 | 职责 |
|
||||
| --- | --- |
|
||||
| Redis | 原子 Seq、全局活动月份、长期 Topic 月段路由、短期发送幂等、24 小时热消息、在线状态、实时推送 |
|
||||
| Kafka | 异步落库、削峰、重试、死信、消费幂等 |
|
||||
| MySQL 月表 | 群消息正文最终存储 |
|
||||
| MySQL 路由镜像 | 月表目录、Topic 月段路由持久化镜像;Redis 丢失时用于重建,不进入正常查询热路径 |
|
||||
| `im_dellog` | 用户视角删除范围,每张月表查询时继续过滤 |
|
||||
|
||||
## 3. 表结构
|
||||
|
||||
### 3.1 月表模板
|
||||
|
||||
第一阶段保持当前字段兼容,降低 Store 层改造范围。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
|
||||
message_id BIGINT NOT NULL COMMENT 'Snowflake 全局消息 ID',
|
||||
topic VARCHAR(64) NOT NULL COMMENT '群 Topic',
|
||||
group_id BIGINT NOT NULL COMMENT '业务群 ID',
|
||||
seqid BIGINT NOT NULL COMMENT 'Topic 内递增,跨月不重置',
|
||||
from_user BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64) NULL,
|
||||
msg_type VARCHAR(32) NOT NULL DEFAULT 'text',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
content JSON NULL,
|
||||
head JSON NULL,
|
||||
mentioned_user_ids JSON NULL,
|
||||
mention_all TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reply_seqid BIGINT NULL,
|
||||
root_seqid BIGINT NULL,
|
||||
file_count INT NOT NULL DEFAULT 0,
|
||||
content_size INT NOT NULL DEFAULT 0,
|
||||
has_risk TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3) NULL,
|
||||
recalled_by BIGINT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
expires_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (message_id),
|
||||
UNIQUE KEY uk_topic_seq (topic, seqid),
|
||||
UNIQUE KEY uk_topic_sender_client_mid (topic, from_user, client_msg_id),
|
||||
KEY idx_group_seq (group_id, seqid),
|
||||
KEY idx_topic_created (topic, created_at),
|
||||
KEY idx_topic_from_seq (topic, from_user, seqid),
|
||||
KEY idx_audit_created (audit_status, created_at),
|
||||
KEY idx_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `message_id` 使用 Snowflake 全局唯一 ID,作为月表物理主键和跨表引用 ID。
|
||||
2. 聊天协议定位、补拉和已读水位仍统一使用 `topic + seqid`,不向客户端暴露物理主键。
|
||||
3. 单表唯一索引不能覆盖跨月重试,跨月幂等由 Redis、消费日志和幂等表共同保证。
|
||||
4. 文件二进制仍在 OSS,月表仅保存消息和文件元数据。
|
||||
|
||||
### 3.2 月表目录
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_shards (
|
||||
shard_month CHAR(6) NOT NULL COMMENT 'UTC 月份 YYYYMM',
|
||||
table_name VARCHAR(64) NOT NULL,
|
||||
shard_start DATETIME(3) NOT NULL,
|
||||
shard_end DATETIME(3) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'prepared'
|
||||
COMMENT 'prepared/writable/sealing/sealed/archived',
|
||||
row_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NULL,
|
||||
max_created_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (shard_month),
|
||||
UNIQUE KEY uk_table_name (table_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
表名只允许由服务端根据月份生成,并且必须存在于目录表,禁止使用请求参数直接拼接 SQL。
|
||||
|
||||
### 3.3 Redis Topic 月段路由
|
||||
|
||||
在线查询使用 Redis 路由,路由 Key 长期不过期:
|
||||
|
||||
```text
|
||||
# 当前可写月份
|
||||
im:{tenant_id}:groupmsg:active_month
|
||||
STRING = 202610
|
||||
|
||||
# Topic 月份段,score 为该月首条 seq
|
||||
im:{tenant_id}:groupmsg:route:{topic}
|
||||
ZSET: 202609 => 1
|
||||
202610 => 10087
|
||||
|
||||
# 月段完整范围,field 为 YYYYMM
|
||||
im:{tenant_id}:groupmsg:route_meta:{topic}
|
||||
HASH: 202609 => min_seq=1,max_seq=10086,count=10086
|
||||
202610 => min_seq=10087,max_seq=12000,count=1914
|
||||
```
|
||||
|
||||
路由规则:
|
||||
|
||||
1. `route` 和 `route_meta` 不设置 TTL,只在群物理删除或对应消息保留期结束后清理。
|
||||
2. 精确 Seq 定位使用 `ZREVRANGEBYSCORE route seq -inf LIMIT 0 1`,找到最大 `min_seq <= seq` 的月份,再校验 Hash 中的 `max_seq`。
|
||||
3. 倒序分页通过 `ZREVRANGE` 按月段倒序读取,正向补拉通过 `ZRANGE` 读取。
|
||||
4. 路由 Key 使用 Redis Cluster hash tag,保证 Lua 和 Pipeline 访问同一槽位。
|
||||
5. Redis 开启 AOF everysec、主从复制和持久化备份;同时保留 MySQL 镜像作为灾备重建来源。
|
||||
|
||||
活动月份和 Seq 必须在同一个 Lua 脚本中读取/分配,两个 Key 使用相同的租户 hash tag:
|
||||
|
||||
```lua
|
||||
local shard_month = redis.call('GET', KEYS[1])
|
||||
if not shard_month or shard_month < ARGV[1] then
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
shard_month = ARGV[1]
|
||||
end
|
||||
|
||||
local seq_id = redis.call('INCR', KEYS[2])
|
||||
return {seq_id, shard_month}
|
||||
```
|
||||
|
||||
其中 `KEYS[1]` 是租户活动月份,`KEYS[2]` 是 Topic Seq,`ARGV[1]` 来自 Redis `TIME` 转换后的 UTC 月份。业务节点不得根据本机时间计算月份;首次写入可初始化活动月份,之后 Lua 只允许月份单向推进,旧节点不能回拨。
|
||||
|
||||
### 3.4 MySQL 路由镜像
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
shard_month CHAR(6) NOT NULL,
|
||||
min_seq BIGINT NOT NULL,
|
||||
max_seq BIGINT NOT NULL,
|
||||
message_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NOT NULL,
|
||||
max_created_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (topic, shard_month),
|
||||
KEY idx_topic_max_seq (topic, max_seq),
|
||||
KEY idx_month_topic (shard_month, topic)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
该表不参与正常消息查询,只用于 Kafka 事务持久化、Redis 路由重建、历史迁移、数据核对和归档。Redis 与镜像不一致时,以月表实际数据和 MySQL 镜像修复 Redis。
|
||||
|
||||
路由一致性规则:
|
||||
|
||||
1. Kafka 消费事务同时提交消息和 MySQL 路由镜像,保证最终数据与镜像一致。
|
||||
2. MySQL 提交成功后再更新 Redis `route/route_meta`;Redis 更新失败不回滚消息,进入路由修复重试队列。
|
||||
3. 查询发现 Redis 路由 Key 不存在时,从 MySQL 镜像加载该 Topic 全部有效月段并回填 Redis,然后重试查询。
|
||||
4. Shard Manager 启动时可按租户增量重建路由,并定时抽样核对 Redis、MySQL 镜像和月表实际 Seq 范围。
|
||||
5. Redis 路由不设置 TTL;只有群被物理删除,或消息保留策略确认清除对应月份后,才同步删除相应月段。
|
||||
|
||||
### 3.5 跨月幂等表
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_dedup (
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
from_user BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64) NOT NULL,
|
||||
seqid BIGINT NOT NULL,
|
||||
shard_month CHAR(6) NOT NULL,
|
||||
message_id BIGINT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (topic, from_user, client_msg_id),
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_topic_seq (topic, seqid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
- Redis `SET NX` 是入口快速幂等。
|
||||
- Kafka 消费日志按 `event_id` 防止同一事件重复消费。
|
||||
- 该表防止月末发送、月初重试时同一 `client_msg_id` 落入两张表。
|
||||
- 建议保留 7 至 30 天后清理,不作为永久消息索引。
|
||||
|
||||
## 4. 建表和月切换
|
||||
|
||||
每个租户库运行统一 Shard Manager:
|
||||
|
||||
1. 每天巡检上月、当月和未来两个月。
|
||||
2. 缺表时根据固定模板创建,成功后登记目录。
|
||||
3. 使用 Redis 分布式锁和 Redis `TIME` 判断月界,确认新月表可写后再原子更新 `active_month`。
|
||||
4. 当月设置为 `writable`,所有节点通过 Redis 读取同一个活动月份。
|
||||
5. 上月进入 `sealing`,建议继续接受 7 天延迟 Kafka 消息。
|
||||
6. 确认没有积压和死信后改为 `sealed`。
|
||||
7. 建表失败立即告警,不切换 `active_month`,禁止在业务写入事务中临时执行 DDL。
|
||||
|
||||
月切换示例:
|
||||
|
||||
```text
|
||||
2026-09-30 23:59:59 UTC seq=10086 -> im_group_messages_202609
|
||||
2026-10-01 00:00:00 UTC seq=10087 -> im_group_messages_202610
|
||||
```
|
||||
|
||||
Kafka 事件必须固定路由信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp_xxx",
|
||||
"seq_id": 10087,
|
||||
"created_at": "2026-10-01T00:00:00.123Z",
|
||||
"shard_month": "202610",
|
||||
"client_msg_id": "client_mid_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 群消息落库
|
||||
|
||||
### 5.1 生产端
|
||||
|
||||
1. 校验群成员、禁言、群状态和内容策略。
|
||||
2. 使用 Redis 幂等 Key 抢占 `client_msg_id`。
|
||||
3. 使用 Redis Lua 原子获取 Topic 下一个 `seqid` 和全局 `active_month`。
|
||||
4. 生成服务端 UTC `created_at`,并把 Lua 返回的月份作为 `shard_month`。
|
||||
5. 把 `shard_month` 写入 Kafka;所有重试沿用原值。
|
||||
6. Kafka 生产成功后按现有策略向客户端返回 Seq。
|
||||
7. 写入 Redis 热消息并执行实时推送。
|
||||
|
||||
### 5.2 Kafka 消费端
|
||||
|
||||
消费端按 `tenant + shard_month` 分组批量写入:
|
||||
|
||||
1. 校验月份格式和允许范围。
|
||||
2. 从目录确认目标表已存在。
|
||||
3. 开启事务。
|
||||
4. 插入或确认 `im_group_message_dedup`。
|
||||
5. 插入目标月表。
|
||||
6. 在同一事务中 UPSERT MySQL 路由镜像的最小/最大 Seq、时间和消息数。
|
||||
7. 更新消费幂等状态并提交事务。
|
||||
8. 提交成功后更新 Redis `route/route_meta`,并把 Redis 幂等状态改为 `done`。
|
||||
|
||||
路由 UPSERT:
|
||||
|
||||
```sql
|
||||
INSERT INTO im_group_message_topic_shards
|
||||
(topic, shard_month, min_seq, max_seq, message_count,
|
||||
min_created_at, max_created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
min_seq = LEAST(min_seq, VALUES(min_seq)),
|
||||
max_seq = GREATEST(max_seq, VALUES(max_seq)),
|
||||
message_count = message_count + 1,
|
||||
min_created_at = LEAST(min_created_at, VALUES(min_created_at)),
|
||||
max_created_at = GREATEST(max_created_at, VALUES(max_created_at));
|
||||
```
|
||||
|
||||
### 5.3 异常处理
|
||||
|
||||
- 目标月表不存在:消费失败、进入重试并高优告警。
|
||||
- `sealing` 月表:允许携带原 `shard_month` 的延迟事件写入。
|
||||
- `sealed` 月表:进入补偿队列,人工确认后临时开放或补写。
|
||||
- 重复 `event_id`:消费日志直接判定成功。
|
||||
- 重复 `client_msg_id`:返回原 `seqid/shard_month`,不新增消息。
|
||||
- 超过最大重试次数:进入死信队列,不能静默丢弃。
|
||||
- Redis 热缓存失败不回滚 MySQL,客户端按 Seq 回源补拉。
|
||||
- Redis 路由更新失败:进入独立修复重试;查询侧可从 MySQL 镜像重建,不影响已提交消息。
|
||||
|
||||
## 6. 消息查询
|
||||
|
||||
### 6.1 总流程
|
||||
|
||||
```text
|
||||
客户端 Topic Seq 请求
|
||||
-> ACL/成员身份校验
|
||||
-> Redis 24 小时热消息
|
||||
-> Redis 是否完整覆盖请求范围
|
||||
-> Redis Topic 月段路由
|
||||
-> 月表逐张查询
|
||||
-> 按 seqid 合并、去重、排序、截断
|
||||
-> 应用 im_dellog 用户视角过滤
|
||||
-> 返回客户端
|
||||
```
|
||||
|
||||
Redis 只有完整覆盖整页时才能直接返回;有缺口必须回源 MySQL。
|
||||
|
||||
### 6.2 最新消息倒序分页
|
||||
|
||||
请求 `topic=grp_xxx&before=10088&limit=50`:
|
||||
|
||||
1. 从 Redis Topic 月段路由找出 `min_seq < before` 的月份,按月倒序。
|
||||
2. 先查最新候选月:
|
||||
|
||||
```sql
|
||||
SELECT ...
|
||||
FROM im_group_messages_202610 AS m
|
||||
LEFT JOIN im_dellog AS d
|
||||
ON d.topic = m.topic
|
||||
AND m.seqid BETWEEN d.low AND d.hi - 1
|
||||
AND d.deletedfor = ?
|
||||
WHERE m.topic = ?
|
||||
AND m.seqid < ?
|
||||
AND m.status != 'deleted'
|
||||
AND d.deletedfor IS NULL
|
||||
ORDER BY m.seqid DESC
|
||||
LIMIT ?;
|
||||
```
|
||||
|
||||
3. 数量不足时继续查前一个月。
|
||||
4. 服务层按 `seqid DESC` 合并并截取 `limit`。
|
||||
5. 下一页游标仍是本页最小 `seqid`,客户端不感知月份。
|
||||
|
||||
禁止对所有历史表执行无上限 `UNION ALL`。
|
||||
|
||||
### 6.3 Since 正向补拉
|
||||
|
||||
1. 从 Redis Topic 月段路由查找 `max_seq > since_seq` 的月份。
|
||||
2. 按月升序执行 `seqid > since_seq ORDER BY seqid ASC LIMIT N`。
|
||||
3. 合并后按 Seq 升序返回。
|
||||
4. 达到上限后返回最后一条 Seq,客户端继续请求。
|
||||
|
||||
### 6.4 指定消息
|
||||
|
||||
撤回、回复预览、举报取证按 `topic + seqid` 定位:
|
||||
|
||||
```text
|
||||
ZREVRANGEBYSCORE im:{tenant_id}:groupmsg:route:{topic} {seqid} -inf LIMIT 0 1
|
||||
HGET im:{tenant_id}:groupmsg:route_meta:{topic} {shard_month}
|
||||
```
|
||||
|
||||
先找到最大 `min_seq <= seqid` 的月份,再校验该月段 `max_seq >= seqid`,随后执行 `WHERE topic=? AND seqid=? LIMIT 1`。Redis 路由 Key 缺失时读取 MySQL 镜像并回填 Redis;镜像也缺失时,仅降级扫描当前月和最近若干月并记录修复任务,不允许扫描全部历史表。
|
||||
|
||||
### 6.5 上下文和范围
|
||||
|
||||
查询目标消息前后各 20 条时:
|
||||
|
||||
1. 定位目标所在月。
|
||||
2. 在本月分别查询目标前后消息。
|
||||
3. 任一方向不足时继续查询相邻月份。
|
||||
4. 合并、去重后按 Seq 正序返回。
|
||||
|
||||
### 6.6 搜索和统计
|
||||
|
||||
- 短期搜索必须带起止时间,最多覆盖 3 个月,逐月查询后归并。
|
||||
- 中长期通过 Kafka 建独立搜索索引;结果只返回 `topic + seqid`,服务端回源月表并重新校验 ACL。
|
||||
- Admin 统计使用按日/月汇总表,不实时扫描所有月表执行 `COUNT(*)`。
|
||||
|
||||
## 7. 撤回、编辑和删除
|
||||
|
||||
撤回、编辑流程:
|
||||
|
||||
1. 根据 `topic + seqid` 定位目标月表。
|
||||
2. 在目标月表事务中 `SELECT ... FOR UPDATE`。
|
||||
3. 校验版本、权限和状态。
|
||||
4. 更新目标月表并写修订记录、业务事件。
|
||||
5. 提交后更新或删除 Redis 热消息。
|
||||
|
||||
修订记录建议增加月份:
|
||||
|
||||
```sql
|
||||
ALTER TABLE im_message_revisions
|
||||
ADD COLUMN shard_month CHAR(6) NULL AFTER topic;
|
||||
```
|
||||
|
||||
用户侧删除继续使用 `im_dellog` 的 Topic Seq 范围,每张月表查询都应用相同过滤。全局删除或合规处置先定位月表,再更新 `status/deleted_at/content/head`;OSS 附件仍走文件生命周期任务。
|
||||
|
||||
## 8. Store 层设计
|
||||
|
||||
所有动态表访问统一经过路由组件:
|
||||
|
||||
```go
|
||||
type GroupMessageShardRouter interface {
|
||||
WriteTable(createdAt time.Time, shardMonth string) (string, error)
|
||||
CandidateTablesBySeq(topic string, seq int64) ([]string, error)
|
||||
TablesBefore(topic string, before int64) ([]string, error)
|
||||
TablesSince(topic string, since int64) ([]string, error)
|
||||
}
|
||||
```
|
||||
|
||||
改造范围:
|
||||
|
||||
- `TenantGroupMessageSave`:按事件 `shard_month` 写入。
|
||||
- `TenantGroupMessageGetOne`:路由后查询目标月。
|
||||
- `TenantGroupMessageGetAll`:逐月查询并归并。
|
||||
- `TenantGroupMessageGetByClientID`:先查 Redis/幂等表,再查询目标月。
|
||||
- `TenantGroupMessageRecall/Edit`:定位目标月后执行事务。
|
||||
- `TenantMessageSearch`:按时间选月,或转独立搜索。
|
||||
- Admin 内部消息查询、举报、收藏、回复和群文件引用同步改造。
|
||||
|
||||
动态表名必须满足 `^im_group_messages_[0-9]{6}$` 且月份已登记。SQL 参数不能绑定表名,必须严格白名单。
|
||||
|
||||
## 9. 旧表迁移
|
||||
|
||||
原 `im_group_messages` 先保留,不删除。
|
||||
|
||||
推荐步骤:
|
||||
|
||||
1. 上线路由表、幂等表和 Shard Manager。
|
||||
2. 预建历史月份、当月和未来两个月表。
|
||||
3. 先发布支持“旧表 + 月表”双读的代码,写入仍走旧表。
|
||||
4. 记录切换水位 `cutover_seq/cutover_time`。
|
||||
5. 切换新消息只写月表,Kafka 开始携带 `shard_month`。
|
||||
6. 按 `created_at` 分批把旧表数据迁入对应月表。
|
||||
7. 每批使用 `INSERT IGNORE`,校验 `topic + seqid`、数量和内容哈希。
|
||||
8. 回填 MySQL Topic 月段路由镜像,并由重建任务写入 Redis 路由。
|
||||
9. 双读期间按 `topic + seqid` 去重。
|
||||
10. 全量校验通过后停止读取旧表。
|
||||
11. 旧表改名 `im_group_messages_legacy`,只读保留。
|
||||
12. 观察至少一个完整保留周期后再单独审批清理。
|
||||
|
||||
不建议长期双写旧表和月表,否则两个写目标部分成功会扩大一致性问题。
|
||||
|
||||
迁移批次要求:
|
||||
|
||||
- 使用主键或 `created_at + id` 游标,禁止 OFFSET 深分页。
|
||||
- 单批 1,000 至 5,000 条,根据线上延迟调整。
|
||||
- 每批独立事务,记录开始/结束游标和成功/失败数。
|
||||
- 支持暂停、续跑和重复执行。
|
||||
|
||||
## 10. 归档与保留
|
||||
|
||||
状态流转:
|
||||
|
||||
```text
|
||||
prepared -> writable -> sealing -> sealed -> archived
|
||||
```
|
||||
|
||||
- `writable`:当月正常写。
|
||||
- `sealing`:月末后接受延迟消息,默认 7 天。
|
||||
- `sealed`:在线 MySQL 只读。
|
||||
- `archived`:转对象存储或低成本库,在线查询走归档服务。
|
||||
|
||||
归档前确认 Kafka 无积压/死信、路由完整、消息数量和 Seq 范围一致,并确认举报、审计、收藏和群文件引用仍可定位。
|
||||
|
||||
## 11. 监控与告警
|
||||
|
||||
| 指标 | 说明 |
|
||||
| --- | --- |
|
||||
| `group_message_persist_total{month,status}` | 各月落库成功/失败 |
|
||||
| `group_message_persist_latency` | Kafka 到 MySQL 提交耗时 |
|
||||
| `group_message_shard_missing_total` | 目标月表不存在 |
|
||||
| `group_message_query_tables` | 单次请求扫描表数 |
|
||||
| `group_message_query_latency{source}` | Redis、单月、跨月耗时 |
|
||||
| `group_message_dedup_hit_total` | 幂等命中 |
|
||||
| `group_message_route_miss_total` | Redis 路由缺失 |
|
||||
| `group_message_route_rebuild_total{status}` | 从 MySQL 镜像重建 Redis 路由 |
|
||||
| `group_message_route_repair_backlog` | Redis 路由修复积压 |
|
||||
| `group_message_dead_letter_total` | 落库死信 |
|
||||
|
||||
未来月表未创建、当月不可写、`active_month` 缺失、Kafka 延迟超限、单次扫描超过 3 张表、Redis 路由缺失或修复积压增长时必须告警。
|
||||
|
||||
## 12. 验收测试
|
||||
|
||||
1. 月末和月初消息落入正确表,Seq 连续。
|
||||
2. Kafka 次月才消费时仍写事件指定月份。
|
||||
3. 同一 `client_msg_id` 跨月重试只保留一条。
|
||||
4. 最新 50 条跨两个月时数量、顺序、游标正确。
|
||||
5. `since_seq` 跨月补拉无遗漏、无重复。
|
||||
6. `topic + seq` 可定位历史消息。
|
||||
7. 回复上下文可跨月读取。
|
||||
8. 撤回、编辑、举报、收藏、群文件引用可定位旧月。
|
||||
9. 用户删除范围跨月时过滤正确。
|
||||
10. Redis 热消息不完整时正确回源。
|
||||
11. 月表缺失时进入重试/死信并告警。
|
||||
12. 迁移双读无重复,迁移任务可暂停、续跑。
|
||||
13. 万人群高并发时 Seq 唯一且落库稳定。
|
||||
14. 非法月份和非法动态表名被拒绝。
|
||||
15. 多节点跨月并发时 Lua 返回同一活动月份,Seq 不重号且月段不交叠。
|
||||
16. 删除 Redis 路由后可从 MySQL 镜像自动重建,查询结果不缺失。
|
||||
|
||||
## 13. 实施阶段
|
||||
|
||||
### 第一阶段:基础设施
|
||||
|
||||
- 建月表模板、目录、MySQL 路由镜像和跨月幂等表。
|
||||
- 实现 Shard Manager,预建未来两个月。
|
||||
- 建立长期不过期的 Redis Topic 月段路由和原子 `seqid + active_month` Lua。
|
||||
- Kafka 群消息事件增加 `shard_month`。
|
||||
|
||||
### 第二阶段:写入切换
|
||||
|
||||
- 消费者按月落库。
|
||||
- 完成幂等、重试、死信、MySQL 镜像和 Redis 路由更新。
|
||||
- Redis 热消息与实时推送保持不变。
|
||||
|
||||
### 第三阶段:查询切换
|
||||
|
||||
- 完成精确查询、倒序分页、Since 补拉和上下文跨月查询。
|
||||
- 改造撤回、编辑、删除、举报及 Admin 内部查询。
|
||||
- 增加跨月查询监控。
|
||||
|
||||
### 第四阶段:历史迁移
|
||||
|
||||
- 上线旧表/月表双读。
|
||||
- 回填历史消息与 MySQL 路由镜像,重建 Redis Topic 路由。
|
||||
- 校验后把旧表改为只读保留。
|
||||
|
||||
### 第五阶段:搜索和归档
|
||||
|
||||
- 消息搜索切换独立索引或限制时间范围。
|
||||
- 建立 `sealed/archived` 生命周期和历史恢复机制。
|
||||
|
||||
## 14. 结论
|
||||
|
||||
群消息按月分表不能只改写入表名,必须一起实现固定 `shard_month`、Redis Topic 月段路由、MySQL 路由镜像、跨月幂等、逐月游标查询、历史消息定位和旧表迁移。
|
||||
|
||||
最终链路:
|
||||
|
||||
```text
|
||||
发送:Redis Lua 原子分配 Seq 和 active_month -> Kafka 固定 shard_month -> MySQL 月表
|
||||
实时:Redis Pub/Sub/Stream -> WebSocket
|
||||
热读:Redis 24 小时消息
|
||||
冷读:Redis Topic 月段路由 -> MySQL 月表逐月查询
|
||||
容灾:MySQL 路由镜像 -> 重建长期 Redis 路由
|
||||
补偿:客户端按 Topic Seq 拉取
|
||||
```
|
||||
|
||||
客户端协议无需增加月份字段,月表路由完全由服务端处理。
|
||||
@@ -244,42 +244,5 @@ CREATE TABLE IF NOT EXISTS im_group_admin_logs (
|
||||
INDEX idx_im_group_admin_logs_object (object_type, object_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- 11. Group operation idempotency.
|
||||
-- This can later be replaced by a global HTTP idempotency table.
|
||||
CREATE TABLE IF NOT EXISTS im_group_idempotency (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
method VARCHAR(8) NOT NULL,
|
||||
route VARCHAR(256) NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
body_hash CHAR(64) NOT NULL,
|
||||
result_code INT NOT NULL DEFAULT 0,
|
||||
result_body JSON,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_group_idempotency_request (user_id, method, route, idempotency_key),
|
||||
INDEX idx_im_group_idempotency_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- 12. Group outbox events.
|
||||
-- Transactional bridge for group.membership.updated, group.join_request.created,
|
||||
-- group.announcement.updated, group.role.updated, group.mute.updated, group.dissolved, etc.
|
||||
CREATE TABLE IF NOT EXISTS im_group_events_outbox (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
event_id VARCHAR(64) NOT NULL,
|
||||
topic VARCHAR(32) NOT NULL,
|
||||
actor_user_id BIGINT NOT NULL DEFAULT 0,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload JSON,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
next_retry_at DATETIME(3),
|
||||
delivered_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE INDEX uk_im_group_events_outbox_event (event_id),
|
||||
INDEX idx_im_group_events_outbox_pending (status, next_retry_at, id),
|
||||
INDEX idx_im_group_events_outbox_topic (topic, id DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
-- 11. Group operation idempotency is handled by Redis-backed HTTP idempotency.
|
||||
DROP TABLE IF EXISTS im_group_idempotency;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# IM「联系我们」移动端接口对接文档
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-09-21
|
||||
> 状态:**已实现**,可按本文直接联调
|
||||
> 适用端:iOS / Android / Web(im_webapp)
|
||||
|
||||
## 1. 功能说明
|
||||
|
||||
移动端「设置 / 关于 / 支持」页面的**联系我们**入口,展示管理员配置的客服邮箱。
|
||||
|
||||
数据链路:
|
||||
|
||||
```text
|
||||
管理后台「系统管理 → 字典管理 → 联系我们」
|
||||
→ IM 管理端数据库(字典类型 im_contact_us,条目即 label/value)
|
||||
→ im-server 缓存(内存 TTL 1 分钟兜底 + 管理端变更事件主动失效)
|
||||
→ 移动端 GET /v1/im/contact-us
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 条目内容由运营在管理后台维护,客户端不需要硬编码任何联系方式。
|
||||
- 管理后台修改后**近实时生效**(事件失效,秒级);事件通道不可用时最长 1 分钟内收敛(缓存 TTL 兜底)。
|
||||
- 条目数量不固定,客户端必须按列表渲染,不能假设只有一个条目。
|
||||
- 接口返回为空列表时,客户端应**隐藏联系我们入口**,不要渲染空区块。
|
||||
|
||||
## 2. 接口定义
|
||||
|
||||
### 2.1 获取联系我们配置
|
||||
|
||||
```text
|
||||
GET {api_path}v1/im/contact-us
|
||||
```
|
||||
|
||||
**请求头**(公共约定同《IM-前后端对接文档.md》§0.2):
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `X-MeetingIM-APIKey` | 是 | 应用级接入标识 |
|
||||
| `X-IM-Schema` | 是 | 固定 `1` |
|
||||
| `X-Request-ID` | 是 | 请求追踪 ID |
|
||||
| `Authorization` | 是 | `Bearer <token>`,登录态必填 |
|
||||
|
||||
**请求参数**:无
|
||||
|
||||
**成功响应**(HTTP 200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{ "label": "客服邮箱", "value": "support@example.com" }
|
||||
]
|
||||
},
|
||||
"request_id": "req-contactus-2",
|
||||
"server_time": "2026-09-21T04:58:19.521Z"
|
||||
}
|
||||
```
|
||||
|
||||
`data` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `items` | `array` | 联系方式条目列表,按管理后台配置的排序返回;可能为空数组 `[]` |
|
||||
| `items[].label` | `string` | 条目名称,如「客服邮箱」,客户端原样展示 |
|
||||
| `items[].value` | `string` | 条目值,邮箱地址 |
|
||||
|
||||
**失败响应**:
|
||||
|
||||
| HTTP | code | reason | 场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 403 | 30003 | `API_KEY_REQUIRED` | API Key 缺失或无效 |
|
||||
| 401 | 30043 | `AUTH_REQUIRED` | 未登录 / token 过期,引导重新登录 |
|
||||
| 405 | 30002 | `METHOD_NOT_ALLOWED` | 非 GET 请求 |
|
||||
| 500 | 90000 | `INTERNAL_ERROR` | 服务端内部错误,可重试 |
|
||||
|
||||
## 3. 客户端渲染规则
|
||||
|
||||
按条目值的形态渲染(客户端本地判断,与服务端无关):
|
||||
|
||||
| value 形态 | 判断 | 交互 |
|
||||
| --- | --- | --- |
|
||||
| 邮箱 | 包含 `@` 且形如 `x@y.z` | `mailto:{value}`,图标用 `email` |
|
||||
| 其他 | 不满足邮箱格式 | 纯文本展示,不可点击 |
|
||||
|
||||
补充约定:
|
||||
|
||||
- 每个条目的展示文案使用 `label`,不要自行翻译或改写。
|
||||
- 接口失败(网络/500)时静默降级:若客户端保留了旧版本静态联系方式可继续展示,否则隐藏入口,不弹 toast。
|
||||
- 不需要本地缓存该接口结果;进入页面时请求即可,服务端已有缓存兜底。
|
||||
|
||||
## 4. 本地联调示例
|
||||
|
||||
```bash
|
||||
curl -s "http://{host}/{api_path}v1/im/contact-us" \
|
||||
-H "X-MeetingIM-APIKey: <api_key>" \
|
||||
-H "X-IM-Schema: 1" \
|
||||
-H "X-Request-ID: req-contactus-2" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
实测返回:
|
||||
|
||||
```json
|
||||
{"code":0,"data":{"items":[{"label":"客服邮箱","value":"support@example.com"}]},"request_id":"req-contactus-2","server_time":"2026-09-21T04:58:19.521Z"}
|
||||
```
|
||||
|
||||
## 5. 配置维护(运营侧)
|
||||
|
||||
- 入口:管理后台「系统管理 → 字典管理」→ 字典类型 `联系我们`(type=`im_contact_us`)。
|
||||
- 新增/修改/删除条目即配置生效,`状态=停用` 的条目不会返回。
|
||||
- 测试环境初始化 SQL:`im_manager/docs/im/IM-联系我们字典配置.sql`(im_manager 仓库)。
|
||||
- 首次部署后如接口返回空列表,先确认管理后台该字典类型下存在 `状态=启用` 的条目。
|
||||
|
||||
## 6. 参考实现
|
||||
|
||||
Web 端(im_webapp)已完成对接,可直接参考:
|
||||
|
||||
- API 封装:`src/lib/im-api.js` 中 `getContactUs()`
|
||||
- 页面渲染:`src/views/acc-support-view.jsx`(邮箱 `mailto:`、空列表回退静态链接)
|
||||
@@ -23,7 +23,7 @@ Idempotency-Key: <uuid>
|
||||
|
||||
测试环境验证码固定为 `123456`。`action_token` 为 5 分钟有效、单次消费,只能用于指定动作,不能在多个接口之间重复使用。
|
||||
|
||||
当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;业务接口层的深夜消息免打扰、内容过滤、支付拦截尚未实际强制执行,响应中的 `restriction_items[].enforced=false` 用于明确表达这一点。后续某条规则真正接入后台策略和业务拦截后,应把对应项改为 `true`。
|
||||
当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条。现阶段后端已完成状态持久化、开启/关闭校验和策略返回;`night_quiet` 已接入音视频呼叫发起限制,`content_filter` 已接入消息发送前内容安全审核,响应中的这两项 `restriction_items[].enforced=true`。`payment_guard` 当前仍为预留项,`enforced=false`。
|
||||
|
||||
## 2. 开启青少年模式
|
||||
|
||||
@@ -47,13 +47,13 @@ GET /v1/im/settings/youth-mode
|
||||
"code": "night_quiet",
|
||||
"title": "深夜免打扰",
|
||||
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
||||
"enforced": false
|
||||
"enforced": true
|
||||
},
|
||||
{
|
||||
"code": "content_filter",
|
||||
"title": "内容过滤",
|
||||
"description": "对不适合青少年的内容进行识别和限制展示。",
|
||||
"enforced": false
|
||||
"enforced": true
|
||||
},
|
||||
{
|
||||
"code": "payment_guard",
|
||||
@@ -202,13 +202,13 @@ POST /v1/im/settings/youth-mode/enable
|
||||
"code": "night_quiet",
|
||||
"title": "深夜免打扰",
|
||||
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
|
||||
"enforced": false
|
||||
"enforced": true
|
||||
},
|
||||
{
|
||||
"code": "content_filter",
|
||||
"title": "内容过滤",
|
||||
"description": "对不适合青少年的内容进行识别和限制展示。",
|
||||
"enforced": false
|
||||
"enforced": true
|
||||
},
|
||||
{
|
||||
"code": "payment_guard",
|
||||
|
||||
@@ -41,7 +41,7 @@ ServerComMessage
|
||||
2. `sess.bkgTimer` — 后台超时定时器
|
||||
3. `sess.stop` — 停止信号
|
||||
4. `sess.detach` — 取消订阅
|
||||
5. `ticker` — Ping 帧(每 30s 发送一次)
|
||||
5. `ticker` — Ping 帧(每 15s 发送一次)
|
||||
|
||||
### Keep-Alive
|
||||
|
||||
@@ -49,7 +49,7 @@ ServerComMessage
|
||||
|------|-----|------|
|
||||
| `writeWait` | 10s | 写超时 |
|
||||
| `pongWait` | `idleSessionTimeout` | 期望收到 Pong 的时间(当前默认 45s) |
|
||||
| `pingPeriod` | 30s | WebSocket Ping 间隔 |
|
||||
| `pingPeriod` | 15s | WebSocket Ping 间隔 |
|
||||
| 心跳探测 | WebSocket Ping/Pong | 当前 IM 长连接保活以协议层 Ping/Pong 为准;业务 `note kp/kpa/kpv` 不能替代 Pong |
|
||||
|
||||
### 缓冲区设置
|
||||
|
||||
@@ -174,8 +174,8 @@ Tinode 的心跳配置:
|
||||
```go
|
||||
const (
|
||||
writeWait = 10 * time.Second // 写操作超时
|
||||
pongWait = idleSessionTimeout // 等待 Pong 超时(默认 60 秒)
|
||||
pingPeriod = (pongWait * 9) / 10 // 发送 Ping 周期(54 秒)
|
||||
pongWait = idleSessionTimeout // 等待 Pong 超时(默认 45 秒)
|
||||
pingPeriod = 15 * time.Second // 发送 Ping 周期(15 秒)
|
||||
)
|
||||
```
|
||||
|
||||
@@ -190,7 +190,7 @@ const (
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ for { │ │
|
||||
│ │ select { │ │
|
||||
│ │ case <-ticker.C: // 每 54 秒 │ │
|
||||
│ │ case <-ticker.C: // 每 15 秒 │ │
|
||||
│ │ ws.WriteMessage(PingMessage, nil) // 发送 Ping │ │
|
||||
│ │ case msg <- sess.send: // 有消息待发送 │ │
|
||||
│ │ ws.WriteMessage(TextMessage, msg) // 发送消息 │ │
|
||||
@@ -554,8 +554,8 @@ func (sess *Session) writeLoop() {
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `writeWait` | 10秒 | 写操作超时 |
|
||||
| `pongWait` | 配置项 | 等待 Pong 超时(默认 60 秒) |
|
||||
| `pingPeriod` | `pongWait * 0.9` | 发送 Ping 周期 |
|
||||
| `pongWait` | `idleSessionTimeout` | 等待 Pong 超时(默认 45 秒) |
|
||||
| `pingPeriod` | 15 秒 | 发送 Ping 周期 |
|
||||
|
||||
### 消息格式
|
||||
|
||||
@@ -1290,7 +1290,7 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
|
||||
"listen": ":6060",
|
||||
"grpc_listen": ":16060",
|
||||
"max_message_size": 262144,
|
||||
"idle_session_timeout": 60000,
|
||||
"idle_session_timeout": 45000,
|
||||
"ws_compression": true,
|
||||
|
||||
"api_key_salt": "your-hmac-salt",
|
||||
@@ -1350,4 +1350,4 @@ curl "http://localhost:6060/v0/channels/lp?sid=SESSION_ID"
|
||||
| `pbx/model.proto` | gRPC 协议定义 |
|
||||
| `server/http.go` | HTTP 服务器 |
|
||||
| `server/session.go` | 会话管理 |
|
||||
| `server/datamodel.go` | 消息结构定义 |
|
||||
| `server/datamodel.go` | 消息结构定义 |
|
||||
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 336 KiB |
@@ -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. 其他上游负载均衡或云网络事件。
|
||||
@@ -0,0 +1,495 @@
|
||||
# IM 后端问题清单
|
||||
|
||||
_2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的实测,能读代码的读代码,均标注核实方式),并把此前散落在各处(`docs/IM-后端问题清单-2026-09-04.md`、历次前端重写工程记录)但从未收录进本清单的问题一并归档,同时确认了几条此前记录、后来查明其实是前端自己的bug(已在下方"已排除"列出,不再作为后端问题)。这次复核期间发现后端正在把数据库从单机MySQL迁移到新k3s集群的Percona XtraDB Cluster(PXC)——`server/meetingim.conf`/`tinode.conf`的MySQL/Redis/Kafka地址已经从旧VM改成新集群地址,代码里也新增了专门处理PXC兼容性问题(命名锁、自增ID越界)的分支——这是今天新发现的几个MySQL相关问题(尤其是周期性的驱动层连接损坏)很可能与之相关的重要背景,供后续排查参考。_
|
||||
|
||||
## 目录
|
||||
|
||||
**P0 · 安全漏洞 / 核心功能完全不可用**
|
||||
|
||||
暂无。
|
||||
|
||||
**P1 · 数据完整性 / 权限失效 / 主要功能缺陷**
|
||||
|
||||
| 编号 | 接口 | 一句话 |
|
||||
|---|---|---|
|
||||
| [问题36](#问题36) | `POST /messages/forward` | 并发转发下响应说"失败"其实已经真实写入——2026-09-14用干净的12并发复测,仍有2/3的"失败"响应对应真实落库的消息,最近一次代码改动(`926b4bb`)改善了部分逻辑但未解决根本问题 |
|
||||
| [新增80](#新增80) | Bearer Token鉴权(影响所有需要登录态的接口) | 同一个token并发请求,20-45%概率随机返回401 AUTH_REQUIRED,鉴权层把"token真无效"和"Redis/DB瞬时报错"统一处理成同一个错误码且完全不打日志,无法从现有日志区分具体原因 |
|
||||
| [新增81](#新增81) | `GET /v0/file/s/{id}`(媒体文件服务) | 查询数据库里不存在的列`mtg.recorder_user_id`,25分钟观测窗口内命中这条SQL的请求243次全部失败 |
|
||||
|
||||
**P2 · 数据一致性 / 体验缺口 / 校验缺失**
|
||||
|
||||
| 编号 | 接口 | 一句话 |
|
||||
|---|---|---|
|
||||
| [问题25](#问题25) | `POST /messages/forward`、`/favorites/forward` | 带附言转发时 `head` 声明类型与 `content` 实际结构对不上 |
|
||||
| [问题27](#问题27) | `GET /reports/daily` | `status` 参数不做取值校验,传任何值都返回200+空列表 |
|
||||
| [问题33](#问题33) | `GET /reports/daily` | `permissions` 四个字段恒为同一组硬编码值,与用户/参数无关 |
|
||||
| [问题35](#问题35) | `GET /users/{id}/profile` | 设了备注之后拿不到对方的原昵称(`GET /friends`一侧已修复) |
|
||||
| [新增3](#新增3) | `PATCH /conversations/{topic}/settings` | 单聊置顶没有批量查询接口,会话列表永远看不出置顶效果 |
|
||||
| [新增8](#新增8) | `GET /friends` | 完全不感知拉黑关系,与另外两个接口表现不一致 |
|
||||
| [新增15](#新增15) | `POST /meetings/{id}/cancel` | 完全不校验 `version`,乐观锁摆设 |
|
||||
| [新增24](#新增24) | `GET /groups/{topic}`、`/settings` | 群成员数存在两套互相独立、可永久失配的计数器 |
|
||||
| [新增25](#新增25) | 原生WS `{pub}` | link类型消息协议层可构造,但没有真实用户可从UI触达的发送路径 |
|
||||
| [新增30](#新增30) | `GET /settings/youth-mode` | 开启后不对任何功能做实际限制,`enforced` 恒为硬编码false(前端已移除此功能,仅保留记录完整性) |
|
||||
| [新增31](#新增31) | `POST /reports/abuse` | 不校验 `requires_description`,可对必填原因提交空描述 |
|
||||
| [新增34](#新增34) | `POST /tenant-applications` | 统一社会信用代码无去重机制,可无限重复提交 |
|
||||
| [新增41](#新增41) | `POST /groups/{topic}/mutes` | 无角色层级保护,管理员可禁言群主、任何角色可自我禁言 |
|
||||
| [新增47](#新增47) | `POST /v0/file/u/` | 真实文件上传上限是32MB而非声明的100MB,且从未用于前端预校验 |
|
||||
| [新增49](#新增49) | `POST /reports/daily`、`/monthly` | 创建接口对日期只校验格式不校验范围,年份0000/2099均可创建 |
|
||||
| [新增51](#新增51) | `POST /meetings` | 不校验 `attendee_user_ids` 是否真实存在,可创建任意规模"幽灵参会人" |
|
||||
| [新增54](#新增54) | 日报/月报SUBMIT | 不校验必填内容,可提交完全空白报告 |
|
||||
| [新增56](#新增56) | `POST /settings/youth-mode/enable` | `policy_version` 不做版本校验,传任意值即被无条件接受(前端已移除此功能) |
|
||||
| [新增76](#新增76) | `GET /app/releases/latest?platform=web` | `platform` 只接受ios/android,web端完全不支持检查更新 |
|
||||
| [新增77](#新增77) | `GET /groups/{topic}/announcements/latest` | 响应没有已读人数字段,"已读N/M"这个设计要求的功能无法实现 |
|
||||
| [新增78](#新增78) | `GET /topics/{topic}/messages/search`、收藏/通话记录的相关字段 | 发送者/对方user_id被裁掉`usr`前缀,或返回内部hash而非可寻址id |
|
||||
| [新增82](#新增82) | `cache group settings`(内部缓存,影响群相关读接口的性能与正确性) | 群设置缓存反序列化失败,高频出现(242次/25分钟),覆盖大量不同群 |
|
||||
| [新增83](#新增83) | `media serve`(文件服务) | 死锁(1213/40001)没有捕获重试,命中的请求直接失败 |
|
||||
| [新增84](#新增84) | 内部后台任务"media gc" | 周期性(约每50秒一次)触发MySQL驱动层连接损坏("busy buffer"/协议序号错乱),与PXC迁移的时间点吻合,怀疑相关 |
|
||||
| [新增85](#新增85) | `GET /groups/{topic}/members?role=admin` 等按查询参数区分的缓存 | 写操作(如提升管理员)后,特定查询参数组合的结果有90秒以上的缓存滞后 |
|
||||
| [新增86](#新增86) | `GET /calls` | 响应字段结构与协议文档完全不同(无`peer`对象、无`incoming`布尔值、时间戳拆成4个字段、`topic`是内部hash) |
|
||||
| [新增87](#新增87) | 群二维码 `POST /groups/{topic}/qrcodes` vs `POST /qrcodes/resolve` | 铸造接口用`group_snapshot`字段名,解析接口对同样的数据用`group`字段名,两个接口不一致;解析接口另有一个协议文档完全没提过的`status`字段 |
|
||||
| [新增88](#新增88) | `GET /topics/{topic}/messages/search` | 部分明显应该命中的关键词搜索返回空结果(搜索召回不稳定,2026-09-14复测比更早的记录有改善但未完全解决) |
|
||||
| [新增89](#新增89) | `im_file_review_tasks` / 养成动态图片审核 | IM 仅创建文件审核任务,没有执行 Worker,所有带图动态都会永久停在 `reviewing/pending` |
|
||||
| (无编号,功能缺口非bug) | 无 | 整份协议文档没有"移出/踢出群成员"这个操作,`03-group.dc.html`里"移出"按钮因此没有对应接口可用——确认是协议本身没设计这个功能,不是遗漏 |
|
||||
| (无编号,可能是设计而非缺口) | 无 | 无手机号查询/换绑接口、无登录设备列表接口;`POST /security/challenges`这个"敏感操作二次验证"机制目前只服务青少年模式两个场景,没有覆盖改密码等场景 |
|
||||
|
||||
---
|
||||
|
||||
## 本次复核确认已修复(不再是问题,供归档参考)
|
||||
|
||||
以下条目在2026-09-14用真实账号实测或直接读当前代码确认已经修复,从清单中移除:
|
||||
|
||||
- **问题22**(无登出/Token撤销接口):**已修复**。`POST /v1/im/auth/logout`现在是真实可用的接口(之前误判404的路径是没有走对,正确路径是这个)。实测:新token连续3次200 → 调用logout返回200 → 同一token之后5次全部401,确认真实吊销,不是假成功。
|
||||
- **问题28**(`purpose=user_avatar`被拒):**已修复**。实测提交真实PNG文件,200成功。
|
||||
- **新增16**(总经理信箱超长内容返回500):**已修复**。代码确认`serveIMExecutiveMailboxSubmissions`现在显式捕获`ErrPolicy`并返回400 VALIDATION_FAILED,不再落到default的500分支。
|
||||
- **新增17**(`writeIMRelationError`把`ErrNotFound`错误映射成`MALFORMED_REQUEST`):**已修复**。代码确认现在`ErrNotFound`正确映射到404+`RESOURCE_NOT_FOUND`,reason与message一致。
|
||||
- **新增27**(工作台`unread_count`恒为0):**已修复**(2026-09-10记录"只接入bootstrap,workbench本身还没接",现在确认`serveIMWorkbench`内部也调用了`populateWorkbenchModuleUnreadCounts`)。注:目前只有"会议"模块会计算真实值,其余模块代码上仍是原始SQL的0值,但这正好是原始bug报告实测覆盖的场景,已修复。
|
||||
- **新增42**(群公告按id查询恒定返回最新公告):**已修复**。代码确认现在正确解析路径里的`announcement_id`并调用`GetAnnouncement`按id查询,不再无视这个参数。
|
||||
- **新增43**(群文件登记接口无长度/存在性校验):**已修复**。代码确认现在对`file_name`(255)/`mime_type`(128)做长度校验,对`file_size`做负数校验,且会调用`store.Files.Get`验证`file_id`真实存在。
|
||||
- **新增45**(收藏/搜索/宫格分页off-by-one永久丢失边界记录):**已修复**。实测:24条真实收藏,用limit=10分3页拉取(10+10+4),合并后24条一条不少、无重复。
|
||||
- **问题30**(系统通知`summary`/`action`结构不一致 + 会议响应事件双写):**已修复/已失效**(2026-10-09复核)。①会议响应双写随上游`0e2f4e7`(2026-09-17把整个workbench/会议模块从im_server移除)消失,现仅剩Kafka单路径写入且按固定event_id幂等upsert,不再适用;②`action`由读路径`normalizeSystemNotificationAction`统一归一为`{type,target_id,payload,...}`;③空`summary`读路径回退`title`;④机器码`summary`(`accepted`/`rejected`/`continue`/`cancel`)已在4处写入点改为人类可读文案(`serveIMFriendRequestAction`/`serveIMRiskChallengeConfirm`/`TenantFriendRequestAccept`/`TenantFriendRequestResolve`)。实测:A对B发好友申请、B拒绝后,A的通知列表返回`summary:"对方已婉拒你的好友申请"`、`action`为归一化结构。
|
||||
- **新增32**(系统通知重复已读,响应回显请求值但存储不变):**已修复**(2026-10-09复核)。存储层`TenantSystemNotificationMarkRead`在`COALESCE`更新后回读真实行返回,handler用返回值构造响应(单测`TestServeIMSystemNotificationsListAndRead`覆盖)。实测:同一通知先以`read_at=T1`标记已读、再以`T2`重复标记,两次响应均返回持久化的`T1`,不再回显本次提交值。
|
||||
|
||||
## 本次复核确认仍是历史误判(从未是真正的后端bug,不应再作为后端问题提出)
|
||||
|
||||
以下条目此前曾被记录为"疑似后端bug",后来(多为9月上中旬的复核)证实其实是前端自己调用错了接口/传错了参数,backend行为一直是对的——**不要把这几条再当成后端问题反馈**:
|
||||
|
||||
- `GET .../groups/{topic}/members/{user_id}/profile` 查自己恒定400——真相:有专门的`members/me/profile`接口可以正确查询,前端读操作一直在用错误的通用by-id接口,写操作反而一直用对了。已切换到正确接口,问题消失。
|
||||
- `POST .../groups/{topic}/mutes/batch` 恒定405——真相:真实的批量禁言接口是`/mutes`(不带`/batch`后缀),前端一直在调一个不存在的路径。已改用正确路径。
|
||||
- `GET /users/{id}/profile` 对好友`usrW9ijv236hHA`恒定403——真相:响应体里其实带着`message:"profile is private"`,是对方自己设置了资料保护,属于设计内行为,不是bug。
|
||||
- `PATCH .../groups/{topic}/member-settings`(置顶/免打扰)恒定400——真相:这个接口的请求体结构里根本没有`version`字段,后端解码器对未声明字段严格拒绝;前端一直在照抄别的接口的乐观锁写法多传了一个`version`字段。已去掉,问题消失。
|
||||
- 群设置`governance`字段"被后端写死成默认值"——这是一次静态代码审查的猜测,当场用真实接口交叉验证后证明是错的:`/settings`和`/governance`返回一致,写入后能正确反映在读接口上。从未真正成立过。
|
||||
|
||||
---
|
||||
|
||||
<a id="问题36"></a>
|
||||
## 问题 36(P1,2026-09-14复测:仍未修复,最近一次代码改动效果有限):并发转发到同一个会话时,响应说"失败"的条目里相当一部分其实已经成功写入,重试会造成真实内容重复
|
||||
|
||||
**证据强度**:✅ 实测确认(多轮独立复现,含2026-09-14最新一轮干净复测)+代码确认
|
||||
|
||||
**2026-09-14复测方法与结果**:`git pull`确认`server/kafka_message_persist.go`在9月11日有过一次专门的修复提交(`926b4bb fix im forwarding and message list regressions`),代码上看这次改动新增了`Pending`状态(区分"确定成功"/"确定失败"/"不确定,可能已经发出"三种情况,而不是原来的非成功即失败)。但不能只信代码改动就下结论,实际用真实群(`grpDclMebv3MuI`)、真实消息(seq 135)做了一次干净的12并发转发测试(每个请求用独立的幂等键,模拟"用户看到失败提示后重新点击"的真实场景):
|
||||
- 12个请求里7个报告"sent"成功(seq 136/137/138/139/140/142/143),3个报告"failed"(`INTERNAL_ERROR`),2个命中已知的401鉴权问题(新增80,未到达转发逻辑)。
|
||||
- 测试前群里搜索"测试"能查到的最大seq是135;测试后变成130-144连续9个新seq(136-144)。
|
||||
- **9个真实新消息 vs 7个确认成功——多出来的136-144里的141和144,对应的正是那3条报"failed"里的2条,它们实际上真的写入成功了,只是响应告诉客户端"失败"。**
|
||||
|
||||
**结论**:本质问题没有解决——响应仍然不可信,"failed"不能被客户端安全地当作"真的没发生"来处理,重试依然会造成真实重复。9月11日的那次改动可能改善了部分场景(比如更细粒度的pending状态跟踪),但没有堵住这个具体的漏洞。
|
||||
|
||||
**根因**(此前已逐行核对,本次未见改变):开启Kafka时,`saveMessageMaybeKafka`先原子预留seq,再异步把消息发布到Kafka——如果produce调用本身报错(例如含糊的ack超时),HTTP handler判定失败并返回给客户端,但已预留的seq和可能已经在网络层提交成功、只是没收到ack的Kafka消息都回滚不了,只要Kafka消费者最终异步写入,"失败"响应背后就会真实落库。
|
||||
|
||||
**接口**:`POST /v1/im/messages/forward`
|
||||
|
||||
**前端现状**:批量转发的失败提示照原样展示后端返回的`status`/`reason`,无法感知也无法规避"响应可能撒谎"这个问题,需要后端修正响应准确性(例如:明确区分"确定失败可安全重试"和"结果不确定,建议先检查再决定是否重试"两种状态,而不是都归为同一种"failed")。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增80"></a>
|
||||
## 新增 80(P1,2026-09-14新发现):Bearer Token鉴权在并发请求下随机出现401,且无法从日志定位具体原因
|
||||
|
||||
**证据强度**:✅ 实测确认(自己独立复现,两个不同接口各自独立测试)+代码确认(完整调用链,含具体文件行号)
|
||||
|
||||
**现象**:用同一个有效token对`GET /v1/im/groups/{topic}`或`GET /v1/im/me`连续发起20个并发请求,大约15%-65%会随机返回`401 {"code":30043,"reason":"AUTH_REQUIRED"}`,比例不稳定(同一token连续测两轮,一轮5/20失败,几秒后另一轮13/20失败)。对照组:同样并发打`POST /v1/im/tenants/resolve`(只需要API Key,不需要bearer token)20/20全部成功,证明问题只出在bearer token鉴权这一层,不是网络或网关层面的问题。
|
||||
|
||||
**排除的可能性**:进入当前所有正在运行的3个`im-server` Pod内部,对`AUTH_TOKEN_KEY`/`MYSQL_DSN`/`REDIS_ADDR`/`REDIS_PASSWORD`/`TENANT_REGISTRY_DB`/`API_KEY_SALT`/`UID_ENCRYPTION_KEY`共7个关键配置项,在每个Pod自己进程内部单独计算哈希值做比对(不在外部解密Secret),3个Pod完全一致——排除"多实例配置不一致"这个最初怀疑的方向;3个Pod是同一批次创建、同一个ReplicaSet,没有游离的旧实例。
|
||||
|
||||
**根因(读当前`im_be`源码确认,文件行号)**:`GET /v1/im/groups/{topic}`等接口的鉴权链条是 `authenticateIMHTTPBearer`(`server/im_http.go:7093`) → `resolveIMHTTPBearerToken`(`server/im_http.go:7111`) → `(*authenticator).Authenticate`(`server/auth/token/auth_token.go:103`)。这个函数内部,token签名错误、过期、`serial_num`不匹配、Redis撤销状态查询报错(`IsLoginTokenRevoked`)、按租户路由的DB鉴权版本号查询报错(`GetAuthVersion`/`TenantAdapterFor`)——**所有这些情况的错误全部被统一转换成同一个`types.ErrPermissionDenied`**,最终一律报401,且**这条失败路径上完全没有任何日志输出**,导致即使线上真实发生了这类失败,事后也无法从日志区分具体是哪一种原因触发的。
|
||||
|
||||
其中`GetAuthVersion`背后的`TenantAdapterFor`(`server/db/mysql/adapter.go:469`)使用**每个进程自己独立维护的懒加载租户连接缓存**,某个租户如果还没被当前进程缓存过,一波并发请求会各自独立地去开一条新数据库连接并`Ping()`,这种"并发抢着建连接"的场景比"某个pod配置固定错误"更能解释"20个并发里随机几个失败、且失败比例还会波动"这个现象。
|
||||
|
||||
**支持证据**:查了同一时间窗口的k8s事件,3个pod中有2个在过去1小时内真实触发过健康检查失败(`Unhealthy: context deadline exceeded`,探测超时只给了1秒);同时机器上2/3节点内存占用在69%-81%(这是个多个服务共享的测试集群)。另外,MySQL的`aborted_connects`计数器非零(13),跟"存在瞬时连接失败"这个方向一致,但不是决定性证据。
|
||||
|
||||
**重要背景**:这次复核时发现后端仓库刚经历一次数据库基础设施迁移——`server/meetingim.conf`/`tinode.conf`里的MySQL/Redis/Kafka地址从旧VM换成了新k3s集群的PXC(Percona XtraDB Cluster)地址,且代码里新增了`isPXCStrictModeNamedLockError`/`isIDOutOfRange`两个专门处理PXC兼容性问题的错误分支,说明这个迁移目前还在持续排查兼容性问题的阶段。这个401问题的时间点与这次迁移吻合,虽然鉴权代码本身没有直接调用命名锁,但不排除是同一次基础设施变动带来的连锁不稳定,建议后端一并排查。
|
||||
|
||||
**接口**:影响所有需要bearer token鉴权的接口(穷举测试了`GET /v1/im/groups/{topic}`、`GET /v1/im/me`两个,机制上应该影响全部同类接口)
|
||||
|
||||
**给后端的建议**:(1)在这条失败路径上补充日志,区分"token确实无效"和"内部依赖(Redis/DB)报错"两种情况,不要两者都报同一个401;(2)审视`TenantAdapterFor`懒加载连接的并发安全性,避免同一租户的并发请求各自重复建连接。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增81"></a>
|
||||
## 新增 81(P1,2026-09-14新发现):文件服务查询数据库里不存在的列,几乎每次命中都失败
|
||||
|
||||
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计243次)
|
||||
|
||||
**现象**:`media serve`(文件服务,`/v0/file/s/{id}`)报错`Error 1054 (42S22): Unknown column 'mtg.recorder_user_id' in 'where clause'`,25分钟的观测窗口里3个pod合计出现243次,是这次复核发现的所有问题里频率最高的一个——几乎所有命中这条SQL路径的真实请求都会失败。
|
||||
|
||||
**推测**:列名前缀`mtg`大概率是"会议(meeting)"相关表,可能是代码引用了一个数据库里还不存在的列(缺一次迁移),也可能是列名本身写错了。不确定具体原因,需要后端自己确认。
|
||||
|
||||
**接口**:`GET /v0/file/s/{id}`(原生Tinode大文件下载,命中会议关联文件时)
|
||||
|
||||
**基础设施侧的态度**:这类"代码查询数据库结构对不上"的问题,本身可能是缺一次迁移,但我们不建议自己去手动改数据库列(不清楚正确的类型/默认值/是否有对应的后端代码逻辑依赖这个列的具体语义),必须由后端确认。
|
||||
|
||||
---
|
||||
|
||||
<a id="问题25"></a>
|
||||
## 问题 25(P2,最常见路径已修故降级):带附言转发/收藏转发时,`head` 声明的类型与 `content` 的实际结构对不上
|
||||
|
||||
**证据强度**:✅ 实测确认(四条路径全部真实构造验证)
|
||||
|
||||
**现象**:逐条转发带附言、收藏转发带附言两条路径,`head`仍声明原消息类型(如image/text),但`content`换成`{forwarded,comment,snapshot}`等包装对象,客户端按head类型解析content会得到空白/无效内容。逐条转发不带附言这条最常见路径已经修好(content原样透传)。
|
||||
|
||||
**期望**:附言作为独立的一条文本消息发送(对接文档本来就是这么描述的),转发的那条消息仍然原样透传原content。
|
||||
|
||||
**接口**:`POST /v1/im/messages/forward`;`POST /v1/im/favorites/forward`
|
||||
|
||||
---
|
||||
|
||||
<a id="问题27"></a>
|
||||
## 问题 27(P2):`status` 参数不做取值校验,传任何值都返回200+空列表
|
||||
|
||||
**证据强度**:✅ 实测确认(2026-09-14重测,传`status=totally_bogus_value_xyz`依然200+`items:[]`,未变)
|
||||
|
||||
**现象**:传完全不存在的status值也返回200,客户端无法区分"确实没数据"和"根本不支持"。
|
||||
|
||||
**接口**:`GET /v1/im/reports/daily?status=`(月报同构)
|
||||
|
||||
---
|
||||
|
||||
<a id="问题33"></a>
|
||||
## 问题 33(P2):`permissions` 的四个字段在不同参数下恒为同一组值
|
||||
|
||||
**证据强度**:✅ 代码确认(2026-09-14重读源码,确认仍是硬编码map字面量,行号已随文件改动漂移到2614-2619行左右)
|
||||
|
||||
**现象**:`can_create`/`can_remind_unsubmitted`/`can_view_managed`/`makeup_allowed_days`换任何用户、scope、日期区间组合都恒为同一组值,因为响应体里这部分是Go代码写死的map字面量,完全不引用`principal`/`reports`/任何请求参数。
|
||||
|
||||
**接口**:`GET /v1/im/reports/daily`
|
||||
|
||||
---
|
||||
|
||||
<a id="问题35"></a>
|
||||
## 问题 35(P2,`GET /friends`一侧已修复,范围收窄到`GET /users/{id}/profile`):设了备注之后,好友主页详情接口拿不到对方的原昵称
|
||||
|
||||
**证据强度**:✅ 实测确认(2026-09-14再次复核,问题依然存在)
|
||||
|
||||
**现象**:`GET /v1/im/friends`响应里带的嵌套`peer`对象(`peer.display_name`)能正确带对方的真实原始昵称,这一侧的诉求已经满足。但`GET /v1/im/users/{id}/profile`依然没有修:既没有remark字段,也没有独立的原昵称字段——保存备注后本地乐观更新正确显示新备注,但一旦触发这个详情接口的背景刷新重新拉取,会因为接口不带remark字段而把显示"冲"回"未设置",数据没有真的丢,只是这个详情页的显示会"看起来复原了"。
|
||||
|
||||
**接口**:`GET /v1/im/users/{id}/profile`(仍缺);对照已修复的`GET /v1/im/friends`
|
||||
|
||||
**期望**:`GET /users/{id}/profile`比照`GET /friends`已经落地的`peer`对象模式,补上对方的真实原始昵称字段。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增3"></a>
|
||||
## 新增 3(P2):单聊的"置顶聊天"没有任何办法让会话列表知道,因为没有批量查询会话设置的接口
|
||||
|
||||
**证据强度**:✅ 实测确认(对照群聊置顶排除法)
|
||||
|
||||
**现象**:群聊置顶写入订阅原生private字段能随meta同步下发;单聊置顶写入独立表且不写private字段,只有单条查询接口无批量接口,前端无法为几十上百个会话逐一查询,设置生效但用户看不出效果。
|
||||
|
||||
**接口**:`PATCH /v1/im/conversations/{topic}/settings`(写入);缺失批量查询接口
|
||||
|
||||
---
|
||||
|
||||
<a id="新增8"></a>
|
||||
## 新增 8(P2):`GET /friends` 完全不感知拉黑关系,与另外两个接口表现不一致
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认
|
||||
|
||||
**现象**:拉黑后该好友仍显示`relationship:"friend"`且权限位全开,与`GET /conversations/{topic}/context`(正确显示`blocked_by_me`)、`GET /users/{id}/profile`(裸403)三者互不一致。
|
||||
|
||||
**根因**:`friendListItemPayload`及数据源`types.FriendListItem`从结构体到SQL全程未涉及黑名单表。
|
||||
|
||||
**接口**:`GET /v1/im/friends`;对照`GET /v1/im/users/{id}/profile`、`GET /v1/im/conversations/{topic}/context`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增15"></a>
|
||||
## 新增 15(P2):`POST /meetings/{id}/cancel` 完全不校验 `version`
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码,`serveIMMeetingCancel`依然只解析`Version int`但从未在调用`store.Meetings.Cancel`时传入这个字段)
|
||||
|
||||
**现象**:故意传`version:999`也直接成功,与同资源my-response接口(版本校验正常409)形成对照——乐观锁形同虚设。
|
||||
|
||||
**接口**:`POST /v1/im/meetings/{id}/cancel`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增24"></a>
|
||||
## 新增 24(P2):群成员数存在两套互相独立、可永久失配的计数器
|
||||
|
||||
**证据强度**:✅ 实测确认(3个真实群交叉对照)+代码确认(2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:同一群同一时刻,`GET /groups/{topic}`与`/settings`显示的`member_count`,跟真实成员列表、qrcodes的`group_snapshot`可能不一致。
|
||||
|
||||
**根因**:业务层`im_group_settings.member_count`(只看`im_group_members`)与Tinode原生层`im_topics.subcnt`(只看`im_subscriptions`)是两套独立计数器,`mergeGroupSettingsPayload`(`server/im_http.go:8012`)无条件用业务层值覆盖了本来准确的subcnt值。
|
||||
|
||||
**接口**:`GET /v1/im/groups/{topic}`;`/settings`;`POST /groups/{topic}/qrcodes`;`GET /groups/{topic}/members`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增25"></a>
|
||||
## 新增 25(P2,产品需求缺口):link类型消息——协议层面可构造,但没有任何真实用户可从webapp UI触达的路径
|
||||
|
||||
**证据强度**:✅ 实测确认(正反两方向均构造真实数据验证)
|
||||
|
||||
**现象**:WS`{pub}`可直接构造出`content:{url:...}`扁平结构被判定为link类型(可收藏可搜索),但`im_webapp`所有正常发送路径产出的content都不是这个形状,"我的收藏-链接"等tab对真实用户永远是空的。
|
||||
|
||||
**接口**:WS `{pub}`(消息发送),无特定REST路径
|
||||
|
||||
---
|
||||
|
||||
<a id="新增30"></a>
|
||||
## 新增 30(P2):青少年模式开启后不对任何功能做实际限制,`enforced` 恒为硬编码false
|
||||
|
||||
**证据强度**:✅ 实测确认(2026-09-14重新实测,未变)+代码确认
|
||||
|
||||
**现象**:restriction_items三项(深夜免打扰/内容过滤/支付守护)的`enforced`恒为硬编码false,全仓库穷举确认没有任何消息发送/好友添加/群聊/支付相关接口会查询用户的青少年模式状态。
|
||||
|
||||
**接口**:`GET /v1/im/settings/youth-mode`
|
||||
|
||||
**⚠️ 注**:`im_webapp`已明确移除青少年模式这个前端功能(用户产品决策,非技术限制),这条对当前`im_webapp`已无实际影响,仅为完整性保留在清单里。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增31"></a>
|
||||
## 新增 31(P2):`POST /reports/abuse` 不校验 `requires_description`,可以对必填原因提交空描述
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:某原因码(如illegal/other)的`requires_description`为true,但提交该reason_code、description为空依然200成功——代码里只校验了`description`长度上限(500),从未检查对应`reason_code`是否要求必填。
|
||||
|
||||
**接口**:`POST /v1/im/reports/abuse`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增34"></a>
|
||||
## 新增 34(P2):企业注册申请接口对同一个统一社会信用代码没有任何去重/防重复提交机制
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:同一信用代码用三个不同幂等键连续提交三次,全部独立成功建单,与需求文档"应防止重复提交"矛盾。代码上`serveIMTenantApplications`直接调用`CreateTenantApplication`,没有先查询该信用代码是否已有pending申请。
|
||||
|
||||
**接口**:`POST /v1/im/tenant-applications`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增41"></a>
|
||||
## 新增 41(P2):群禁言接口没有任何角色层级保护——管理员可以禁言群主、任何角色都能自我禁言
|
||||
|
||||
**证据强度**:✅ 实测确认(代码逐行核对根因,2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:GET成员列表明确标注群主`eligible:false`/`ineligible_reason:"owner"`,但`POST /groups/{topic}/mutes`对群主本人自我禁言、以及管理员禁言群主均返回200成功——代码只检查了操作者本人是否是管理员,从未检查目标成员的角色。
|
||||
|
||||
**接口**:`POST /groups/{topic}/mutes`;对照`GET /groups/{topic}/members?mute_eligible=true`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增47"></a>
|
||||
## 新增 47(P2):真实文件上传大小上限是32MB,不是仓库配置声明的100MB,且这个真实上限从未被前端用来做任何提前校验
|
||||
|
||||
**证据强度**:✅ 实测确认(2026-09-14重测`GET /bootstrap`,`max_file_size`依然是33554432即32MB,与之前记录一致)
|
||||
|
||||
**现象**:`GET /v1/im/bootstrap`声明的`data.limits.max_file_size`是33554432(32MB),但代码仓库`tinode.conf`/`meetingim.conf`里配置的是104857600(100MB)——声明值和真实生效值不一致,且这个字段从未被前端读取用于上传前的预校验。
|
||||
|
||||
**接口**:`POST /v0/file/u/`(Tinode原生大文件上传);`GET /v1/im/bootstrap`(`data.limits.max_file_size`)
|
||||
|
||||
**⚠️ 说明**:此前记录曾提到仓库配置文件已经改成一致的104857600,但线上实测一直是32MB——这次复核确认线上依然是32MB,怀疑部署环境的实际配置和仓库里能看到的配置文件不是同一份,或者中间有额外的网关/环境变量覆盖,建议后端确认线上部署实际生效的配置来源。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增49"></a>
|
||||
## 新增 49(P2):日报/月报创建接口对日期只校验格式不校验范围,年份0000、2099均可创建并成功提交
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:`report_date:"2099-12-31"`、`"0000-01-01"`均能创建、保存草稿、提交成功走完整流程。`parseIMDate`只用`time.ParseInLocation("2006-01-02",...)`校验格式,没有任何年份范围校验。
|
||||
|
||||
**接口**:`POST /v1/im/reports/daily`(report_date)、`POST /v1/im/reports/monthly`(month)及对应的`/submit`接口
|
||||
|
||||
---
|
||||
|
||||
<a id="新增51"></a>
|
||||
## 新增 51(P2):会议创建接口不校验 `attendee_user_ids` 对应的用户是否真实存在,可创建任意规模的"幽灵参会人"
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:用50个语法合法但真实不存在的user_id+1个真实好友,一次性创建52人会议成功。`parseIMUserIDList`只做格式校验(`types.ParseUserId`)和去重,从未查询数据库确认这些用户真实存在。
|
||||
|
||||
**接口**:`POST /v1/im/meetings`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增54"></a>
|
||||
## 新增 54(P2):日报/月报SUBMIT接口不校验必填内容,可提交完全空白的报告,与响应自身声明的 `validation.*_required:true` 矛盾
|
||||
|
||||
**证据强度**:✅ 实测确认(现象)+代码确认(2026-09-14找到确切根因:`TenantDailyReportSubmit`的UPDATE语句只匹配`report_id`/`author_id`/`version`,完全不检查`work_summary`/`next_plan`等内容字段)
|
||||
|
||||
**现象**:`work_summary`/`next_plan`均为空字符串的月报草稿直接调用submit,返回200且`status:"submitted"`,同时响应体自己声明这两个字段`validation.*_required:true`。
|
||||
|
||||
**接口**:`POST /v1/im/reports/daily/{id}/submit`、`POST /v1/im/reports/monthly/{id}/submit`
|
||||
|
||||
---
|
||||
|
||||
<a id="新增56"></a>
|
||||
## 新增 56(P2):青少年模式启用接口 `policy_version` 不做版本校验,传任意正整数即被无条件接受并永久覆盖
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
|
||||
|
||||
**现象**:代码只检查`body.PolicyVersion <= 0`(必须是正数),从未跟服务端已知的当前真实policy_version做比对,传任意大的正整数都会被接受并覆盖存储值。
|
||||
|
||||
**接口**:`POST /v1/im/settings/youth-mode/enable`
|
||||
|
||||
**⚠️ 注**:同新增30,`im_webapp`已移除此功能,仅为完整性保留记录。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增76"></a>
|
||||
## 新增 76(P2):`GET /app/releases/latest` 的 `platform` 参数只接受 `ios`/`android`,web端完全不支持检查更新
|
||||
|
||||
**证据强度**:✅ 代码确认(2026-09-14重读当前HEAD确认未变)
|
||||
|
||||
**现象**:`GET /v1/im/app/releases/latest?platform=web`恒定`400 MALFORMED_REQUEST {"details":{"field":"platform"}}`。
|
||||
|
||||
**接口**:`GET /v1/im/app/releases/latest`
|
||||
|
||||
**前端现状**:`AboutPage.tsx`捕获这个错误后显示"暂不支持检查更新",不会一直转圈或显示假的"已是最新版本"。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增77"></a>
|
||||
## 新增 77(P2):群公告接口响应里没有已读人数字段,设计要求的"已读N/M"功能无法实现
|
||||
|
||||
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读`groupAnnouncementPayload`/`groupAnnouncementPayloadWithPermissions`确认未变)
|
||||
|
||||
**现象**:`GET /groups/{topic}/announcements/latest`真实响应确认没有任何"已读数"/"已读人数"相关字段,设计稿明确要求的"已读18/28"这类展示前端拿不到数据来源。
|
||||
|
||||
**接口**:`GET /v1/im/groups/{topic}/announcements/latest`
|
||||
|
||||
**前端现状**:不展示已读人数(没有编造假数据)。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增78"></a>
|
||||
## 新增 78(P2):消息搜索/收藏/通话记录里,涉及用户/话题id的字段有不一致的裁剪或哈希化处理
|
||||
|
||||
**证据强度**:✅ 实测确认(三处独立复现)
|
||||
|
||||
**现象**:
|
||||
- `GET /topics/{topic}/messages/search`的`items[].from`、收藏`type:"text"`的`snapshot.from`:真实user_id被裁掉`usr`前缀(如`usrH5dvZ3ckWxo`变成`H5dvZ3ckWxo`),直接拿这个值和当前用户完整id比较会永远为false。
|
||||
- 收藏的`source.topic`(P2P来源)、通话记录`GET /calls`的`topic`字段:用的是服务端内部`p2p...`哈希形式,不是SDK寻址用的`usr...`形式,客户端没有办法把这个哈希反解回对方user_id。
|
||||
|
||||
**接口**:`GET /v1/im/topics/{topic}/messages/search`;收藏接口的`snapshot.from`/`source.topic`;`GET /v1/im/calls`的`topic`
|
||||
|
||||
**前端现状**:都补了对应的兜底逻辑(`normalizeSender()`补回`usr`前缀;`p2p`前缀的topic降级显示"单聊消息"通用文案),不会崩溃,只是丢失了直接寻址能力。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增82"></a>
|
||||
## 新增 82(P2,2026-09-14新发现):群设置缓存反序列化失败,高频出现,覆盖大量不同群
|
||||
|
||||
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计242次,覆盖十几个不同真实群)
|
||||
|
||||
**现象**:`cache group settings[{topic}]: read failed: Uid.UnmarshalJSON: invalid length`,几乎是常态出现,不是个别数据问题——说明这条缓存读取逻辑本身有系统性bug,大概率是并发访问时缓存里的数据被写坏/读到了不完整的中间状态。其中至少一次复现(`grpDclMebv3MuI`)是被我们自己的并发请求触发的,其余多次是某个后台任务在循环访问一批固定的群时触发的。
|
||||
|
||||
**接口**:内部缓存逻辑,不直接对应某个REST接口,但会影响所有需要读取群设置的接口的正确性
|
||||
|
||||
---
|
||||
|
||||
<a id="新增83"></a>
|
||||
## 新增 83(P2,2026-09-14新发现):`media serve` 死锁没有捕获重试
|
||||
|
||||
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计82次)
|
||||
|
||||
**现象**:`Error 1213 (40001): Deadlock found when trying to get lock; try restarting transaction`——MySQL自己在错误信息里就写明了"try restarting transaction",这是标准的死锁处理方式,但代码里没有捕获这个特定错误码后自动重试,命中死锁的请求直接失败。
|
||||
|
||||
**接口**:`GET /v0/file/s/{id}`(文件服务)
|
||||
|
||||
---
|
||||
|
||||
<a id="新增84"></a>
|
||||
## 新增 84(P2,2026-09-14新发现):内部周期性后台任务"media gc"约每50秒触发一次MySQL驱动层连接损坏
|
||||
|
||||
**证据强度**:✅ 实测确认(真实运行日志,从09:42到10:03连续观察,间隔稳定在46-67秒之间,与是否有外部请求流量无关)
|
||||
|
||||
**现象**:`[mysql] connection.go:215 busy buffer`紧接着`packets.go:68 [warn] unexpected sequence nr: expected 0, got 5`,然后`media gc: N driver: bad connection`——这是MySQL驱动协议层面的连接状态错乱,是同一个进程内部对同一条MySQL连接被并发误用导致的典型症状,不是网络问题。
|
||||
|
||||
**与PXC迁移的关系**:这个问题的出现时间与本次复核发现的数据库迁移(旧VM单机MySQL → 新k3s集群PXC)时间点吻合,怀疑与迁移相关,但没有直接证据(比如没有看到这个后台任务代码里调用了新增的PXC兼容分支),仅供后端排查参考。
|
||||
|
||||
**接口**:内部后台任务,不直接对应外部接口,但可能影响同一时间段内其它数据库操作的稳定性
|
||||
|
||||
---
|
||||
|
||||
<a id="新增85"></a>
|
||||
## 新增 85(P2,历史遗留,曾用编号"新增7"记录在`docs/IM-后端问题清单-2026-09-04.md`但未被带入本清单,本次复核重新收录):按查询参数区分的缓存,写操作后特定参数组合的结果有明显滞后
|
||||
|
||||
**证据强度**:✅ 实测确认(提升管理员后90秒以上仍查不到);⚠️2026-09-14本次复核未重新实测(判断优先级较低,仅确认历史记录未遗漏)
|
||||
|
||||
**现象**:`POST /groups/{topic}/admins/batch`提升成功(version从1到2),但`GET /groups/{topic}/members?role=admin`反复查询超过90秒仍然是`items:[]`——用直连接口的方式排除了前端自己的客户端缓存,是服务端按`role=admin`这个具体参数组合单独缓存了旧的空结果,写操作没有使它失效。
|
||||
|
||||
**接口**:`GET /v1/im/groups/{topic}/members?role=admin`(及其它类似的按查询参数缓存的接口)
|
||||
|
||||
---
|
||||
|
||||
<a id="新增86"></a>
|
||||
## 新增 86(P2):`GET /calls` 响应字段结构与协议文档完全不同
|
||||
|
||||
**证据强度**:✅ 实测确认(真实响应逐字段核对)
|
||||
|
||||
**现象**:协议文档描述的字段形状和真实响应完全对不上——没有`peer`对象,只有扁平的`peer_user_id`(真的是`usr...`形式);没有`incoming`布尔值,而是`direction:"incoming"|"outgoing"`字符串;时间戳拆成了`created_at/started_at/finished_at/updated_at`四个字段,没有单一的`ts`;`topic`字段是`p2p...`哈希形式(同新增78的毛病)。
|
||||
|
||||
**接口**:`GET /v1/im/calls`
|
||||
|
||||
**前端现状**:已按实测字段改正类型声明和渲染逻辑。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增87"></a>
|
||||
## 新增 87(P2):群二维码铸造/解析两个接口对同一份群数据用了不同的字段名,解析接口还多了一个文档没提到的字段
|
||||
|
||||
**证据强度**:✅ 实测确认(真实铸造+解析双向验证)
|
||||
|
||||
**现象**:`POST /groups/{topic}/qrcodes`(铸造)响应把群信息放在`group_snapshot`字段下;`POST /qrcodes/resolve`(解析,`purpose:"join_group"`)响应把同样性质的数据放在`group`字段下——同一份数据两个接口用了不同字段名。另外`resolve`响应还带了一个协议文档完全没提过的`status`字段,扫描"自己已经在的群"时返回`status:"joined"`;"真的还不是成员"这个场景下`status`具体是什么值没有实测到(当时没有第二个测试账号)。
|
||||
|
||||
**接口**:`POST /v1/im/groups/{topic}/qrcodes`;`POST /v1/im/qrcodes/resolve`
|
||||
|
||||
**前端现状**:做了双重兜底读取(同时尝试`group_snapshot`和`group`两个字段名);`status`按"不是joined就走提交申请"的宽松逻辑兜底处理。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增88"></a>
|
||||
## 新增 88(P2,未定论,需要后续继续观察):部分明显应该命中的关键词搜索返回空结果
|
||||
|
||||
**证据强度**:⚠️实测确认现象,但成因未定论
|
||||
|
||||
**现象**:在真实群里搜索"验证"/"链接"等明显应该命中历史消息的关键词,一度返回`items:[]`,同一时间搜索"测试"能正常命中。2026-09-14复测:搜索"验证"这次能正确命中真实消息了,看起来比之前有改善;但"链接"依然返回空——不过这次没能确认这个群里是否真的存在包含"链接"二字的消息,不能算作确凿的回归证据。
|
||||
|
||||
**接口**:`GET /v1/im/topics/{topic}/messages/search`
|
||||
|
||||
**建议**:这条目前证据不够充分下定论(可能是搜索索引/召回策略的问题,也可能是测试数据本身的问题),建议后续有更多测试数据时再复核一次。
|
||||
|
||||
---
|
||||
|
||||
<a id="新增89"></a>
|
||||
## 新增 89(P1,2026-09-24):文件审核任务无 Worker 消费,带图养成动态永久无法发布
|
||||
|
||||
**证据强度**:✅ 测试环境真实日志 + 数据库 + 代码确认
|
||||
|
||||
**现象**:用户发布带图动态后,动态保持 `status=reviewing,audit_status=pending`,`scope=friends`列表只查询 `published` 状态,因此列表返回空。实例动态 `1577072518975639552` 的两条图片审核任务(`aliyun_green` 和 `aliyun_sas`)均为 `pending`。
|
||||
|
||||
**根因**:文件上传时会无条件向 `im_file_review_tasks` 插入内容审核和恶意文件检测任务,但 IM 当前仅有阿里云提交/轮询客户端,没有实现读取、执行和回写这些任务的 Worker。测试库 `meeting_im.im_file_review_tasks` 共有 174 条任务,全部为 `pending`,`attempts=0`。
|
||||
|
||||
**当前临时策略**(2026-09-24 确认):图片审核暂时关闭并默认通过。`cultivate_server` 仍调用 IM 校验文件存在性、归属和上传完成状态,但不再因文件审核的 `pending/risky` 状态阻塞动态发布。文字内容审核保持原有逻辑。
|
||||
|
||||
**待正式解决**:
|
||||
|
||||
1. 在 IM 中实现文件审核 Worker,完成任务抢占、阿里云提交/轮询、重试、超时、失败降级和结果回写。
|
||||
2. 区分内容审核与恶意文件检测是否真正启用,未启用的检测不得创建永久 `pending` 的任务。
|
||||
3. Worker 稳定上线后恢复 `cultivate_server` 的图片审核门禁,并确定现有 174 条历史任务的补偿或废弃策略。
|
||||
@@ -0,0 +1,239 @@
|
||||
# IM 后端问题报告:好友申请通过后 `friend_added` 系统提示被投递到旧命名话题 `usr<uid>`,同一会话存在两套 Topic
|
||||
|
||||
> 报告日期:2026-09-13
|
||||
> 客户端:iOS(MeetingIM Swift SDK)
|
||||
> 涉及账号:`usrv97xq86jw3c`(echarles5,**通过人本人**,本报告取证端)、`usrHnkwn8VSfKc`(charles1,**申请人**)
|
||||
> 涉及话题:`usrHnkwn8VSfKc`(旧标准命名)与 `p2pv97xq86jw3ceeTCfxVJ8pw`(后端内部命名)
|
||||
> 优先级建议:**高**(会话列表消息与未读不更新,用户可见)
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题概述
|
||||
|
||||
好友申请通过后,服务端把一条**会话内系统提示** `system_notice / friend_added`("你们已成为好友,现在可以开始聊天了。")下发到了
|
||||
|
||||
- ❌ `usrHnkwn8VSfKc`(MeetingIM 的 `usr<对端uid>` 旧标准命名,**本端并未订阅该话题**)
|
||||
|
||||
而不是
|
||||
|
||||
- ✅ `p2pv97xq86jw3ceeTCfxVJ8pw`(服务端自己在 `me` 的 `meta sub` 里下发、并在 `friend.request.accepted` 的 `payload.topic` 里给出的**真正会话**)。
|
||||
|
||||
同一时刻服务端还把**同一条逻辑消息**(同 `ts`、同 `seq=17`)写进了 `p2p...` 话题,形成**双写**。
|
||||
|
||||
结果:客户端本地同一好友出现**两条单聊会话**,导致用户可见的两个问题:
|
||||
|
||||
1. 会话行的 `usr<uid>` 那条没有服务端下发的 `acs` → `isJoiner == false` → 客户端在会话行右下角显示出 `nosign`(🚫)禁止图标;
|
||||
2. 客户端会话列表去重逻辑保留 `usr<uid>`、隐藏 `p2p...` → **对方的后续消息与未读数都不更新**(未读还被从总数里扣掉,"消息"Tab 角标偏小)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 时间线(取证端 = 通过人本人 B,2026-09-13)
|
||||
|
||||
| 时间(UTC) | 事件 |
|
||||
| --- | --- |
|
||||
| 06:13:39 | B(echarles5)删除好友 A(charles1),双方关系解除(A 端收到 `friend.deleted`) |
|
||||
| 06:27:09.610 | A 申请、B 通过;服务端下发 `friend_added` 系统提示,**同时写入 `usrHnkwn8VSfKc`(seq 17)与 `p2pv97xq86jw3ceeTCfxVJ8pw`(seq 17)** |
|
||||
| 06:27:09.594 | `GET /v1/im/friends` 返回 A 的会话 `topic = p2pv97xq86jw3ceeTCfxVJ8pw` |
|
||||
| 06:27:10.367 | `POST /v1/im/conversations/p2p {"peer_user_id":"usrHnkwn8VSfKc"}` 返回 `topic = p2pv97xq86jw3ceeTCfxVJ8pw` |
|
||||
| 06:27:11.070 | `me` 的 `meta sub` 只下发 `p2pv97xq86jw3ceeTCfxVJ8pw`(订阅 `acs=JRWPA`、`priv.peer=usrHnkwn8VSfKc`),**没有 `usrHnkwn8VSfKc`** |
|
||||
| 06:27:32.089 | A 发送消息 `a`,落在 `p2pv97xq86jw3ceeTCfxVJ8pw` seq 18 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务端下行报文(原文)
|
||||
|
||||
### 3.1 系统提示被实时投递到旧命名话题(本端未订阅)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"topic": "usrHnkwn8VSfKc",
|
||||
"ts": "2026-09-13T06:27:09.61Z",
|
||||
"seq": 17,
|
||||
"head": {
|
||||
"x-im-from": "system",
|
||||
"x-im-schema": 1,
|
||||
"x-im-template": "friend_added",
|
||||
"x-im-type": "system_notice"
|
||||
},
|
||||
"content": {
|
||||
"actors": [
|
||||
{ "user_id": "usrv97xq86jw3c", "click": { "target_id": "usrv97xq86jw3c", "type": "user_profile" } },
|
||||
{ "user_id": "usrHnkwn8VSfKc", "click": { "target_id": "usrHnkwn8VSfKc", "type": "user_profile" } }
|
||||
],
|
||||
"extra": { "request_id": "frq_OYo72xKkkgU" },
|
||||
"level": "normal",
|
||||
"template": "friend_added",
|
||||
"text": "你们已成为好友,现在可以开始聊天了。"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端侧随即落库并回执(说明客户端把它当作**合法会话消息**处理):
|
||||
|
||||
```text
|
||||
SqlStore - msgReceived:来自话题 payload usrHnkwn8VSfKc,topicId=9,userId=3,seq=17
|
||||
出站[回执(note)] {"note":{"what":"recv","topic":"usrHnkwn8VSfKc","seq":17}}
|
||||
入站[在线状态(pres)] {"pres":{"topic":"me","src":"usrHnkwn8VSfKc","what":"msg","seq":17}}
|
||||
```
|
||||
|
||||
### 3.2 同一条消息也被写进真会话(双写证据)
|
||||
|
||||
随后客户端订阅真会话拉历史,拿到**同 `ts`、同 `seq`** 的同一条提示:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"topic": "p2pv97xq86jw3ceeTCfxVJ8pw",
|
||||
"ts": "2026-09-13T06:27:09.61Z",
|
||||
"seq": 17,
|
||||
"head": { "x-im-from": "system", "x-im-schema": 1, "x-im-template": "friend_added", "x-im-type": "system_notice" },
|
||||
"content": { "template": "friend_added", "text": "你们已成为好友,现在可以开始聊天了。", "...": "与上一条 content 完全一致" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
SqlStore - msgReceived:来自话题 payload p2pv97xq86jw3ceeTCfxVJ8pw,topicId=11,userId=3,seq=17
|
||||
```
|
||||
|
||||
> 同一 `ts` + 同一 `seq=17` 出现在两个 topic ⇒ 服务端对该提示**写了两份**。
|
||||
> 本地 `topicId`:`usrHnkwn8VSfKc = 9`、`p2pv97xq86jw3ceeTCfxVJ8pw = 11` —— 旧命名话题**更早存在**,本轮关系重建后并未被撤销或合并。
|
||||
|
||||
### 3.3 服务端自己只承认 `p2p...`(`me` 的 `meta sub`)
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"id": "115975",
|
||||
"topic": "me",
|
||||
"ts": "2026-09-13T06:27:32.444Z",
|
||||
"sub": [
|
||||
{
|
||||
"updated": "2026-09-13T06:27:11.07Z",
|
||||
"online": true,
|
||||
"acs": { "want": "JRWPA", "given": "JRWPA", "mode": "JRWPA" },
|
||||
"public": {
|
||||
"avatar_file_id": "ZrI4Fut4heE.jpg",
|
||||
"display_name": "charles1",
|
||||
"fn": "charles1",
|
||||
"name": "charles1",
|
||||
"nickname": "charles1",
|
||||
"public_account": "usrHnkwn8VSfKc"
|
||||
},
|
||||
"private": { "created_by": "http", "peer": "usrHnkwn8VSfKc" },
|
||||
"topic": "p2pv97xq86jw3ceeTCfxVJ8pw",
|
||||
"touched": "2026-09-13T06:27:32.089Z",
|
||||
"seq": 18
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `sub` 数组里**只有** `p2pv97xq86jw3ceeTCfxVJ8pw`。服务端却把 `{data}` 投到了 `usrHnkwn8VSfKc` —— 这是一个**本端从未订阅**的话题。
|
||||
|
||||
### 3.4 业务事件与 HTTP 接口都指向 `p2p...`
|
||||
|
||||
```json
|
||||
{"info":{"topic":"usrv97xq86jw3c","from":"usrHnkwn8VSfKc","what":"im","event":"friend.request.accepted",
|
||||
"payload":{"topic":"p2pv97xq86jw3ceeTCfxVJ8pw","user_id":"usrHnkwn8VSfKc","request_id":"frq_OYo72xKkkgU"}}}
|
||||
```
|
||||
|
||||
```json
|
||||
{"user_id":"usrHnkwn8VSfKc","display_name":"charles1","relationship":"friend",
|
||||
"topic":"p2pv97xq86jw3ceeTCfxVJ8pw","avatar_file_id":"ZrI4Fut4heE.jpg"}
|
||||
```
|
||||
|
||||
> `friend.request.accepted` 的 `payload.topic`、`GET /v1/im/friends` 的 `topic`、`POST /v1/im/conversations/p2p` 的响应都明确是 `p2pv97xq86jw3ceeTCfxVJ8pw`。
|
||||
> 说明服务端**知道**该会话的真实 topic,但系统提示仍写到了 `usr<uid>`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与接口文档契约的冲突
|
||||
|
||||
| 文档位置 | 约定 | 实际 |
|
||||
| --- | --- | --- |
|
||||
| 《IM-前后端对接文档》0.5.4.3.1(约 330 行) | "会话内系统消息**必须进入对应 P2P 或群 Topic 的消息流**,占用 `seq`" | `friend_added` 同时进入 `usr<uid>`(非对应会话) |
|
||||
| 同章 `system_notice` 下行示例(约 337 行) | `{"data":{"topic":"p2p...", ... "x-im-template":"friend_added" ...}}` | 实际 `"topic":"usrHnkwn8VSfKc"` |
|
||||
| 同文档 3.5(约 1108 行) | 好友申请通过后服务端"在同一事务内建立双方关系和 **P2P 订阅**" | 建立了 `p2p...` 订阅,但旧 `usr<uid>` topic/订阅仍在,且系统提示写到旧 topic |
|
||||
| 同文档 `GET /v1/im/friends`(约 1564 行) | 好友的会话 `topic` 为 `p2p...` | 一致 ✅(正因如此客户端更无法理解 `usr<uid>` 上的消息) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 对客户端的影响(为什么客户端只能兜、不能治)
|
||||
|
||||
1. **会话列表出现两条同一好友的会话**,去重只能靠启发式规则(按对端 uid 分组打分)择一保留;
|
||||
2. **保留错的那条就丢消息/未读**:旧命名话题 `acs` 为空(`isJoiner == false`)但本地已落库消息,客户端历史上"标准命名优先"的规则会保留它、隐藏真会话 → 用户看到会话不更新;
|
||||
3. **误显示禁止图标**:`acs` 为空时客户端无法区分"未同步"与"已不是成员",会在会话行右下角画 `nosign`(🚫);
|
||||
4. **历史数据永久分裂**:已有一批用户的本地历史落在 `usr<uid>`,服务端新会话在 `p2p...`,客户端无法自动合并;
|
||||
5. 客户端对**未订阅**话题收到的 `{data}` 只能被动落库(已回执 `recv`),无法判断该不该收。
|
||||
|
||||
---
|
||||
|
||||
## 6. 需要后端确认 / 处理
|
||||
|
||||
1. **`friend_added` 等会话内系统提示为什么写到 `usr<uid>`?**
|
||||
请改为只写**会话 topic**(当前为 `p2p...`),与 `payload.topic` / `GET /friends` 的 `topic` 保持一致。
|
||||
2. **同一对好友为什么存在两套 Topic + 两份订阅?**
|
||||
重装/删好友/重新加好友后,旧的 `usr<uid>` topic 与订阅应当被撤销或合并;
|
||||
当前 `usrHnkwn8VSfKc`(本端 uid 维度)与 `p2pv97xq86jw3ceeTCfxVJ8pw` 同时存在,且系统提示双写。
|
||||
3. **`{data}` 投递到未在 `me` 的 `meta sub` 里的话题是否预期?**
|
||||
若不是,请修投递;若是(设计如此),请在文档里明确 `usr<uid>` 仍是合法会话名,并同步在 `meta sub` 里下发它的 `acs`/`seq`/`pub`,否则客户端无法正确渲染。
|
||||
4. **是否可以把单聊统一收敛到 `p2p...`**,并提供旧 `usr<uid>` 历史消息的迁移/合并方案(客户端愿意配合按话题重定向)。
|
||||
5. **(附带,需确认口径)** `friend.request.accepted` 的 `payload.user_id` 与文档不一致:
|
||||
文档写 `payload:{"user_id":"usr_actor"}`(通过人),实测该帧在**通过人本人**设备上 `from` 和 `payload.user_id` 都是**申请人** `usrHnkwn8VSfKc`。客户端目前已按"`payload.user_id` = 对方"消费(用于刷新会话 context、反查会话),若后端要改回文档口径请提前同步。
|
||||
|
||||
---
|
||||
|
||||
## 7. 复现步骤
|
||||
|
||||
### 前置
|
||||
- 账号 A:`usrHnkwn8VSfKc`(charles1),账号 B:`usrv97xq86jw3c`(echarles5),两台设备/两个模拟器分别登录;
|
||||
- 两端都开启 Debug 日志(会打印 `入站[消息数据(data)]` 与 `SqlStore - msgReceived`)。
|
||||
|
||||
### 步骤
|
||||
1. **先制造"旧命名话题"残留**:B 删除 A 好友(B 端:聊天 → A 的会话 → 右上角设置 → 「删除好友」,确认删除)。
|
||||
- 预期:A 端收到 `{"info":{"...","event":"friend.deleted","payload":{"user_id":"usrv97xq86jw3c"}}}`。
|
||||
- 此时 B 端本地已存在旧命名话题 `usr<A的uid>`(本次取证中为 `usrHnkwn8VSfKc`)。
|
||||
2. A 向 B 发起好友申请(A 端:添加好友 → 搜索 `echarles5` → 发送申请)。
|
||||
3. B 通过好友申请(B 端:通讯录 → 新的朋友 → 通过)。
|
||||
4. **观察 B 端日志**,应能复现:
|
||||
```text
|
||||
入站[消息数据(data)] {"data":{"topic":"usrHnkwn8VSfKc","seq":17,"head":{"x-im-template":"friend_added","x-im-type":"system_notice"},...}}
|
||||
SqlStore - msgReceived:来自话题 payload usrHnkwn8VSfKc,topicId=9,userId=3,seq=17
|
||||
入站[在线状态(pres)] {"pres":{"topic":"me","src":"usrHnkwn8VSfKc","what":"msg","seq":17}}
|
||||
```
|
||||
随后拉真会话历史时,同 `ts`/`seq` 的提示又出现在 `p2pv97xq86jw3ceeTCfxVJ8pw`;
|
||||
并且 `me` 的 `{meta sub}` 里**只有** `p2pv97xq86jw3ceeTCfxVJ8pw`。
|
||||
5. **观察 B 端界面**:会话列表里 A 的那一行副标题是"你们已成为好友,现在可以开始聊天了。",右下角带 🚫 图标。
|
||||
6. A 再发一条消息(如 `a`)→ B 端该会话行**副标题与未读数都不更新**(消息实际落在 `p2pv97xq86jw3ceeTCfxVJ8pw`,被去重隐藏)。
|
||||
7. **服务端核对**:`topics` 表应能查到同一对好友的两行
|
||||
(`usrHnkwn8VSfKc` 与 `p2pv97xq86jw3ceeTCfxVJ8pw`),以及双方的订阅记录;`messages` 表里 `friend_added` 在两个话题下各有一条(同 `ts`、同 `seq`)。
|
||||
|
||||
### 最小验证(不依赖"先删好友"这段历史)
|
||||
让 A 申请、B 通过,直接核对服务端把 `friend_added` 写到了哪个 topic:若仍是 `usr<A的uid>`,即复现本问题(第 1 步只是让残留话题必然存在,便于稳定复现)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 好友申请通过后,`friend_added` **只**下发到会话 topic(当前为 `p2p...`),且该 topic 出现在 `me` 的 `meta sub` 中;`usr<uid>` 上不再出现任何消息;
|
||||
2. `me` 的 `meta sub` 中同一对好友**只有一个** P2P topic、一份订阅;历史遗留的 `usr<uid>` topic/订阅被撤销或合并;
|
||||
3. 对不存在的会话不再有"投递到未订阅话题"的 `{data}`(或按文档补齐该话题的 `meta sub`);
|
||||
4. 客户端表现(不依赖本轮客户端兜底):B 端会话列表只出现**一行** A 的会话,副标题为最新消息、未读角标正确、**无 🚫 图标**;A 端同理。
|
||||
|
||||
---
|
||||
|
||||
## 9. 客户端已做的兜底(不依赖后端修复,但不能替代后端修)
|
||||
|
||||
1. `Utils.duplicateP2PTopicNames` 改为**按对端 uid 分组 + 打分择一保留**
|
||||
(本地有消息 8 > 仍是成员 `isJoiner` 4 > 标准命名 2 > 有 `priv.peer` 1),
|
||||
让"有消息且在关系内"的 `p2p...` 真会话优先,同时保留"有历史优先"以免去重丢消息;
|
||||
2. `ChatListViewCell` 的禁止图标改为**仅在确认服务端下发过 `acs.mode` 且确非成员**时显示
|
||||
(`acs` 未同步不再误画 🚫);
|
||||
3. 会话列表过滤对"本地已有消息但 `acs` 未下发"的单聊放宽(`isLocalKnownP2PConversation`),
|
||||
保证"有消息就一定有会话行"。
|
||||
|
||||
> 以上兜底只解决"显示哪一条、图标对不对"。**系统提示被写进隐藏话题、历史分裂在两个 topic** 这两点必须后端收敛后才能根治。
|
||||
@@ -0,0 +1,35 @@
|
||||
**1. `/healthz`接口**
|
||||
|
||||
现在的健康检查(`nc -z localhost 6060`/`8000`)只能证明端口开着,测不出"连接池耗尽、死锁"这种进程还活着但已经不干活的情况。
|
||||
|
||||
要求:`GET /healthz`,不需要鉴权,正常200/异常503,body统一格式:
|
||||
```json
|
||||
{"status": "ok", "checks": {"mysql": "ok", "redis": "ok", "kafka": "ok"}}
|
||||
```
|
||||
异常时把出问题的依赖写清楚原因,其他正常的照样标ok:
|
||||
```json
|
||||
{"status": "error", "checks": {"mysql": "ok", "kafka": "timeout: dial tcp kafka:9092: i/o timeout"}}
|
||||
```
|
||||
|
||||
各服务测什么:
|
||||
- **im-server**:MySQL(唯一确认在用的外部依赖)。Redis代码里还没真正接入,先不用测
|
||||
- **im-manager**:MySQL、Redis(`REDIS_DB=2`在用)、Kafka(崩溃日志能看到它真的在跑queue consumer,是真实依赖)
|
||||
|
||||
检测建议用真实操作确认能用(MySQL `SELECT 1`/`Ping()`,Redis `PING`,Kafka查broker元数据),不要只判断连接对象是不是nil。
|
||||
|
||||
顺手把两个服务Dockerfile里的`HEALTHCHECK`换成打这个接口,`deploy.sh`的探测逻辑也建议一起换。
|
||||
|
||||
---
|
||||
|
||||
**2. `/metrics`业务指标接口**(不急,可以往后放)
|
||||
|
||||
`im-server`加`GET /metrics`,标准Prometheus文本格式,Go生态直接用官方库`prometheus/client_golang/promhttp`,几行代码接完。
|
||||
|
||||
按价值排序,可以只做第一个:
|
||||
|
||||
| 指标 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ws_connections_active` | Gauge | 当前WS连接数,**优先级最高**——直接对应之前"WS总掉线"那次排查,有曲线就不用再靠人工连测 |
|
||||
| `http_requests_total{method,path,status}` | Counter | 按路径/状态码分的请求量,看5xx占比 |
|
||||
| `http_request_duration_seconds` | Histogram | 接口延迟分布 |
|
||||
| `db_query_duration_seconds` | Histogram | 数据库查询延迟 |
|
||||
@@ -2,32 +2,10 @@
|
||||
-- Date: 2026-08-26
|
||||
-- Scope: run once in every tenant business database, such as tinode.
|
||||
-- Notes:
|
||||
-- * Do not run in hotgo. Admin/HotGo side needs its own admin_command/outbox tables.
|
||||
-- * Tables do not contain tenant_id because each tenant has an independent database.
|
||||
-- * Do not run in hotgo. Admin/HotGo side needs its own admin_command tables.
|
||||
-- * Kafka consume idempotency/retry state is Redis-backed; MySQL only keeps manual dead letters.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
event_id VARCHAR(96) NOT NULL,
|
||||
consumer_group VARCHAR(128) NOT NULL,
|
||||
source_topic VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(96) NOT NULL DEFAULT '',
|
||||
biz_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
biz_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'processing',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
last_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
last_error VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
processing_deadline DATETIME(3),
|
||||
succeeded_at DATETIME(3),
|
||||
dead_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_kafka_consume_log_event_group(event_id, consumer_group),
|
||||
INDEX idx_im_kafka_consume_log_status(status, processing_deadline),
|
||||
INDEX idx_im_kafka_consume_log_biz(biz_type, biz_id),
|
||||
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
DROP TABLE IF EXISTS im_kafka_consume_log;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- 仅用于本地环境:为 IM 业务表补充 tenant_id 字段。
|
||||
--
|
||||
-- 本脚本只修改表结构并回填现有数据,不修改索引、唯一约束或应用代码,
|
||||
-- 也不会启用多商户共库能力。
|
||||
|
||||
USE `tinode`;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_im_tenant_columns$$
|
||||
CREATE PROCEDURE add_im_tenant_columns(IN target_schema VARCHAR(64), IN default_tenant_id BIGINT)
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE target_table VARCHAR(128);
|
||||
DECLARE has_id INT DEFAULT 0;
|
||||
DECLARE table_cursor CURSOR FOR
|
||||
SELECT t.table_name
|
||||
FROM information_schema.tables t
|
||||
WHERE t.table_schema = target_schema
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name LIKE 'im\_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name
|
||||
AND c.column_name = 'tenant_id'
|
||||
);
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN table_cursor;
|
||||
table_loop: LOOP
|
||||
FETCH table_cursor INTO target_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE table_loop;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO has_id
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = target_schema
|
||||
AND table_name = target_table
|
||||
AND column_name = 'id';
|
||||
|
||||
SET @position_sql = IF(has_id > 0, ' AFTER `id`', ' FIRST');
|
||||
SET @alter_sql = CONCAT(
|
||||
'ALTER TABLE `', target_schema, '`.`', target_table,
|
||||
'` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT ',
|
||||
default_tenant_id,
|
||||
' COMMENT ''租户ID''',
|
||||
@position_sql
|
||||
);
|
||||
PREPARE alter_stmt FROM @alter_sql;
|
||||
EXECUTE alter_stmt;
|
||||
DEALLOCATE PREPARE alter_stmt;
|
||||
END LOOP;
|
||||
CLOSE table_cursor;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 本地默认业务库,现有数据归属 tenant_id=1。
|
||||
CALL add_im_tenant_columns('tinode', 1);
|
||||
|
||||
-- 本地独立业务库,现有数据归属 tenant_id=2。
|
||||
CALL add_im_tenant_columns('meeting_im', 2);
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_im_tenant_columns;
|
||||
|
||||
-- 验证:以下结果中 missing_count 应全部为 0。
|
||||
SELECT 'tinode' AS db_name, COUNT(*) AS missing_count
|
||||
FROM information_schema.tables t
|
||||
WHERE t.table_schema = 'tinode'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name LIKE 'im\_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name
|
||||
AND c.column_name = 'tenant_id'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'meeting_im', COUNT(*)
|
||||
FROM information_schema.tables t
|
||||
WHERE t.table_schema = 'meeting_im'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name LIKE 'im\_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name
|
||||
AND c.column_name = 'tenant_id'
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
-- 仅用于本地环境:将高数据量表迁移为“共库、按商户分表”。
|
||||
-- 目标命名:
|
||||
-- im_001_friendships
|
||||
-- im_001_messages
|
||||
-- im_001_group_messages_YYYYMM
|
||||
-- 测试环境不得执行本文件。
|
||||
|
||||
-- 1. 商户编号改为事务序列分配,下一编号按当前最大 ID 顺序递增。
|
||||
USE `hotgo`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `im_tenant_id_sequence` (
|
||||
`sequence_name` VARCHAR(32) NOT NULL,
|
||||
`next_id` BIGINT NOT NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`sequence_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
INSERT INTO `im_tenant_id_sequence` (`sequence_name`, `next_id`)
|
||||
SELECT 'tenant', COALESCE(MAX(`id`), 0) + 1 FROM `im_tenant`
|
||||
ON DUPLICATE KEY UPDATE `next_id` = GREATEST(`next_id`, VALUES(`next_id`));
|
||||
|
||||
-- 禁止绕过序列表使用 AUTO_INCREMENT 生成跳号或历史大整数编号。
|
||||
ALTER TABLE `hg_admin_member` DROP FOREIGN KEY `fk_hg_admin_member_im_tenant`;
|
||||
ALTER TABLE `im_tenant_config` DROP FOREIGN KEY `fk_im_tenant_config_tenant`;
|
||||
ALTER TABLE `im_tenant` MODIFY `id` BIGINT NOT NULL;
|
||||
ALTER TABLE `hg_admin_member` ADD CONSTRAINT `fk_hg_admin_member_im_tenant`
|
||||
FOREIGN KEY (`tenant_id`) REFERENCES `im_tenant` (`id`);
|
||||
ALTER TABLE `im_tenant_config` ADD CONSTRAINT `fk_im_tenant_config_tenant`
|
||||
FOREIGN KEY (`tenant_id`) REFERENCES `im_tenant` (`id`);
|
||||
|
||||
UPDATE `im_tenant` AS target
|
||||
JOIN `im_tenant` AS shared ON shared.`code`='default'
|
||||
SET target.`db_name`=shared.`db_name`, target.`db_dsn`=shared.`db_dsn`
|
||||
WHERE target.`id`<>shared.`id`;
|
||||
|
||||
-- 2. 月表路由元数据改为以 tenant_id 为第一维度。
|
||||
USE `tinode`;
|
||||
|
||||
ALTER TABLE `im_group_message_shards`
|
||||
DROP PRIMARY KEY,
|
||||
DROP INDEX `uk_im_group_message_shards_table`,
|
||||
DROP INDEX `idx_im_group_message_shards_status`,
|
||||
ADD PRIMARY KEY (`tenant_id`, `shard_month`),
|
||||
ADD UNIQUE INDEX `uk_im_group_message_shards_table` (`tenant_id`, `table_name`),
|
||||
ADD INDEX `idx_im_group_message_shards_status` (`tenant_id`, `status`, `shard_month`);
|
||||
|
||||
DROP TABLE IF EXISTS `im_group_message_topic_shards`;
|
||||
|
||||
DROP TABLE IF EXISTS `im_group_message_dedup`;
|
||||
|
||||
-- 3. 创建好友与单聊消息分表并迁移现有数据。
|
||||
CREATE TABLE IF NOT EXISTS `im_001_friendships` LIKE `im_friendships`;
|
||||
ALTER TABLE `im_001_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
|
||||
INSERT IGNORE INTO `im_001_friendships` SELECT * FROM `im_friendships` WHERE `tenant_id` = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `im_001_messages` LIKE `im_messages`;
|
||||
ALTER TABLE `im_001_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
|
||||
INSERT IGNORE INTO `im_001_messages` SELECT * FROM `im_messages` WHERE `tenant_id` = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `im_002_friendships` LIKE `im_friendships`;
|
||||
ALTER TABLE `im_002_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
|
||||
INSERT IGNORE INTO `im_002_friendships` SELECT * FROM `meeting_im`.`im_friendships`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `im_002_messages` LIKE `im_messages`;
|
||||
ALTER TABLE `im_002_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
|
||||
INSERT IGNORE INTO `im_002_messages` SELECT * FROM `meeting_im`.`im_messages`;
|
||||
|
||||
-- 4. 迁移所有已存在的标准月表。过程只识别 im_group_messages_YYYYMM。
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_group_month_tables$$
|
||||
CREATE PROCEDURE migrate_group_month_tables(IN source_schema VARCHAR(64), IN source_tenant BIGINT, IN tenant_code CHAR(3))
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE source_table VARCHAR(64);
|
||||
DECLARE month_code CHAR(6);
|
||||
DECLARE target_table VARCHAR(64);
|
||||
DECLARE month_cursor CURSOR FOR
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = source_schema
|
||||
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN month_cursor;
|
||||
month_loop: LOOP
|
||||
FETCH month_cursor INTO source_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE month_loop;
|
||||
END IF;
|
||||
SET month_code = RIGHT(source_table, 6);
|
||||
SET target_table = CONCAT('im_', tenant_code, '_group_messages_', month_code);
|
||||
SET @create_sql = CONCAT('CREATE TABLE IF NOT EXISTS `tinode`.`', target_table,
|
||||
'` LIKE `', source_schema, '`.`', source_table, '`');
|
||||
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @alter_sql = CONCAT('ALTER TABLE `tinode`.`', target_table,
|
||||
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT ', source_tenant, ' COMMENT ''商户ID''');
|
||||
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @copy_sql = CONCAT('INSERT IGNORE INTO `tinode`.`', target_table,
|
||||
'` SELECT * FROM `', source_schema, '`.`', source_table,
|
||||
'` WHERE `tenant_id`=', source_tenant);
|
||||
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE month_cursor;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_group_month_tables('tinode', 1, '001');
|
||||
CALL migrate_group_month_tables('meeting_im', 2, '002');
|
||||
DROP PROCEDURE IF EXISTS migrate_group_month_tables;
|
||||
|
||||
-- 旧的非月表数据按创建月份归入 1 号商户月表(本地现有数据为 202608)。
|
||||
INSERT IGNORE INTO `im_001_group_messages_202608`
|
||||
(`tenant_id`,`message_id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,`status`,
|
||||
`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,
|
||||
`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,`deleted_at`,
|
||||
`expires_at`,`created_at`,`updated_at`)
|
||||
SELECT 1,`id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,`status`,
|
||||
`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,
|
||||
`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,`deleted_at`,
|
||||
`expires_at`,`created_at`,`updated_at`
|
||||
FROM `im_group_messages`
|
||||
WHERE `tenant_id` = 1 AND DATE_FORMAT(`created_at`, '%Y%m') = '202608';
|
||||
|
||||
-- 5. 修正 1 号商户路由,并合入 2 号商户路由元数据。
|
||||
UPDATE `im_group_message_shards`
|
||||
SET `table_name` = CONCAT('im_001_group_messages_', `shard_month`)
|
||||
WHERE `tenant_id` = 1;
|
||||
|
||||
INSERT INTO `im_group_message_shards`
|
||||
(`tenant_id`,`shard_month`,`table_name`,`shard_start`,`shard_end`,`status`,`row_count`,
|
||||
`min_created_at`,`max_created_at`,`created_at`,`updated_at`)
|
||||
SELECT 2,`shard_month`,CONCAT('im_002_group_messages_', `shard_month`),`shard_start`,`shard_end`,`status`,
|
||||
`row_count`,`min_created_at`,`max_created_at`,`created_at`,`updated_at`
|
||||
FROM `meeting_im`.`im_group_message_shards`
|
||||
ON DUPLICATE KEY UPDATE `table_name`=VALUES(`table_name`),`status`=VALUES(`status`);
|
||||
|
||||
-- Topic-to-month routes are Redis-backed and are rebuilt by runtime reads.
|
||||
|
||||
UPDATE `im_group_message_shards` AS s
|
||||
SET s.`row_count`=(SELECT COUNT(*) FROM `im_001_group_messages_202608`),
|
||||
s.`min_created_at`=(SELECT MIN(`created_at`) FROM `im_001_group_messages_202608`),
|
||||
s.`max_created_at`=(SELECT MAX(`created_at`) FROM `im_001_group_messages_202608`)
|
||||
WHERE s.`tenant_id`=1 AND s.`shard_month`='202608';
|
||||
|
||||
-- 验证结果。
|
||||
SELECT `sequence_name`,`next_id` FROM `hotgo`.`im_tenant_id_sequence`;
|
||||
SELECT `table_name`,`table_rows`
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'tinode'
|
||||
AND table_name REGEXP '^im_[0-9]{3}_(friendships|messages|group_messages_[0-9]{6})$'
|
||||
ORDER BY table_name;
|
||||
@@ -0,0 +1,272 @@
|
||||
-- 仅用于测试环境复制库 meeting_im_sharding_preview。
|
||||
-- 源库 meeting_im 和注册库 hotgo 不在本脚本修改范围内。
|
||||
|
||||
USE `meeting_im_sharding_preview`;
|
||||
|
||||
-- 1. 共享业务表补充商户维度,复制库全部现有数据归属商户 2。
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_preview_tenant_columns$$
|
||||
CREATE PROCEDURE add_preview_tenant_columns()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE target_table VARCHAR(128);
|
||||
DECLARE has_id INT DEFAULT 0;
|
||||
DECLARE table_cursor CURSOR FOR
|
||||
SELECT t.table_name
|
||||
FROM information_schema.tables t
|
||||
WHERE t.table_schema = 'meeting_im_sharding_preview'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND t.table_name LIKE 'im\_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema=t.table_schema
|
||||
AND c.table_name=t.table_name
|
||||
AND c.column_name='tenant_id'
|
||||
);
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN table_cursor;
|
||||
table_loop: LOOP
|
||||
FETCH table_cursor INTO target_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE table_loop;
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO has_id
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='meeting_im_sharding_preview'
|
||||
AND table_name=target_table AND column_name='id';
|
||||
SET @position_sql = IF(has_id > 0, ' AFTER `id`', ' FIRST');
|
||||
SET @alter_sql = CONCAT('ALTER TABLE `meeting_im_sharding_preview`.`', target_table,
|
||||
'` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT ''商户ID''',
|
||||
@position_sql);
|
||||
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE table_cursor;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL add_preview_tenant_columns();
|
||||
DROP PROCEDURE IF EXISTS add_preview_tenant_columns;
|
||||
|
||||
-- 2. 群消息路由元数据的唯一性增加商户维度。
|
||||
ALTER TABLE `im_group_message_shards`
|
||||
DROP PRIMARY KEY,
|
||||
DROP INDEX `uk_im_group_message_shards_table`,
|
||||
DROP INDEX `idx_im_group_message_shards_status`,
|
||||
ADD PRIMARY KEY (`tenant_id`,`shard_month`),
|
||||
ADD UNIQUE INDEX `uk_im_group_message_shards_table` (`tenant_id`,`table_name`),
|
||||
ADD INDEX `idx_im_group_message_shards_status` (`tenant_id`,`status`,`shard_month`);
|
||||
|
||||
DROP TABLE IF EXISTS `im_group_message_topic_shards`;
|
||||
|
||||
DROP TABLE IF EXISTS `im_group_message_dedup`;
|
||||
|
||||
-- 3. 创建商户 2 的好友与单聊消息分表并复制快照数据。
|
||||
CREATE TABLE `im_002_friendships` LIKE `im_friendships`;
|
||||
ALTER TABLE `im_002_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
|
||||
INSERT INTO `im_002_friendships` SELECT * FROM `im_friendships` WHERE `tenant_id`=2;
|
||||
|
||||
CREATE TABLE `im_002_messages` LIKE `im_messages`;
|
||||
ALTER TABLE `im_002_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
|
||||
INSERT INTO `im_002_messages` SELECT * FROM `im_messages` WHERE `tenant_id`=2;
|
||||
|
||||
-- 4. 将所有现存标准月表复制为商户 2 的月表。
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_preview_group_month_tables$$
|
||||
CREATE PROCEDURE migrate_preview_group_month_tables()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE source_table VARCHAR(64);
|
||||
DECLARE month_code CHAR(6);
|
||||
DECLARE target_table VARCHAR(64);
|
||||
DECLARE month_cursor CURSOR FOR
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema='meeting_im_sharding_preview'
|
||||
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN month_cursor;
|
||||
month_loop: LOOP
|
||||
FETCH month_cursor INTO source_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE month_loop;
|
||||
END IF;
|
||||
SET month_code = RIGHT(source_table, 6);
|
||||
SET target_table = CONCAT('im_002_group_messages_', month_code);
|
||||
SET @create_sql = CONCAT('CREATE TABLE `', target_table, '` LIKE `', source_table, '`');
|
||||
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @alter_sql = CONCAT('ALTER TABLE `', target_table,
|
||||
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT ''商户ID''');
|
||||
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @copy_sql = CONCAT('INSERT INTO `', target_table, '` SELECT * FROM `', source_table,
|
||||
'` WHERE `tenant_id`=2');
|
||||
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE month_cursor;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_preview_group_month_tables();
|
||||
DROP PROCEDURE IF EXISTS migrate_preview_group_month_tables;
|
||||
|
||||
UPDATE `im_group_message_shards`
|
||||
SET `table_name`=CONCAT('im_002_group_messages_',`shard_month`)
|
||||
WHERE `tenant_id`=2;
|
||||
|
||||
-- 5. 补齐 default 商户(ID=1,分表编号 001)的核心分表。
|
||||
-- tinode 是 default 商户的原库,只从该库读取,不修改原表。
|
||||
CREATE TABLE `im_001_friendships` LIKE `im_friendships`;
|
||||
ALTER TABLE `im_001_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
|
||||
INSERT INTO `im_001_friendships` (
|
||||
`id`,`tenant_id`,`user_id`,`peer_id`,`status`,`remark`,`topic`,`created_at`,`updated_at`
|
||||
)
|
||||
SELECT `id`,1,`user_id`,`peer_id`,`status`,`remark`,`topic`,`created_at`,`updated_at`
|
||||
FROM `tinode`.`im_friendships`;
|
||||
|
||||
CREATE TABLE `im_001_messages` LIKE `im_messages`;
|
||||
ALTER TABLE `im_001_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
|
||||
INSERT INTO `im_001_messages` (
|
||||
`id`,`tenant_id`,`createdat`,`updatedat`,`deletedat`,`delid`,`seqid`,`topic`,`from`,
|
||||
`client_msg_id`,`version`,`recalled_at`,`recalled_by`,`head`,`content`,`content_nonce`,`key_version`
|
||||
)
|
||||
SELECT `id`,1,`createdat`,`updatedat`,`deletedat`,`delid`,`seqid`,`topic`,`from`,
|
||||
`client_msg_id`,`version`,`recalled_at`,`recalled_by`,`head`,`content`,`content_nonce`,`key_version`
|
||||
FROM `tinode`.`im_messages`;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_default_group_month_tables$$
|
||||
CREATE PROCEDURE migrate_default_group_month_tables()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE source_table VARCHAR(64);
|
||||
DECLARE month_code CHAR(6);
|
||||
DECLARE target_table VARCHAR(64);
|
||||
DECLARE month_cursor CURSOR FOR
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema='tinode'
|
||||
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN month_cursor;
|
||||
month_loop: LOOP
|
||||
FETCH month_cursor INTO source_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE month_loop;
|
||||
END IF;
|
||||
SET month_code = RIGHT(source_table, 6);
|
||||
SET target_table = CONCAT('im_001_group_messages_', month_code);
|
||||
SET @create_sql = CONCAT('CREATE TABLE `', target_table,
|
||||
'` LIKE `im_group_messages_', month_code, '`');
|
||||
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @alter_sql = CONCAT('ALTER TABLE `', target_table,
|
||||
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT ''商户ID''');
|
||||
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @copy_sql = CONCAT(
|
||||
'INSERT INTO `', target_table, '` (`tenant_id`,`message_id`,`topic`,`group_id`,`seqid`,',
|
||||
'`from_user`,`client_msg_id`,`msg_type`,`status`,`content`,`content_nonce`,`key_version`,',
|
||||
'`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,`file_count`,',
|
||||
'`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,',
|
||||
'`deleted_at`,`expires_at`,`created_at`,`updated_at`) ',
|
||||
'SELECT 1,`message_id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,',
|
||||
'`status`,`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,',
|
||||
'`reply_seqid`,`root_seqid`,`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,',
|
||||
'`recalled_at`,`recalled_by`,`deleted_at`,`expires_at`,`created_at`,`updated_at` ',
|
||||
'FROM `tinode`.`', source_table, '`'
|
||||
);
|
||||
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE month_cursor;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_default_group_month_tables();
|
||||
DROP PROCEDURE IF EXISTS migrate_default_group_month_tables;
|
||||
|
||||
INSERT INTO `im_group_message_shards` (
|
||||
`tenant_id`,`shard_month`,`table_name`,`shard_start`,`shard_end`,`status`,`row_count`,
|
||||
`min_created_at`,`max_created_at`,`created_at`,`updated_at`
|
||||
)
|
||||
SELECT 1,`shard_month`,CONCAT('im_001_group_messages_',`shard_month`),`shard_start`,`shard_end`,
|
||||
`status`,`row_count`,`min_created_at`,`max_created_at`,`created_at`,`updated_at`
|
||||
FROM `tinode`.`im_group_message_shards`;
|
||||
|
||||
-- Topic-to-month routes are Redis-backed and are rebuilt by runtime reads.
|
||||
|
||||
-- 以实际复制后的物理表为准刷新统计,避免源库路由统计滞后。
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS refresh_default_group_shard_stats$$
|
||||
CREATE PROCEDURE refresh_default_group_shard_stats()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE month_code CHAR(6);
|
||||
DECLARE target_table VARCHAR(64);
|
||||
DECLARE shard_cursor CURSOR FOR
|
||||
SELECT `shard_month`
|
||||
FROM `im_group_message_shards`
|
||||
WHERE `tenant_id`=1;
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN shard_cursor;
|
||||
shard_loop: LOOP
|
||||
FETCH shard_cursor INTO month_code;
|
||||
IF done = 1 THEN
|
||||
LEAVE shard_loop;
|
||||
END IF;
|
||||
SET target_table = CONCAT('im_001_group_messages_', month_code);
|
||||
SET @stats_sql = CONCAT(
|
||||
'UPDATE `im_group_message_shards` SET ',
|
||||
'`row_count`=(SELECT COUNT(*) FROM `', target_table, '`),',
|
||||
'`min_created_at`=(SELECT MIN(`created_at`) FROM `', target_table, '`),',
|
||||
'`max_created_at`=(SELECT MAX(`created_at`) FROM `', target_table, '`) ',
|
||||
'WHERE `tenant_id`=1 AND `shard_month`=''', month_code, ''''
|
||||
);
|
||||
PREPARE stmt FROM @stats_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE shard_cursor;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL refresh_default_group_shard_stats();
|
||||
DROP PROCEDURE IF EXISTS refresh_default_group_shard_stats;
|
||||
|
||||
-- 6. 验证:missing_tenant_id 必须为 0,两个商户的分表与路由均存在。
|
||||
SELECT COUNT(*) AS missing_tenant_id
|
||||
FROM information_schema.tables t
|
||||
WHERE t.table_schema='meeting_im_sharding_preview'
|
||||
AND t.table_type='BASE TABLE'
|
||||
AND t.table_name LIKE 'im\_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema=t.table_schema
|
||||
AND c.table_name=t.table_name
|
||||
AND c.column_name='tenant_id'
|
||||
);
|
||||
|
||||
SELECT 'friendships' AS data_type,
|
||||
(SELECT COUNT(*) FROM `im_friendships` WHERE `tenant_id`=2) AS source_count,
|
||||
(SELECT COUNT(*) FROM `im_002_friendships`) AS target_count
|
||||
UNION ALL
|
||||
SELECT 'messages',
|
||||
(SELECT COUNT(*) FROM `im_messages` WHERE `tenant_id`=2),
|
||||
(SELECT COUNT(*) FROM `im_002_messages`);
|
||||
|
||||
SELECT s.`shard_month`,s.`table_name`,s.`status`,s.`row_count`
|
||||
FROM `im_group_message_shards` s
|
||||
WHERE s.`tenant_id` IN (1,2)
|
||||
ORDER BY s.`tenant_id`,s.`shard_month`;
|
||||
|
||||
SELECT t.table_name
|
||||
FROM information_schema.tables t
|
||||
WHERE t.table_schema='meeting_im_sharding_preview'
|
||||
AND t.table_name REGEXP '^im_(001|002)_(friendships|messages|group_messages_[0-9]{6})$'
|
||||
ORDER BY t.table_name;
|
||||
@@ -0,0 +1,134 @@
|
||||
-- MySQL 8.0. Runtime fixes for the shared meeting_im database.
|
||||
-- Execute with a privileged account that can read hotgo.im_tenant.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_user_account_sequences (
|
||||
tenant_id BIGINT NOT NULL,
|
||||
id TINYINT UNSIGNED NOT NULL,
|
||||
next_value INT UNSIGNED NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (tenant_id, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- If this table was created by the older independent-database script, its
|
||||
-- primary key may still be only (id). Shared DB must key by tenant_id too.
|
||||
ALTER TABLE im_user_account_sequences
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (tenant_id, id);
|
||||
|
||||
INSERT INTO im_user_account_sequences (tenant_id, id, next_value)
|
||||
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
|
||||
FROM im_users
|
||||
WHERE tenant_id > 0
|
||||
GROUP BY tenant_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
next_value = GREATEST(next_value, VALUES(next_value));
|
||||
|
||||
INSERT IGNORE INTO im_user_account_sequences (tenant_id, id, next_value)
|
||||
SELECT id, 1, 999
|
||||
FROM hotgo.im_tenant
|
||||
WHERE state = 1;
|
||||
|
||||
DROP TABLE IF EXISTS im_login_lockouts;
|
||||
|
||||
-- HTTP write idempotency is Redis-backed. Keep it out of MySQL.
|
||||
DROP TABLE IF EXISTS im_http_idempotency;
|
||||
DROP TABLE IF EXISTS im_group_idempotency;
|
||||
DROP TABLE IF EXISTS im_group_message_dedup;
|
||||
DROP TABLE IF EXISTS im_group_message_topic_shards;
|
||||
DROP TABLE IF EXISTS im_kafka_outbox;
|
||||
DROP TABLE IF EXISTS im_kafka_consume_log;
|
||||
DROP TABLE IF EXISTS im_business_events_outbox;
|
||||
DROP TABLE IF EXISTS im_group_events_outbox;
|
||||
DROP TABLE IF EXISTS im_login_failure_logs;
|
||||
DROP TABLE IF EXISTS im_action_tokens;
|
||||
|
||||
-- User identity indexes must be unique inside one tenant, not globally.
|
||||
ALTER TABLE im_users
|
||||
DROP INDEX uk_im_users_account_no,
|
||||
DROP INDEX users_phone,
|
||||
DROP INDEX users_username,
|
||||
ADD UNIQUE INDEX uk_im_users_tenant_account_no (tenant_id, account_no),
|
||||
ADD UNIQUE INDEX uk_im_users_tenant_phone (tenant_id, phone),
|
||||
ADD UNIQUE INDEX uk_im_users_tenant_username (tenant_id, username);
|
||||
|
||||
ALTER TABLE im_auth
|
||||
DROP INDEX auth_userid,
|
||||
ADD UNIQUE INDEX auth_tenant_userid (tenant_id, userid);
|
||||
|
||||
-- Topic/subscription runtime indexes for shared-database deployments.
|
||||
-- These tables are on hot paths for topic lookup, message seq updates,
|
||||
-- unread calculation and subscription permission checks. Keep tenant_id as
|
||||
-- the leading column so one tenant does not scan another tenant's rows.
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @sql := IF((
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'im_topics'
|
||||
AND index_name = 'uk_topics_tenant_name'
|
||||
) = 0,
|
||||
'ALTER TABLE im_topics ADD UNIQUE INDEX uk_topics_tenant_name(tenant_id, name)',
|
||||
'SELECT ''skip uk_topics_tenant_name'' AS status'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql := IF((
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'im_topics'
|
||||
AND index_name = 'idx_topics_tenant_name_state_seqid'
|
||||
) = 0,
|
||||
'ALTER TABLE im_topics ADD INDEX idx_topics_tenant_name_state_seqid(tenant_id, name, state, seqid)',
|
||||
'SELECT ''skip idx_topics_tenant_name_state_seqid'' AS status'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql := IF((
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'im_topics'
|
||||
AND index_name = 'idx_topics_tenant_owner'
|
||||
) = 0,
|
||||
'ALTER TABLE im_topics ADD INDEX idx_topics_tenant_owner(tenant_id, owner)',
|
||||
'SELECT ''skip idx_topics_tenant_owner'' AS status'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql := IF((
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'im_subscriptions'
|
||||
AND index_name = 'idx_subscriptions_tenant_topic_deletedat'
|
||||
) = 0,
|
||||
'ALTER TABLE im_subscriptions ADD INDEX idx_subscriptions_tenant_topic_deletedat(tenant_id, topic, deletedat)',
|
||||
'SELECT ''skip idx_subscriptions_tenant_topic_deletedat'' AS status'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql := IF((
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'im_subscriptions'
|
||||
AND index_name = 'idx_subscriptions_tenant_user_topic_deletedat'
|
||||
) = 0,
|
||||
'ALTER TABLE im_subscriptions ADD INDEX idx_subscriptions_tenant_user_topic_deletedat(tenant_id, userid, topic, deletedat)',
|
||||
'SELECT ''skip idx_subscriptions_tenant_user_topic_deletedat'' AS status'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Default app release rows used by /v1/im/app/releases/latest.
|
||||
INSERT INTO hotgo.im_app_releases (
|
||||
platform, channel, latest_version, min_supported_version, update_type,
|
||||
release_notes, remind_after_seconds
|
||||
) VALUES
|
||||
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
|
||||
('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
|
||||
('web', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
latest_version = VALUES(latest_version),
|
||||
min_supported_version = VALUES(min_supported_version),
|
||||
update_type = VALUES(update_type),
|
||||
release_notes = VALUES(release_notes),
|
||||
remind_after_seconds = VALUES(remind_after_seconds),
|
||||
updated_at = CURRENT_TIMESTAMP(3);
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS `im_content_moderation_logs` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`request_id` VARCHAR(64) NOT NULL,
|
||||
`scene` VARCHAR(32) NOT NULL,
|
||||
`topic` VARCHAR(96) NOT NULL DEFAULT '',
|
||||
`seqid` BIGINT DEFAULT NULL,
|
||||
`actor_id` BIGINT DEFAULT NULL,
|
||||
`provider` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`provider_request_id` VARCHAR(96) NOT NULL DEFAULT '',
|
||||
`risk_level` VARCHAR(16) NOT NULL DEFAULT 'none',
|
||||
`risk_labels` JSON DEFAULT NULL,
|
||||
`action` VARCHAR(16) NOT NULL,
|
||||
`content_hash` CHAR(64) NOT NULL,
|
||||
`latency_ms` BIGINT NOT NULL DEFAULT 0,
|
||||
`error_code` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_im_content_moderation_request` (`request_id`),
|
||||
KEY `idx_im_content_moderation_scene_time` (`scene`,`created_at` DESC,`id` DESC),
|
||||
KEY `idx_im_content_moderation_actor_time` (`actor_id`,`created_at` DESC,`id` DESC),
|
||||
KEY `idx_im_content_moderation_topic_seq` (`topic`,`seqid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
@@ -1,8 +1,9 @@
|
||||
-- MySQL 8.0. Admin message governance and authentication audit tables.
|
||||
-- Execute this script in each tenant business database.
|
||||
-- Execute this script in the shared meeting_im database; tenant_id provides tenant isolation.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_user_login_logs (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
account_type VARCHAR(24) NOT NULL DEFAULT '',
|
||||
@@ -24,40 +25,17 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs (
|
||||
metadata JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC),
|
||||
INDEX idx_im_user_login_session_close (tenant_id, session_id, logout_at),
|
||||
INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC),
|
||||
INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC),
|
||||
INDEX idx_im_user_login_device_time (device_id(64), login_at DESC),
|
||||
INDEX idx_im_user_login_request (request_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_login_failure_logs (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NULL,
|
||||
account_type VARCHAR(24) NOT NULL DEFAULT '',
|
||||
account_hash CHAR(64) NOT NULL DEFAULT '',
|
||||
account_mask VARCHAR(128) NOT NULL DEFAULT '',
|
||||
failure_type VARCHAR(32) NOT NULL,
|
||||
reason VARCHAR(96) NOT NULL DEFAULT '',
|
||||
detail VARCHAR(500) NOT NULL DEFAULT '',
|
||||
ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
region VARCHAR(128) NOT NULL DEFAULT '',
|
||||
device_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
app_version VARCHAR(32) NOT NULL DEFAULT '',
|
||||
user_agent VARCHAR(512) NOT NULL DEFAULT '',
|
||||
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||
risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
|
||||
request_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
metadata JSON NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_im_login_failure_account_time (account_hash, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_user_time (user_id, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_ip_time (ip, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_type_time (failure_type, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_request (request_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
DROP TABLE IF EXISTS im_login_failure_logs;
|
||||
|
||||
DROP TABLE IF EXISTS im_login_lockouts;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_message_admin_actions (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- IM 消息变更同步水位
|
||||
-- 用于撤回/编辑等 seq 不变的原地消息变更离线补偿。
|
||||
|
||||
ALTER TABLE im_topics
|
||||
ADD COLUMN mutation_version BIGINT NOT NULL DEFAULT 0 AFTER subcnt;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- IM private-message Snowflake ID compatibility migration.
|
||||
-- Scope: the shared business database (for example meeting_im).
|
||||
-- Review and back up before execution. This script does not replay dead letters.
|
||||
|
||||
-- 1. Preflight: every listed id column must become BIGINT.
|
||||
SELECT TABLE_NAME, COLUMN_TYPE, IS_NULLABLE, EXTRA
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND (TABLE_NAME = 'im_messages' OR TABLE_NAME REGEXP '^im_[0-9]+_messages$')
|
||||
AND COLUMN_NAME = 'id'
|
||||
ORDER BY TABLE_NAME;
|
||||
|
||||
-- 2. Upgrade the template/base table used by new tenant shards.
|
||||
ALTER TABLE `im_messages`
|
||||
MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT;
|
||||
|
||||
-- 3. Upgrade every existing tenant private-message shard.
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_private_message_id_to_bigint$$
|
||||
CREATE PROCEDURE migrate_private_message_id_to_bigint()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE shard_table VARCHAR(64);
|
||||
DECLARE shard_cursor CURSOR FOR
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME REGEXP '^im_[0-9]+_messages$'
|
||||
ORDER BY TABLE_NAME;
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN shard_cursor;
|
||||
shard_loop: LOOP
|
||||
FETCH shard_cursor INTO shard_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE shard_loop;
|
||||
END IF;
|
||||
SET @alter_message_id_sql = CONCAT(
|
||||
'ALTER TABLE `', REPLACE(shard_table, '`', '``'),
|
||||
'` MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT'
|
||||
);
|
||||
PREPARE alter_message_id_stmt FROM @alter_message_id_sql;
|
||||
EXECUTE alter_message_id_stmt;
|
||||
DEALLOCATE PREPARE alter_message_id_stmt;
|
||||
END LOOP;
|
||||
CLOSE shard_cursor;
|
||||
END$$
|
||||
|
||||
CALL migrate_private_message_id_to_bigint()$$
|
||||
DROP PROCEDURE migrate_private_message_id_to_bigint$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 4. Post-check: this query must return zero rows.
|
||||
SELECT TABLE_NAME, COLUMN_TYPE, IS_NULLABLE, EXTRA
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND (TABLE_NAME = 'im_messages' OR TABLE_NAME REGEXP '^im_[0-9]+_messages$')
|
||||
AND COLUMN_NAME = 'id'
|
||||
AND DATA_TYPE <> 'bigint';
|
||||
|
||||
-- 5. Observe pending recovery work. Replay only after the post-check passes.
|
||||
SELECT status, last_reason, COUNT(*) AS message_count,
|
||||
MIN(created_at) AS first_created_at, MAX(created_at) AS last_created_at
|
||||
FROM im_kafka_dead_letters
|
||||
WHERE event_type = 'message.persist.p2p'
|
||||
GROUP BY status, last_reason
|
||||
ORDER BY status, last_reason;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 在 meeting_im 共库执行。用于并发安全地生成每个租户的 im_users.account_no。
|
||||
CREATE TABLE IF NOT EXISTS im_user_account_sequences (
|
||||
tenant_id BIGINT NOT NULL,
|
||||
id TINYINT UNSIGNED NOT NULL,
|
||||
next_value INT UNSIGNED NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (tenant_id, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- 如果历史脚本已经创建过单库版 PRIMARY KEY(id),需要先改成共库主键。
|
||||
ALTER TABLE im_user_account_sequences
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (tenant_id, id);
|
||||
|
||||
-- 兼容已有用户数据,从每个租户当前最大账号编号继续递增,账号编号最小为 1000。
|
||||
INSERT INTO im_user_account_sequences (tenant_id, id, next_value)
|
||||
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
|
||||
FROM im_users
|
||||
WHERE tenant_id > 0
|
||||
GROUP BY tenant_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
next_value = GREATEST(next_value, VALUES(next_value));
|
||||
|
||||
-- 如果当前租户暂时还没有用户,也要先塞一行,否则新注册无法生成账号编号。
|
||||
INSERT IGNORE INTO im_user_account_sequences (tenant_id, id, next_value)
|
||||
SELECT id, 1, 999
|
||||
FROM hotgo.im_tenant
|
||||
WHERE state = 1;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 登录失败日志表(共库多租户版)。
|
||||
-- 背景:登录失败日志仅记录触发账号锁定的事件(见 session.go lockedNow 分支),
|
||||
-- 普通失败不再落库;锁定计数器存 Redis,im_login_lockouts 不再使用。
|
||||
-- 执行范围:每个租户业务库(共库模式即 tinode / meeting_im 库各执行一次)。
|
||||
-- 上游 scripts/IM-租户业务库初始化.sql 中的同名表无 tenant_id 列,勿用旧版执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0,
|
||||
`user_id` bigint DEFAULT NULL,
|
||||
`account_type` varchar(24) NOT NULL DEFAULT '',
|
||||
`account_hash` char(64) NOT NULL DEFAULT '',
|
||||
`account_mask` varchar(128) NOT NULL DEFAULT '',
|
||||
`failure_type` varchar(32) NOT NULL,
|
||||
`reason` varchar(96) NOT NULL DEFAULT '',
|
||||
`detail` varchar(500) NOT NULL DEFAULT '',
|
||||
`ip` varchar(64) NOT NULL DEFAULT '',
|
||||
`region` varchar(128) NOT NULL DEFAULT '',
|
||||
`device_id` varchar(255) NOT NULL DEFAULT '',
|
||||
`platform` varchar(32) NOT NULL DEFAULT 'unknown',
|
||||
`app_version` varchar(32) NOT NULL DEFAULT '',
|
||||
`user_agent` varchar(512) NOT NULL DEFAULT '',
|
||||
`risk_level` varchar(16) NOT NULL DEFAULT 'medium',
|
||||
`risk_action` varchar(24) NOT NULL DEFAULT 'audit_only',
|
||||
`request_id` varchar(64) NOT NULL DEFAULT '',
|
||||
`attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`metadata` json DEFAULT NULL,
|
||||
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_im_login_failure_tenant_time` (`tenant_id`,`attempt_at` DESC,`id` DESC),
|
||||
KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC),
|
||||
KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC),
|
||||
KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC),
|
||||
KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC),
|
||||
KEY `idx_im_login_failure_request` (`request_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
@@ -1,3 +1,6 @@
|
||||
-- Legacy single-table baseline. New deployments must also run
|
||||
-- IM-群消息按月分表SQL.sql; this table remains only for migration-time dual reads.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
topic VARCHAR(64) NOT NULL COMMENT '群 Topic,如 grp_xxx',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- IM group-message monthly sharding, baseline month: 2026-09 (UTC).
|
||||
-- Run in each tenant database. The application pre-creates subsequent months.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_shards (
|
||||
shard_month CHAR(6) NOT NULL,
|
||||
table_name VARCHAR(64) NOT NULL,
|
||||
shard_start DATETIME(3) NOT NULL,
|
||||
shard_end DATETIME(3) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'prepared',
|
||||
row_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NULL,
|
||||
max_created_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (shard_month),
|
||||
UNIQUE KEY uk_im_group_message_shards_table (table_name),
|
||||
KEY idx_im_group_message_shards_status (status, shard_month)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
DROP TABLE IF EXISTS im_group_message_topic_shards;
|
||||
|
||||
DROP TABLE IF EXISTS im_group_message_dedup;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
|
||||
message_id BIGINT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
group_id BIGINT NOT NULL,
|
||||
seqid BIGINT NOT NULL,
|
||||
from_user BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64) NULL,
|
||||
msg_type VARCHAR(32) NOT NULL DEFAULT 'text',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
content JSON NULL,
|
||||
head JSON NULL,
|
||||
mentioned_user_ids JSON NULL,
|
||||
mention_all TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reply_seqid BIGINT NULL,
|
||||
root_seqid BIGINT NULL,
|
||||
file_count INT NOT NULL DEFAULT 0,
|
||||
content_size INT NOT NULL DEFAULT 0,
|
||||
has_risk TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3) NULL,
|
||||
recalled_by BIGINT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
expires_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (message_id),
|
||||
UNIQUE KEY uk_202609_group_seq (group_id, seqid),
|
||||
UNIQUE KEY uk_202609_topic_seq (topic, seqid),
|
||||
UNIQUE KEY uk_202609_sender_client (topic, from_user, client_msg_id),
|
||||
KEY idx_202609_group_status_seq (group_id, status, seqid),
|
||||
KEY idx_202609_topic_created (topic, created_at),
|
||||
KEY idx_202609_topic_from_seq (topic, from_user, seqid),
|
||||
KEY idx_202609_audit_created (audit_status, created_at),
|
||||
KEY idx_202609_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages_202608 LIKE im_group_messages_202609;
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages_202610 LIKE im_group_messages_202609;
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages_202611 LIKE im_group_messages_202609;
|
||||
|
||||
INSERT INTO im_group_message_shards
|
||||
(shard_month,table_name,shard_start,shard_end,status)
|
||||
VALUES
|
||||
('202608','im_group_messages_202608','2026-08-01 00:00:00.000','2026-09-01 00:00:00.000','sealing'),
|
||||
('202609','im_group_messages_202609','2026-09-01 00:00:00.000','2026-10-01 00:00:00.000','writable'),
|
||||
('202610','im_group_messages_202610','2026-10-01 00:00:00.000','2026-11-01 00:00:00.000','prepared'),
|
||||
('202611','im_group_messages_202611','2026-11-01 00:00:00.000','2026-12-01 00:00:00.000','prepared')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
table_name=VALUES(table_name),
|
||||
shard_start=VALUES(shard_start),
|
||||
shard_end=VALUES(shard_end),
|
||||
status=IF(status IN ('sealed','archived'),status,VALUES(status));
|
||||
|
||||
-- Historical migration is deliberately separate from schema deployment.
|
||||
-- Backfill im_group_messages into the matching month table in bounded batches,
|
||||
-- then warm Redis topic routes before removing legacy reads.
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Keep device push token schema aligned across tenant business databases.
|
||||
-- The current test environment uses:
|
||||
-- default -> tinode
|
||||
-- system -> meeting_im
|
||||
|
||||
SET @im_schema = 'tinode';
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN sdk_version VARCHAR(64) NOT NULL DEFAULT '''' AFTER platform'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_device_push_tokens'
|
||||
AND column_name = 'sdk_version'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN push_env VARCHAR(32) NOT NULL DEFAULT '''' AFTER sdk_version'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_device_push_tokens'
|
||||
AND column_name = 'push_env'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN notification_permission VARCHAR(32) NOT NULL DEFAULT '''' AFTER push_env'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_device_push_tokens'
|
||||
AND column_name = 'notification_permission'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_schema = 'meeting_im';
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN sdk_version VARCHAR(64) NOT NULL DEFAULT '''' AFTER platform'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_device_push_tokens'
|
||||
AND column_name = 'sdk_version'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN push_env VARCHAR(32) NOT NULL DEFAULT '''' AFTER sdk_version'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_device_push_tokens'
|
||||
AND column_name = 'push_env'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN notification_permission VARCHAR(32) NOT NULL DEFAULT '''' AFTER push_env'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_device_push_tokens'
|
||||
AND column_name = 'notification_permission'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Add personal privacy switch for requiring approval when others add the user as a friend.
|
||||
-- Default is on to preserve the current "friend request needs approval" behavior.
|
||||
-- Run this for every tenant business database. The current test environment uses:
|
||||
-- default -> tinode
|
||||
-- system -> meeting_im
|
||||
|
||||
SET @im_schema = 'tinode';
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_user_privacy_settings'
|
||||
AND column_name = 'friend_request_verification'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_schema = 'meeting_im';
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_user_privacy_settings'
|
||||
AND column_name = 'friend_request_verification'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
@@ -1,14 +1,34 @@
|
||||
-- Add personal privacy switch for requiring approval when others invite the user into a group.
|
||||
-- Default is off to preserve existing behavior.
|
||||
-- Run this for every tenant business database. The current test environment uses:
|
||||
-- default -> tinode
|
||||
-- system -> meeting_im
|
||||
|
||||
SET @im_schema = 'tinode';
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts',
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'tinode'
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_user_privacy_settings'
|
||||
AND column_name = 'group_invite_verification'
|
||||
);
|
||||
PREPARE im_stmt FROM @im_sql;
|
||||
EXECUTE im_stmt;
|
||||
DEALLOCATE PREPARE im_stmt;
|
||||
|
||||
SET @im_schema = 'meeting_im';
|
||||
SET @im_sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts'),
|
||||
'SELECT 1'
|
||||
)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @im_schema
|
||||
AND table_name = 'im_user_privacy_settings'
|
||||
AND column_name = 'group_invite_verification'
|
||||
);
|
||||
|
||||
@@ -249,9 +249,9 @@ ALTER TABLE im_topictags
|
||||
|
||||
ALTER TABLE im_subscriptions
|
||||
ADD UNIQUE KEY uk_subscriptions_tenant_topic_user (tenant_id, topic, userid),
|
||||
ADD KEY idx_subscriptions_tenant_user_topic_deleted
|
||||
ADD KEY idx_subscriptions_tenant_user_topic_deletedat
|
||||
(tenant_id, userid, topic, deletedat),
|
||||
ADD KEY idx_subscriptions_tenant_topic_deleted
|
||||
ADD KEY idx_subscriptions_tenant_topic_deletedat
|
||||
(tenant_id, topic, deletedat),
|
||||
DROP INDEX subscriptions_topic_userid;
|
||||
|
||||
|
||||
@@ -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,150 @@
|
||||
# IM allrich 双机压测执行记录
|
||||
|
||||
日期:2026-09-27
|
||||
|
||||
## 1. 测试环境
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 企业码 | `allrich` |
|
||||
| 企业名称 | 大家一起发 |
|
||||
| HTTP API | `https://api.118im.com` |
|
||||
| WebSocket | `wss://api.118im.com/v0/channels` |
|
||||
| API Key | 通过 `IM_API_KEY` 注入,不写入 Git |
|
||||
| 测试验证码 | `123456`,仅测试环境使用 |
|
||||
| 测试密码 | 通过 `IM_LOAD_PASSWORD` 注入,不写入 Git |
|
||||
| 机器 A | 当前开发机,`192.168.1.135` |
|
||||
| 机器 B | 另一台压测机,运行 `-machine b` |
|
||||
|
||||
`config.json`、`.env.test`、`manifest.json` 均被 Git 忽略。manifest 含账号 Token,只能通过受控内网传输。
|
||||
|
||||
## 2. 数据准备结果
|
||||
|
||||
2026-09-27 首次准备曾按 2000 账号目标执行。当前分阶段压测使用的实际运行文件为:
|
||||
|
||||
- `users_per_machine=500`。
|
||||
- 账号:1000,机器 A/B 各 500。
|
||||
- 好友关系与 P2P Topic:500 对。
|
||||
- 测试群:1 个,当前 manifest 的 `group_topic=grp3Ita5tyBIMs`。
|
||||
|
||||
正式的 2000 人群测试前,必须将 `users_per_machine` 恢复为 1000,在机器 A 重新执行 `prepare` 和 `verify`,并将新 manifest 同步给机器 B。不能用当前 1000 人清单宣称已完成 2000 人群压测。
|
||||
|
||||
准备过程观察到注册和好友同意接口存在少量瞬时 500/404,100 人群邀请批次存在 502、超过 2 分钟及 `IDEMPOTENCY_IN_PROGRESS`。工具使用相同业务幂等键和退避重试后全部恢复;这些属于准备链路现象,不计入正式消息到达率。
|
||||
|
||||
## 3. 最新有效轮次
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| Run ID | `p2p500-r5-20260927-05` |
|
||||
| 场景 | `p2p` |
|
||||
| Git 提交 | `ea8732e` |
|
||||
| 连接 | A/B 各 500,合计 1000/1000 READY |
|
||||
| 发送 | 10000/10000 获得 ACK |
|
||||
| 实时到达 | 10000/10000,到达率 100% |
|
||||
| 历史落库 | 10000/10000,落库率 100% |
|
||||
| 异常 | 丢失 0、重复 0、乱序 0、断连 0、发送错误 0 |
|
||||
| ACK 延迟 | P50 约 650ms,P95 1313ms,最大 2075ms |
|
||||
| 同机 echo 投递 P95 | 2s 直方图桶 |
|
||||
| 报告门禁 | 正确性通过;延迟门禁未通过 |
|
||||
|
||||
本轮中 A/B 时钟未对齐,A 端记录到 4334 个跨机器负延迟样本,因此不使用跨机器单向延迟作为结论;延迟门禁使用发送机本机 ACK 和 echo 口径。
|
||||
|
||||
### 3.1 服务端阶段耗时
|
||||
|
||||
| 阶段 | 平均耗时 | P95 所在桶 |
|
||||
|---|---:|---:|
|
||||
| `relationship_check` | 66.9ms | 250ms |
|
||||
| `redis_idempotency` | 21.1ms | 100ms |
|
||||
| `redis_sequence` | 62.7ms | 250ms |
|
||||
| `kafka_produce` | 11.0ms | 25ms |
|
||||
| `save` | 116.9ms | 500ms |
|
||||
|
||||
`meeting_im` 和 `hotgo` 连接池在该轮起压窗口的等待次数和等待时间增量均为 0,未出现连接池枯竭。
|
||||
|
||||
### 3.2 Kafka 和落库
|
||||
|
||||
- `im-server.message_persist` 的 consumer lag 峰值为 4663。
|
||||
- lag 在发压结束后约 2 分钟清零。
|
||||
- 测试窗口中批量持久化的单批平均耗时约 3.52s,P95 落在 10s 直方图桶。
|
||||
- agent 在等待 Kafka 追平后回查历史,10000 条 ACK 消息全部找到,没有持久化丢失。
|
||||
|
||||
结论是当前系统能在突发后最终消化积压,但前台 ACK 和异步落库延迟仍高,这两项应单独作为后续优化指标。
|
||||
|
||||
## 4. 历史机器 B 运行文件
|
||||
|
||||
以下为首轮历史运行包,不能继续用于新轮次:
|
||||
|
||||
```text
|
||||
/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
|
||||
SHA-256: 479f119e7d01e1829c7262cace282c72a5f53ad8e7f5128abc2ade75d046db64
|
||||
```
|
||||
|
||||
机器 B 必须先安全获取该文件,再执行:
|
||||
|
||||
```bash
|
||||
git switch dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
# 运行包不在 Git 中,必须从机器 A 单独复制
|
||||
scp youshunlai@192.168.1.135:/tmp/allrich-machine-b-runtime-p2p-20260927-01.zip /tmp/
|
||||
shasum -a 256 /tmp/allrich-machine-b-runtime-p2p-20260927-01.zip
|
||||
|
||||
unzip -o /tmp/allrich-machine-b-runtime-p2p-20260927-01.zip \
|
||||
-d loadtest/im-pressure
|
||||
chmod 600 loadtest/im-pressure/.env.test \
|
||||
loadtest/im-pressure/config.json \
|
||||
loadtest/im-pressure/manifest.json
|
||||
|
||||
set -a
|
||||
source loadtest/im-pressure/.env.test
|
||||
set +a
|
||||
cd loadtest/im-pressure
|
||||
go build -o im-pressure .
|
||||
|
||||
./im-pressure agent \
|
||||
-config config.json \
|
||||
-manifest manifest.json \
|
||||
-machine b \
|
||||
-coordinator http://192.168.1.135:8090 \
|
||||
-metrics-listen :9105 \
|
||||
-output results/p2p-20260927-01/agent-b-summary.json
|
||||
```
|
||||
|
||||
完整 B 机操作和故障处理见 [`loadtest/im-pressure/MACHINE-B.md`](../../loadtest/im-pressure/MACHINE-B.md)。
|
||||
|
||||
## 5. 多 Pod 口径说明
|
||||
|
||||
当前 A/B 配置均使用 `wss://api.118im.com/v0/channels` 公共入口。该配置验证 LB 实际分配下的多 Pod 能力,但不能保证两台压测机固定落到不同 Pod。若验收要求强制指定不同 Pod,需要提供两个独立 WS 定向入口,分别配置到 `endpoints.a` 和 `endpoints.b`。
|
||||
|
||||
## 6. 后续测试顺序
|
||||
|
||||
### 6.1 P0:扩容到 2000 个用户
|
||||
|
||||
1. 将机器 A 的 `config.json` 修改为 `users_per_machine=1000`。
|
||||
2. 机器 A 执行 `prepare` 和 `verify`,确认 2000 账号、1000 对好友、1000 个 P2P Topic 和 2000 人群都就绪。
|
||||
3. 重新生成包含 `.env.test`、`config.json`、`manifest.json` 的 B 机运行包,B 机解压后执行 `verify`。
|
||||
4. A/B 都执行 NTP 校时,要求偏差小于 50ms,否则继续忽略跨机器单向延迟。
|
||||
|
||||
### 6.2 P1:2000 人群聊
|
||||
|
||||
使用 `scenario=group`:
|
||||
|
||||
- A/B 各建立 1000 个 WS,合计 2000 个在线群成员。
|
||||
- A/B 各取前 500 个发送者,每人 2 条,合计 2000 条群消息。
|
||||
- A/B 交错每 100ms 发送 1 条,单轮发送窗口约 100s。
|
||||
- 期望实时投递为 2000 条消息 x 2000 连接,即 400 万次有效到达。
|
||||
- 检查群消息 ACK、每个 WS 的到达数、重复、乱序、断连、`im.group.message.persist` lag 和群消息月表落库。
|
||||
|
||||
群聊是下一个应执行的场景,因为它直接验证本次多 Pod 改造的 Redis fanout 和群消息 Kafka 持久化。
|
||||
|
||||
### 6.3 P2:混合场景
|
||||
|
||||
群聊正确性通过后使用 `scenario=mixed`:
|
||||
|
||||
- 300 对私聊用户双向各发 5 条,合计 3000 条私聊消息。
|
||||
- A/B 各 250 个群发送者,每人 2 条,合计 1000 条群消息。
|
||||
- 同时观察私聊与群聊的到达率、两个 Kafka 持久化 Topic 的 lag,以及 Redis、MySQL 和 3 个 Pod 的资源争用。
|
||||
|
||||
### 6.4 P3:单聊完整规模回归
|
||||
|
||||
最后在 2000 连接下重跑 `p2p`,即 1000 对用户双向各发 10 条,共 20000 条。延迟门禁仍为 ACK P95 <= 500ms、同机 echo P95 <= 1000ms;若只有正确性通过而延迟未通过,结论必须标记为“可靠性通过,性能未达标”。
|
||||
@@ -0,0 +1,145 @@
|
||||
# IM 2000 人群聊压测问题分析
|
||||
|
||||
日期:2026-09-27
|
||||
Run ID:`group2k-r1-20260927-01`
|
||||
场景:A/B 各 1000 个 WebSocket,各 500 个发送者发 2 条,计划发送 2000 条群消息。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本轮失败,且不能仅解读为 WebSocket 丢包。核心问题是 2000 人群每条消息触发 O(群成员数) 的额外用户 `me` Topic 路由,导致 Topic master、集群通道和 MySQL 连接池连锁积压。服务端处理吞吐下降后,发送端先经历 ACK 超时,随后 Topic 队列写满并返回 503。
|
||||
|
||||
同时,压测程序对 ACK 超时的口径不完整:超时后服务端仍可能接受、广播并落库,不能把 `ctrl_timeout` 直接当作明确拒绝。
|
||||
|
||||
## 2. 客户端结果
|
||||
|
||||
| 指标 | A | B | 合计 |
|
||||
|---|---:|---:|---:|
|
||||
| WS READY | 1000 | 1000 | 2000 |
|
||||
| 发送尝试 | 1000 | 1000 | 2000 |
|
||||
| 收到 2xx ACK | 16 | 19 | 35 |
|
||||
| 503 | 224 | 272 | 496 |
|
||||
| ACK 超时 | 732 | 709 | 1441 |
|
||||
| socket 错误 | 28 | 0 | 28 |
|
||||
| 断线 | 21 | 0 | 21 |
|
||||
| ACK 台账内实时缺失 | 735 | 0 | 735 |
|
||||
| ACK 台账内历史落库缺失 | 0 | 0 | 0 |
|
||||
| 未在 ACK 台账中的实时投递 | 368270 | 377000 | 745270 |
|
||||
|
||||
35 条 ACK 消息理论上应产生 70000 次本轮投递,实际共记录 69265 次,其中 A 端少 735 次。但这不是服务端实际接受的全量;额外 745270 次投递来自“未及时收到 ACK,但服务端已处理”的消息。
|
||||
|
||||
## 3. 服务端证据
|
||||
|
||||
### 3.1 实际接受和落库
|
||||
|
||||
- `im_message_publish_stage_duration_seconds_count{kind="group",stage="save",result="ok"}` 增加 488。
|
||||
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 488。
|
||||
- 群消息 Kafka 批持久化平均约 46.6ms,488 条全部 batch success。
|
||||
|
||||
因此,服务端至少接受并落库 488 条,而不是客户端 ACK 台账中的 35 条。其中至少 453 条属于“已接受但 ACK 未在 20 秒内返回”。
|
||||
|
||||
### 3.2 队列溢出
|
||||
|
||||
| Pod | 日志 | 次数 |
|
||||
|---|---|---:|
|
||||
| `im-server-2` | `s.publish: sub.broadcast channel full` | 496 |
|
||||
| `im-server-2` | `hub: topic's broadcast queue is full` | 1655 |
|
||||
| `im-server-1` | `cluster: proxy channel full` | 47 |
|
||||
|
||||
群 Topic `grp3Ita5tyBIMs` 的 master 在 `im-server-2`。其 `clientMsg` 队列容量只有 192,proxy 响应队列容量为 128。队列满时会立即返回 503 或丢弃 proxy 响应。
|
||||
|
||||
### 3.3 每条群消息的路由放大
|
||||
|
||||
`Topic.saveAndBroadcastMessage` 在每条消息中同步执行:
|
||||
|
||||
1. `presSubsOffline("msg", ..., offlineOnly=true)` 遍历 `t.perUser` 的 2000 个成员。
|
||||
2. 为每个成员构建一条用户 `me` Topic presence 消息。
|
||||
3. `routeUserServerMessage` 同时走 Tinode 原生 Cluster 路由和 Redis user fanout。
|
||||
4. 最后才向群 Topic 的会话广播 `{data}`。
|
||||
|
||||
488 条已接受群消息产生了约 98 万次额外用户路由:
|
||||
|
||||
- `realtime_fanout_events_total{stage="publish",target_type="user"}` 增加 979979。
|
||||
- `realtime_fanout_events_total{stage="receive",target_type="user"}` 增加 894360。
|
||||
- Redis 接收后真正本地投递只有 666 次,`no_local_session` 为 893694。
|
||||
- Cluster route 约增加 109 万次。
|
||||
|
||||
这证明绝大多数用户 fanout 在当前全员已订阅群 Topic 的场景中完全无效,却阻塞了 Topic master 的串行处理协程。
|
||||
|
||||
### 3.4 MySQL 连接等待
|
||||
|
||||
3 个 Pod 的 `meeting_im` 连接池在测试窗口中合计:
|
||||
|
||||
- 等待次数增加 4011。
|
||||
- 等待时间增加约 35399 秒。
|
||||
- DB `exec ok` 增加 3453,平均耗时约 501ms。
|
||||
|
||||
连接池每 Pod 最大 24 个连接。这不是本轮第一个堵点,但会进一步拖慢群权限检查、未读数更新和其他业务 SQL。仅提高连接数不会消除 O(成员数) 的放大。
|
||||
|
||||
## 4. 压测程序口径问题
|
||||
|
||||
1. `ctrl_timeout` 和 socket 错误属于发布结果未知,不是明确拒绝。
|
||||
2. recorder 能够统计 `unexpected_messages`,但 merge 报告没有汇总该字段,也没有将它加入门禁。
|
||||
3. 历史回查只检查 ACK 台账,因此只检查了 35 条,无法单独证明其他超时消息的落库情况。
|
||||
4. 群聊应增加“已 ACK”、“已明确拒绝”、“结果未知”、“未 ACK 但已观测广播”和“最终落库”五种状态。
|
||||
|
||||
## 5. 改进顺序
|
||||
|
||||
### P0
|
||||
|
||||
1. 停止在原生 Cluster 已开启时对同一条 user message 再做 Redis user fanout,避免双通道重复路由。
|
||||
2. 重构群消息的 unread presence:已订阅该群 Topic 的用户不再生成额外 `me` 通知;未订阅但在线的成员使用节点级批量 fanout,不得在 Topic master 协程中逐人跨 Pod 路由。
|
||||
3. 为 Topic `clientMsg`、proxy 响应队列和 session send queue 增加 length/capacity/drop Prometheus 指标。
|
||||
4. 压测 merge 报告增加结果未知及 unacked-observed 口径,并把 `unexpected_messages` 纳入失败门禁。
|
||||
|
||||
### P1
|
||||
|
||||
1. 检查群消息未读数更新和 Push receipt 是否对 2000 人逐人访问 Redis/MySQL,改为批处理或延迟合并。
|
||||
2. 为 `CanPost` 的全员禁言和单人禁言结果增加短 TTL 缓存,管理操作时精确失效。
|
||||
3. P0 完成后先跑 100、500、1000、2000 人群阶梯,每档检查 ACK P95、Topic 队列水位、Cluster route 数、DB pool wait 和 Kafka lag,不直接重跑全量。
|
||||
|
||||
## 6. 本轮验收结论
|
||||
|
||||
- 连接建立:通过,2000/2000 READY。
|
||||
- 发布成功率:失败。
|
||||
- ACK 延迟:失败,已超过 20 秒超时窗口。
|
||||
- 实时广播:失败,存在队列丢弃和断线。
|
||||
- Kafka 持久化:已处理的 488 条全部成功,但该结论不代表计划的 2000 条均被接受。
|
||||
- 总体:失败,必须完成 P0 后按阶梯重测。
|
||||
|
||||
## 7. P0 修复实现
|
||||
|
||||
本轮分析后的代码修复如下:
|
||||
|
||||
1. 群消息的 `{pres what=msg}` 不再由 Topic master 对每个成员逐条跨 Pod 路由。master 将收件人清单合并为一条 `group_unread/users` Redis Pub/Sub 消息;来源 Pod 本地投递一次,其他 Pod 各消费一次。
|
||||
2. 每个 Pod 只扫描一次本地 WebSocket:已订阅该群 Topic 的连接依靠正常 `{data}` 广播,不重复发送 `me` presence;同一用户未订阅该群的其他设备仍收到 `me` presence,保留多开语义。
|
||||
3. Redis fanout 未启用或批量发布失败时,自动回退到原来的逐用户 Topic 路由,避免静默漏掉未读通知。
|
||||
4. 原生 Tinode Cluster 开启时,普通 user message 不再重复发布 Redis user fanout;session control 仍保留 Redis 跨 Pod 广播。
|
||||
5. `routeSrv` 改为有界非阻塞写入,队列满时记录错误和指标,不再无限阻塞 Topic 处理链路。
|
||||
6. 新增 `im_internal_queue_enqueue_total`、`im_internal_queue_depth`、`im_internal_queue_capacity`,覆盖 `session_topic_publish`、`hub_route_server`、`topic_client`、`topic_server`、`cluster_topic_proxy`。
|
||||
|
||||
修复后的复杂度从“每条消息约 2000 次 Cluster route + 2000 次 Redis publish”降为“一次 Redis publish + 每 Pod 一次本地 session 扫描”。2000 人都订阅群 Topic 时,批量 unread 在本地扫描后不会产生重复 WebSocket 帧。
|
||||
|
||||
## 8. 复测观测项
|
||||
|
||||
复测继续使用 `group2k-r1` 相同发送模型,并重点检查:
|
||||
|
||||
```promql
|
||||
sum by (queue, result) (rate(im_internal_queue_enqueue_total[1m]))
|
||||
|
||||
histogram_quantile(
|
||||
0.95,
|
||||
sum by (le, queue) (rate(im_internal_queue_depth_bucket[1m]))
|
||||
)
|
||||
|
||||
sum by (stage, kind, target_type, status) (
|
||||
increase(realtime_fanout_events_total[5m])
|
||||
)
|
||||
```
|
||||
|
||||
验收要求:
|
||||
|
||||
- `session_topic_publish`、`topic_client`、`topic_server`、`cluster_topic_proxy` 的 `result="full"` 均为 0。
|
||||
- `group_unread/users` 的 publish 数应接近服务端实际接受的群消息数,不再乘以群成员数。
|
||||
- 群压测窗口内普通 `message/user` Redis publish 不再随群成员数增长。
|
||||
- `im_cluster_route_total` 不应再出现每条群消息约 2000 次的线性放大。
|
||||
- 2000 条计划消息需分别核对 ACK、未知结果、实时观测和最终落库,不能只按 ACK 台账判断落库率。
|
||||
@@ -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,134 @@
|
||||
# IM 2000 人群聊 r4 复测分析
|
||||
|
||||
日期:2026-09-28
|
||||
|
||||
Run ID:`group2k-r4-20260928-01`
|
||||
|
||||
场景:机器 A/B 各 1000 个 WebSocket、各发送 1000 条,计划共发送 2000 条群消息。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本轮不能按合并报告中的 `14.39%` 实时丢失率直接判定服务端群消息丢失。两台压测机结果存在明显单边故障:
|
||||
|
||||
- 机器 B 的 1000 个连接全部稳定,1000 次发送全部收到 ACK,1497000 次 ACK 台账内投递全部到达,断线和实时缺失均为 0。
|
||||
- 机器 A 出现 654 次断线、314 次 socket 错误和 189 次 ACK 超时。A 的 TCP 源地址错误中包含 `broken pipe`、`websocket closed`,是本轮 430832 次实时缺失的主要来源。
|
||||
- 服务端实际接受并持久化 1601 条消息,其中客户端收到 ACK 1497 条,另外 104 条属于“客户端未收到 ACK,但服务端已接受”。B 恰好多收到 104000 次消息,即 104 条乘以 B 的 1000 个连接,账目完全吻合。
|
||||
- ACK 台账内 1497 条消息历史回查全部找到,`persistence_lost=0`;Kafka 群消息消费 lag 在采样点均为 0。
|
||||
- 上一轮的 O(群成员数) Redis fanout 放大已经消除:1601 条服务端消息只产生 1601 次 `group_unread/users` publish,每个远端 Pod 各 receive 1601 次,不再产生约 320 万次逐用户 Redis publish。
|
||||
- 实时发送与 60 秒收尾阶段没有新增 `topic_server` 或 `cluster_topic_proxy` 满队列。满队列发生在约 5 分钟后的并发历史回查阶段,属于另一项需要优化的问题,不能作为本轮实时丢失的原因。
|
||||
|
||||
因此,本轮结论为:**批量群未读 fanout 修复通过;B 侧实时投递通过;数据库持久化通过;A 侧网络稳定性失败;历史回查并发会打满 Topic/Proxy 队列。整体尚不能完成 2000 人群聊验收。**
|
||||
|
||||
## 2. 客户端结果
|
||||
|
||||
| 指标 | A | B | 合计 |
|
||||
|---|---:|---:|---:|
|
||||
| READY 连接 | 1000 | 1000 | 2000 |
|
||||
| 发送尝试 | 1000 | 1000 | 2000 |
|
||||
| 收到 2xx ACK | 497 | 1000 | 1497 |
|
||||
| 明确拒绝 | 0 | 0 | 0 |
|
||||
| ACK 超时 | 189 | 0 | 189 |
|
||||
| socket 结果未知 | 314 | 0 | 314 |
|
||||
| 断线 | 654 | 0 | 654 |
|
||||
| ACK 台账内应收 | 1497000 | 1497000 | 2994000 |
|
||||
| ACK 台账内实收 | 1066168 | 1497000 | 2563168 |
|
||||
| ACK 台账内实时缺失 | 430832 | 0 | 430832 |
|
||||
| ACK 台账外额外投递 | 68668 | 104000 | 172668 |
|
||||
| 重复 / 乱序 | 0 / 0 | 0 / 0 | 0 / 0 |
|
||||
| 历史检查 / 找到 | 1497 / 1497 | 0 / 0 | 1497 / 1497 |
|
||||
|
||||
合并报告按 ACK 台账计算出的到达率为 `85.61%`,但这只是 A/B 网络条件混合后的观测值,不代表服务端总体投递率。B 单机的 ACK 台账内到达率是 `100%`,A 单机是 `71.22%`。
|
||||
|
||||
A 端记录了 160074 个跨机器负延迟样本,说明两机时钟没有达到单向延迟统计要求。本轮不使用跨机器 P95/P99 判断性能,只使用 ACK、到达计数、断线、历史回查和服务端阶段指标判断正确性。
|
||||
|
||||
## 3. 未收到 ACK 的 503 条不是服务端拒绝
|
||||
|
||||
本轮没有任何明确的 HTTP/Tinode `503` 响应。报告字段 `publish_rejected=503` 实际由以下结果未知项组成:
|
||||
|
||||
- `ctrl_timeout=189`
|
||||
- `socket=314`
|
||||
|
||||
服务端持久化计数增加 1601,而 ACK 只有 1497,说明至少 104 条结果未知消息已被服务端成功接受。B 的 `unexpected_messages=104000` 与这 104 条消息对 1000 个 B 连接的广播数量完全一致。
|
||||
|
||||
后续报告应把字段继续显示为 `publish_unknown`,不能把它展示为 `rejected`,否则容易误判服务端主动拒绝。
|
||||
|
||||
## 4. 服务端与中间件
|
||||
|
||||
### 4.1 Kafka 与数据库落库
|
||||
|
||||
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 1601。
|
||||
- `im.group.message.persist` consumer lag 在采样点均为 0。
|
||||
- 1497 条 ACK 台账消息通过历史接口全部找到,持久化缺失为 0。
|
||||
- 服务端群消息 `save` 总耗时增加约 26.27s,平均约 16.4ms/条。
|
||||
- Kafka produce 总耗时增加约 19.15s,平均约 12.0ms/条。
|
||||
|
||||
这证明已接受消息能够及时进入 Kafka 并完成落库。104 条结果未知消息也计入 1601 条 batch success,但当前历史校验只以 ACK 台账为准,下一版应对“已观测但未 ACK”的消息也做 message ID 回查。
|
||||
|
||||
### 4.2 Redis fanout 修复验证
|
||||
|
||||
1601 条服务端接受消息对应:
|
||||
|
||||
- master Pod `group_unread/users publish ok` 增加 1601。
|
||||
- 两个远端 Pod 的 `group_unread/users receive ok` 各增加 1601。
|
||||
- 普通 `message/user` 在原生 Cluster 开启时继续使用 `skip_native_cluster`,没有恢复逐成员 Redis 发布。
|
||||
|
||||
这符合“一条群消息一次 Redis publish、每个 Pod 一次本地扫描”的设计,上一轮每条消息约 2000 次 Redis publish 的放大已经消失。
|
||||
|
||||
### 4.3 队列满发生在历史回查阶段
|
||||
|
||||
按时间序列拆分:
|
||||
|
||||
- `16:25:05Z` 开始发送。
|
||||
- `16:28:36Z` 最后一台压测机结束发送,之后进入收尾和历史等待。
|
||||
- 截至 `16:34:01Z`,`topic_server/full` 仍停留在测试前基线 1349,`cluster_topic_proxy/full` 仍为 0。
|
||||
- `16:34:25Z` 起,历史回查并发开始使 `topic_server/full` 增加。
|
||||
- 最终历史阶段新增 `topic_server/full=1355`、`cluster_topic_proxy/full=55`。
|
||||
|
||||
因此这些队列丢弃不是实时发送阶段 430832 次缺失的原因。历史回查一次并发拉取大量消息,经 Topic/Proxy 回送时会形成独立洪峰,应限制每机历史校验并发或分页节流,并单独验收历史吞吐。
|
||||
|
||||
## 5. A 机器故障判断
|
||||
|
||||
A 与 B 使用相同代码、相同公共入口和相同发送模型,但只有 A 发生 654 次断线。A 的错误包含:
|
||||
|
||||
- `websocket closed`
|
||||
- `write: broken pipe`
|
||||
- `ctrl timeout`
|
||||
- 到多个公网入口 IP 的 TCP 读写超时
|
||||
|
||||
这种强烈单边性不符合服务端全局过载的表现。当前最可能的故障域依次是 A 本机网络/NAT/安全软件、A 到公网 LB 的链路、A 的文件描述符或本地调度压力。由于 Kubernetes Metrics API 被 RBAC 拒绝,本轮没有取得 Pod CPU/内存数据,不能用资源指标进一步排除 Pod 饱和。
|
||||
|
||||
## 6. 下一轮执行顺序
|
||||
|
||||
1. 先做 A/B 角色互换:B 仍发 1000 条,A 暂时只保持 1000 个连接不发送。若 A 仍大量断线,可确认与服务端发送负载无关。
|
||||
2. 在 A 机同步采集 `ping`、TCP retransmit、连接跟踪、文件描述符、CPU、内存和进程调度数据;压测前检查 `ulimit -n`。
|
||||
3. 将历史回查改为分页和限并发,或先关闭自动历史回查,仅验证实时阶段;实时阶段结束后再启动独立历史校验。
|
||||
4. 修正报告字段,把超时/socket 明确归类为 `publish_unknown`,并把 104 条 unacked-observed 消息纳入历史校验。
|
||||
5. 补充 `backend-debug` 对 `metrics.k8s.io/pods` 的只读权限,否则下一轮仍无法采集 Pod CPU/内存。
|
||||
6. A 链路稳定后,使用相同 2000 连接、2000 条消息模型重新验收。门禁要求两侧断线为 0、明确拒绝为 0、实时到达率 100%、持久化丢失为 0,并分别报告实时阶段和历史阶段的队列指标。
|
||||
|
||||
## 7. 结果文件
|
||||
|
||||
本轮原始结果位于:
|
||||
|
||||
```text
|
||||
loadtest/im-pressure/results/group2k-r4-20260928-01/
|
||||
agent-a-summary.json
|
||||
agent-b-summary.json
|
||||
coordinator.json
|
||||
report.json
|
||||
pre-metrics.log
|
||||
pod-metrics.log
|
||||
post-metrics.log
|
||||
```
|
||||
|
||||
## 8. 已实施改造
|
||||
|
||||
本分析完成后,压测工具已实施以下改造:
|
||||
|
||||
1. agent 分开输出 `publish_rejected` 和 `publish_outcome_unknown`;前者只包含服务端明确拒绝,后者包含 ACK 超时和 socket 失败。
|
||||
2. A/B 在 drain 后向 coordinator 提交“实时已观测但未 ACK”的消息台账,coordinator 去重合并后将其加入历史落库核验。
|
||||
3. 报告新增 `history_unacked_observed_checked` 和 `history_unacked_observed_found`,可直接确认结果未知消息是否最终落库。
|
||||
4. r5 复测后,历史回查进一步调整为 1 个 worker、每页 20 条、全局每 500ms 最多发起一次查询,避免核验程序自身制造 Topic/Proxy 洪峰。
|
||||
5. 新增 `backend-debug-metrics-rbac.yaml`,为测试环境的 `argocd/backend-debug` ServiceAccount 提供 `metrics.k8s.io/pods` 的 namespace 只读权限。
|
||||
|
||||
上述改造只解决统计口径、历史查询压力和资源指标权限。机器 A 的公网连接异常仍需通过下一轮主机与网络观测定位。
|
||||
@@ -0,0 +1,92 @@
|
||||
# IM 2000 人群聊 r5 复测分析
|
||||
|
||||
日期:2026-09-28
|
||||
|
||||
Run ID:`group2k-r5-20260928-01`
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本轮再次证明服务端对稳定客户端的群消息可靠性没有丢失,失败集中在机器 A 的本地代理/TUN 链路:
|
||||
|
||||
- B:1000 次发送全部 ACK,1426000 次 ACK 台账内投递全部到达,断线、重复、乱序和实时缺失均为 0。
|
||||
- A:574 次发布结果未知、657 次统计窗口内断线、464596 次 ACK 台账内实时缺失。
|
||||
- A 的 broken pipe 全部写向 `198.18.0.81:443`;本机 DNS 也将 `api.118im.com` 解析为 `198.18.0.81`,路由经 `utun19827 -> 172.19.0.1`。`198.18.0.0/15` 是代理/TUN 常用 Fake-IP 网段,不是 API 的真实公网地址。
|
||||
- 机器 A 压测进程 CPU 平均占用不高、内存峰值约 150MB,B 在同一服务端负载下完全稳定,不支持“服务端全局过载”解释。
|
||||
- 新增观测台账生效:574 条结果未知中,241 条被实时观测并加入历史核验,241 条全部找到。
|
||||
- 历史总核验 1667/1667,持久化丢失为 0。
|
||||
- 历史回查仍产生 1197 次 `topic broadcast queue full`,但全部发生在历史阶段,不在实时发送阶段。
|
||||
|
||||
总体结论:**群消息落库通过,B 侧实时可靠性通过;A 侧 TUN/Fake-IP 链路失败;历史查询限速仍需继续降低。**
|
||||
|
||||
## 2. 汇总结果
|
||||
|
||||
| 指标 | A | B | 合计 |
|
||||
|---|---:|---:|---:|
|
||||
| READY | 1000 | 1000 | 2000 |
|
||||
| 发送尝试 | 1000 | 1000 | 2000 |
|
||||
| ACK | 426 | 1000 | 1426 |
|
||||
| 明确拒绝 | 0 | 0 | 0 |
|
||||
| ACK 超时 | 444 | 0 | 444 |
|
||||
| socket 结果未知 | 130 | 0 | 130 |
|
||||
| 断线 | 657 | 0 | 657 |
|
||||
| ACK 台账内应收 | 1426000 | 1426000 | 2852000 |
|
||||
| ACK 台账内实收 | 961404 | 1426000 | 2387404 |
|
||||
| ACK 台账内实时缺失 | 464596 | 0 | 464596 |
|
||||
| ACK 台账外额外投递 | 185738 | 241000 | 426738 |
|
||||
| 历史核验 | 1667/1667 | 0/0 | 1667/1667 |
|
||||
| 未 ACK 观测消息核验 | 241/241 | 0/0 | 241/241 |
|
||||
|
||||
服务端实际处理消息至少为 `1426 + 241 = 1667` 条。其余 333 条结果未知既没有 ACK,也没有任何实时观测证据,不能证明服务端接受。
|
||||
|
||||
## 3. A 机器故障证据
|
||||
|
||||
### 3.1 网络路径
|
||||
|
||||
测试结束后的本机检查结果:
|
||||
|
||||
```text
|
||||
api.118im.com -> 198.18.0.81
|
||||
route: 198.18.0.81 -> gateway 172.19.0.1, interface utun19827
|
||||
broken pipe remote: 198.18.0.81:443
|
||||
```
|
||||
|
||||
A 的错误构成为:
|
||||
|
||||
- `ctrl_timeout=444`
|
||||
- `websocket_closed=103`
|
||||
- `broken_pipe=27`
|
||||
- 其他 1
|
||||
|
||||
连接从 `17:05:12 UTC` 开始下降,随后从 1000 持续降至约 333;B 同期保持 1000 连接。下一轮前必须关闭该代理/TUN,或至少将 `api.118im.com` 设置为 DIRECT,并确认 DNS 不再返回 `198.18.0.0/15` Fake-IP。
|
||||
|
||||
### 3.2 本机资源
|
||||
|
||||
- 进程 RSS 峰值约 150MB。
|
||||
- 压测期间累计 CPU 增量约 75 秒,对约 625 秒窗口而言不构成 CPU 饱和。
|
||||
- open FDs 在连接大量断开后仍约为 1011。`WSClient.readLoop` 在读失败返回时没有立即关闭底层连接,虽然进程结束时会统一关闭,但应修复运行期 FD 滞留。
|
||||
|
||||
## 4. 服务端和历史核验
|
||||
|
||||
实时发送和 drain 阶段没有新增以下错误:
|
||||
|
||||
- `sub.broadcast channel full`
|
||||
- `cluster: proxy channel full`
|
||||
- `topic's broadcast queue is full`
|
||||
- Redis fanout publish failure
|
||||
|
||||
历史核验约在 `17:13:15 UTC` 开始。`im-server-2` 从 `17:13:22` 到 `17:14:14` 记录 1197 次群 Topic broadcast queue full。当前 1 worker、每页 50 条、100ms 间隔仍会让单次历史回放快速灌入容量为 64 的 Topic 队列。
|
||||
|
||||
尽管重试后 1667 条全部找到,历史核验不应持续制造队列丢弃。建议调整为:
|
||||
|
||||
- `history_page_size=20`
|
||||
- `history_query_interval=500ms`
|
||||
- `history_workers=1`
|
||||
- 历史超时保持 3 分钟;1667 条约需 42 秒完成首轮查询。
|
||||
|
||||
## 5. 下一步
|
||||
|
||||
1. 修复 `WSClient.readLoop` 退出时未关闭底层连接的问题。
|
||||
2. 将历史分页调整为 20 条、查询间隔 500ms。
|
||||
3. 在 A 机关闭 TUN/代理,或配置 `api.118im.com` DIRECT;起压前检查 DNS 和路由不再进入 `198.18.0.0/15`、`utun*`。
|
||||
4. 测试环境尚未应用 metrics RBAC,`kubectl auth can-i list pods.metrics.k8s.io` 仍返回 `no`,因此本轮没有 Pod CPU/内存数据。需要将 `backend-debug-metrics-rbac.yaml` 加入实际 ArgoCD overlay。
|
||||
5. 完成上述三项后再运行同规模 r6。若 A/B 都稳定,再根据 B 本轮 ACK P95 约 1.14s、同机投递 P95 位于 2s 桶的结果继续做服务端延迟优化。
|
||||
@@ -0,0 +1,93 @@
|
||||
# IM 2000 人群聊 r6 复测分析
|
||||
|
||||
日期:2026-09-28
|
||||
|
||||
Run ID:`group2k-r6-20260928-01`
|
||||
|
||||
## 1. 结论
|
||||
|
||||
r6 比 r5 有改善,但仍未通过。A 去掉 Fake-IP 后 ACK 数从 426 增加到 758,统计窗口断线从 657 降到 213;然而 `api.118im.com` 的真实公网 IP 仍经 `utun4`,A 在正式开始前就发生 28 次连接抖动,正式开始后继续成批断线。后续排查确认该 `utun4` 由 OpenVPN Connect 的全局路由持有,而不是 LightXtreme。B 也出现 2 次断线和 1 次发布结果未知,不再是完全零错误。
|
||||
|
||||
历史落库仍然可靠:ACK 台账和未 ACK 观测台账合计核验 1870/1870,持久化丢失为 0。FD 修复已验证生效,A 的存活连接从 1000 降至 787 时,open FDs 从约 1013 同步降至约 799,不再滞留断开的 socket。
|
||||
|
||||
历史限速和退出行为仍会污染服务端队列指标:历史查询阶段约产生 436 次 Topic 队列满;A 结束时一次性关闭大量群订阅连接,又集中产生约 598 次队列满。
|
||||
|
||||
总体结论:**落库正确性通过;FD 修复通过;实时可靠性失败;A 的 OpenVPN/TUN 链路仍未绕过;历史回放与集中断连仍需改进。**
|
||||
|
||||
## 2. 汇总结果
|
||||
|
||||
| 指标 | A | B | 合计 |
|
||||
|---|---:|---:|---:|
|
||||
| READY | 1000 | 1000 | 2000 |
|
||||
| 发送尝试 | 1000 | 1000 | 2000 |
|
||||
| ACK | 758 | 999 | 1757 |
|
||||
| ACK 超时 | 122 | 0 | 122 |
|
||||
| socket/连接结果未知 | 120 | 1 | 121 |
|
||||
| 实际明确拒绝 | 0 | 0 | 0 |
|
||||
| 断线 | 213 | 2 | 215 |
|
||||
| ACK 台账内应收 | 1757000 | 1757000 | 3514000 |
|
||||
| ACK 台账内实收 | 1468873 | 1756514 | 3225387 |
|
||||
| ACK 台账内实时缺失 | 288127 | 486 | 288613 |
|
||||
| ACK 台账外额外投递 | 94965 | 112984 | 207949 |
|
||||
| 历史核验 | 1870/1870 | 0/0 | 1870/1870 |
|
||||
| 未 ACK 观测消息核验 | 113/113 | 0/0 | 113/113 |
|
||||
|
||||
服务端实际处理消息至少为 `1757 + 113 = 1870` 条。其余 130 条结果未知没有 ACK 或实时观测证据,不能证明服务端接受。
|
||||
|
||||
## 3. 报告口径修正
|
||||
|
||||
合并报告当前显示 91 次明确拒绝和 152 次结果未知,这是错误分类:
|
||||
|
||||
- A 的 91 次 `use of closed network connection` 被归类为 `other`。
|
||||
- 该错误是连接关闭后的写失败,应该归类为 `socket` 结果未知。
|
||||
- 修正后本轮明确拒绝为 0,结果未知为 243。
|
||||
|
||||
此外,群历史只由 A 执行。A 已证明全部 1757 条 ACK 消息落库,但 B 的 `realtime_lost_but_persisted` 无法读取 A 的历史结果,因此 B 的 486 次实时缺失没有计入“实时缺失但已落库”。合并报告需要共享全局历史核验结果后再计算该字段。
|
||||
|
||||
## 4. A 网络与 FD
|
||||
|
||||
本轮 DNS 返回真实地址:
|
||||
|
||||
```text
|
||||
64.118.158.131
|
||||
216.236.31.189
|
||||
216.236.31.191
|
||||
```
|
||||
|
||||
但到真实地址的系统路由仍为:
|
||||
|
||||
```text
|
||||
gateway: 172.27.240.1
|
||||
interface: utun4
|
||||
```
|
||||
|
||||
这说明 Fake-IP 已关闭,但流量仍进入 VPN/TUN。A 在 barrier 提交前持续掉线重连,正式开始后约 50 秒开始掉线,最终只剩约 787 个连接。后续确认 OpenVPN Connect 下发了 `0/1` 和 `128.0/1` 两条全局路由;LightXtreme 的 DIRECT 规则无法绕过另一套 OpenVPN TUN。r7 前已为 `api.118im.com` 当前三个 IP 添加经 `192.168.1.1/en1` 的 `/32` 主机路由,保留 OpenVPN 内网访问的同时让压测 HTTP/WS 直连。
|
||||
|
||||
FD 时间序列证明 read loop 修复有效:
|
||||
|
||||
- READY 1000 时 open FDs 约 1013。
|
||||
- READY 901 时 open FDs 约 913。
|
||||
- READY 787 时 open FDs 约 799。
|
||||
|
||||
连接每减少一个,FD 基本同步减少一个。
|
||||
|
||||
## 5. Topic 队列满
|
||||
|
||||
实时发送和 drain 阶段仍没有 Topic/Proxy 满队列。`im-server-2` 的 1034 次 `topic's broadcast queue is full` 分为:
|
||||
|
||||
- `00:35:59–00:36:11 UTC`:约 436 次,发生在历史回查阶段。
|
||||
- `00:37:19 UTC`:约 598 次,发生在 A agent 完成后批量关闭连接阶段。
|
||||
|
||||
20 条/500ms 虽有改善,但 Topic 历史数据仍以突发方式回送。建议:
|
||||
|
||||
1. 历史每页进一步降至 10 条,查询间隔保持 500ms。
|
||||
2. 每页请求后等待本页目标消息全部收到或超时,再发送下一页,而不是只按固定时间节流。
|
||||
3. agent 退出时按 10–20ms 间隔分批关闭连接,避免 1000 个群订阅同时离线。
|
||||
|
||||
## 6. 后续修复
|
||||
|
||||
1. 已将 `use of closed network connection` 分类为 socket 结果未知。
|
||||
2. 已在 coordinator 共享两机历史命中 ID,使 B 也能正确计算实时缺失但已落库。
|
||||
3. 历史回查已改为每页 10 条:同一 Topic 每页完成确认后再请求下一页,不同 Topic 最多 12 路并行。
|
||||
4. agent 已改为在 10 秒窗口内渐进关闭 WebSocket。
|
||||
5. r7 开始前重新解析 `api.118im.com`,确认每个 IP 都通过 `/32` 主机路由走 `en1`;OpenVPN 重连、DNS 变更或机器重启后必须重新核验。
|
||||
@@ -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,125 @@
|
||||
# IM system 租户接口冒烟记录 - 2026-09-16
|
||||
|
||||
## 环境
|
||||
|
||||
- Base URL: `https://api-test.118im.com`
|
||||
- Tenant Code: `system`
|
||||
- API Key: 新测试环境 Key,接口解析返回的 WebSocket 地址可用
|
||||
- 测试账号: 本次临时注册 `s832453991` / `17832453991`
|
||||
- 测试时间: `2026-09-16 11:40 CST`
|
||||
|
||||
## 结论
|
||||
|
||||
`system` 租户登录、注册、WebSocket 登录和大部分登录后接口可用;本轮确认 1 个确定后端问题:
|
||||
|
||||
`GET /v1/im/me/privacy` 在 `system` 租户下返回 `500 INTERNAL_ERROR`。该问题已在 `2026-09-16 12:27 CST` 通过补齐 `meeting_im.im_user_privacy_settings.group_invite_verification` 字段修复,复测返回 200。
|
||||
|
||||
初步根因是 `system` 租户实际业务库 `meeting_im` 的 `im_user_privacy_settings` 表缺少 `group_invite_verification` 字段,而当前代码查询和更新都会使用该字段。
|
||||
|
||||
## 确认通过
|
||||
|
||||
| 接口 | 结果 |
|
||||
| --- | --- |
|
||||
| `POST /v1/im/tenants/resolve` | 200,返回 `tenant.code=system` |
|
||||
| `POST /v1/im/auth/verification-codes` | 200 |
|
||||
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | 200,固定验证码 `123456` 可用 |
|
||||
| WebSocket `hi` | 201 |
|
||||
| WebSocket `acc user:new` | 200,新账号创建成功 |
|
||||
| WebSocket `login scheme=token` | 200 |
|
||||
| WebSocket `sub me` | 200 |
|
||||
| `GET /v1/im/bootstrap` | 200 |
|
||||
| `GET /v1/im/me` | 200 |
|
||||
| `GET /v1/im/me/profile` | 200 |
|
||||
| `GET /v1/im/security/realname` | 200 |
|
||||
| `GET /v1/im/workbench` | 200,返回 `enterprise.tenant_code=system` |
|
||||
| `GET /v1/im/organization/departments` | 200 |
|
||||
| `GET /v1/im/organization/members?limit=20` | 200 |
|
||||
| `GET /v1/im/organization/search?q=1&limit=20` | 200 |
|
||||
| `GET /v1/im/attendance/config` | 200 |
|
||||
| `GET /v1/im/attendance/records?limit=20` | 200 |
|
||||
| `GET /v1/im/meeting-notes?limit=20` | 200 |
|
||||
| `GET /v1/im/meetings?limit=20` | 200 |
|
||||
| `GET /v1/im/reports/abuse/reasons?target_type=user` | 200 |
|
||||
| `GET /v1/im/reports/abuse/reasons?target_type=group` | 200 |
|
||||
| `GET /v1/im/reports/daily/statistics?date=2026-09-16` | 200 |
|
||||
| `GET /v1/im/reports/daily?limit=20` | 200 |
|
||||
| `GET /v1/im/reports/monthly/statistics?month=2026-09` | 200 |
|
||||
| `GET /v1/im/reports/monthly?limit=20` | 200 |
|
||||
| `GET /v1/im/executive-mailbox/config` | 200 |
|
||||
| `GET /v1/im/feedback/config` | 200 |
|
||||
| `GET /v1/im/app/releases/latest?platform=ios¤t_version=1.0.0&channel=stable` | 200 |
|
||||
| `GET /v1/im/search/conversations?q=&limit=20` | 200 |
|
||||
| `GET /v1/im/forward-targets?q=&limit=20` | 200 |
|
||||
| `GET /v1/im/system-notifications?limit=20` | 200 |
|
||||
| `GET /v1/im/favorites?limit=20` | 200 |
|
||||
| `GET /v1/im/groups?limit=20` | 200 |
|
||||
| `GET /v1/im/calls?limit=20` | 200 |
|
||||
|
||||
## 问题记录
|
||||
|
||||
### P0: `GET /v1/im/me/privacy` 返回 500
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
GET /v1/im/me/privacy
|
||||
X-Tenant-Code: system
|
||||
X-IM-Schema: 1
|
||||
Authorization: Bearer <system 用户 token>
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 90000,
|
||||
"reason": "INTERNAL_ERROR",
|
||||
"message": "internal error"
|
||||
}
|
||||
```
|
||||
|
||||
数据库检查:
|
||||
|
||||
- `hotgo.im_tenant` 中 `system` 租户:`db_name=meeting_im`,`db_dsn=''`。
|
||||
- `meeting_im.im_user_privacy_settings` 当前字段包含:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `add_me_methods`
|
||||
- `profile_visibility`
|
||||
- `last_seen_visibility`
|
||||
- `read_receipts`
|
||||
- `version`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `tinode.im_user_privacy_settings` 比 `meeting_im` 多一个字段:
|
||||
- `group_invite_verification`
|
||||
|
||||
代码使用点:
|
||||
|
||||
- `/Users/youshunlai/go_workspace/im_server/server/db/mysql/tenant_adapter.go:6613`
|
||||
- `/Users/youshunlai/go_workspace/im_server/server/db/mysql/tenant_adapter.go:7194`
|
||||
|
||||
判断:
|
||||
|
||||
`system` 租户库 `meeting_im` 未执行最新隐私设置表结构迁移。当前代码查询 `group_invite_verification` 字段时会在该库报 SQL 错误,最终 HTTP 统一包装成 `500 INTERNAL_ERROR`。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 测试库 `mysql.118im.com:30306/tinode` 已有该字段。
|
||||
- 测试库 `mysql.118im.com:30306/meeting_im` 已补字段。
|
||||
- 本地库 `127.0.0.1:3306/tinode` 已补字段。
|
||||
- 本地库 `127.0.0.1:3306/meeting_im` 已补字段。
|
||||
- 复测 `GET /v1/im/me/privacy` 返回 200,响应包含 `group_invite_verification:false`。
|
||||
|
||||
## 误测排除
|
||||
|
||||
以下接口第一次测试时返回 400/405,是因为请求参数或方法不完整,不属于本轮确认问题:
|
||||
|
||||
- `GET /v1/im/reports/abuse/reasons`:需要 `target_type=user|group`。
|
||||
- `GET /v1/im/reports/daily/statistics`:需要 `date=YYYY-MM-DD`。
|
||||
- `GET /v1/im/reports/monthly/statistics`:需要 `month=YYYY-MM`。
|
||||
- `GET /v1/im/reports/daily/reminders`:实现为 `POST`,GET 返回 405 属正常方法限制。
|
||||
|
||||
## 额外注意
|
||||
|
||||
旧 API Key 访问 `system` 或 `default` 都返回 `403 API_KEY_REQUIRED`;新测试环境应使用租户解析接口返回的新 Key 和 WebSocket 地址。
|
||||
@@ -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`
|
||||
@@ -0,0 +1,515 @@
|
||||
# IM 单聊压测 Pod 日志根因分析与改进方案
|
||||
|
||||
日期:2026-09-27
|
||||
|
||||
## 1. 结论
|
||||
|
||||
`p2p-20260927-01` 暴露了两个相互独立的 P0 故障:
|
||||
|
||||
1. **Tinode 原生 Cluster 转发能力在突发流量下耗尽**,造成 8133 次 publish 拒绝或超时。
|
||||
2. **Kafka 异步落库写入 Snowflake ID 时与 MySQL 32 位 `INT` 字段不兼容**,造成客户端已 ACK 的消息没有落入 MySQL。
|
||||
|
||||
两者都不是 Pod 崩溃、Pod 重启或集群节点断开导致。实时投递接近成功不代表持久化成功,本轮必须同时判定 Cluster 投递和 MySQL 落库不通过。
|
||||
|
||||
直接故障链路:
|
||||
|
||||
1. 客户端连接所在 Pod 不是 P2P Topic 的 master 时,请求进入 proxy-to-master 转发。
|
||||
2. 当前每个远端节点只有 1 个同步 RPC 发送协程,队列容量只有 64。
|
||||
3. 队列满后仅等待 20ms,随即返回 `cluster: load exceeded`。
|
||||
4. Topic proxy 将该错误转换为 WebSocket `502 cluster unreachable`。
|
||||
5. 反向投递进入 `hub.routeSrv` 时,4096 容量队列也出现满载;当前 RPC 调用方忽略 `rejected` 结果,存在静默丢弃路径。
|
||||
|
||||
因此,本轮 `40.665%` 的发送失败率是 Cluster 内部排队与串行 RPC 的结果。Redis/Kafka 没有参与这条 Topic proxy-to-master 请求路径,不是发送失败的故障点;但 Kafka 消费到 MySQL 的异步持久化链路另有独立故障。
|
||||
|
||||
## 2. 压测结果对照
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 2000/2000 |
|
||||
| Publish 尝试 | 20000 |
|
||||
| 服务端 ACK | 11867 |
|
||||
| 拒绝或超时 | 8133 |
|
||||
| ACK 成功率 | 59.335% |
|
||||
| ACK 后应到达 | 11867 |
|
||||
| ACK 后实际到达 | 11859 |
|
||||
| 实时丢失 | 8 |
|
||||
| ACK 后到达率 | 99.9326% |
|
||||
| 重复/乱序/断连 | 0 / 2 / 1 |
|
||||
| ACK 平均/最大延迟 | 3.079s / 18.715s |
|
||||
| 投递平均/最大延迟 | 4.178s / 21.741s |
|
||||
|
||||
8 条丢失全部集中在 `B -> A` 用户序号 0959,与 A 机记录到的 1 次 WS 断连同时出现。
|
||||
|
||||
### 2.1 MySQL 直接核验
|
||||
|
||||
2026-09-27 通过 Pod 当前的只读数据库连接,对租户 `allrich`(`tenant_id=8`)进行了两层核验:
|
||||
|
||||
| 核验项 | 结果 |
|
||||
|---|---:|
|
||||
| 客户端收到 ACK | 11867 |
|
||||
| `im_008_messages` 按本次 `client_msg_id` 前缀命中 | 0 |
|
||||
| `im_008_messages` 按 `content.run_id=p2p-20260927-01` 命中 | 0 |
|
||||
| `im_messages` 按 `content.run_id=p2p-20260927-01` 命中 | 0 |
|
||||
| ACK 消息精确匹配 `client_mid + topic + seq` | 0/11867 |
|
||||
| 本轮已生成 Kafka 死信(核验时点) | 27 |
|
||||
| 死信错误 | `Error 1264: Out of range value for column 'id'` |
|
||||
|
||||
结论:修复前,本轮 ACK 消息的 **MySQL 落库率为 0%**。27 是首次核验时已完成重试并转入死信的数量,不是最终总数;当时其余消息仍位于 Kafka backlog 或处理中。
|
||||
|
||||
### 2.2 修复执行与数据恢复结果
|
||||
|
||||
2026-09-27 已在本地和测试环境执行 `docs/sql/IM-消息表Snowflake-ID-BIGINT修复SQL.sql` 对应迁移:
|
||||
|
||||
- 本地 `meeting_im.im_messages.id` 已由 `INT` 改为 `BIGINT NOT NULL AUTO_INCREMENT`。
|
||||
- 测试环境 `meeting_im.im_messages` 以及 `im_001_messages` 至 `im_009_messages` 共 10 张表已全部改为 `BIGINT NOT NULL AUTO_INCREMENT`,后置校验异常数为 0。
|
||||
- 修复后 Kafka backlog 自动恢复落库 11835 条;本轮已有 32 条消息进入死信,通过系统 `kafka.dead_letter.replay` 命令完成重放。
|
||||
|
||||
使用原始 ACK ledger 对恢复后的数据库逐条校验:
|
||||
|
||||
| 核验项 | 最终结果 |
|
||||
|---|---:|
|
||||
| ACK ledger | 11867 |
|
||||
| 数据库本轮消息行 | 11867 |
|
||||
| `client_mid + topic + seq` 精确匹配 | 11867 |
|
||||
| 缺失 | 0 |
|
||||
| 错配 | 0 |
|
||||
| 重复 | 0 |
|
||||
| 拒绝/超时请求意外落库 | 0/8133 |
|
||||
|
||||
因此本轮已确认 ACK 消息最终 100% 落库。这里的最终结果是执行字段迁移、等待 Kafka backlog 排空并重放死信后的恢复结果,不代表故障发生时的异步持久化链路正常。
|
||||
|
||||
## 3. Kubernetes 现状
|
||||
|
||||
| 项目 | 实际值 |
|
||||
|---|---|
|
||||
| Namespace | `im-server-system` |
|
||||
| Workload | `StatefulSet/im-server` |
|
||||
| 副本 | 3/3 Ready |
|
||||
| Pod | `im-server-0/1/2` |
|
||||
| 节点分布 | 3 个 Pod 在 3 个不同 K8s Node |
|
||||
| 重启 | 全部为 0 |
|
||||
| 镜像 | 3 Pod 使用相同 digest |
|
||||
| CPU request/limit | `100m / 500m` |
|
||||
| Memory request/limit | `128Mi / 256Mi` |
|
||||
| Cluster 端口 | 12000 |
|
||||
|
||||
Pod 没有 OOM/Kill,内存 cgroup 没有 `oom` 或 `oom_kill`。未观察到集群 TCP 断开、`node not connected`、panic 或 fatal。
|
||||
|
||||
## 4. Pod 日志证据
|
||||
|
||||
压测发送窗口:2026-09-26 18:02:45Z 至 18:03:24Z(北京时间 02:02:45 至 02:03:24)。
|
||||
|
||||
`im-server-0` 的可用完整窗口日志中:
|
||||
|
||||
| 日志 | 次数 |
|
||||
|---|---:|
|
||||
| `route broadcast request from proxy to master failed - cluster: load exceeded` | 2259 |
|
||||
| `cluster Route: server busy` | 3889 |
|
||||
| `cluster: node ... not connected` | 0 |
|
||||
| cluster RPC connection error | 0 |
|
||||
| panic/fatal | 0 |
|
||||
|
||||
`im-server-2` 当前保留的日志从 18:03:19Z 才开始,仅最后约 5 秒已有 496 次 `cluster Route: server busy`。`im-server-1` 当前日志从 18:15:47Z 开始,本次发送窗口已被轮转覆盖。
|
||||
|
||||
因此 2259 和 4385(3889+496)都是 **可见日志下界**,不是整个集群的全量。这也是日志量与客户端 8133 次拒绝/超时不完全相等的主要原因之一。
|
||||
|
||||
错误从发送开始后立即出现,`im-server-0` 前 10 秒每秒约有 180-226 条 Cluster 过载日志,说明它不是某个后台定时任务引起,而是与本轮 publish 突发直接相关。
|
||||
|
||||
## 5. 代码级根因
|
||||
|
||||
### 5.1 proxy-to-master 队列过小
|
||||
|
||||
`server/cluster.go` 将 `clusterProxyToMasterBuffer` 固定为 64,`clusterP2MTimeout` 固定为 20ms。3 节点时 `len(c.nodes)==2`,扩容逻辑不生效,所以每个远端 peer 实际只有 64 个缓冲位。
|
||||
|
||||
队列满后,`proxyToMasterAsync` 在 20ms 后返回 `cluster: load exceeded`。`topic_proxy.go` 随即给客户端返回 502。日志与代码路径一致。
|
||||
|
||||
### 5.2 每个 peer 只有一个同步 RPC worker
|
||||
|
||||
`p2mSenderLoop` 只启动一次,循环内调用同步 `endpoint.Call`。该 peer 上任何一个较慢的 TopicMaster RPC 都会阻塞后续所有 Topic。在 1000 对用户同时 publish 时,64 队列很快被填满。
|
||||
|
||||
### 5.3 接收侧有静默丢弃路径
|
||||
|
||||
`Cluster.Route` 在 `globals.hub.routeSrv` 满时设置 `rejected=true`并打印 `cluster Route: server busy`。但 `ClusterNode.route` 只检查 RPC error,传入的 bool 命名为 `unused`,没有检查被远端拒绝的结果。
|
||||
|
||||
这意味着发送方可能认为 RPC 成功,但接收方已经因队列满而丢弃消息。本轮 8 条 ACK 后实时丢失与此风险方向一致,但因全量日志已部分轮转,无法将每一条 Client MID 与具体 server-busy 日志逐条对齐。
|
||||
|
||||
### 5.4 资源限制会放大排队
|
||||
|
||||
每个 Pod 的 CPU limit 只有 500m,同时承担 1000 量级 WS、Topic goroutine、net/rpc、MySQL 持久化和实时投递。当前 cgroup 累计存在 CPU throttling,但没有压测时间窗的 Prometheus 历史样本,所以只能将 CPU 限制定义为高概率放大因素,不将其单独认定为根因。
|
||||
|
||||
### 5.5 Kafka 异步落库主键类型不兼容
|
||||
|
||||
当前代码在 Kafka 入队前通过 `store.Store.GetUid()` 生成 Snowflake 消息 ID。消费者批量落库时,`TenantMessageSaveBatch` 显式把该 ID 写入 `im_008_messages.id`。
|
||||
|
||||
线上实际字段类型为:
|
||||
|
||||
| 表 | `id` 类型 |
|
||||
|---|---|
|
||||
| `im_messages` | `INT` |
|
||||
| `im_008_messages` | `INT` |
|
||||
|
||||
Snowflake ID 超过有符号 32 位 `INT` 上限,批量插入立即报 1264。代码降级为逐条处理时仍调用同一个批量写入方法,因此不会绕过该错误;达到重试上限后消息进入 `im_kafka_dead_letters`。与此同时,WebSocket 202/ACK 在 Kafka publish 成功后就返回,因而客户端会观察到“ACK 成功、实时广播成功、历史和数据库没有消息”。
|
||||
|
||||
这是数据库 DDL 与当前异步持久化代码契约不一致。仓库初始化 SQL 也仍把 `im_messages.id` 定义为 `INT`,因此新环境同样会复现。
|
||||
|
||||
## 6. 次要问题
|
||||
|
||||
### 6.1 403 permission denied
|
||||
|
||||
两台 agent 保留的前 1000 条错误中,A 有 11 条、B 有 24 条 403。多个相同用户序号在 A/B 两侧同时出现,例如 0082、0175、0304、0401、0773。这表明少量 P2P Topic 的发布权限或好友/Topic 初始化状态不一致,与 Cluster 过载是独立问题。
|
||||
|
||||
### 6.2 历史回查程序串行阻塞
|
||||
|
||||
压测程序对 1000 个 Topic 串行调用 history GET,每次控制应答最长等 20 秒,导致收尾耗时达 13-19 分钟。原始 `history_found=0` 本身不能证明 MySQL 丢数据;本报告新增的 MySQL 直查与 Kafka 死信证据已经独立确认落库失败。
|
||||
|
||||
### 6.3 日志保留不足
|
||||
|
||||
压测后不到 20 分钟,`im-server-1` 和 `im-server-2` 的关键窗口已部分或全部被容器日志轮转覆盖。没有集中日志时,无法完整重建三 Pod 时间线。
|
||||
|
||||
## 7. 改进方案
|
||||
|
||||
### P0:修复 Cluster 转发实现
|
||||
|
||||
1. 将 cluster buffer、enqueue timeout、worker 数改为可配置项,不再使用 64/20ms 硬编码。
|
||||
2. 每个 peer 使用有界并发 RPC worker pool。为保证同一 Topic 的顺序,应按 `tenant_id + topic` 做固定分片,同一 Topic 始终进入同一 worker。
|
||||
3. 首轮建议值:每 peer 16 个 worker、总 buffer 4096、enqueue timeout 200-500ms。该数值必须通过阶梯压测定标,不应作为无上限扩容。
|
||||
4. 修复 `ClusterNode.route`:检查远端 `rejected` 并返回明确 overload error,禁止静默成功。
|
||||
5. 对已持久化的 ServerComMessage 建立有界重试(例如 3 次指数退避)。超过重试上限要记录可检索的 tenant/topic/seq/target pod,并由客户端重连后历史同步补齐。
|
||||
6. 将 overload 与 node unreachable 分开:队列满返回 503/`cluster overloaded`,只有节点断连才返回 502/`cluster unreachable`。
|
||||
|
||||
### P0:恢复 Kafka 消息持久化
|
||||
|
||||
1. 暂停新的压力流量,避免继续扩大 Kafka backlog 和死信数量。
|
||||
2. 审核并执行 `docs/sql/IM-消息表Snowflake-ID-BIGINT修复SQL.sql`,把基础表及所有 `im_NNN_messages.id` 改为 `BIGINT NOT NULL AUTO_INCREMENT`。保留 `AUTO_INCREMENT` 是为了兼容 Kafka 关闭时不显式传 ID 的同步写入路径。
|
||||
3. 变更后先发 1 条带唯一 `client_mid` 的探针消息,确认 Kafka 消费、MySQL 行、Topic seq 和历史查询一致。
|
||||
4. 再通过现有死信管理接口重放本轮 `message.persist.p2p` 死信;Kafka 中尚未提交的 backlog 会由消费者继续处理。不要直接删除死信或重置 offset。
|
||||
5. 以本轮 11867 个 ACK ledger 做最终精确核对,要求 `client_mid + topic + seq` 为 11867/11867,重复为 0。
|
||||
6. 增加启动前 schema contract 检查:Kafka 持久化启用时,若私聊消息基础表或任何租户分表的 `id` 不是 BIGINT,Pod readiness 必须失败并输出明确的表名和字段类型。
|
||||
7. 增加 `kafka_message_persist_total{result=batch_ok|single_ok|retry|dead}`、消费 lag、死信累计量和最老 backlog age 告警。客户端 ACK 语义应明确为“Kafka 已接收(pending)”,服务端另行提供最终持久化状态;若产品要求 ACK 等于已落库,则必须改为同步确认或增加持久化回执。
|
||||
|
||||
### P0:调整部署资源与入口
|
||||
|
||||
1. 测试环境首轮将每 Pod request 调整为 `cpu: 1, memory: 512Mi`,limit 调整为 `cpu: 2, memory: 1Gi`;运行阶梯压测后再根据 P95 调整。
|
||||
2. 核对客户端 Service/Ingress:`sessionAffinity: None`,WebSocket upstream 不使用 `ip_hash`,建议 `least_conn` 或等价的长连接均衡策略。
|
||||
3. 在压测开始前记录每 Pod `ws_connections_active`,单 Pod 连接数不得超过平均值的 1.3 倍。
|
||||
4. readiness 增加 `LiveClusterNodes == TotalClusterNodes-1` 和 cluster queue 长时间高水位检查,但不应因瞬时高水位造成 Pod 反复摘除。
|
||||
|
||||
### P1:增加可观测性
|
||||
|
||||
新增以下 Prometheus 指标,必须带 `pod` 和 `peer` 维度,Topic 只记 hash:
|
||||
|
||||
- `im_cluster_p2m_queue_length/capacity`
|
||||
- `im_cluster_p2m_enqueue_total{result=ok|timeout}`
|
||||
- `im_cluster_rpc_inflight`
|
||||
- `im_cluster_rpc_duration_seconds`
|
||||
- `im_cluster_route_total{result=ok|hub_full|peer_rejected|disconnected}`
|
||||
- `im_cluster_proxy_response_total{result=ok|channel_full|unknown_topic}`
|
||||
- `im_ws_connections_active{pod}`
|
||||
- `im_message_persist_duration_seconds`
|
||||
|
||||
告警建议:
|
||||
|
||||
- queue 使用率连续 30s 超过 70%:预警;超过 90%:严重。
|
||||
- `enqueue timeout` 或 `hub_full` 1 分钟内大于 0:严重。
|
||||
- `LiveClusterNodes` 低于期望值:严重。
|
||||
- WS 连接不均衡比超过 1.3:预警。
|
||||
|
||||
日志应接入 Loki/ELK 等集中系统,至少保留 7 天。限速相同 overload 日志,用计数指标补全被限速的数量,避免大量重复日志反过来抢占 CPU 和 IO。
|
||||
|
||||
### P1:修复压测程序
|
||||
|
||||
1. history 回查改为 32-64 有界并发,总收尾超时不超过 3 分钟。
|
||||
2. 分开统计 `publish_502`、`publish_503`、`publish_403`、`publish_ctrl_timeout`、`socket_error`,不再只保留前 1000 条文本错误。
|
||||
3. 对所有 1000 对用户做发布权限预检,修复 403 后再开始正式计时。
|
||||
4. 在两台压测机启动前、发送中、收尾后自动抓取 Pod/Redis/Kafka/MySQL 指标快照。
|
||||
5. 历史落库验证增加 MySQL 直查作为独立通道,以 `tenant_id + topic + client_mid/seq` 分批校验。
|
||||
|
||||
## 8. 复测顺序与通过门槛
|
||||
|
||||
1. 200 对、500 对、1000 对分三档,每档至少跑 3 轮。
|
||||
2. 每档同时执行“均匀爬坡”和“同时突发”两种模式。
|
||||
3. 最终 1000 对场景通过门槛:
|
||||
- publish ACK 成功率不低于 99.99%。
|
||||
- ACK 后对端到达率为 100%,重复为 0。
|
||||
- Cluster enqueue timeout、hub full、peer rejected 均为 0。
|
||||
- ACK P95 < 500ms,对端投递 P95 < 1s。
|
||||
- MySQL 落库校验 100%。
|
||||
- Kafka consumer lag 在发送停止后 60 秒内归零,死信新增为 0。
|
||||
- 单 Pod CPU P95 < 70%,内存 P95 < limit 的 70%,无持续 CPU throttling。
|
||||
|
||||
## 9. 本次调查限制
|
||||
|
||||
- 调试 ServiceAccount 可读 Pod/StatefulSet/log,但不可读 Service/Endpoint 和 metrics-server 历史数据。
|
||||
- `im-server-1/2` 的压测窗口日志已部分轮转,日志次数只能作为下界。
|
||||
- 未获得当时的 CPU、内存、网络、MySQL、Redis、Kafka 时序监控,不对这些组件的历史利用率做无证据推断。
|
||||
- Kafka backlog 的精确条数无法通过当前调试 ServiceAccount 直接读取;死信 27 条只代表核验时已经完成失败重试的部分。
|
||||
|
||||
即使存在上述限制,现有证据已足以分别确认 Cluster 转发过载和 Kafka 持久化主键溢出。修复并完成死信/backlog 恢复前,本轮数据不能视为已持久化。
|
||||
|
||||
## 10. P0/P1 实施状态
|
||||
|
||||
2026-09-27 已完成代码与发布配置改造:
|
||||
|
||||
- Cluster 使用按 `tenant_id + topic` 固定分片的 16 worker、每 peer 总队列 4096、入队超时 300ms;远端 `hub_full` 有 3 次指数退避重试,过载返回 503,节点断连保持 502。
|
||||
- TopicMaster 的客户端 publish 接收队列满时会通过 multiplex session 回 503,不再只记录日志后等待客户端超时;同类过载日志每秒限速,完整次数由指标保留。
|
||||
- Kafka 启用时,启动阶段校验基础私聊表及所有活动租户私聊分表的 `id` 必须为 `BIGINT NOT NULL AUTO_INCREMENT`,不满足时进程不会进入 Ready。
|
||||
- 增加 Cluster 队列/RPC/路由、WebSocket 连接、Kafka 持久化结果/lag/backlog age/死信及持久化耗时指标和 PrometheusRule 告警。
|
||||
- `/healthz` 检查全部 Cluster peer,并仅在队列超过 90% 持续 30 秒时摘除 readiness;新增 `/livez` 供 liveness 使用,避免 Cluster 短暂异常触发 Pod 重启。
|
||||
- 测试环境发布流水线应用 `request=1C/512Mi`、`limit=2C/1Gi`,设置 Cluster 参数、Service `sessionAffinity=None`,并在存在 `Ingress/im-server` 时设置 NGINX `least_conn`。
|
||||
- 压测程序增加全量 P2P 预检、48 worker 历史回查、3 分钟总超时、publish 错误分类、指标快照阶段标记、P95/ACK 成功率门禁及 MySQL 独立精确核验。
|
||||
|
||||
当前调试 kubeconfig 对 StatefulSet/Service/Ingress 没有 patch 权限,因此现存 Pod 资源尚未被本地会话直接修改;以上部署项会在新提交触发有权限的 Gitea deploy runner 后生效。生效后必须按第 8 节执行 200/500/1000 对阶梯复测,不能用单元测试替代容量验收。
|
||||
|
||||
## 11. 200 对阶梯复测结果
|
||||
|
||||
Run ID:`p2p-200-20260927-01`,A/B 两台压测机各模拟 200 个用户,共 200 对、400 条 WebSocket 连接。
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 400/400 |
|
||||
| Publish 尝试 | 4000 |
|
||||
| 服务端 ACK | 3890 |
|
||||
| 403 拒绝 | 110 |
|
||||
| 502 / 503 / ctrl timeout / socket error | 0 / 0 / 0 / 0 |
|
||||
| ACK 后应到达 / 实际到达 | 3890 / 3890 |
|
||||
| ACK 后到达率 | 100% |
|
||||
| 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
|
||||
| Kafka P2P `batch_ok` | 3890 |
|
||||
| Kafka consumer lag / backlog age | 0 / 0 |
|
||||
| MySQL 精确匹配 | 3890/3890 |
|
||||
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
|
||||
|
||||
本轮没有再出现 `cluster: load exceeded`、`server busy`、502 或 503,说明 Cluster P0 改造在 200 对档位已生效。Kafka 消费指标和 MySQL 对 ACK ledger 的独立精确查询均确认 3890 条消息已全部落库。
|
||||
|
||||
压测报告当时的 `history_found=2213`、`persistence_lost=1677` 不是真实落库丢失。旧实现首轮查询后仅等待 2 秒,再进行两次 2 秒重试,并未持续到配置的 3 分钟截止时间。已改为在 `history_timeout` 内持续重试,并按 Topic 合并缺失区间查询,避免过早判定和大量单条请求。
|
||||
|
||||
### 11.1 403 根因与修复
|
||||
|
||||
110 次 403 稳定集中在用户序号 `0018`、`0043`、`0065`、`0082`、`0175`、`0183` 的 6 个 P2P Topic,与 Cluster 转发无关。`EnsureP2P` 已将 MySQL 中双方订阅修复为 `JRWPA`,但已加载 Topic 的 `perUser` 仍保留旧权限,因此预检通过后 WebSocket publish 仍被内存态拒绝。
|
||||
|
||||
已增加两层修复:
|
||||
|
||||
1. HTTP `EnsureP2P` 成功后向 Topic master 发布内部权限刷新事件,原生 Cluster 由 `routeSrv` 路由,非原生多 Pod 模式由 Redis realtime fanout 路由。
|
||||
2. P2P publish 发现内存态无写权限时,先从 MySQL 重载该用户订阅再判定,作为刷新事件延迟或丢失时的自愈保障。数据库仍无写权限时依然返回 403。
|
||||
|
||||
### 11.2 当前未达标项
|
||||
|
||||
- ACK P95 和到达 P95 均落在 2 秒直方图上界,未达 500ms / 1s 门槛。
|
||||
- ArgoCD 已将 StatefulSet 资源回滚为 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍使用 `/healthz`。压测期间存在 CPU throttling 累计,部署配置必须在 GitOps 声明源中固化,否则应用后仍会被回滚。
|
||||
- 403 修复发布后应重跑 200 对;只有 ACK 成功率、实时到达率、MySQL 精确落库均达标,才进入 500 对档位。
|
||||
|
||||
## 12. 200 对第三轮复测结果
|
||||
|
||||
Run ID:`p2p-200-20260927-03`。压测前修复了压测工具复用固定 `Idempotency-Key` 而跳过好友关系校验的问题,并重新核对、修复 200 对测试账号。
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 400/400 |
|
||||
| Publish 尝试 / ACK | 4000 / 4000 |
|
||||
| Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 |
|
||||
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
|
||||
| 实时到达率 | 100% |
|
||||
| 历史查询 | 4000/4000 |
|
||||
| MySQL 精确匹配 | 4000/4000 |
|
||||
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
|
||||
| 丢失 / 重复 / 断连 | 0 / 0 / 0 |
|
||||
| 乱序 | 1 |
|
||||
| Cluster 队列采样最大值 | 0 |
|
||||
| Kafka 峰值 lag / 最旧 backlog age / 最终 lag | 3028 / 92.624s / 0 |
|
||||
|
||||
结论:跨 Pod 发送、ACK 后实时投递、Kafka 最终消费和 MySQL 落库的数据正确性已通过 200 对验证。结果报告仍为不通过,原因是 B 端记录 1 次单连接 Topic seq 回退,以及 ACK P95 和投递 P95 均落入 1-2s 区间。
|
||||
|
||||
资源采样显示,三个 Pod 峰值内存低于 94MiB,但 Pod 0/1/2 在本轮窗口内的 CPU throttled 增量约为 7.62s / 0.78s / 8.51s。实际 StatefulSet 仍是 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍是 `/healthz`。因此延迟和 Kafka 追平速度尚不能作为扩容到 500 对的验收基线。
|
||||
|
||||
已增加 `deploy/argocd/im-server-test/statefulset-performance-patch.yaml`,要求 ArgoCD 声明源固化 `request 1C/512Mi`、`limit 2C/1Gi`和 `/livez` liveness。部署流水线改为等待 ArgoCD 回写后严格校验,如果再被回滚则直接失败。压测工具保留乱序为失败门禁,并新增最多 100 条 `receiver/topic_hash/previous_seq/current_seq/client_mid` 明细,用于下轮精确定位。
|
||||
|
||||
## 13. GitOps 资源生效后的第四轮复测
|
||||
|
||||
Run ID:`p2p-200-20260927-04`。ArgoCD Application `im-server-test` 的真实配置仓库已固化 `request 1C/512Mi`、`limit 2C/1Gi` 和 `/livez`,三个 Pod 完成滚动后均为 Ready、零重启。
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 400/400 |
|
||||
| Publish 尝试 / ACK | 4000 / 4000 |
|
||||
| Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 |
|
||||
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
|
||||
| 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
|
||||
| 历史查询 | 4000/4000 |
|
||||
| MySQL 精确匹配 | 4000/4000 |
|
||||
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
|
||||
| 精确 ACK P50 / P95 / P99 / Max | 368.189 / 566.683 / 725.654 / 1600.975 ms |
|
||||
| 投递 P95 直方图上界 | 1000 ms |
|
||||
| Cluster 队列采样最大值 | 0 |
|
||||
| Kafka message persist 峰值 lag / 归零耗时 | 2946 / 发送结束后 113s |
|
||||
| Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 usec |
|
||||
| Pod 0/1/2 峰值内存 | 87.86 / 80.80 / 85.18 MiB |
|
||||
|
||||
结论:消息正确性全部通过,第三轮出现的 1 条 seq 回退没有复现。资源扩容完全消除了本轮 CPU throttling,证明旧的 500m CPU limit 是延迟放大因素之一;精确 ACK P95 从第三轮的 1006.288ms 降至 566.683ms,下降约 43.7%,但仍比 500ms 门槛高约 13.3%,因此报告只因 ACK 延迟门禁判定失败。
|
||||
|
||||
Kafka `im-server.message_persist` lag 在发送后峰值为 2946,并在发送结束后约 113 秒归零,超过 60 秒目标。历史 API 和 MySQL 最终均确认 4000 条完整落库,说明这是落库延迟而不是消息丢失。进入 500 对测试前,应优先检查 message persist consumer 的批量写入、worker/partition 并行度和 MySQL 写入耗时。
|
||||
|
||||
连接集中关闭期间,三个 Pod 分别记录 10、14、22 次 `failed to close login audit ... Deadlock found`,共 46 次。该问题未影响消息表,但会导致登录审计结束状态不完整。登录审计关闭更新应增加 MySQL 1213/1205 有界重试,并检查更新条件和索引以缩小锁范围。
|
||||
|
||||
## 14. 持久化并行与审计死锁修复后的第五轮复测
|
||||
|
||||
Run ID:`p2p-200-20260927-05`。本轮已将 `im.message.persist` 和 `im.group.message.persist` 固化为 3 分区、3 副本;同一 Topic 继续作为 Kafka key,保证会话内顺序。服务端同时按 batch 内最大 Seq 合并 Topic 和发送者已读位点更新,并为登录审计关闭增加复合索引和 MySQL 死锁重试。
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 400/400 |
|
||||
| Publish 尝试 / ACK | 4000 / 4000 |
|
||||
| ACK 后应到达 / 实际到达 | 4000 / 4000 |
|
||||
| 丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 |
|
||||
| 历史查询 / MySQL 精确匹配 | 4000/4000 / 4000/4000 |
|
||||
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
|
||||
| 总体精确 ACK P50 / P95 / P99 / Max | 375.314 / 547.516 / 670.317 / 2845.347 ms |
|
||||
| A 机精确 ACK P95 | 408.155 ms |
|
||||
| B 机精确 ACK P95 | 594.533 ms |
|
||||
| Pod 0/1/2 Kafka 峰值 lag | 676 / 251 / 143 |
|
||||
| 最慢 Kafka 分区归零时间 | 发送结束后约 52.5s |
|
||||
| Cluster 队列峰值 / CPU throttled 增量 | 0 / 三 Pod 均为 0 |
|
||||
| 登录审计关闭死锁 | 三 Pod 均为 0 |
|
||||
| Kafka batch failure / Pod 重启 | 0 / 0 |
|
||||
|
||||
结论:三分区已由三个 Pod 实际并行消费,单 Pod 峰值 lag 从第四轮的 2946 降至 676,最慢消费者在 60 秒门槛内归零。消息正确性、最终落库、会话内顺序、CPU 限流和登录审计死锁均通过。
|
||||
|
||||
报告仍因 ACK P95 门禁显示失败。直方图只能给出 1000ms 上界,按 4000 条 ACK 原始时间戳和 nearest-rank 口径计算的实际 P95 为 547.516ms。A 机已经低于 500ms,而 B 机为 594.533ms,且 B 机存在 2.845s 最大长尾;两台机器使用相同公共入口和相同服务端版本,因此继续修改服务端前,应先采集 B 机到 `api.118im.com` 的 DNS、TCP、TLS、WebSocket RTT 与时钟偏差,并使用定向 Pod 入口做 A/B 对照,区分公网链路长尾和服务端处理耗时。
|
||||
|
||||
## 15. 500 对第一轮容量测试
|
||||
|
||||
Run ID:`p2p-500-20260927-01`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。正式发压前补齐了第 201–500 对中缺失的 9 对好友权限,两端预检和 Ready 均通过。
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 1000/1000 |
|
||||
| Publish 尝试 / ACK | 10000 / 9946 |
|
||||
| Publish 失败 | 54(44 条服务端 500/other,10 条 socket error) |
|
||||
| Publish 成功率 | 99.46% |
|
||||
| ACK 后应到达 / 实际到达 | 9946 / 9936 |
|
||||
| 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 |
|
||||
| 实时到达率 | 99.8995% |
|
||||
| 总体精确 ACK P95 / Max | 2429.954 / 3636.045 ms |
|
||||
| 压测期历史回查 | 8580/9946 |
|
||||
| Kafka 完全排空后 MySQL 精确匹配 | 9946/9946 |
|
||||
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 |
|
||||
| Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 |
|
||||
| Kafka 归零时间 | 发送结束后约 4 分钟 |
|
||||
| Pod 0/1/2 峰值内存 | 148.1 / 130.5 / 122.4 MiB |
|
||||
| Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 |
|
||||
| Pod 重启 | 0 |
|
||||
|
||||
本轮不通过,不得升级到 1000 对。根因是 MySQL 连接容量与 IM Server 连接池上限不匹配:
|
||||
|
||||
- MySQL `max_connections=170`,本轮 `Max_used_connections=172`,`Connection_errors_max_connections=27`。
|
||||
- IM Server 每 Pod `max_open_conns=64`,3 个 Pod 的理论连接池上限已达 192,还未计入 admin server、其他服务和运维连接。
|
||||
- 本轮 Pod 0/1 分别记录 153/19 次 `Error 1040: Too many connections`,包括 50 次 `failed to save message`和 44 次 P2P 关系检查失败;这些错误与 44 条服务端 500 同时出现。
|
||||
- A 机用户 190 的 WebSocket 断开,产生 10 次 socket error,并导致对端已 ACK 的 10 条消息未能实时投递到该连接。
|
||||
- 压测结束阶段 Pod 2 记录 282 次群 Topic broadcast queue full,发生在大量会话同时清理期,需要与消息发送链路分开处理。
|
||||
|
||||
压测阶段的 `history_found=8580`不是最终丢库:历史回查结束时 Kafka 仍在积压,等 lag 归零后从 Pod 内使用 ACK ledger 独立查询 `im_008_messages`,9946 条全部精确匹配。因此当前是“已接受消息最终零丢失,但持久化延迟超标”。
|
||||
|
||||
下一轮 500 对复测前必须先完成:
|
||||
|
||||
1. 统一规划 MySQL 全局连接预算,覆盖 IM Server、admin server 及其他共库服务;单个服务的连接池上限不能独立设置到总库容量。
|
||||
2. 修正 IM Server 每 Pod 的 `max_open_conns/max_idle_conns`,或在验证 MySQL CPU、内存、线程和 PXC 限制后提高 `max_connections`;必须保留至少 20% 全局余量。
|
||||
3. 压测中增加 DB pool `OpenConnections/InUse/Idle/WaitCount/WaitDuration`和 MySQL `Threads_connected/Threads_running/Max_used_connections/Connection_errors_max_connections` 指标。
|
||||
4. 复测要求 Publish ACK 10000/10000、实时丢失和断连为 0、Kafka 60 秒内归零、MySQL 1040 和清理期 queue full 为 0,然后才能进入 1000 对。
|
||||
|
||||
### 15.1 为什么 Kafka 可以积压,但本轮仍会报错
|
||||
|
||||
Kafka 积压只能保护“已经成功写入 Kafka”的消息。正常情况下,consumer 落库变慢只会使 lag 上升,消息会在 Kafka 中等待,不应导致 Publish 报错。本轮的 54 条失败发生在 Kafka 之前:
|
||||
|
||||
1. 普通文本消息先同步查询青少年模式设置,但当前规则无论查询结果都允许消息继续进入内容审核。
|
||||
2. P2P 发布在写 Kafka 前调用 `CanSendP2P`,旧实现即使 Redis 已命中好友关系,仍会读取 MySQL 用户和好友记录。
|
||||
3. 入口校验、Kafka consumer、历史查询和登录审计共用 MySQL 连接预算。连接耗尽后,入口校验直接返回 500,这些消息没有进入 Kafka,因此无法通过“后续慢慢消费”恢复。
|
||||
|
||||
### 15.2 IM 侧改造
|
||||
|
||||
2026-09-27 已完成以下修复:
|
||||
|
||||
- 普通消息不再读取青少年模式表;通话的夜间禁用规则仍保留数据库查询。
|
||||
- P2P 热路径先查双向黑名单缓存,再使用好友正缓存;缓存未命中或 Redis 异常时才回源 MySQL。新增拉黑会同时清理双向好友正缓存,避免安全状态陈旧。
|
||||
- `meeting_im` 每 Pod 连接池设为 `max_open=24/max_idle=8`,`hotgo` 注册表库独立设为 `max_open=4/max_idle=2`。3 个 Pod 的 IM 理论总上限由 384 降为 84;结合 Workbench 30、Manager 20 的上限,整套服务理论上限为 134,在当前 MySQL 170 连接上限下保留 36 条全局余量。
|
||||
- 连接池参数改为环境变量,并同时固化到应用仓库及 ArgoCD 真实配置源。发布流水线会在 ArgoCD 回写后校验四个连接池参数。
|
||||
- 新增 `im_db_pool_*` 指标,包括 max/open/in-use/idle/wait count/wait duration/关闭计数,并为连接池 85%/95% 使用率配置预警和严重告警。
|
||||
|
||||
这些修复的目标是先让入口稳定完成缓存校验和 Kafka enqueue,把 MySQL 慢转化为可观测、可恢复的 Kafka lag,而不是 Publish 500。如果复测时 `im_db_pool_wait_total` 持续增长或 Kafka 仍无法在 60 秒内归零,再根据 PXC 的 CPU/内存/线程容量将 MySQL `max_connections` 持久化提高到 300–400,不使用临时 `SET GLOBAL`代替配置源。
|
||||
|
||||
## 16. 500 对连接池改造后复测
|
||||
|
||||
Run ID:`p2p500-fix-20260927-02`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。三个 IM Pod 均已使用 `meeting_im=24/8`、`hotgo=4/2` 的连接池配置。
|
||||
|
||||
| 指标 | 改造前 | 本轮 |
|
||||
|---|---:|---:|
|
||||
| WS Ready | 1000/1000 | 1000/1000 |
|
||||
| Publish ACK | 9946/10000 | 10000/10000 |
|
||||
| 服务端 500 / socket error | 44 / 10 | 0 / 0 |
|
||||
| 实时到达 | 9936/9946 | 10000/10000 |
|
||||
| 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 | 0 / 0 / 0 / 0 |
|
||||
| ACK P50 / P95 / P99 / Max | - / 2429.954 / - / 3636.045 ms | 662.062 / 1567.569 / 2620.878 / 3156.025 ms |
|
||||
| 压测期历史回查 | 8580/9946 | 6738/10000 |
|
||||
| Kafka 排空后 MySQL 精确匹配 | 9946/9946 | 10000/10000 |
|
||||
| MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 |
|
||||
| Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 | 2259 / 2875 / 2326 |
|
||||
| Kafka 最慢归零 | 约 4 分钟 | 发送结束后约 4 分 30 秒 |
|
||||
| MySQL 1040 | 172 次 Pod 日志记录 | 0 |
|
||||
| Pod 重启 | 0 | 0 |
|
||||
|
||||
结论分为两部分:
|
||||
|
||||
1. 入口和实时正确性修复有效。10000 次 Publish 全部被接受,实时投递、最终落库、顺序、重复和断连门禁全部通过;连接池上限把全局 MySQL 连接耗尽转化成了进程内排队,没有再出现 `Too many connections`。
|
||||
2. 容量门禁仍未通过,暂不升级到 1000 对。ACK P95 仍为 1567.569ms,Kafka 未在 60 秒内归零,压测期历史回查因落库未追平只看到 6738 条。
|
||||
|
||||
本轮新增指标进一步定位出两个瓶颈:
|
||||
|
||||
- 发送阶段 `hotgo` 注册库连接池出现 5841 次新增等待,累计等待约 210.25 秒。`TenantAdapterFor` 当前在命中已创建的租户 adapter 之前仍执行 `tenantByID`,使共库路由的高频操作重复查询 `im_tenant`;4 条注册库连接因此成为 ACK 热路径瓶颈。
|
||||
- 发送结束 30 秒后,两台 agent 同时以默认 48 worker 执行历史核验,形成 96 路历史查询。三个 `meeting_im` 连接池随后全部达到 24/24;主库新增 6780 次连接等待,累计等待约 11010.16 秒。历史查询与 Kafka persistence 共用连接池,直接延长了 lag 排空时间。
|
||||
- Pod 2 有一个 100 条批次因 `context deadline exceeded` 降级为逐条写入,100 条最终均成功;三个 Pod 共记录 8 个数据库超时相关日志,没有死信,最终数据库精确核验为 10000/10000。
|
||||
|
||||
下一轮前应完成:
|
||||
|
||||
1. 为租户元数据增加带失效机制的进程内缓存;`TenantAdapterFor` 命中有效缓存时不得每次读取 `hotgo.im_tenant`,租户停用或配置变更通过现有配置事件主动失效,并保留短 TTL 兜底。
|
||||
2. 压测程序把实时发压和持久化核验拆成两个阶段:先观察 Kafka 自然排空,再以每机 8–16 个 worker 执行历史核验,避免核验流量反向干扰被测落库吞吐。同时另设“历史读取并发压测”场景验证读写混合容量。
|
||||
3. persistence 与在线历史查询至少增加独立并发预算;若继续共用连接池,应为 persistence 保留连接或限制历史查询并发,避免读请求耗尽全部 24 条连接。
|
||||
4. 完成上述改造后仍以 500 对复测,门禁保持 ACK 10000/10000、实时错误为 0、Kafka 60 秒内归零、最终数据库 10000/10000,再决定是否升级到 1000 对。
|
||||
|
||||
### 16.1 路由缓存和分阶段核验改造
|
||||
|
||||
2026-09-27 已完成下一轮复测所需代码调整:
|
||||
|
||||
- MySQL `TenantAdapterFor` 增加 1 分钟租户路由有效期;有效期内直接复用 tenant adapter,不再读取 `hotgo.im_tenant`。
|
||||
- 同一租户的缓存刷新使用 singleflight 合并,TTL 到期瞬间最多执行一次注册库查询,避免并发击穿。
|
||||
- `tenant.updated`、`tenant.status.changed`、`tenant.limits.updated`、`tenant.features.updated` 和 `cache.tenant.invalidate` 继续使用现有 Kafka 广播事件;`InvalidateTenantConfigCache` 现在同时使数据库路由缓存失效。失效与刷新并发时通过 generation 校验阻止旧查询重新写入有效缓存。
|
||||
- 压测配置新增 `history_settle`,默认 5 分钟。实时 drain 完成后先等待该窗口,再发起历史查询,从而单独测量 Kafka 自然排空时间。
|
||||
- 历史查询默认并发从每机 48 降为 12;历史读取高并发能力后续使用独立读写混合场景验证,不再污染消息落库吞吐结果。
|
||||
|
||||
## 17. 路由缓存发布后 500 对复测与延迟改造
|
||||
|
||||
Run ID:`p2p500-r3-20260927-03`。A/B 各 500 条 WebSocket、每人发送 10 条消息,总计 10000 次 Publish。
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---:|
|
||||
| WS Ready | 1000/1000 |
|
||||
| Publish ACK | 10000/10000 |
|
||||
| 实时到达 / 丢失 | 10000/10000 / 0 |
|
||||
| 历史回查 / 落库丢失 | 10000/10000 / 0 |
|
||||
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
|
||||
| 总体精确 ACK P95 | 1308.150 ms |
|
||||
| A / B 精确 ACK P95 | 1121.2 / 1519.2 ms |
|
||||
| 原报告投递 P95 | 2000 ms(受双机时钟偏差污染) |
|
||||
| Kafka 最终 lag | 0 |
|
||||
| `hotgo` pool wait | 0 |
|
||||
|
||||
租户路由缓存已经消除 `hotgo` 热路径等待,消息正确性和最终落库全部通过。剩余 ACK 延迟集中在消息 4–6 的突发积压阶段。发压窗口内,`meeting_im` 连接池出现明显等待;同时 Kafka persistence consumer 正在使用同一连接池。Cluster Route RPC 平均约 1–2ms,P2M 队列始终为 0,因此跨 Pod RPC 不是秒级延迟来源。
|
||||
|
||||
原投递 P95 不能作为服务端结论:压测程序使用发送机绝对时间戳在接收机计算单向耗时。A 只接受了 5777 个非负样本,而 B 接受了全部 10000 个样本,说明 B 时钟领先 A;A 丢弃负值,B 则把时钟偏差计入延迟。
|
||||
|
||||
本次完成以下改造:
|
||||
|
||||
1. 压测程序新增同机与跨机两个投递直方图;`delivery_local_p95_ms` 作为门禁,跨机负延迟计入 `cross_machine_negative_latency_samples`,不再静默丢弃后继续生成误导性 P95。
|
||||
2. 压测消息显式携带 `sender_machine`,旧消息仍可从 `client_mid` 兼容识别来源。
|
||||
3. 好友关系首次数据库确认后双向回填 Redis,减少双向聊天后续回源。
|
||||
4. 新增 `im_message_publish_stage_duration_seconds{kind,stage,result}`,覆盖 `relationship_check`、`redis_idempotency`、`redis_sequence`、`kafka_produce` 和整体 `save` 阶段。
|
||||
5. 新增 `log_client_packets` 配置,默认关闭逐包 INFO 日志,避免每条消息额外 JSON 解析和容器 stdout I/O;需要诊断时才短期开启。
|
||||
|
||||
下一轮仍使用 500 对复测。重点比较 `relationship_check`、`kafka_produce` 和 `save` 的 P95,并以 `delivery_local_p95_ms` 判断投递延迟;跨机延迟只有在 chrony/NTP 偏差确认小于 5ms 后才用于验收。数据库连接池暂不扩大,避免再次超过 MySQL 全局 170 连接预算;若 `relationship_check` 已稳定但 pool wait 与 Kafka lag 仍持续增长,再设计 persistence 独立连接预算或批量更新方案。
|
||||
@@ -0,0 +1,55 @@
|
||||
# IM 群消息按月分表本地验收报告
|
||||
|
||||
## 验收环境
|
||||
|
||||
- 时间:2026-09-15
|
||||
- IM Server:`http://127.0.0.1:6060`
|
||||
- WebSocket:`ws://127.0.0.1:6060/v0/channels`
|
||||
- MySQL:`127.0.0.1:3306`,租户库 `tinode`
|
||||
- Redis:`127.0.0.1:6379`
|
||||
- Kafka:`127.0.0.1:9092`
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 项目 | 结果 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 编译、定向单测、Go Vet | 通过 | MySQL、Store、Server 本次变更相关用例全部通过 |
|
||||
| WebSocket 协议审计 | 通过 | 29/29,覆盖注册、登录、P2P、群聊、文件、回执、通话与异常鉴权 |
|
||||
| HTTP 接口审计 | 通过 | 98/98,未发现非预期失败或非预期 404 |
|
||||
| Kafka 群消息异步落库 | 通过 | 消费成功,消费组 LAG 为 0 |
|
||||
| 群消息分月写入 | 通过 | 新消息写入 `im_group_messages_202609`,未写入旧表 `im_group_messages` |
|
||||
| Kafka 重放幂等 | 通过 | 同一消息使用新事件 ID 重放后仍只有一行,路由计数不重复增加 |
|
||||
| Redis Seq | 通过 | 群 Topic Seq 原子增长,键 TTL 为 `-1` |
|
||||
| Redis 月份路由 | 通过 | 路由和元数据完整,键 TTL 为 `-1` |
|
||||
| 服务重启后历史拉取 | 通过 | 清除进程内 Topic 后,客户端可从分表恢复历史消息 |
|
||||
| 跨月合并查询 | 通过 | 构造 202608、202609 两个月份数据后,一次订阅正确返回两个分表的消息 |
|
||||
| 并发注册与群消息冒烟 | 通过 | 3 组并发、每组 29/29;账号编号无重复,群消息全部落入月表 |
|
||||
| Manager 内部接口 | 通过 | 56/56,覆盖列表、详情、分页、筛选、用户创建及回读 |
|
||||
| Manager HMAC 安全 | 通过 | 未签名、错签名、过期签名均返回 401,重复 Nonce 第二次返回 401 |
|
||||
|
||||
## 测试中修复
|
||||
|
||||
1. 注册写入未生成 `im_users.account_no`,第二个新用户会命中唯一键冲突。现改为租户库单行序列表原子分配。
|
||||
2. Kafka 群消息生产前未分配持久化 `message_id`,消费者会判定消息格式不完整。现由生产端生成并随事件固定传递。
|
||||
3. HTTP 审计工具过早执行退出登录,导致后续接口误报 401。现将退出登录移到整轮审计末尾。
|
||||
4. WebSocket 审计工具并发运行时可能生成相同手机号。现将进程 ID 纳入测试数据后缀。
|
||||
5. 个人资料刷新错误地向订阅表写入不存在的 `public` 字段。现改为清理好友订阅缓存并在下次读取时重新投影。
|
||||
6. `Uid` 零值序列化为空字符串后无法反序列化,导致群配置缓存持续回源。现已补齐零值 JSON 往返支持及单测。
|
||||
7. Manager 的看板和群消息查询仍只统计历史总表。现已按分片元数据合并月表与未迁移历史数据。
|
||||
8. 群消息历史表与月表 collation 不一致,`UNION` 查询返回 500。现已在历史分支显式统一 collation。
|
||||
9. Manager 群消息详情、文件详情和审计日志仍使用旧表或旧字段口径。现已支持月表详情、19 位数字文件 ID,并完成审计字段映射。
|
||||
10. Manager 群列表混入 `p2p` 和 `slf` Topic。现已在数据源层限定仅返回 `grp` Topic。
|
||||
11. Manager 内部查询输出的用户相关 ID 已统一为数据库 `BIGINT/int64`,不再转换为 `usr...`。
|
||||
|
||||
## Manager 内部接口验收
|
||||
|
||||
- 基础查询:用户、群组、私聊消息、群消息、文件、登录日志、审计、实名、举报、反馈、配置、工作台、组织架构和看板均返回 200。
|
||||
- 群治理查询:群详情、成员、入群申请、邀请、公告、禁言、管理日志和群文件均通过。
|
||||
- 运维查询:业务 Outbox、群 Outbox、Kafka 死信列表及详情均通过。
|
||||
- 写入冒烟:`POST /internal/admin-query/tenants/default/users` 创建成功,返回数字型 `user_id`,随后详情回读成功。
|
||||
- 协议口径:`user_id`、`owner_id`、`from_user_id`、`file_id` 和 `seq_id` 均按 JSON number 返回;群列表仅包含 `grp` Topic。
|
||||
- 安全边界:HMAC 签名覆盖 GET/POST,`X-Nonce` 为一次性值,防重放状态由 Redis 保持。
|
||||
|
||||
## 结论
|
||||
|
||||
本次群消息按月分表相关的写入、读取、重启恢复、跨月查询、Kafka 幂等、Redis 路由及并发链路均已通过本地验收;Manager 内部接口 56/56 及 HMAC 安全用例也已全部通过,可以进入测试环境验证。外部 OSS、内容安全、Push 和真实音视频媒体质量不属于本轮本地验收范围。
|
||||
@@ -0,0 +1,78 @@
|
||||
# IM 群聊测试环境验收报告
|
||||
|
||||
## 1. 测试信息
|
||||
|
||||
- 测试时间:2026-09-16
|
||||
- HTTP:`https://api.118im.com`
|
||||
- WebSocket:`wss://api.118im.com/v0/channels`
|
||||
- 租户:`default`
|
||||
- 数据库:测试环境租户库 `tinode`
|
||||
- Redis:测试环境 Redis
|
||||
- Kafka:测试环境 Kafka
|
||||
- API Key、Token、数据库与中间件凭据未写入报告。
|
||||
|
||||
## 2. 验收结论
|
||||
|
||||
群聊主要链路通过;群主转让测试发现一处群设置缓存版本未失效问题,代码已修复,等待重新发布后复验转让与解散:
|
||||
|
||||
- 群业务 HTTP 深度审计:基础及治理、审批项 `41/41` 通过。
|
||||
- 群 WebSocket、消息、文件专项:`16/16` 通过。
|
||||
- 群实时收发核心路径额外连续执行 3 轮,`24/24` 通过。
|
||||
- 群消息 Kafka 消费组 lag 为 `0`。
|
||||
- 群主转让:测试环境当前版本失败,返回 `409 VERSION_CONFLICT`;已定位并修复,需发布复验。
|
||||
- 群解散:依赖转让成功后的新群主身份,本轮未能完成最终测试环境验收。
|
||||
|
||||
## 3. 功能结果
|
||||
|
||||
| 范围 | 结果 | 验证内容 |
|
||||
| --- | --- | --- |
|
||||
| 群基础管理 | 通过 | 创建、列表、详情、资料修改、个人群设置、群昵称、成员列表与成员资料 |
|
||||
| 群治理 | 通过 | 治理配置读取与成功更新,普通成员越权返回 403,版本号递增 |
|
||||
| 管理员 | 通过 | 管理员数量限制、设置管理员、管理员修改治理配置、取消管理员 |
|
||||
| 禁言 | 通过 | 群主禁言、成员查询自身禁言、被禁言 WS 发消息返回 403、解禁后立即恢复发送 |
|
||||
| 公告 | 通过 | 发布公告、公告详情、成员已读 |
|
||||
| 入群审批 | 通过 | 非成员申请、普通成员不可读审批、群主查询并通过、新成员进群后可访问、主动退群 |
|
||||
| 实时消息 | 通过 | 双端订阅、双向广播、历史拉取,连续 3 轮均成功 |
|
||||
| 消息幂等 | 通过 | 相同 `x-im-client-mid` 重复发送返回同一 `seq`,数据库仅保留一条消息 |
|
||||
| 消息查询 | 通过 | 消息上下文、聊天记录搜索 |
|
||||
| 消息撤回 | 通过 | 发送者在时限内撤回,状态变为 `recalled`,版本由 1 递增为 2 |
|
||||
| 群文件 | 通过 | 文件上传 OSS、关联群文件、成员检索、群主删除关联 |
|
||||
| 权限隔离 | 通过 | 普通成员修改治理、读取管理限制、禁言他人、读取审批列表均被拒绝 |
|
||||
| 群主转让 | 待复验 | 二次验证成功,但读取到缓存版本 4,MySQL 实际版本为 6,提交返回版本冲突 |
|
||||
| 群解散 | 待复验 | 转让失败后目标用户不是群主,解散按权限规则返回 403 |
|
||||
|
||||
## 4. 数据链路核验
|
||||
|
||||
最终消息验证群:`grpMQrTw9wvUlA`。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 月表落库 | `im_group_messages_202609` 存在 `seq=1,2,3` 三条消息 |
|
||||
| 旧表隔离 | `im_group_messages` 中该群消息数量为 0 |
|
||||
| 撤回状态 | `seq=1` 为 `recalled`,`version=2` |
|
||||
| 幂等记录 | `dedup-16737970` 对应 `seq=2`,状态 `done`,无重复行 |
|
||||
| 月段路由 | `202609`,`min_seq=1`,`max_seq=3`,`message_count=3` |
|
||||
| Redis 原子序号 | `last_seq=3` |
|
||||
| Redis 热消息索引 | 包含 `seq=1,2,3` |
|
||||
| Redis 月段路由 | `202609 -> 1`,元数据为 `1:3:3` |
|
||||
| Kafka 异步落库 | `im-server.group_message_persist` 检查时当前 offset 62、末端 offset 62、lag 0 |
|
||||
| 禁言业务状态 | 三轮禁言记录均由 `active` 正常流转为 `revoked`,版本为 2 |
|
||||
|
||||
## 5. 测试数据
|
||||
|
||||
本轮创建了多组 `a*`、`b*`、`c*` 自动化用户和 `grp*` 自动化群,均位于测试租户 `default`。数据用于回归追踪,后续可按账号前缀和测试时间清理。
|
||||
|
||||
## 6. 补充说明
|
||||
|
||||
综合 WebSocket 审计中观察到一次 P2P `read` 回执等待超时;群聊段已改为独立重连后执行,所有群聊用例稳定通过。该现象不影响本报告的群聊验收结论,但建议后续单独对 P2P 回执跨节点投递做压力与稳定性测试。
|
||||
|
||||
## 7. 已定位问题与修复
|
||||
|
||||
入群审批成功和成员退群都会在 MySQL 中重算人数并递增 `im_group_settings.version`,但原实现只清理成员分页缓存,没有清理 Redis `group_settings` 对象缓存。群治理接口因此返回旧版本,导致后续群主转让稳定触发版本冲突。
|
||||
|
||||
本轮已在成员直接邀请、接受邀请、审批通过、主动退群和群主转让后统一清理群设置缓存,并增加缓存键单元测试。相关定向测试通过:
|
||||
|
||||
- `go test ./server/store ./server/db/mysql`
|
||||
- `go test ./server -run 'TestServeIMGroup|TestGroup' -count=1`
|
||||
|
||||
修复尚未发布到测试环境;发布后应重新执行“读取最新版本 -> 群主转让 -> 新群主解散 -> 旧成员读取返回 410”闭环。
|
||||
@@ -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 验证完整客户端上传链路。
|
||||
@@ -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';
|
||||