Compare commits

...
25 Commits
Author SHA1 Message Date
richjohnrambo cb7dc36cad feat: add attendance APIs 2026-08-31 13:27:12 +08:00
richjohnrambo 9f647de4d0 feat: switch client API branding to MeetingIM 2026-08-31 01:22:53 +08:00
richjohnrambo c6a243ee59 Add meeting display fields 2026-08-29 19:28:10 +08:00
richjohnrambo 866222cb39 Fix missing risk challenge response 2026-08-29 10:59:50 +08:00
richjohnrambo 42916b85aa Fix meeting list and media search issues 2026-08-29 09:46:50 +08:00
richjohnrambo 78e6308174 fix im business event delivery 2026-08-28 16:24:57 +08:00
richjohnrambo 9a46fdf43c feat: handle password reset admin command 2026-08-28 14:18:27 +08:00
richjohnrambo d756aeb4c2 fix: serialize internal user ids as strings 2026-08-28 13:07:17 +08:00
richjohnrambo 5987f2d87a fix: return not found for missing internal user 2026-08-28 13:05:26 +08:00
richjohnrambo 08a4f138e3 fix: skip nonce replay check for internal reads 2026-08-28 11:28:28 +08:00
richjohnrambo 4ea828fb09 fix: tolerate retried internal api nonce 2026-08-28 10:43:01 +08:00
richjohnrambo ba06ee3472 chore: use internal admin service url 2026-08-28 01:08:51 +08:00
richjohnrambo a54ddfa548 chore: use public admin internal api 2026-08-28 00:48:42 +08:00
richjohnrambo 94552a84b4 chore: configure internal api auth 2026-08-28 00:40:40 +08:00
richjohnrambo 84439d63cb chore: configure test kafka 2026-08-28 00:35:10 +08:00
richjohnrambo ca734a48ab feat: support kafka scram auth 2026-08-28 00:31:08 +08:00
richjohnrambo 5cc6eebc1a feat: add kafka admin integration 2026-08-28 00:05:54 +08:00
richjohnrambo b2e5097708 feat: add daily report list preview 2026-08-27 10:08:03 +08:00
richjohnrambo a0fc553c6f fix: improve conversation and report stability 2026-08-26 14:12:58 +08:00
richjohnrambo dc80aa5104 fix: stabilize mysql message reads 2026-08-26 00:03:16 +08:00
richjohnrambo 58cce46dee fix: restore tenant id on cached store objects 2026-08-25 23:20:19 +08:00
richjohnrambo eac2bc2a47 fix: load verification challenges from db 2026-08-25 22:35:09 +08:00
richjohnrambo c020171e52 fix: restore mysql docker build 2026-08-25 22:17:59 +08:00
richjohnrambo f900a08a01 feat: add redis cache and fanout support 2026-08-25 20:54:03 +08:00
richjohnrambo 07376c70e3 fix: stabilize im server startup and p2p topic scans 2026-08-24 16:20:02 +08:00
145 changed files with 15442 additions and 6069 deletions
+4
View File
@@ -13,3 +13,7 @@
/.codex-gocache/ /.codex-gocache/
/.github/ /.github/
/.github/ /.github/
/docs/third/三方服务依赖信息.md
/docs/test/im_third_party_file_safety_test_plan.md
docs/*.zip
+1 -1
View File
@@ -45,7 +45,7 @@ rm -fR ./releases/tmp
mkdir -p ./releases/tmp/templ mkdir -p ./releases/tmp/templ
# Copy templates and database initialization files # Copy templates and database initialization files
cp ./server/tinode.conf ./releases/tmp cp ./server/meetingim.conf ./releases/tmp
cp ./server/templ/*.templ ./releases/tmp/templ cp ./server/templ/*.templ ./releases/tmp/templ
cp ./tinode-db/data.json ./releases/tmp cp ./tinode-db/data.json ./releases/tmp
cp ./tinode-db/*.jpg ./releases/tmp cp ./tinode-db/*.jpg ./releases/tmp
+86 -3
View File
@@ -18,11 +18,23 @@
多通过配置的方式处理 前端不要写死 多通过配置的方式处理 前端不要写死
一定要考虑好群聊的推送问题 一定要考虑好群聊的推送问题
离线和在线状态存到redis里面 离线和在线状态存到redis里面 这个是一个难点
离线的不推
离线的我们的服务不推 走极光的推送 离线的我们的服务不推 走极光的推送
在线的直接ws推
多用开关 多用开关
校验营业执照
第三方
短信
kyc
营业执照
声网 Agora 音视频服务
极光推送 离线 Push 服务
oss对象存储
文件安全扫描 / 内容安全服务
admin后台群操作通过kafka投递到c端 c端通过ws传到客户端 admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
记录先放到Kafka、 再异步落库。用考虑重复落库的可能 记录先放到Kafka、 再异步落库。用考虑重复落库的可能
@@ -33,7 +45,78 @@ admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
5. 有 OpenIM 或类似 IM 项目经验优先。 5. 有 OpenIM 或类似 IM 项目经验优先。
1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行 1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行
2.验证码验证作为开关 关闭的情况下的话后台不想第三方发送请求,前端正常ui的倒计时,但是收不到验证码 2.验证码验证作为开关 关闭的情况下的话后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码
3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册 3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册
这个是现在的情况
后台开关在系统设置 字典管理 增加一个系统枚举 系统开关的 ID = 171
用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段 用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段
看看对接文档1.4的注册页面 增加逻辑 1.注册邀请码 (非必填 )不填邀请码可以正常注册。用真实手机号注册的,且后台发送注册验证码的开关打开的情况下,可以收到验证码的流程也能正常注册
2.发送注册验证码的开关关闭的情况下,后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码,此时填了邀请码,且邀请码校验合格的话,没有验证码仍然可以注册
我看了很多users相关的表 你总结一下分别是什么作用
你看看手机号是如何存的
im_users的 tags 有啥用 存的啥
昵称用中文实现
对一样的,因为给好友改了备注,所以上面就会显示,但是昵称是好友,本身自己起的网名,如果对方没有起,系统会默认给一个
用户名和手机号注册的话 如果验证码开关是开的 以验证码为准 如果没开 就以邀请码流程 用户名不能忽略 要保存
前后端对接文档 补充 请求和响应的json
文档的覆盖测试
发的网站功能的复制
web地址:https://web.xcwykk.top/
管理后台地址:https://admin.adfahi.cn
账号:heiyue
密码:Heiyue123
测试名额:100
群操作
文件操作
redis的接入 性能提升
kafka的接入 异步操作 幂等性
弱网
QUIC传输技术
日志框架的接入
重中之重。加密 + 接口鉴权 防止被扫 日志要记录下来 每个小时告警
法相 (三方IM 资金电话确认), [Aug 27, 2026 at 22:13:24]:
....进群的时候拿群成员列表信息 这个怎么做成缓存 然后怎么更新缓存
2.多标签要独立的登录状态,可以用不同域名来解决这个问题,但是要token过期时间要限制一下 7天或者10天 每次登录都重置这个时间
3.转发消息 涉及到转发群和转发个人 大批量转发 消息会不会串
4.我在第一个标签转发了100个群,然后直接切换到第二个标签,你第一个标签还没转发完 你该怎么处理 会不会串到第二个标签页
群成员信息这个是很耗性能的 一个群几千个人 你要考虑是进一次就拿一次群成员信息 还是拿一次
我说的进不是给你拉进群 是你点击群聊这个会话框
+3 -3
View File
@@ -82,7 +82,7 @@ type audit struct {
func main() { func main() {
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL") baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL") wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key") apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "MeetingIM API key")
tenant := flag.String("tenant", "default", "tenant code") tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path") out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path")
flag.Parse() flag.Parse()
@@ -442,7 +442,7 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
if err != nil { if err != nil {
return 0, imResp{}, "", err return 0, imResp{}, "", err
} }
req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")) req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", ""))
req.Header.Set("X-Tenant-Code", a.tenantCode) req.Header.Set("X-Tenant-Code", a.tenantCode)
@@ -452,7 +452,7 @@ func (a *audit) call(method, path string, body any, token string, idem bool) (in
if token != "" { if token != "" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
if strings.HasPrefix(path, "/v0/file/") { if strings.HasPrefix(path, "/v0/file/") {
req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token))) req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
} }
} }
if idem { if idem {
+6 -6
View File
@@ -70,7 +70,7 @@ type wsClient struct {
func main() { func main() {
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL") baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL") wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key") apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "MeetingIM API key")
tenant := flag.String("tenant", "default", "tenant code") tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_websocket_testenv_results_2026-08-21.md", "report path") out := flag.String("out", "docs/test/im_websocket_testenv_results_2026-08-21.md", "report path")
flag.Parse() flag.Parse()
@@ -601,7 +601,7 @@ func (a *audit) uploadFile() (string, error) {
return "", err return "", err
} }
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenantCode) req.Header.Set("X-Tenant-Code", a.tenantCode)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
@@ -771,7 +771,7 @@ func (c *wsClient) pubAndPeerReceives(peer *wsClient, topic, text string) (int,
"id": id, "id": id,
"topic": topic, "topic": topic,
"head": map[string]any{"mime": "text/plain"}, "head": map[string]any{"mime": "text/plain"},
// Keep this as native Tinode text content. Product-specific x-im // Keep this as native MeetingIM text content. Product-specific x-im
// headers should be covered by a separate WS message-header matrix. // headers should be covered by a separate WS message-header matrix.
"content": text, "content": text,
}}); err != nil { }}); err != nil {
@@ -945,7 +945,7 @@ func (a *audit) call(method, path string, body any, token string) (int, imResp,
return 0, imResp{}, err return 0, imResp{}, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tinode-APIKey", a.apiKey) req.Header.Set("X-MeetingIM-APIKey", a.apiKey)
req.Header.Set("X-Tenant-Code", a.tenantCode) req.Header.Set("X-Tenant-Code", a.tenantCode)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "req-ws-audit-"+fmt.Sprintf("%d", time.Now().UnixNano())) req.Header.Set("X-Request-ID", "req-ws-audit-"+fmt.Sprintf("%d", time.Now().UnixNano()))
@@ -954,7 +954,7 @@ func (a *audit) call(method, path string, body any, token string) (int, imResp,
} }
if token != "" { if token != "" {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token))) req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
} }
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
@@ -986,7 +986,7 @@ func (a *audit) report() string {
fmt.Fprintf(&b, "- 用户 B:`%s` / `%s` / `%s`\n", a.userB, a.usernameB, a.phoneB) fmt.Fprintf(&b, "- 用户 B:`%s` / `%s` / `%s`\n", a.userB, a.usernameB, a.phoneB)
fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n") fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n")
fmt.Fprintf(&b, "## 2. 总体结论\n\n") fmt.Fprintf(&b, "## 2. 总体结论\n\n")
fmt.Fprintf(&b, "本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `%d` 项,通过 `%d` 项,未通过 `%d` 项。\n\n", total, passed, total-passed) fmt.Fprintf(&b, "本轮专项验证 MeetingIM WebSocket 真实协议链路,共执行 `%d` 项,通过 `%d` 项,未通过 `%d` 项。\n\n", total, passed, total-passed)
fmt.Fprintf(&b, "| ID | 场景 | 方法 | 目标 | 结果 | 备注 |\n| --- | --- | --- | --- | --- | --- |\n") fmt.Fprintf(&b, "| ID | 场景 | 方法 | 目标 | 结果 | 备注 |\n| --- | --- | --- | --- | --- | --- |\n")
for _, r := range a.results { for _, r := range a.results {
status := "通过" status := "通过"
+5
View File
@@ -22,6 +22,11 @@ x-exporter:
x-tinode-env-vars: &tinode-env-vars x-tinode-env-vars: &tinode-env-vars
"STORE_USE_ADAPTER": "mysql" "STORE_USE_ADAPTER": "mysql"
"PPROF_URL": "/pprof" "PPROF_URL": "/pprof"
"REDIS_ADDR": "host.docker.internal:56379"
"REDIS_PASSWORD": "HMTJzkm.h>Y+E9ep"
"REDIS_DB": 0
"REDIS_KEY_PREFIX": "im"
"REDIS_GROUP_FANOUT_ENABLED": "true"
# You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to
# "EXT_CONFIG": "/etc/tinode/tinode.conf" # "EXT_CONFIG": "/etc/tinode/tinode.conf"
"WAIT_FOR": "mysql:3306" "WAIT_FOR": "mysql:3306"
@@ -17,6 +17,11 @@ x-tinode:
x-tinode-env-vars: &tinode-env-vars x-tinode-env-vars: &tinode-env-vars
"STORE_USE_ADAPTER": "mysql" "STORE_USE_ADAPTER": "mysql"
"PPROF_URL": "/pprof" "PPROF_URL": "/pprof"
"REDIS_ADDR": "host.docker.internal:56379"
"REDIS_PASSWORD": "HMTJzkm.h>Y+E9ep"
"REDIS_DB": 0
"REDIS_KEY_PREFIX": "im"
"REDIS_GROUP_FANOUT_ENABLED": "true"
# You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to
# "EXT_CONFIG": "/etc/tinode/tinode.conf" # "EXT_CONFIG": "/etc/tinode/tinode.conf"
"WAIT_FOR": "mysql:3306" "WAIT_FOR": "mysql:3306"
+57 -2
View File
@@ -41,7 +41,7 @@ ENV RESET_DB=false
ENV UPGRADE_DB=false ENV UPGRADE_DB=false
# Option to skip DB initialization when it's missing. # Option to skip DB initialization when it's missing.
ENV NO_DB_INIT=false ENV NO_DB_INIT=true
# Load sample data to database from data.json. # Load sample data to database from data.json.
ARG SAMPLE_DATA=data.json ARG SAMPLE_DATA=data.json
@@ -52,6 +52,60 @@ ENV DEFAULT_COUNTRY_CODE=US
# The MySQL DSN connection. # The MySQL DSN connection.
ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci' ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci'
ENV REDIS_ADDR=
ENV REDIS_USERNAME=
ENV REDIS_PASSWORD=
ENV REDIS_DB=0
ENV REDIS_KEY_PREFIX=im
ENV REDIS_DIAL_TIMEOUT_MS=2000
ENV REDIS_READ_TIMEOUT_MS=2000
ENV REDIS_WRITE_TIMEOUT_MS=2000
ENV REDIS_PENDING_TTL_SECONDS=120
ENV REDIS_DONE_TTL_SECONDS=86400
ENV REDIS_FAILED_TTL_SECONDS=600
ENV REDIS_HOT_MESSAGE_TTL_SECONDS=86400
ENV REDIS_PRESENCE_TTL_SECONDS=120
ENV REDIS_CACHE_TTL_SECONDS=300
ENV REDIS_SHORT_STATE_TTL_SECONDS=600
ENV REDIS_GROUP_FANOUT_ENABLED=true
ENV KAFKA_ENABLED=false
ENV KAFKA_BROKERS_JSON='[]'
ENV KAFKA_CLIENT_ID=im-server
ENV KAFKA_CONSUMER_GROUP=im-server
ENV KAFKA_DIAL_TIMEOUT_MS=3000
ENV KAFKA_WRITE_TIMEOUT_MS=3000
ENV KAFKA_READ_TIMEOUT_MS=3000
ENV KAFKA_REQUIRED_ACKS=1
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
ENV KAFKA_TLS_ENABLED=false
ENV KAFKA_SASL_ENABLED=false
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=5
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
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
ENV KAFKA_TOPIC_CONFIG_EVENT=im.config.event
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
ENV KAFKA_TOPIC_MESSAGE_PUSH=im.message.push
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_FILE_LIFECYCLE=im.file.lifecycle
ENV KAFKA_TOPIC_WORKBENCH_EVENT=im.workbench.event
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=false
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET=
ENV ADMIN_INTERNAL_API_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# The PostgreSQL DSN connection. # 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/tinode?sslmode=disable&connect_timeout=10'
@@ -130,7 +184,7 @@ ENV TNPG_ORG=
# Video calls configuration. # Video calls configuration.
ENV WEBRTC_ENABLED=false ENV WEBRTC_ENABLED=false
ENV ICE_SERVERS_FILE= ENV ICE_SERVERS_FILE=/opt/tinode/ice-servers.json
# Use the target db by default. # Use the target db by default.
# When TARGET_DB is "alldbs", it is the user's responsibility # When TARGET_DB is "alldbs", it is the user's responsibility
@@ -153,6 +207,7 @@ WORKDIR /opt/tinode
# Copy config template to the container. # Copy config template to the container.
COPY config.template . COPY config.template .
COPY entrypoint.sh . COPY entrypoint.sh .
COPY ice-servers.json .
# Get the desired Tinode build. # Get the desired Tinode build.
ADD https://github.com/tinode/chat/releases/download/v$BINVERS/tinode-$TARGET_DB.linux-amd64.tar.gz . ADD https://github.com/tinode/chat/releases/download/v$BINVERS/tinode-$TARGET_DB.linux-amd64.tar.gz .
+55 -10
View File
@@ -27,15 +27,65 @@ LABEL name="ImServer"
ENV WAIT_FOR= ENV WAIT_FOR=
ENV RESET_DB=false ENV RESET_DB=false
ENV UPGRADE_DB=false ENV UPGRADE_DB=false
ENV NO_DB_INIT=false ENV NO_DB_INIT=true
# Empty by default: do not load Tinode's canned demo users into a real deployment. # Empty by default: do not load Tinode's canned demo users into a real deployment.
# Override explicitly if sample data is ever wanted. # Override explicitly if sample data is ever wanted.
ENV SAMPLE_DATA= ENV SAMPLE_DATA=
ENV DEFAULT_COUNTRY_CODE=US ENV DEFAULT_COUNTRY_CODE=US
# DB connection is provided at deploy time via secret-backed env var. No default value.
ENV MYSQL_DSN=
ENV STORE_USE_ADAPTER=mysql ENV STORE_USE_ADAPTER=mysql
ENV REDIS_ADDR=
ENV REDIS_USERNAME=
ENV REDIS_DB=0
ENV REDIS_DIAL_TIMEOUT_MS=2000
ENV REDIS_READ_TIMEOUT_MS=2000
ENV REDIS_WRITE_TIMEOUT_MS=2000
ENV REDIS_PENDING_TTL_SECONDS=120
ENV REDIS_DONE_TTL_SECONDS=86400
ENV REDIS_FAILED_TTL_SECONDS=600
ENV REDIS_HOT_MESSAGE_TTL_SECONDS=86400
ENV REDIS_PRESENCE_TTL_SECONDS=120
ENV REDIS_CACHE_TTL_SECONDS=300
ENV REDIS_SHORT_STATE_TTL_SECONDS=600
ENV REDIS_GROUP_FANOUT_ENABLED=true
ENV KAFKA_ENABLED=false
ENV KAFKA_BROKERS_JSON='[]'
ENV KAFKA_CLIENT_ID=im-server
ENV KAFKA_CONSUMER_GROUP=im-server
ENV KAFKA_DIAL_TIMEOUT_MS=3000
ENV KAFKA_WRITE_TIMEOUT_MS=3000
ENV KAFKA_READ_TIMEOUT_MS=3000
ENV KAFKA_REQUIRED_ACKS=1
ENV KAFKA_ALLOW_AUTO_TOPIC_CREATION=false
ENV KAFKA_TLS_ENABLED=false
ENV KAFKA_SASL_ENABLED=false
ENV KAFKA_SASL_MECHANISM=SCRAM-SHA-512
ENV KAFKA_SASL_USERNAME=
ENV KAFKA_SASL_PASSWORD=
ENV KAFKA_MAX_RETRY_COUNT=5
ENV KAFKA_RETRY_BACKOFF_MS='[10000,30000,60000,300000,900000]'
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
ENV KAFKA_TOPIC_CONFIG_EVENT=im.config.event
ENV KAFKA_TOPIC_BUSINESS_EVENT=im.business.event
ENV KAFKA_TOPIC_MESSAGE_PERSIST=im.message.persist
ENV KAFKA_TOPIC_MESSAGE_PUSH=im.message.push
ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_FILE_LIFECYCLE=im.file.lifecycle
ENV KAFKA_TOPIC_WORKBENCH_EVENT=im.workbench.event
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=false
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET=
ENV ADMIN_INTERNAL_API_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# Tenant registry lives in the hotgo database (shared with im_manager); tenant # 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. # DB routing resolves to the same tinode DB for the seeded "default" tenant.
@@ -51,23 +101,18 @@ ENV FCM_PUSH_ENABLED=false
ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true
ENV TNPG_PUSH_ENABLED=false ENV TNPG_PUSH_ENABLED=false
ENV WEBRTC_ENABLED=false ENV WEBRTC_ENABLED=false
ENV ICE_SERVERS_FILE=/opt/tinode/ice-servers.json
ENV SERVER_STATUS_PATH='' ENV SERVER_STATUS_PATH=''
ENV ACC_GC_ENABLED=false ENV ACC_GC_ENABLED=false
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
# Secrets: intentionally NO default values. Must be injected at deploy time.
# An empty/missing value fails loudly at startup rather than silently falling
# back to Tinode's publicly-known demo keys.
ENV API_KEY_SALT=
ENV AUTH_TOKEN_KEY=
ENV UID_ENCRYPTION_KEY=
RUN apk add --no-cache ca-certificates bash grep RUN apk add --no-cache ca-certificates bash grep
WORKDIR /opt/tinode WORKDIR /opt/tinode
COPY docker/tinode/config.template . COPY docker/tinode/config.template .
COPY docker/tinode/entrypoint.sh . COPY docker/tinode/entrypoint.sh .
COPY docker/tinode/ice-servers.json .
COPY tinode-db/credentials.sh . COPY tinode-db/credentials.sh .
COPY server/templ ./templ COPY server/templ ./templ
COPY --from=build /out/tinode . COPY --from=build /out/tinode .
+69
View File
@@ -96,9 +96,78 @@
"addresses": "mongodb", "addresses": "mongodb",
"replica_set": "rs0" "replica_set": "rs0"
} }
},
"redis": {
"addr": "$REDIS_ADDR",
"username": "$REDIS_USERNAME",
"password": "$REDIS_PASSWORD",
"db": $REDIS_DB,
"key_prefix": "$REDIS_KEY_PREFIX",
"dial_timeout_ms": $REDIS_DIAL_TIMEOUT_MS,
"read_timeout_ms": $REDIS_READ_TIMEOUT_MS,
"write_timeout_ms": $REDIS_WRITE_TIMEOUT_MS,
"pending_ttl_seconds": $REDIS_PENDING_TTL_SECONDS,
"done_ttl_seconds": $REDIS_DONE_TTL_SECONDS,
"failed_ttl_seconds": $REDIS_FAILED_TTL_SECONDS,
"hot_message_ttl_seconds": $REDIS_HOT_MESSAGE_TTL_SECONDS,
"presence_ttl_seconds": $REDIS_PRESENCE_TTL_SECONDS,
"cache_ttl_seconds": $REDIS_CACHE_TTL_SECONDS,
"short_state_ttl_seconds": $REDIS_SHORT_STATE_TTL_SECONDS,
"group_fanout_enabled": $REDIS_GROUP_FANOUT_ENABLED
} }
}, },
"kafka": {
"enabled": true,
"brokers": ["64.118.137.182:9094"],
"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,
"required_acks": $KAFKA_REQUIRED_ACKS,
"allow_auto_topic_creation": $KAFKA_ALLOW_AUTO_TOPIC_CREATION,
"tls_enabled": false,
"sasl_enabled": true,
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": $KAFKA_MAX_RETRY_COUNT,
"retry_backoff_ms": $KAFKA_RETRY_BACKOFF_MS,
"dead_letter_retention_seconds": $KAFKA_DEAD_LETTER_RETENTION_SECONDS,
"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",
"message_push": "$KAFKA_TOPIC_MESSAGE_PUSH",
"file_security": "$KAFKA_TOPIC_FILE_SECURITY",
"file_lifecycle": "$KAFKA_TOPIC_FILE_LIFECYCLE",
"workbench_event": "$KAFKA_TOPIC_WORKBENCH_EVENT",
"cache_invalidate": "$KAFKA_TOPIC_CACHE_INVALIDATE",
"audit_event": "$KAFKA_TOPIC_AUDIT_EVENT",
"dead_letter": "$KAFKA_TOPIC_DEAD_LETTER"
}
},
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"services": {
"hotgo-admin": {
"secret": "8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b",
"allow": ["admin_query"]
}
}
},
"admin_internal_api": {
"enabled": true,
"base_url": "http://im-admin:8000",
"service_name": "im-server",
"secret": "b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097",
"timeout_ms": 3000
},
"acc_validation": { "acc_validation": {
"email": { "email": {
"add_to_tags": true, "add_to_tags": true,
+54 -20
View File
@@ -12,6 +12,8 @@ if [ ! -z "$EXT_CONFIG" ] ; then
else else
CONFIG=working.config CONFIG=working.config
: "${REDIS_KEY_PREFIX:=im}"
# Remove the old config. # Remove the old config.
rm -f working.config rm -f working.config
@@ -113,27 +115,59 @@ if [ ! -z "$WAIT_FOR" ] ; then
until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 3; done until nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo "waiting for ${WAIT_FOR}..."; sleep 3; done
fi fi
# Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested. run_with_retries() {
init_stdout=./init-db-stdout.txt local name="$1"
./init-db \ shift
--reset=${RESET_DB} \ local attempts="${STARTUP_RETRY_ATTEMPTS:-10}"
--upgrade=${UPGRADE_DB} \ local delay="${STARTUP_RETRY_DELAY_SECONDS:-3}"
--config=${CONFIG} \ local i=1
--data=${SAMPLE_DATA} \ while [ "$i" -le "$attempts" ]; do
--no_init=${NO_DB_INIT} \ "$@"
1>${init_stdout} local status=$?
if [ $? -ne 0 ]; then if [ "$status" -eq 0 ]; then
echo "./init-db failed. Quitting." return 0
exit 1 fi
fi echo "$name failed with status $status (attempt $i/$attempts)."
if [ "$i" -eq "$attempts" ]; then
return "$status"
fi
sleep "$delay"
i=$((i + 1))
done
}
# Create/upgrade the hotgo tenant registry and the workbench/org/meetings/ init_db_once() {
# reports/mailbox/etc. tables that init-db above does not manage. Idempotent ./init-db \
# (IF NOT EXISTS / ON DUPLICATE KEY UPDATE throughout), safe to run every start. --reset=${RESET_DB} \
IM_MYSQL_DSN="${MYSQL_DSN}" ./apply-hotgo-tenant-db --upgrade=${UPGRADE_DB} \
if [ $? -ne 0 ]; then --config=${CONFIG} \
echo "./apply-hotgo-tenant-db failed. Quitting." --data=${SAMPLE_DATA} \
exit 1 --no_init=${NO_DB_INIT} \
1>${init_stdout}
}
apply_hotgo_tenant_db_once() {
IM_MYSQL_DSN="${MYSQL_DSN}" ./apply-hotgo-tenant-db
}
init_stdout=./init-db-stdout.txt
if [ "$NO_DB_INIT" = "true" ] ; then
echo "NO_DB_INIT=true: skipping init-db and apply-hotgo-tenant-db."
else
# Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested.
run_with_retries "init-db" init_db_once
if [ $? -ne 0 ]; then
echo "./init-db failed. Quitting."
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.
run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once
if [ $? -ne 0 ]; then
echo "./apply-hotgo-tenant-db failed. Quitting."
exit 1
fi
fi fi
# If sample data was provided, try to find Tino password. # If sample data was provided, try to find Tino password.
+5
View File
@@ -0,0 +1,5 @@
[
{
"urls": ["stun:stun.l.google.com:19302"]
}
]
@@ -0,0 +1,599 @@
# IM Admin 内部查询接口缺口清单
> 目标:梳理 HotGo/Admin 迁移到“Admin 只访问 hotgo,IM Server 只访问租户库”后,IM Server 需要补齐的 `/internal/admin-query/*` 只读接口。
> 当前日期:2026-08-27
> 适用项目:`im_manager` Admin 后台、`im_server` IM 服务端。
## 1. 背景
Kafka 写链路已经在 Admin 侧补齐:Admin 写操作落 `im_admin_commands`、`im_admin_outbox`,通过 Kafka 发给 IM Server,IM Server 执行业务后回传 `im.admin.command.result`。
查询链路还没有完全隔离。Admin 当前仍有不少页面通过 `imtenant.DB()` 或 `resolveImTenantDB()` 直连租户库。若要严格满足目标架构,Admin 查询租户业务数据时应调用 IM Server 的内部只读接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/...
```
IM Server 当前已经具备部分内部查询接口,但还不能覆盖 Admin 后台页面。
## 2. 已有接口
以下接口在 IM Server 当前实现中已经存在,可供 Admin 先迁移:
| 接口 | 用途 | 备注 |
|---|---|---|
| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 用户详情 | 需要增强字段,见 P0。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 群详情和治理配置 | 需要增强 topic 基础字段,见 P0。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 群成员列表 | 当前偏 cursor 分页,Admin 表格还需要 total。 |
| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 消息详情 | 当前 Admin 暂未完整迁移消息页面。 |
| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 文件详情 | 还缺文件列表和引用列表。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | Kafka 死信列表 | Admin 已接入。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | Kafka 死信详情 | Admin 已接入。 |
## 3. 统一约定
### 3.1 鉴权
沿用 IM Server 内部 API HMAC 鉴权:
```text
X-Service-Name
X-Timestamp
X-Nonce
X-Signature
```
服务端需要校验:
1. service 是否存在。
2. service 是否包含 `admin_query` capability。
3. timestamp 是否在允许窗口内。
4. nonce 是否防重放。
5. 签名是否匹配。
### 3.2 分页返回
Admin 的 `BasicTable` 更适合 page/per_page/total 格式。建议所有列表接口统一返回:
```json
{
"code": 0,
"data": {
"items": [],
"total": 0,
"page": 1,
"per_page": 20
}
}
```
如果 IM Server 内部更适合 cursor,也建议同时返回:
```json
{
"code": 0,
"data": {
"items": [],
"total": 0,
"page": 1,
"per_page": 20,
"next_cursor": "",
"has_more": false
}
}
```
### 3.3 详情返回
详情接口统一返回:
```json
{
"code": 0,
"data": {}
}
```
### 3.4 时间与 ID
1. 时间统一返回 RFC3339/RFC3339Nano 字符串。
2. 雪花 ID、Tinode 用户 ID、19 位整数 ID 建议以字符串返回,避免前端丢精度。
3. 入参同时兼容字符串 ID 和纯数字 ID 时,IM Server 内部自行解析。
## 4. P0:优先补齐
P0 是 Admin 当前核心页面迁移所需接口,补完后可以先迁移用户、群组、文件等高频页面。
### 4.1 用户列表
```text
GET /internal/admin-query/tenants/{tenant_code}/users
```
用途:IM 用户管理列表。
建议查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `user_id` | string | 用户 ID,支持精确查询。 |
| `nickname` | string | 昵称模糊查询。 |
| `auth_name` | string | 登录标识,匹配 username/phone/email/凭证。 |
| `state` | int | 用户状态。 |
| `created_at_start` | string | 创建时间开始。 |
| `created_at_end` | string | 创建时间结束。 |
| `last_seen_start` | string | 最后在线开始。 |
| `last_seen_end` | string | 最后在线结束。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量,最大建议 200。 |
建议返回字段:
| 字段 | 说明 |
|---|---|
| `user_id` | 字符串用户 ID。 |
| `username` | 用户名。 |
| `phone` | 手机号。 |
| `nickname` | 昵称。 |
| `public_account` | 公开账号。 |
| `state` | 用户状态。 |
| `gender` | 性别。 |
| `region` | 地区。 |
| `auth_names` | 登录标识摘要。 |
| `credentials` | 凭证摘要。 |
| `auth_version` | 鉴权版本。 |
| `password_updated_at` | 密码更新时间。 |
| `last_seen` | 最后在线时间。 |
| `user_agent` | 最后设备 UA。 |
| `created_at` | 创建时间。 |
| `updated_at` | 更新时间。 |
当前 Admin 直连涉及表:`im_users`、`im_user_profiles`、`im_user_security`、`im_credentials`。
### 4.2 用户详情增强
现有接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}
```
建议增强返回字段:
| 字段 | 说明 |
|---|---|
| `profile` | 结构化用户资料。 |
| `public` | Tinode public JSON。 |
| `trusted` | Tinode trusted JSON。 |
| `access` | 访问权限 JSON。 |
| `tags` | 标签。 |
| `security` | `auth_version/password_updated_at` 等安全信息。 |
| `credentials` | 凭证列表,包含 method/value/done。 |
### 4.3 用户设备列表
```text
GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}/devices
```
用途:用户详情中的登录设备列表。
建议查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `device_id` | string | 设备 ID。 |
| `platform` | string | 平台。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量。 |
建议返回字段:
| 字段 | 说明 |
|---|---|
| `device_id` | 设备 ID。 |
| `platform` | 平台。 |
| `user_agent` | UA。 |
| `last_seen` | 最后在线。 |
| `created_at` | 创建时间。 |
| `updated_at` | 更新时间。 |
当前 Admin 直连涉及表:`im_devices`。
### 4.4 群列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups
```
用途:IM 群组管理列表。
建议查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `topic` | string | 群 Topic。 |
| `owner_id` | string | 群主 ID。 |
| `state` | int | Tinode topic 状态。 |
| `group_status` | string | 业务群状态。 |
| `created_at_start` | string | 创建时间开始。 |
| `created_at_end` | string | 创建时间结束。 |
| `touched_at_start` | string | 活跃时间开始。 |
| `touched_at_end` | string | 活跃时间结束。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量。 |
建议返回字段:
| 字段 | 说明 |
|---|---|
| `topic` | 群 Topic。 |
| `state` | Tinode 状态。 |
| `owner_id` | 群主 ID。 |
| `seq_id` | 最新消息序号。 |
| `del_id` | 删除序号。 |
| `sub_count` | Tinode 订阅成员数。 |
| `touched_at` | 活跃时间。 |
| `created_at` | 创建时间。 |
| `updated_at` | 更新时间。 |
| `public` | Tinode public JSON。 |
| `trusted` | Tinode trusted JSON。 |
| `tags` | 标签。 |
| `aux` | 辅助 JSON。 |
| `group_name` | 群名称。 |
| `avatar_file_id` | 群头像文件 ID。 |
| `description` | 群简介。 |
| `group_status` | 业务群状态。 |
| `join_approval` | 入群审批开关。 |
| `mute_all` | 全员禁言。 |
| `member_upload_files` | 成员上传文件。 |
| `member_private_chat` | 成员私聊。 |
| `member_invite` | 成员邀请。 |
| `max_member_count` | 最大成员数。 |
| `max_admin_count` | 最大管理员数。 |
| `business_member_count` | 业务成员数。 |
| `settings_version` | 治理配置版本。 |
| `settings_updated_at` | 治理配置更新时间。 |
当前 Admin 直连涉及表:`im_topics`、`im_group_settings`。
### 4.5 群详情增强
现有接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}
```
当前接口主要返回 `im_group_settings`。建议增强 Tinode topic 基础字段:
| 字段 | 说明 |
|---|---|
| `state` | Topic 状态。 |
| `seq_id` | 最新消息序号。 |
| `del_id` | 删除序号。 |
| `sub_count` | 订阅数量。 |
| `public` | public JSON。 |
| `trusted` | trusted JSON。 |
| `access` | access JSON。 |
| `tags` | tags JSON。 |
| `aux` | aux JSON。 |
| `touched_at` | 活跃时间。 |
### 4.6 群成员列表增强
现有接口:
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members
```
建议增强查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `user_id` | string | 用户 ID 精确过滤。 |
| `role` | string | `owner/admin/member`。 |
| `member_status` | string | 成员状态。 |
| `q` | string | 昵称、公开账号等关键词。 |
| `eligible_role` | string | 候选角色筛选。 |
| `page` | int | 页码。 |
| `per_page` | int | 每页数量。 |
| `cursor` | string | 兼容 cursor 分页。 |
| `limit` | int | 兼容 cursor 分页。 |
建议增强返回:
| 字段 | 说明 |
|---|---|
| `total` | 总数,Admin 表格需要。 |
| `items[].user_id` | 用户 ID。 |
| `items[].nickname` | 昵称。 |
| `items[].state` | 用户状态。 |
| `items[].mode_want` | 期望权限。 |
| `items[].mode_given` | 授予权限。 |
| `items[].recv_seq_id` | 接收序号。 |
| `items[].read_seq_id` | 已读序号。 |
| `items[].del_id` | 删除序号。 |
| `items[].role` | 业务角色。 |
| `items[].member_status` | 成员状态。 |
| `items[].group_nickname` | 群昵称。 |
| `items[].pinned` | 置顶。 |
| `items[].mute_notifications` | 免打扰。 |
| `items[].join_source` | 加入来源。 |
| `items[].joined_by` | 邀请或处理人。 |
| `items[].joined_at` | 加入时间。 |
| `items[].left_at` | 离开时间。 |
| `items[].removed_by` | 移除人。 |
| `items[].version` | 版本。 |
当前 Admin 直连涉及表:`im_group_members`、`im_subscriptions`、`im_users`、`im_user_profiles`。
### 4.7 入群申请列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/join-requests
```
建议查询参数:`status`、`applicant_user_id`、`page`、`per_page`。
建议返回字段:`request_id`、`topic`、`applicant_user_id`、`inviter_user_id`、`source`、`message`、`status`、`reject_reason`、`handled_by`、`handled_at`、`expires_at`、`version`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_join_requests`。
### 4.8 群邀请列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/invitations
```
建议查询参数:`status`、`target_user_id`、`inviter_user_id`、`page`、`per_page`。
建议返回字段:`invitation_id`、`topic`、`inviter_user_id`、`target_user_id`、`message`、`status`、`result`、`join_request_id`、`expires_at`、`handled_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_invitations`。
### 4.9 群公告列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/announcements
```
建议查询参数:`status`、`page`、`per_page`。
建议返回字段:`announcement_id`、`topic`、`author_user_id`、`content`、`status`、`version`、`published_at`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_announcements`。
### 4.10 群禁言列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/mutes
```
建议查询参数:`status`、`user_id`、`page`、`per_page`。
建议返回字段:`topic`、`user_id`、`muted_by`、`muted_at`、`expires_at`、`reason`、`status`、`revoked_by`、`revoked_at`、`version`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_mutes`。
### 4.11 群管理日志
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/admin-logs
```
建议查询参数:`action`、`operator_id`、`page`、`per_page`。
建议返回字段:`log_id`、`topic`、`operator_id`、`target_user_id`、`action`、`object_type`、`object_id`、`before_data`、`after_data`、`request_id`、`idempotency_key`、`created_at`。
当前 Admin 直连涉及表:`im_group_admin_logs`。
### 4.12 群文件列表
```text
GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/files
```
用途:群治理页面中的群文件列表。不要和已有单文件详情接口混淆。
建议查询参数:`status`、`file_type`、`file_name`、`uploader_id`、`page`、`per_page`。
建议返回字段:`group_file_id`、`topic`、`file_id`、`source_topic`、`source_seq`、`uploader_id`、`file_name`、`mime_type`、`file_type`、`file_size`、`status`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_group_files`。
### 4.13 文件列表
```text
GET /internal/admin-query/tenants/{tenant_code}/files
```
用途:IM 文件管理列表。
建议查询参数:`file_id`、`user_id`、`status`、`mime_type`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`file_id`、`user_id`、`mime_type`、`file_size`、`file_name`、`location`、`etag`、`status`、`quarantine_reason`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_fileuploads`。
### 4.14 文件引用列表
```text
GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}/links
```
用途:文件详情中展示消息引用。
建议返回字段:`topic`、`seq_id`、`user_id`、`created_at`。
当前 Admin 直连涉及表:`im_filemsglinks`。
## 5. P1:后台审核和运营页面
### 5.1 审计日志
```text
GET /internal/admin-query/tenants/{tenant_code}/audit-logs
GET /internal/admin-query/tenants/{tenant_code}/audit-logs/{id}
```
建议查询参数:`actor_user_id`、`action`、`target_type`、`target_id`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`request_id`、`actor_user_id`、`action`、`target_type`、`target_id`、`ip`、`user_agent`、`detail`、`created_at`。
当前 Admin 直连涉及表:`im_audit_logs`。
### 5.2 实名审核
```text
GET /internal/admin-query/tenants/{tenant_code}/realname-verifications
GET /internal/admin-query/tenants/{tenant_code}/realname-verifications/{id}
```
建议查询参数:`user_id`、`status`、`real_name`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`user_id`、`real_name`、`id_card_no_masked`、`front_file_id`、`back_file_id`、`status`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_realname_verifications`。
### 5.3 举报工单
```text
GET /internal/admin-query/tenants/{tenant_code}/abuse-reports
GET /internal/admin-query/tenants/{tenant_code}/abuse-reports/{id}
GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target
```
建议查询参数:`reporter_user_id`、`target_type`、`target_id`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`report_id`、`reporter_user_id`、`target_type`、`target_id`、`reason_code`、`description`、`evidence_file_ids`、`status`、`handler_id`、`handled_at`、`handle_result`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_abuse_reports`。
### 5.4 意见反馈
```text
GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions
GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions/{id}
```
建议查询参数:`user_id`、`category`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。
建议返回字段:`id`、`feedback_id`、`user_id`、`category`、`content`、`contact`、`file_ids`、`status`、`handler_id`、`handled_at`、`reply`、`created_at`、`updated_at`。
当前 Admin 直连涉及表:`im_feedback_submissions`。
### 5.5 业务 Outbox
```text
GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events
GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events
GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events/{id}
GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events/{id}
```
建议查询参数:`event_type`、`status`、`target_type`、`target`、`created_at_start`、`created_at_end`、`page`、`per_page`。
当前 Admin 直连涉及表:`im_business_events_outbox`、`im_group_events_outbox`。
## 6. P2:如果要完全消除租户库直连
这些不是 Kafka 联调第一优先级,但如果最终要求 Admin 完全不碰租户库,也需要 IM Server 提供内部查询或改为 hotgo 摘要表。
### 6.1 组织架构
```text
GET /internal/admin-query/tenants/{tenant_code}/org/departments
GET /internal/admin-query/tenants/{tenant_code}/org/members
```
当前 Admin 直连涉及表:`im_org_departments`、`im_org_members`、`im_users`。
### 6.2 工作台应用
```text
GET /internal/admin-query/tenants/{tenant_code}/workbench/apps
```
当前 Admin 直连涉及工作台相关租户表。
### 6.3 租户任务看板摘要
```text
GET /internal/admin-query/tenants/{tenant_code}/dashboard/summary
```
当前 Admin 会统计多个租户库中的待处理实名、举报、反馈、Outbox 等数量。
### 6.4 租户配置类查询
如果这些配置仍放在租户库,也需要补接口;如果后续迁到 hotgo 控制库,则不需要通过 IM Server 查询。
```text
GET /internal/admin-query/tenants/{tenant_code}/feedback-config
GET /internal/admin-query/tenants/{tenant_code}/mailbox-config
GET /internal/admin-query/tenants/{tenant_code}/abuse-reasons
GET /internal/admin-query/tenants/{tenant_code}/app-releases
```
涉及模块:反馈配置、总经理信箱配置、举报原因、App 发布策略。
## 6.5 IM Server 补齐状态
截至 2026-08-27,IM Server 已补齐本清单中的内部查询入口,统一走 `/internal/admin-query/tenants/{tenant_code}/...`,并沿用 HMAC 鉴权。
已接入范围:
1. P0:用户列表、用户详情增强、用户设备、群列表、群详情增强、群成员 total、入群申请、群邀请、群公告、群禁言、群管理日志、群文件、文件列表、文件详情、文件引用。
2. P1:审计日志、实名审核、举报工单、举报目标快照、意见反馈、业务 Outbox、群 Outbox。
3. P2:组织部门、组织成员、工作台应用、任务看板摘要、反馈配置、总经理信箱配置、举报原因、App 发布策略。
实现说明:
1. IM Server 内部使用白名单只读查询规格,不提供任意 SQL 入口。
2. 列表接口统一返回 `items/total/page/per_page`。
3. 详情接口统一返回单对象。
4. 复杂字段先返回租户库原始 JSON 或轻量关联摘要,Admin 联调时如需展示专用字段再做二次增强。
5. 若租户库未创建某些 P1/P2 表,对应接口会返回内部错误;发版前需要确保租户库 schema 已完整迁移。
## 7. Admin 侧迁移建议
### 7.1 第一批迁移
在 IM Server 补完 P0 后,Admin 优先迁移:
1. 用户列表、用户详情、用户设备。
2. 群列表、群详情、群成员。
3. 群入群申请、邀请、公告、禁言、管理日志、群文件。
4. 文件列表、文件详情、文件引用。
### 7.2 第二批迁移
在 IM Server 补完 P1 后,Admin 再迁移:
1. 审计日志。
2. 实名审核。
3. 举报工单。
4. 意见反馈。
5. 业务 Outbox。
### 7.3 保留 hotgo 查询的范围
以下数据天然属于 hotgo 控制库,Admin 可继续查 hotgo,不需要走 IM Server:
1. 租户表和租户绑定表。
2. Admin 命令表:`im_admin_commands`。
3. Admin Outbox 表:`im_admin_outbox`。
4. Admin Kafka 审计事件表:`im_audit_events`。
5. 菜单、角色、管理员、系统配置、字典等 HotGo 系统表。
## 8. 验收标准
1. Admin 查询租户业务数据时,不再出现新的 `imtenant.DB()` 调用。
2. Admin 查询 IM Server 内部接口时,统一使用 HMAC 签名。
3. 列表接口支持 `page/per_page/total`,兼容后台表格分页。
4. 详情接口返回结构化 JSON,不返回数据库内部敏感字段。
5. 用户 ID、雪花 ID 等大整数以字符串返回。
6. IM Server 为内部查询写审计日志,至少包含 `tenant_code`、`service_name`、`path`、`query`、`trace_id`、`status`、`duration_ms`。
7. 关闭 IM Server 内部查询能力时,Admin 应得到明确错误,不泄露租户库信息。
+953
View File
@@ -0,0 +1,953 @@
# IM Kafka 接入方案
> 日期:2026-08-26
> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 目标:明确 Admin 后台、IM 服务、HotGo 控制库、租户业务库之间的 Kafka 接入边界、查询方式、鉴权方式和首批落地范围。
## 0. 与范围识别文档的关系
[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md) 是本方案的前置输入,负责按前后端对接文档逐章识别哪些业务适合 Kafka、哪些仍应走 WebSocket/Redis/MySQL/OSS。本方案在该识别结果基础上,进一步确定服务边界、库访问边界、前后台交互模型、内部查询接口、鉴权方式和首批落地步骤。
两份文档分工如下:
| 文档 | 作用 |
| --- | --- |
| [IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md) | 回答“哪些业务该用 Kafka,哪些不该用”。 |
| 本方案 | 回答“Kafka 怎样接入,Admin 与 IM 怎样交互,服务和库怎样隔离”。 |
实施时以范围识别文档的逐章清单确定业务覆盖面,以本方案确定技术实现方式。若后续新增需求,先补充范围识别,再同步调整本方案的 Topic、Command、内部接口和鉴权规则。
## 1. 核心原则
系统按“服务和库一一对应”设计,不允许不同服务直连对方数据库。
| 服务 | 只能直接访问 | 说明 |
| --- | --- | --- |
| Admin/HotGo 服务 | `hotgo` 控制库 | 管理后台、租户主数据、后台配置、审核、操作单。 |
| IM 服务 | 当前租户业务库 | 用户、好友、群、消息、文件引用、工作台业务数据。 |
| Redis | IM 实时状态与缓存 | 在线状态、短状态、热点消息、缓存分层。 |
| Kafka | Admin 与 IM 的命令、事件、结果桥 | 后台写操作、异步任务、业务事件、审计、缓存失效。 |
| OSS | 文件对象存储 | 上传下载字节流不进入 Kafka。 |
最终边界:
1. Admin 不直连租户库。
2. IM 不直连 `hotgo` 库。
3. Admin 写租户业务操作统一通过 Kafka Command。
4. IM 写后台业务状态不直写 `hotgo`,通过 Kafka Result/Event 返回 Admin。
5. Admin 查询租户业务数据通过 IM 内部只读 API。
6. IM 查询租户配置、字典、路由通过 Admin 内部只读 API,或消费 Kafka 配置事件维护本地副本。
7. 在线实时投递继续走 Redis + WebSocket,Kafka 不替代 WS 主链路。
## 2. 总体架构
```text
Admin/HotGo 服务
-> 写 hotgo 操作单
-> 生产 Kafka Command
-> IM 服务消费 Command
-> 写租户业务库
-> 生产 Kafka Result/Event
-> Admin 消费 Result/Event
-> 更新 hotgo 操作状态
Admin 查询租户业务数据
-> 调 IM 内部只读 API
-> IM 查租户业务库
-> 返回 Admin
IM 查询租户配置/字典/路由
-> 优先读本地缓存/本地副本
-> 缓存缺失时调 Admin 内部只读配置 API
-> Admin 查 hotgo
-> 返回 IM
```
## 3. Kafka 使用范围
| 场景 | 是否使用 Kafka | 说明 |
| --- | --- | --- |
| Admin 后台群操作通知 C 端 | 是 | 后台写 hotgo 操作单,投 Kafka,IM 执行租户库变更并 WS 下发。 |
| Admin 后台用户封禁、解封、重置密码 | 是 | IM 消费后更新租户库、踢在线、通知客户端。 |
| Admin 后台租户配置、字典、功能开关变更 | 是 | Admin 写 hotgo 后投配置事件,IM 刷新缓存或本地副本。 |
| 企业注册、实名、举报、反馈审核结果 | 是 | Admin 审核后投 Kafka,IM 生成通知或更新租户侧状态。 |
| 工作台会议、日报、月报、信箱通知 | 是 | 状态变更、提醒、红点更新走 Kafka。 |
| 私聊/群聊消息异步落库 | 是 | WS 收消息后完成鉴权、幂等和 seq,再投 Kafka 异步持久化。 |
| 文件安全检测、恶意文件检测 | 是 | OSS 上传完成后投检测任务,消费者调用第三方并更新状态。 |
| 离线 Push | 是 | 消息或业务事件落地后投 Push 任务。 |
| 在线消息实时 fanout | 不作为主链路 | 继续 Redis Pub/Sub/Stream + WS。 |
| 文件上传下载字节流 | 否 | 走 OSS/文件服务;Kafka 只处理上传完成后的事件。 |
| 验证码、Action Token、限流、在线状态 | 否 | 继续 Redis。 |
| 普通查询接口 | 否 | 走内部只读 API 或本服务数据库。 |
## 4. 前后台交互模型
### 4.1 后台写租户业务
Admin 后台所有会影响租户业务库或 C 端展示的写操作,统一走 Kafka Command。
```text
Admin
1. 校验后台管理员权限
2. 写 hotgo.admin_command,状态 pending
3. 发送 Kafka command
IM
4. 消费 command
5. 校验租户、对象、版本、权限和幂等
6. 写租户业务库
7. 必要时通过 WS 通知在线 C 端
8. 发送 Kafka result
Admin
9. 消费 result
10. 更新 hotgo.admin_command 状态
```
典型操作:
| 操作 | Command | IM 执行动作 | C 端通知 |
| --- | --- | --- | --- |
| 群资料修改 | `group.profile.update` | 更新群表和 Topic 资料 | `group.profile.updated` |
| 设置/删除管理员 | `group.admin.set` / `group.admin.remove` | 更新成员角色 | `group.role.updated` |
| 禁言/解禁 | `group.member.mute` / `group.member.unmute` | 更新禁言表 | `group.mute.updated` |
| 踢人 | `group.member.remove` | 更新成员状态和订阅 | `group.member.removed` |
| 解散群 | `group.delete` | 解散 Topic、撤销权限 | `group.deleted` |
| 用户封禁 | `user.disable` | 更新用户状态、踢在线 | `user.disabled` |
| 重置密码 | `user.password.reset` | 更新密码和 `auth_version` | 可选安全通知 |
### 4.2 后台查询租户业务
Kafka 不适合做普通查询。Admin 需要实时查看租户业务数据时,调用 IM 提供的内部只读 API。
只读 API 只允许 `GET`,不提供通用 SQL 或万能查询接口。
| 接口 | 用途 |
| --- | --- |
| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 查询租户用户详情。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 查询群详情。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 查询群成员。 |
| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq}` | 查询消息受控快照。 |
| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 查询文件引用和审核状态。 |
| `GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target` | 查询举报目标快照。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | 查询 IM 租户库 Kafka 死信列表。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | 查询单条死信详情和原始 envelope。 |
后台列表、统计、高频页面优先使用 `hotgo` 摘要表。摘要由 IM 通过 Kafka 事件同步给 Admin,Admin 查询自己的库。
推荐摘要表:
| 摘要表 | 来源 |
| --- | --- |
| `admin_im_user_snapshot` | IM 用户资料变更事件。 |
| `admin_im_group_snapshot` | IM 群资料、成员数、状态变更事件。 |
| `admin_im_group_member_stat` | 群成员统计事件。 |
| `admin_im_file_audit_snapshot` | 文件审核和引用状态事件。 |
| `admin_im_workbench_snapshot` | 工作台红点、会议、日报/月报状态事件。 |
### 4.3 IM 查询后台配置
IM 不直连 `hotgo`。IM 需要租户路由、租户状态、字典、第三方配置时,使用两种方式:
1. 常态:Admin 修改配置后发送 Kafka 配置事件,IM 消费后更新进程内缓存或本地副本。
2. 兜底:IM 缓存缺失或启动预热时,调用 Admin 内部只读配置 API。
| 接口 | 用途 |
| --- | --- |
| `GET /internal/im-config/tenants/{tenant_code}` | 查询租户状态、名称、路由标识。 |
| `GET /internal/im-config/tenants/{tenant_code}/limits` | 查询人数、群数、上传大小等限制。 |
| `GET /internal/im-config/tenants/{tenant_code}/features` | 查询功能开关。 |
| `GET /internal/im-config/dictionaries?types=oss,file_security,sms,jpush,agora` | 查询字典配置。 |
| `GET /internal/im-config/app/releases/latest` | 查询 App 版本配置。 |
Admin 内部配置 API 只返回 IM 运行需要的字段,不返回后台管理员账号、菜单权限、审核私有字段。
## 5. Topic 规划
| Topic | 用途 |
| --- | --- |
| `im.admin.command` | Admin 发给 IM 的租户业务写命令。 |
| `im.admin.command.result` | IM 回传命令执行结果。 |
| `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 |
| `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 |
| `im.message.persist` | 私聊、群聊消息异步落库。 |
| `im.message.push` | 离线 Push 任务。 |
| `im.file.security` | 文件内容安全、恶意文件检测任务。 |
| `im.file.lifecycle` | 文件引用绑定、释放、回收、合规删除。 |
| `im.workbench.event` | 会议、日报、月报、信箱、工作台红点事件。 |
| `im.cache.invalidate` | 跨节点缓存失效。 |
| `im.audit.event` | 审计事件。 |
| `im.dead.letter` | 消费失败死信。 |
首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。
## 6. 消息结构
统一 Kafka Envelope:
```json
{
"event_id": "evt_01K...",
"trace_id": "req_01K...",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im",
"event_type": "group.member.mute",
"biz_type": "group",
"biz_id": "grp...",
"idempotency_key": "idem_01K...",
"version": 3,
"occurred_at": "2026-08-26T10:00:00.000Z",
"actor": {
"type": "admin",
"id": "admin_1",
"display_name": "管理员"
},
"payload": {}
}
```
Command Result:
```json
{
"event_id": "evt_result_01K...",
"trace_id": "req_01K...",
"tenant_code": "default",
"command_id": "cmd_01K...",
"command_event_id": "evt_01K...",
"status": "succeeded",
"reason": "",
"message": "",
"result": {},
"occurred_at": "2026-08-26T10:00:01.000Z"
}
```
失败时 `status=failed`,`reason` 使用稳定错误码,例如 `GROUP_VERSION_CONFLICT`、`FORBIDDEN`、`RESOURCE_NOT_FOUND`、`COMMAND_DUPLICATED`。
## 7. 幂等、事务与重试
### 7.1 生产端
Admin 写操作必须先落 `hotgo.admin_command`,再发送 Kafka。推荐使用 outbox 模式:
1. 同一事务写 `admin_command` 和 `admin_outbox`。
2. outbox poller 发送 Kafka。
3. Kafka 发送成功后标记 outbox 已发送。
4. 发送失败按退避重试。
IM 内部业务事件也按同样方式写租户库 outbox,避免“数据库成功、事件丢失”。
### 7.2 消费端
每个消费者维护消费幂等记录:
| 字段 | 说明 |
| --- | --- |
| `event_id` | Kafka 事件唯一 ID。 |
| `consumer_group` | 消费组。 |
| `status` | `processing/succeeded/failed/dead`。 |
| `retry_count` | 重试次数。 |
| `last_error` | 最近失败原因。 |
| `created_at/updated_at` | 时间。 |
消费前先插入或锁定 `event_id + consumer_group`。已成功的事件直接跳过;处理中超时可重新抢占。业务表必须再用唯一键兜底,例如 `tenant + command_id`、`tenant + client_mid`、`tenant + topic + seq`。
### 7.3 重试与死信
| 类型 | 策略 |
| --- | --- |
| 临时失败 | 指数退避重试。 |
| 版本冲突 | 不自动重试,回传 failed,由 Admin 刷新后重新发命令。 |
| 权限失败 | 不重试。 |
| 对象不存在 | 不重试,除非明确是延迟同步。 |
| 第三方失败 | 可重试,超过次数写死信和失败表。 |
### 7.4 消费失败处理流程
消费者收到消息后按固定流程处理:
```text
收到 Kafka 消息
-> 校验 Envelope 格式、签名字段、租户和事件类型
-> 抢占 event_id + consumer_group 幂等记录
-> 已 succeeded:提交 offset,跳过
-> processing 超时:允许重新抢占
-> 执行业务逻辑
-> 成功:标记 succeeded,提交 offset
-> 可重试失败:retry_count + 1,按退避策略延迟重试
-> 超过最大重试:写入死信,标记 dead,提交 offset
-> 不可重试失败:标记 failed 或 dead,必要时回传 command result,提交 offset
```
重试建议参数:
| 参数 | 建议值 | 说明 |
| --- | --- | --- |
| `max_retry_count` | 5~10 | 第三方接口可取高值,权限/参数错误不重试。 |
| `retry_backoff` | 10s、30s、1m、5m、15m | 指数退避,可加随机抖动。 |
| `processing_timeout` | 5m~30m | 超过后允许其他消费者重新抢占。 |
| `dead_letter_retention` | 30~90 天 | 便于排障和人工补偿。 |
### 7.5 死信队列处理
进入死信表示该事件已超过自动恢复能力,系统不能再无限重试。死信必须形成“可查看、可诊断、可重放、可关闭”的闭环。
死信写入两处:
1. Kafka `im.dead.letter` Topic:保留原始事件,便于流式订阅、告警和后续自动补偿。
2. 数据库失败表:便于 Admin 后台查询、人工处理和审计。
推荐失败表字段:
| 字段 | 说明 |
| --- | --- |
| `dead_id` | 死信记录 ID。 |
| `event_id` | 原 Kafka 事件 ID。 |
| `source_topic` | 原 Topic。 |
| `consumer_group` | 失败消费组。 |
| `tenant_code` | 租户编码。 |
| `event_type` | 事件类型。 |
| `biz_type` / `biz_id` | 业务对象。 |
| `payload` | 原始 Envelope。 |
| `retry_count` | 已重试次数。 |
| `last_reason` | 稳定失败原因。 |
| `last_error` | 最近错误摘要,敏感字段脱敏。 |
| `status` | `dead/pending_replay/replayed/ignored/resolved`。 |
| `replay_count` | 人工重放次数。 |
| `created_at/updated_at` | 时间。 |
| `resolved_by/resolved_at` | 人工处理人和处理时间。 |
| `resolve_note` | 处理备注。 |
死信来源分类:
| 分类 | 示例 | 默认处理 |
| --- | --- | --- |
| 临时资源失败超过上限 | DB 短暂不可用、第三方超时 | 修复环境后允许重放。 |
| 业务状态冲突 | 群版本冲突、对象已被删除 | 不自动重放,Admin 刷新状态后重新发命令。 |
| 权限失败 | 管理员无权限、租户停用 | 不重放,标记 ignored 或 failed result。 |
| 参数错误 | Envelope 缺字段、payload 非法 | 不重放,修复生产方后关闭。 |
| 第三方永久失败 | 文件违规、Push token 无效 | 按业务结果落状态,不重放或仅重放特定步骤。 |
Admin 后台需要提供死信处理页:
| 操作 | 说明 |
| --- | --- |
| 查看详情 | 展示事件、租户、业务对象、错误原因、重试次数和 trace_id。 |
| 重放 | 将原事件或修正后的事件重新投递到原 Topic,生成新的 `event_id`,保留 `parent_event_id`。 |
| 忽略 | 确认该事件无需继续处理,状态改为 `ignored`。 |
| 标记已处理 | 线下补偿完成后改为 `resolved`。 |
| 重新生成命令 | 对版本冲突类命令,不直接重放旧事件,而是基于最新状态创建新 Command。 |
重放规则:
1. 重放必须生成新的 `event_id`,原 `event_id` 保留在 `parent_event_id`。
2. 重放前重新校验租户状态、对象是否存在、操作者权限和业务版本。
3. 参数错误、权限失败、版本冲突默认禁止直接重放。
4. 文件安全检测、Push、审计写入等可重试任务允许重放。
5. 每次重放都写审计,记录处理人、原因、原事件和新事件。
死信告警:
| 指标 | 建议 |
| --- | --- |
| 单 Topic 死信数 | 5 分钟内超过阈值告警。 |
| 同一 `event_type` 连续失败 | 可能是代码或配置问题,立即告警。 |
| 同一租户大量失败 | 检查租户库、配置或权限状态。 |
| 死信积压时间 | 超过 24 小时未处理提醒。 |
对 Admin Command 类事件,进入死信后必须回传 `im.admin.command.result`,状态为 `failed` 或 `dead`,让后台操作单结束等待状态。否则后台会长期显示处理中。
## 8. 内部接口鉴权
内部 API 鉴权非常关键,必须同时做网络层、服务层和业务层控制。
### 8.1 网络层
1. `/internal/*` 只允许内网网关或内网地址访问。
2. 禁止公网直接暴露内部接口。
3. 有条件时启用 mTLS。
### 8.2 服务签名
内部请求统一携带:
| Header | 说明 |
| --- | --- |
| `X-Service-Name` | 调用方服务名,例如 `hotgo-admin` 或 `im-server`。 |
| `X-Timestamp` | Unix 秒或毫秒时间戳。 |
| `X-Nonce` | 随机串,短期内不可重复。 |
| `X-Request-ID` | 请求追踪 ID。 |
| `X-Signature` | HMAC 签名。 |
签名内容:
```text
METHOD + "\n" +
PATH_WITH_QUERY + "\n" +
BODY_SHA256 + "\n" +
X-Timestamp + "\n" +
X-Nonce
```
校验规则:
1. 时间窗建议 5 分钟。
2. `nonce` 写 Redis,TTL 5 分钟,防重放。
3. 每个服务独立 `service_id/service_secret`。
4. 每个服务配置允许访问的内部接口白名单。
### 8.3 业务授权
Admin 调 IM 查询时,签名 payload 或 Header 中必须包含后台操作者信息:
| 字段 | 说明 |
| --- | --- |
| `admin_user_id` | 后台用户 ID。 |
| `admin_role` | 后台角色。 |
| `admin_permissions` | 本次已校验过的权限列表。 |
| `tenant_code` | 被查询租户。 |
| `reason` | 查询原因或工单号。 |
IM 仍需根据 `tenant_code + permission + resource` 做二次校验,并写内部查询审计。
IM 调 Admin 配置接口时,Admin 只校验服务身份和接口白名单,不返回超出 IM 运行需要的字段。
## 9. 文件与工作台
### 9.1 文件
文件上传下载不走 Kafka,OSS 是文件字节流主链路。Kafka 只处理上传完成后的事件:
1. `im.file.security`:内容安全、恶意文件检测。
2. `im.file.lifecycle`:业务引用绑定、释放、临时文件回收、合规删除。
3. `im.business.event`:文件审核失败、合规处置等 C 端通知。
### 9.2 工作台
工作台查询不走 Kafka,状态变化和提醒走 Kafka:
1. 会议创建、更新、取消、提醒、响应:`im.workbench.event`。
2. 日报/月报提交、撤回、重开、催办:`im.workbench.event`。
3. 总经理信箱提交、处理结果:`im.workbench.event` + `im.audit.event`。
4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。
## 10. 首批落地建议
| 阶段 | 内容 |
| --- | --- |
| 第一步 | 增加 Kafka 配置、生产者、消费者、健康检查、统一 Envelope。 |
| 第二步 | 增加消费幂等表、outbox 表、死信表或死信 Topic。 |
| 第三步 | 接入 `im.admin.command` 和 `im.admin.command.result`,先覆盖后台群治理操作。 |
| 第四步 | 增加 IM 内部只读查询 API,供 Admin 查用户、群、消息、文件快照。 |
| 第五步 | 增加 Admin 内部配置 API,供 IM 查询租户配置、字典、第三方参数。 |
| 第六步 | 接入文件安全检测、离线 Push、工作台事件。 |
| 第七步 | 消息异步落库接入 `im.message.persist`,完善幂等和重试。 |
## 11. 分端任务拆分
### 11.1 Admin/HotGo 侧任务
Admin 侧只访问 `hotgo` 库,负责后台操作入口、配置主数据、命令生产、结果消费和死信处理。
| 模块 | 任务 | 产出 |
| --- | --- | --- |
| Kafka 基础 | 增加 Kafka producer/consumer 配置、健康检查、统一 Envelope 组装。 | 可发送 `im.admin.command`、消费 `im.admin.command.result`。 |
| 操作单 | 新增 `admin_command`、`admin_outbox` 表和状态机。 | `pending/running/succeeded/failed/dead` 操作状态。 |
| Outbox | 后台写操作单与 outbox 同事务落库,poller 发送 Kafka。 | 防止 hotgo 写成功但 Kafka 丢事件。 |
| Command 生产 | 群治理、用户封禁/解封、重置密码、审核结果、工作台配置等后台操作生成 Command。 | `im.admin.command` 事件。 |
| Result 消费 | 消费 IM 回传结果并更新操作单。 | 后台页面可展示最终成功/失败原因。 |
| 配置事件 | 租户配置、字典、功能开关、第三方配置变更后发送 `im.config.event`。 | IM 刷新缓存或本地副本。 |
| 配置查询 API | 提供 `/internal/im-config/*` 只读接口。 | IM 启动预热和缓存缺失兜底查询。 |
| 摘要表 | 建立用户、群、文件、工作台等后台摘要表。 | 后台列表统计优先查 hotgo。 |
| 死信后台 | 提供死信查看、重放、忽略、标记已处理、重新生成命令。 | 运维闭环。 |
| 内部鉴权 | 实现服务签名校验、nonce 防重放、接口白名单、审计。 | 保护 Admin 内部配置 API。 |
| 审计 | 记录后台操作者、租户、资源、Command、Result、死信处理。 | 可追踪后台操作链路。 |
Admin 首批优先级:
1. Kafka 基础配置、Envelope、producer。
2. `admin_command` + `admin_outbox`。
3. 群治理 Command 生产。
4. `im.admin.command.result` 消费。
5. `/internal/im-config/*` 配置查询 API。
6. 死信管理页。
### 11.2 IM Server 侧任务
IM Server 侧只访问租户业务库,负责执行 Admin Command、维护租户业务状态、通过 WS 通知 C 端,并向 Admin 回传结果。
| 模块 | 任务 | 产出 |
| --- | --- | --- |
| Kafka 基础 | 增加 Kafka producer/consumer 配置、健康检查、统一 Envelope 解析。 | 可消费 Command、生产 Result/Event。 |
| 消费幂等 | 新增租户库消费幂等表或统一消费日志。 | `event_id + consumer_group` 幂等。 |
| Command 消费 | 消费 `im.admin.command`。 | 后台命令进入 IM 执行链路。 |
| 群治理执行器 | 执行群资料、管理员、禁言、踢人、解散、群主转让等命令。 | 更新租户库和 Topic 状态。 |
| 用户治理执行器 | 执行封禁、解封、重置密码、踢在线等命令。 | 更新用户状态和 `auth_version`。 |
| Result 生产 | 执行成功/失败后发送 `im.admin.command.result`。 | Admin 操作单闭环。 |
| WS 通知 | Command 成功后生产 C 端业务事件并投递在线客户端。 | `info.what="im|group"` 下发。 |
| 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 |
| 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 |
| 内部只读 API | 提供 `/internal/admin-query/*`。 | Admin 实时查询租户业务数据。 |
| 文件任务 | 上传完成后生产/消费 `im.file.security`、`im.file.lifecycle`。 | 文件审核、引用绑定、回收。 |
| 消息异步落库 | 接入 `im.message.persist`。 | 私聊/群聊消息削峰落库。 |
| Push 任务 | 生产 `im.message.push`。 | 离线 Push 异步投递。 |
| 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 |
| 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 |
IM Server 首批优先级:
1. Kafka 基础配置、Envelope、consumer/producer。
2. 消费幂等表和死信写入。
3. `im.admin.command` 群治理消费执行。
4. `im.admin.command.result` 回传。
5. WS 业务事件下发。
6. `/internal/admin-query/*` 只读查询 API。
### 11.3 双方联调契约
| 契约 | Admin 负责 | IM Server 负责 |
| --- | --- | --- |
| Envelope 字段 | 生产完整字段,保证 `event_id/trace_id/tenant_code/event_type/idempotency_key`。 | 校验字段完整性和事件类型白名单。 |
| Command 状态 | 创建操作单,展示状态。 | 回传成功、失败、死信结果。 |
| 幂等 | 同一后台操作复用 `command_id/idempotency_key`。 | 同一 Command 重复消费只执行一次。 |
| 错误码 | 展示 IM 返回的稳定 `reason`。 | 返回稳定 `reason`,不让 Admin 解析错误文本。 |
| 查询接口 | 带管理员身份、权限、原因和签名。 | 校验签名、权限和租户资源,写审计。 |
| 配置接口 | 提供最小必要配置字段。 | 不直连 hotgo,使用缓存、事件或内部 API。 |
| 死信 | 提供处理入口和人工操作审计。 | 写死信、回传 Command dead/failed 状态。 |
| Trace | 生成并传递 `trace_id`。 | 日志、Kafka、WS 下发沿用同一 `trace_id`。 |
### 11.4 首批联调用例
| 用例 | 预期 |
| --- | --- |
| Admin 禁言群成员 | Admin 操作单 pending,IM 消费后更新租户库,C 端收到 `group.mute.updated`,Admin 状态 succeeded。 |
| Admin 重复提交同一禁言操作 | IM 只执行一次,Result 返回同一最终状态。 |
| Admin 删除管理员但群版本冲突 | IM 返回 `GROUP_VERSION_CONFLICT`,Admin 操作单 failed,不自动重试。 |
| IM 消费时临时 DB 失败 | 自动退避重试,未超限不进入死信。 |
| IM 消费超过重试上限 | 写 `im.dead.letter`,Admin 操作单进入 dead/failed。 |
| Admin 查询群成员 | Admin 调 `/internal/admin-query/*`,IM 校验签名和权限后返回只读数据。 |
| IM 启动加载租户配置 | IM 优先读缓存,缺失时调 `/internal/im-config/*`,Admin 只返回必要配置。 |
### 11.5 IM Server 已接入的首批 Admin Command
IM Server 首批消费 `im.admin.command`,执行后回传 `im.admin.command.result`。所有命令必须带 `tenant_code`,`payload.actor_user_id` 必须是租户内 IM 用户 ID,用于落群事件和 C 端通知。
| Command `event_type` | 说明 | 必填 payload |
| --- | --- | --- |
| `group.profile.update` | 修改群名称、头像、简介。 | `command_id,actor_user_id,topic,profile,version` |
| `group.governance.update` | 修改进群审批、全员禁言、成员邀请等治理开关。 | `command_id,actor_user_id,topic,governance,version` |
| `group.admin.set` | 设置一个或多个管理员。 | `command_id,actor_user_id,topic,user_ids` |
| `group.admin.remove` | 删除管理员,降为普通成员。 | `command_id,actor_user_id,topic,user_id` |
| `group.member.mute` | 禁言成员。 | `command_id,actor_user_id,topic,user_id,reason,expires_at` |
| `group.member.unmute` | 解除禁言。 | `command_id,actor_user_id,topic,user_id` |
| `group.member.remove` | 移出群成员。 | `command_id,actor_user_id,topic,user_id` |
| `group.owner.transfer` | 转让群主。 | `command_id,actor_user_id,topic,target_user_id,version` |
当前 IM Server 消费 `im.admin.command` 时已经接入 `im_kafka_consume_log`:
1. 按 `event_id + consumer_group` 抢占消费。
2. 已成功事件直接跳过,不重复执行业务。
3. 处理中且未超时的事件跳过,避免并发重复执行。
4. 失败或处理超时的事件允许重新抢占。
5. 不可重试错误或超过重试次数后写入 `im_kafka_dead_letters`,并投递 `im.dead.letter`。
6. Admin Command 进入死信时回传 `im.admin.command.result`,`status=dead`。
### 11.6 IM Server 已接入的配置事件
IM Server 首批消费 `im.config.event`,用于 Admin 修改 HotGo 配置后通知 IM 刷新进程内缓存。当前实现以“缓存失效”为主,收到事件后清除对应缓存,下次业务读取时重新加载。
| Config `event_type` | 说明 | IM 行为 |
| --- | --- | --- |
| `tenant.updated` | 租户基础信息变更。 | 清理租户 code/id 缓存。 |
| `tenant.status.changed` | 租户启停、状态变化。 | 清理租户 code/id 缓存。 |
| `tenant.limits.updated` | 租户人数、群数、上传限制等变化。 | 清理租户 code/id 缓存。 |
| `tenant.features.updated` | 租户功能开关变化。 | 清理租户 code/id 缓存。 |
| `dictionary.updated` | 字典配置变化。 | 清理第三方/字典配置缓存。 |
| `third_party.updated` | OSS、文件安全、短信、极光、声网等第三方配置变化。 | 按 group 清理第三方配置缓存。 |
| `registration.config.updated` | 注册、短信开关变化。 | 清理注册配置缓存。 |
事件示例:
```json
{
"event_id": "evt_01K_config",
"trace_id": "req_01K",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im-server",
"event_type": "third_party.updated",
"biz_type": "config",
"biz_id": "im_third_party",
"occurred_at": "2026-08-26T10:05:00.000Z",
"payload": {
"tenant_code": "default",
"group": "im_third_party",
"keys": ["oss.endpoint", "file_security.enabled"]
}
}
```
禁言命令示例:
```json
{
"event_id": "evt_01K_mute",
"trace_id": "req_01K",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im-server",
"event_type": "group.member.mute",
"biz_type": "group",
"biz_id": "grp...",
"idempotency_key": "cmd_01K_mute",
"occurred_at": "2026-08-26T10:00:00.000Z",
"payload": {
"command_id": "cmd_01K_mute",
"actor_user_id": "usr_admin",
"topic": "grp...",
"user_id": "usr_target",
"reason": "刷屏",
"expires_at": null
}
}
```
### 11.7 IM Server 已接入的内部只读查询 API
为满足“Admin 不直连租户库”的边界,IM Server 提供内部只读查询接口给 HotGo/Admin 调用。接口默认关闭,通过 `internal_api.enabled=true` 开启。
请求统一鉴权头:
| Header | 说明 |
| --- | --- |
| `X-Service-Name` | 调用方服务名,如 `hotgo-admin`。 |
| `X-Timestamp` | Unix 秒级时间戳,默认 300 秒时间窗。 |
| `X-Nonce` | 一次性随机串,用于防重放。 |
| `X-Request-ID` | 调用链路 ID。 |
| `X-Signature` | HMAC-SHA256 签名,支持 hex 或 base64,可带 `sha256=` 前缀。 |
nonce 防重放优先使用 Redis 短状态 `SETNX + TTL`,Redis 未配置时退化为单节点进程内防重放;生产多节点必须配置 Redis。
签名明文:
```text
METHOD + "\n" +
PATH_WITH_QUERY + "\n" +
SHA256_HEX(BODY) + "\n" +
X-Timestamp + "\n" +
X-Nonce
```
当前首批接口:
| 接口 | 说明 |
| --- | --- |
| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 查询租户内用户基础资料。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 查询群资料和治理配置。 |
| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 查询群成员分页快照,支持 `limit/cursor/role/eligible_role/q`。 |
| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 查询单条私聊或群聊消息,`seq_id` 为 `BIGINT`。 |
| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 查询文件元信息和存储位置。 |
配置示例:
```json
{
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"services": {
"hotgo-admin": {
"secret": "replace-with-random-secret",
"allow": ["admin_query"]
}
}
}
}
```
Docker 环境变量:
| 环境变量 | 说明 |
| --- | --- |
| `INTERNAL_API_ENABLED` | 是否开启内部接口,默认 `false`。 |
| `INTERNAL_API_TIME_WINDOW_SECONDS` | 签名时间窗。 |
| `INTERNAL_API_HOTGO_SERVICE_NAME` | Admin 服务名,默认 `hotgo-admin`。 |
| `INTERNAL_API_HOTGO_SECRET` | Admin 与 IM 共享签名密钥。 |
### 11.8 IM 访问 Admin 配置查询接口
为满足“IM 服务不直连 hotgo 库”的目标,IM Server 增加 Admin 内部配置查询客户端。该客户端默认关闭,开启后 `store.Tenants`、注册短信开关、第三方配置读取会优先请求 Admin 内部接口,失败时仍可回落旧数据库适配器,便于灰度迁移。
配置示例:
```json
{
"admin_internal_api": {
"enabled": true,
"base_url": "http://hotgo-admin:8080",
"service_name": "im-server",
"secret": "replace-with-random-secret",
"timeout_ms": 3000
}
}
```
接口约定:
| 接口 | 用途 |
| --- | --- |
| `GET /internal/im-config/tenants/by-code/{tenant_code}` | 按企业码获取租户路由信息,需返回 `id/code/name/state/db_name/db_dsn`。 |
| `GET /internal/im-config/tenants/by-id/{tenant_id}` | 按租户 ID 获取租户路由信息。 |
| `GET /internal/im-config/tenants?state=active&limit=1000` | 获取活跃租户列表,用于 Kafka Outbox 扫描。 |
| `GET /internal/im-config/registration/sms` | 获取注册短信开关,返回 `{"enabled": true}`。 |
| `GET /internal/im-config/third-party/{group}?keys=a,b` | 获取第三方配置,返回 `{"values": {"a": "..."}}`。 |
鉴权沿用内部接口 HMAC 方案,请求头为 `X-Service-Name`、`X-Timestamp`、`X-Nonce`、`X-Signature`;签名文本为 `METHOD + "\n" + RequestURI + "\n" + BodySHA256 + "\n" + Timestamp + "\n" + Nonce`。
Docker 环境变量:
| 环境变量 | 说明 |
| --- | --- |
| `ADMIN_INTERNAL_API_ENABLED` | 是否启用 IM 到 Admin 的配置查询客户端,默认 `false`。 |
| `ADMIN_INTERNAL_API_BASE_URL` | Admin 内部接口根地址。 |
| `ADMIN_INTERNAL_API_SERVICE_NAME` | IM 服务名,默认 `im-server`。 |
| `ADMIN_INTERNAL_API_SECRET` | Admin 与 IM 共享签名密钥。 |
| `ADMIN_INTERNAL_API_TIMEOUT_MS` | 请求超时时间。 |
### 11.9 IM Server 已接入的消息异步落库
IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终消息落库解耦。
当前处理方式:
1. WS/HTTP 发送入口先同步完成权限校验、业务幂等、`seq_id` 分配和 Topic 游标占用。
2. Kafka 开启时,发送入口先写租户库 `im_kafka_outbox`,由 Outbox worker 可靠生产 `im.message.persist`,客户端仍立即拿到已分配的 `seq_id`。
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
4. 消费者按 `message_kind` 区分私聊与群聊:
- `p2p` 写入 `im_messages`。
- `group` 写入 `im_group_messages`。
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
6. 失败按统一退避重试,超过上限写入 `im_kafka_dead_letters` 并投递 `im.dead.letter`。
### 11.10 IM Server 已接入的缓存失效消费
IM Server 已订阅 `im.cache.invalidate`,用于接收 Admin 或其他节点发出的通用缓存失效事件。消费侧复用 `im_kafka_consume_log` 做幂等,失败按统一退避重试,超过上限进入死信。
事件体示例:
```json
{
"event_id": "cache:tenant:default:1720000000",
"event_type": "cache.tenant.invalidate",
"tenant_code": "default",
"payload": {
"tenant_code": "default",
"tenant_id": 1,
"namespace": "tenant"
}
}
```
支持的失效范围:
| `event_type` / `namespace` | 处理方式 |
| --- | --- |
| `cache.tenant.invalidate` / `tenant` | 清理租户注册表与租户配置进程内缓存。 |
| `cache.registration.invalidate` / `registration` | 清理注册短信开关缓存。 |
| `cache.third_party.invalidate` / `third_party` | 按 `group` 清理第三方配置缓存。 |
| `cache.dictionary.invalidate` / `dictionary` | 按 `group` 清理字典/配置类缓存。 |
| `cache.redis_object.invalidate` / 任意 Redis `namespace` | 按 `keys` 删除用户、群、关系、权限、未读等 Redis 对象缓存。 |
| `cache.group_hot_messages.invalidate` / `group_hot_messages` | 按 `topic` 和可选 `ranges` 清理群热消息缓存。 |
### 11.11 IM Server 已接入的死信处理闭环
IM Server 已将死信从“只写表”补齐为“可查询、可忽略、可解决、可重放”的处理闭环。Admin 不直连租户库,列表和详情通过 IM 内部只读 API 查询;写操作仍通过 `im.admin.command` 投递给 IM。
Admin 查询接口:
| 接口 | 说明 |
| --- | --- |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters?status=dead&limit=50&cursor=` | 死信分页列表,支持 `status/biz_type/biz_id/event_type` 过滤。 |
| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | 单条死信详情,包含原始 Kafka envelope。 |
Admin 处理命令:
| `event_type` | 说明 | Payload 关键字段 |
| --- | --- | --- |
| `kafka.dead_letter.replay` | 删除原消费日志并按原 `source_topic` 重投 envelope,成功后标记 `replayed`。 | `dead_id`、`actor_user_id`、`note` |
| `kafka.dead_letter.ignore` | 人工确认无需处理,标记 `ignored`。 | `dead_id`、`actor_user_id`、`note` |
| `kafka.dead_letter.resolve` | 线下修复后标记 `resolved`。 | `dead_id`、`actor_user_id`、`note` |
重放规则:
1. IM 先把 `im_kafka_dead_letters.status` 改为 `pending_replay`,并删除对应 `im_kafka_consume_log`。
2. IM 按死信记录中的 `source_topic` 重新投递原 envelope。
3. 投递成功后把死信标记为 `replayed`,并累计 `replay_count`。
4. 如果重放后仍失败,普通消费者会再次写入新的 dead 记录,保留完整处理痕迹。
### 11.12 IM Server 已接入的用户治理命令
IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin 侧写 hotgo 操作单并发送 Kafka Command,IM 消费后修改租户库用户状态、踢在线连接、清理缓存并回传 `im.admin.command.result`。
支持命令:
| `event_type` | 行为 | Payload 关键字段 |
| --- | --- | --- |
| `user.ban` | 封禁用户,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.unban` | 解封用户,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.freeze` | 冻结账号,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.unfreeze` | 恢复账号,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.device.kick` | 踢指定设备在线连接,并删除设备推送记录。 | `actor_user_id`、`user_id` 或 `target_user_id`、`device_id`、`reason` |
| `user.logout_all` | 踢用户所有在线连接,不修改账号状态。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
| `user.account.cancel` | 注销账号,默认软删除;`hard=true` 时硬删除。 | `actor_user_id`、`user_id` 或 `target_user_id`、`hard`、`reason` |
| `user.account.delete` | 删除账号,按硬删除处理。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` |
命令示例:
```json
{
"event_id": "admin:user:ban:default:usrabc:001",
"tenant_code": "default",
"source_service": "hotgo-admin",
"target_service": "im-server",
"event_type": "user.ban",
"biz_type": "user_governance",
"biz_id": "usrabc",
"idempotency_key": "admin-operation-id",
"occurred_at": "2026-08-26T10:10:00.000Z",
"payload": {
"command_id": "admin-operation-id",
"actor_user_id": "usrAdmin",
"user_id": "usrabc",
"reason": "违规处理"
}
}
```
处理说明:
1. 封禁/冻结共用 Tinode 用户状态 `StateSuspended`;解封/恢复共用 `StateOK`。
2. 状态变更后 IM 会清理用户缓存,并向本节点 Hub 投递用户状态变更。
3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。
4. 全端登出只踢在线连接,不修改用户状态。
5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。
6. 命令执行成功后统一回传 `im.admin.command.result`,并写入 `im.audit.event`。
7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。
事件示例:
```json
{
"event_id": "msg.persist.1.grpabc.10086",
"tenant_code": "default",
"source_service": "im-server",
"target_service": "im-server",
"event_type": "message.persist.group",
"biz_type": "message",
"biz_id": "grpabc:10086",
"idempotency_key": "client-mid-001",
"version": 10086,
"occurred_at": "2026-08-26T10:10:00.000Z",
"actor": {
"type": "user",
"id": "usr..."
},
"payload": {
"message_kind": "group",
"read_by_sender": true,
"attachment_urls": [],
"message": {
"SeqId": 10086,
"Topic": "grpabc",
"From": "abcdef",
"Head": {
"x-im-client-mid": "client-mid-001"
},
"Content": {
"txt": "hello"
}
}
}
}
```
### 11.9 IM Server 已接入的 Kafka Outbox Worker
IM Server 已接入租户库 `im_kafka_outbox` 通用发送 worker,用于避免“业务库写成功但 Kafka 未发送”的事件丢失。
当前能力:
1. 启动后按 active 租户扫描每个租户库的 `im_kafka_outbox`。
2. 扫描 `pending/failed` 且已到 `next_retry_at` 的事件。
3. 发送成功后标记 `sent`、写入 `sent_at`。
4. 发送失败后标记 `failed`,按 Kafka 配置的 `retry_backoff_ms` 写入下一次重试时间。
5. 首个接入方为 `im.message.persist`,后续文件生命周期、工作台事件、审计事件可复用同一 outbox。
本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。
### 11.10 IM Server 已接入的文件生命周期事件
IM Server 已接入 `im.file.lifecycle` 的首批生产与消费,用于把文件上传、附件绑定、群文件登记/删除纳入可靠事件链路。
当前事件:
| `event_type` | 生产时机 | 消费行为 |
| --- | --- | --- |
| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前作为可靠生命周期事实记录,文件安全任务仍由上传完成事务创建。 |
| `file.attachments.bind` | `im.message.persist` 消费成功后,如果消息携带附件。 | 私聊写 `im_filemsglinks`;群聊写 `im_group_files`。 |
| `group.file.created` | 群文件 API 登记成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 |
| `group.file.deleted` | 群文件 API 删除成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 |
说明:
1. Kafka 开启时,消息附件绑定从消息落库消费者中拆出,改由 `im.file.lifecycle` 消费者完成。
2. Kafka 关闭时,仍沿用原同步绑定路径,兼容本地环境。
3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。
4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。
### 11.11 IM Server 已接入的工作台事件
IM Server 已接入 `im.workbench.event` 的首批生产与消费。
当前生产范围:
| 模块 | 事件 |
| --- | --- |
| 会议 | `meeting.created`、`meeting.updated`、`meeting.canceled`、`meeting.reminded`、`meeting.response.updated` |
| 日报 | `daily_report.draft.created`、`daily_report.draft.saved`、`daily_report.submitted`、`daily_report.reminded` |
| 月报 | `monthly_report.draft.created`、`monthly_report.draft.saved`、`monthly_report.submitted`、`monthly_report.reopened` |
| 总经理信箱 | `mailbox.submitted` |
当前消费行为:
1. 消费 `im.workbench.event`。
2. 按 `target_user_ids` 生成系统通知。
3. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
4. 使用 `im_kafka_consume_log` 做消费幂等,失败按统一重试和死信规则处理。
### 11.12 IM Server 已接入的审计事件
IM Server 已接入 `im.audit.event` 的可靠生产,审计事件写入租户库 `im_kafka_outbox` 后由 Kafka Outbox worker 发送。
当前审计范围:
| 类型 | 事件 |
| --- | --- |
| 工作台 | 会议、日报、月报、总经理信箱的状态变更。 |
| Admin Command | 后台群治理命令执行成功。 |
| Kafka 死信 | `kafka.dead_letter.created`。 |
| 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 |
审计事件当前不在 IM 内部落审计表,预期由 Admin/HotGo 消费 `im.audit.event` 后写入 `hotgo` 审计库或审计表。
## 12. 风险与约束
1. Kafka 是异步链路,Admin 后台命令不能默认立即成功,只能显示 `pending/running/succeeded/failed`。
2. 查询不能通过 Kafka 常规化实现,高频实时查询必须走内部只读 API 或 hotgo 摘要表。
3. 内部 API 不能暴露公网,签名、nonce、防重放和审计必须首批完成。
4. 配置事件可能丢失或延迟,IM 必须保留启动预热和缓存缺失时的 Admin 配置查询兜底。
5. Kafka 消费可能重复,所有消费者和业务写入都必须幂等。
6. Admin 和 IM 不跨库直连后,排障要依赖 `trace_id` 串联 hotgo 操作单、Kafka 事件、IM 执行日志和 WS 下发日志。
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
# IM Kafka 接入范围识别
> 日期:2026-08-26
> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 目标:从前后端对接文档逐章识别适合接入 Kafka 的链路,明确 Kafka 与 WebSocket、Redis、MySQL、OSS 的边界。
## 1. 总体边界
Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败补偿、后台操作通知 C 端和审计流水;不替代 WebSocket 的在线实时连接,不替代 Redis 的短状态和热读缓存,也不替代 MySQL 的最终状态。
| 类型 | 建议承载 | 说明 |
| --- | --- | --- |
| 在线实时消息投递 | Redis Pub/Sub/Stream + WebSocket | 低延迟 fanout,节点收到后直接写 WS。 |
| 异步落库、重试、补偿 | Kafka | 适合消息持久化、通知、Push、审核、审计等可重放任务。 |
| 短状态、限流、验证码、在线状态 | Redis | TTL 明确,不需要 Kafka。 |
| 最终业务状态 | MySQL | Kafka 消费成功后写入或更新最终状态。 |
| 文件对象存储 | OSS | Kafka 只处理文件元数据、安全检测和生命周期事件。 |
## 2. 推荐 Topic
| Topic | 用途 | 典型生产方 | 典型消费方 |
| --- | --- | --- | --- |
| `im.message.persist` | 私聊、群聊消息异步落库 | IM WS 节点 | 消息持久化消费者 |
| `im.message.push` | 离线 Push 提醒 | 消息落库消费者、业务事件生产方 | 极光/厂商 Push 消费者 |
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 | IM HTTP、Admin 后台 | IM 节点事件投递消费者 |
| `im.group.admin.event` | Admin 后台群操作投递到 C 端 | Admin 后台 | IM 节点 WS 投递消费者 |
| `im.cache.invalidate` | 跨节点缓存失效 | 业务写接口、后台配置 | IM 节点缓存消费者 |
| `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 |
| `im.file.lifecycle` | 孤立文件回收、引用绑定、合规删除 | 文件服务、业务写接口 | 文件生命周期消费者 |
| `im.audit.event` | 审计日志 | 所有敏感写链路 | 审计消费者 |
| `im.workbench.event` | 工作台模块红点、会议、日报、月报、信箱事件 | 工作台业务服务、Admin 后台 | IM WS 投递、系统通知消费者 |
| `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 |
## 3. 幂等与重试
Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、`biz_id`、`action`、`version`、`occurred_at` 和 `payload`。消费端按 `event_id + consumer_group` 做幂等记录,业务表也必须保留唯一键兜底。
消息发送链路使用 `x-im-client-mid` 或 HTTP `Idempotency-Key` 作为业务幂等来源;Kafka 只负责可靠投递,不能作为唯一幂等判断。消费者失败按指数退避重试,超过上限写 `im.dead.letter` 或失败表,由后台补偿。
## 4. 逐章识别
| 章节 | 适合 Kafka 的范围 | 不建议 Kafka 的范围 |
| --- | --- | --- |
| 0. 公共对接约定 | 产品业务事件 outbox 可改为 Kafka 投递;消息异步落库、系统通知、离线 Push、跨节点缓存失效使用 Kafka;`x-im-client-mid` 幂等结果可作为生产 Kafka 前置校验。 | WebSocket 建连、`hi/login/sub/get/note` 本身不走 Kafka;在线状态继续 Redis。 |
| 1. 账号注册、登录与身份安全 | 企业注册申请、企业入口反馈、实名提交、密码重置成功、账号封禁/解封、管理员重置密码、审核结果通知、审计日志可走 Kafka。营业执照和反馈截图上传完成后投 `im.file.security`。 | 登录鉴权、验证码校验、Action Token、限流不走 Kafka;这些必须同步返回或走 Redis。 |
| 2. 消息首页、全局导航与系统通知 | 系统通知创建、已读异步统计、入口红点变更、离线 Push 提醒、消息摘要更新可走 Kafka。 | 首页查询、会话列表拉取、客户端断线重连流程不走 Kafka。 |
| 3. 单聊会话、会话建立与消息状态 | 私聊消息异步落库、好友申请/通过/拒绝/删除事件、P2P 订阅建立后的通知、媒体消息文件绑定事件、离线 Push 可走 Kafka。 | 在线 P2P 实时投递主链路不建议 Kafka;关系判断和发送权限校验必须同步完成。 |
| 4. 消息输入、媒体内容与消息操作 | @提醒 Push、消息撤回/编辑事件、收藏批量创建、转发任务、forward bundle 生成、消息搜索索引更新、附件 ACL 绑定可走 Kafka。 | 输入框、草稿、录音状态、上传进度和客户端 pending 队列不走 Kafka。 |
| 5. 通讯录、好友关系与黑名单 | 好友申请事件、好友关系变更、黑名单变更、二维码使用审计、资料快照更新、搜索索引更新、系统通知生成可走 Kafka。 | 好友列表查询、用户搜索同步结果、二维码解析鉴权不走 Kafka。 |
| 6. 群聊创建、资料维护与成员治理 | 群创建、成员加入/退出、邀请/审批、公告更新、资料更新、角色变更、禁言、群主转让、解散、群文件登记/删除、Admin 后台群操作投递到 C 端都适合 Kafka。Admin 后台写库后生产 `im.group.admin.event`,IM 节点消费后用 WS 下发给在线客户端。 | 群内在线消息 fanout 主链路继续 Redis + WS;群成员权限校验、成员数上限校验必须同步事务完成。 |
| 7. 文件下载、收藏管理与聊天记录检索 | 文件安全检测、恶意文件扫描、文件生命周期、收藏快照异步补全、收藏转发、搜索索引更新、合规删除事件可走 Kafka。文件下载本身不走 Kafka。 | OSS 上传/下载字节流、Range 续传、下载进度不走 Kafka;这些走文件服务和客户端任务管理。 |
| 8. 实时语音与视频通话 | 通话记录落库、未接来电 Push、通话结束审计、后台计费或统计、异常通话风控可走 Kafka。 | WebRTC offer/answer/ICE、接听、挂断等实时信令不建议 Kafka 主链路;仍走 WS。 |
| 9. 企业工作台与办公协同 | 工作台红点更新、会议创建/更新/取消/提醒/响应、日报/月报提交和催办、总经理信箱提交、工作台系统通知、后台配置变更、审计都适合 Kafka。 | 工作台模块列表、组织架构查询、报表详情查询不走 Kafka;普通查询走 MySQL/Redis 缓存。 |
| 10. 个人资料、通用设置、隐私与青少年模式 | 资料变更事件、隐私配置变更、青少年模式开启/关闭、多设备退出、Push Token 变更审计、App 发布配置缓存失效可走 Kafka。头像上传后走文件安全检测。 | 本地清缓存、系统权限引导、密码验证、Action Token 不走 Kafka。 |
| 11. 反诈安全、举报与意见反馈 | 风控确认审计、举报受理、证据文件安全检测、意见反馈、诊断包处理、后台审核通知、合规处置事件可走 Kafka。 | 风险弹窗同步拦截结果、举报原因查询、反馈配置查询不走 Kafka。 |
| 12. 联调实施清单 | Kafka 需要覆盖消息幂等、系统事件可信生产、文件安全检测、后台群操作到 C 端、离线 Push、失败重试和死信补偿验收。 | 前端基础联调请求不直接依赖 Kafka,除非该接口声明为异步任务。 |
## 5. 文件链路结论
文件上传、下载、Range、ETag、OSS 对象读写不走 Kafka。Kafka 只接入文件完成后的业务事件:
1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。
2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。
3. 消息发送、收藏、群文件登记、日报/月报/反馈/举报提交成功后投递 `im.file.lifecycle`,建立业务引用和 ACL。
4. 业务撤回、删除、合规处置或临时文件 TTL 到期后投递 `im.file.lifecycle`,执行引用释放、禁用下载或 OSS 删除。
## 6. 工作台链路结论
工作台“查询类接口”不需要 Kafka,但工作台“状态变化和提醒类事件”适合 Kafka:
1. 会议创建、更新、取消、提醒、参会响应生产 `im.workbench.event`,消费者生成系统通知、红点并通过 WS 通知在线客户端。
2. 日报/月报提交、撤回、重开、管理员催办生产 `im.workbench.event`,用于红点、催办 Push 和审计。
3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。
4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。
## 7. 已补充:缓存失效与 Admin 配置查询
1. `im.cache.invalidate` 已接入 IM 消费端,支持租户、注册短信、第三方配置、字典配置、Redis 对象缓存、群热消息缓存失效。
2. IM 已增加 Admin 内部配置查询客户端,开启 `admin_internal_api.enabled=true` 后优先通过 Admin 查询 hotgo 侧配置,旧数据库读取作为灰度兜底。
3. Admin 侧需要补齐 `/internal/im-config/*` 只读接口,并与 IM 使用同一套 HMAC 签名密钥。
4. Kafka 死信已补齐查询、忽略、解决、重放闭环:查询走 IM 内部 API,处理走 `im.admin.command`。
5. 用户治理类后台动作已接入 `im.admin.command`:封禁/解封、冻结/恢复、踢指定设备、全端登出、注销/删除账号。
## 8. 首批建议实现
1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。
2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。
3. 接入 `im.message.persist`,将消息异步落库和幂等重试闭环。
4. 接入 `im.file.security`,把 OSS 上传完成后的文件安全检测异步化。
5. 接入 `im.workbench.event`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。
@@ -0,0 +1,595 @@
# IM Redis 三层数据分层改造方案
日期:2026-08-25
## 1. 背景与目标
当前 IM 服务需要接入 Redis,但不希望出现“进程内缓存一份、Redis 缓存一份、MySQL 再存一份”的重复状态,避免后续一致性维护困难。
本方案按业务数据特征拆成三层:
- 进程内:只放低频变更配置和本节点运行态。
- Redis:放小体量、高频读取、需要跨节点一致的热数据。
- MySQL:放最终持久化数据、事务数据、审计数据和大体量列表。
第一阶段不把消息正文作为 Redis 的长期存储。万人大群可以把 24 小时内热消息放入 Redis 作为读加速,私聊消息最终来源是租户库 `im_messages`,群聊消息最终来源是租户库 `im_group_messages`。
## 2. 核心原则
1. 同一类业务状态只确定一个主读取层,不在进程内和 Redis 同时维护两套可变状态。
2. 进程内缓存只承载低频配置或本机连接态,不承载跨节点业务状态。
3. 在线、离线、最近活跃等跨节点状态统一从 Redis 查询。
4. MySQL 仍是需要持久化、事务和审计的数据最终来源。
5. Redis 缓存 MySQL 数据时采用 cache-aside 模式,写入以 MySQL 事务成功为准。
6. Redis key 必须带租户标识,虽然业务库是一租户一库,但 Redis 是共享基础设施,需要避免不同租户数据串扰。
## 3. 三层数据分类
| 层级 | 数据类型 | 典型数据 | 读写特点 | 一致性要求 |
| --- | --- | --- | --- | --- |
| 进程内 | 静态或低频配置、本机运行态 | 系统配置、字典、租户配置、OSS/短信/内容安全/声网配置、本机 WebSocket 连接、topic channel | 启动加载,定时刷新;连接态只在本机维护 | 配置允许秒级延迟;连接态只代表本节点 |
| Redis | 小体量热数据、跨节点状态、短期状态 | 用户在线状态、用户基础信息、群基础信息、群成员集合、好友/黑名单集合、24 小时热消息、验证码、临时 token、限流计数 | 高频读,TTL 或主动失效 | 跨节点查询一致,允许短暂最终一致 |
| MySQL | 持久化和事务数据 | 消息、文件记录、群申请审批、举报、审核任务、操作日志、账务类数据、大分页列表 | 事务写入,分页查询,审计追溯 | 最终权威来源 |
## 4. 进程内层设计
### 4.1 适合放进程内的数据
- 系统配置:后台 hotgo 中的系统参数。
- 字典配置:`hg_sys_dict_type`、`hg_sys_dict_data` 等字典项。
- 租户配置:租户启停、基础配置、默认策略。
- 第三方配置:OSS、阿里云内容安全、短信、声网等接入参数。
- 本节点运行态:当前进程内的 WebSocket session、local topic channel、本机订阅关系、本机连接数。
### 4.2 刷新策略
- 服务启动时加载一次配置。
- 定时刷新,建议 60 秒。
- 后台配置变更后,后续可补充主动失效或广播刷新。
- 配置读取失败时保留旧值,并打印告警日志。
### 4.3 边界说明
进程内层不保存用户在线状态的全局结果。进程内只知道“本节点有哪些连接”,跨节点判断用户是否在线必须查 Redis。
## 5. Redis 层设计
### 5.1 第一阶段必须接入
#### 用户在线状态
在线状态是 Redis 第一优先级改造内容。原因是 WebSocket 长连接固定落在某个节点上,本机内存可以管理本机连接,但其他节点无法直接知道该用户是否在线。
建议 key:
```text
im:{tenant_id}:presence:user:{uid}
im:{tenant_id}:presence:user:{uid}:sessions
im:{tenant_id}:presence:node:{node_id}:users
```
`presence:user:{uid}` 建议使用 hash:
```text
online = 1
node_id = 当前节点 ID
session_count = 当前会话数
last_active_at = 最近活跃时间
last_seen_at = 最近可见时间
device = 设备类型
ua = 客户端 UA
```
生命周期:
1. WebSocket 登录成功后写入 Redis 在线状态。
2. 心跳或业务包刷新 TTL。
3. 断开连接时扣减 session 数,最后一个 session 断开后标记离线。
4. 节点异常退出时依赖 TTL 自动过期,避免永久在线。
5. 其他节点查询用户在线状态时只查 Redis。
TTL 建议:
- 在线状态 hash:60 到 120 秒。
- session 集合:60 到 120 秒,随心跳刷新。
- node 用户集合:可不强依赖 TTL,用于辅助排查和节点清理。
当前代码已经接入基础 PresenceStore:
- 登录成功后写入 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。
- 客户端每次上行业务包刷新 `last_active_at` 和 TTL。
- session 清理时移除当前 session,最后一个 session 离线后将 `online` 置为 `0`。
- 对外提供 `store.GetUserPresence` 作为后续用户在线状态查询入口。
- 群 Topic 在线成员已同步维护 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`,订阅/离开时更新,客户端心跳或任意上行业务包会刷新已订阅群的在线成员 TTL。
- 订阅列表中的用户在线状态已改为 Redis 优先读取;Redis 未启用或读取失败时,回退到原有进程内在线判断。
#### 短期状态
以下数据适合放 Redis,减少 MySQL 压力并天然支持过期:
- 注册验证码、登录验证码。
- 邀请码短期校验结果。
- 企业码登录临时 token。
- 登录失败次数、风控计数、接口限流计数。
- 幂等 key、短期操作锁。
### 5.2 第二阶段建议接入
#### 用户基础信息
适合缓存:
- 用户昵称、头像、状态签名。
- 用户公开资料。
- 用户账号到 UID 的映射。
不建议缓存:
- 密码 hash。
- 高敏认证字段。
- KYC 原始材料。
读写规则:
1. 查询先读 Redis。
2. 未命中读 MySQL。
3. MySQL 命中后写入 Redis。
4. 用户资料变更时先提交 MySQL,再删除或更新 Redis。
#### 群相关数据
群数据体量相对可控,且聊天过程中读取频繁,适合逐步放入 Redis:
- 群基础信息:群名、头像、简介、人数、群类型。
- 群配置:入群审批、禁言策略、邀请权限、公告权限。
- 群成员集合:成员 UID 集合。
- 群成员角色:群主、管理员、普通成员。
- 群成员状态:禁言、移出、黑名单。
建议 key:
```text
im:{tenant_id}:group:{group_id}:profile
im:{tenant_id}:group:{group_id}:settings
im:{tenant_id}:group:{group_id}:members
im:{tenant_id}:group:{group_id}:admins
im:{tenant_id}:group:{group_id}:muted
```
#### 关系数据
好友、黑名单、关注类关系适合用 Redis set 承载热读:
```text
im:{tenant_id}:relation:friends:{uid}
im:{tenant_id}:relation:blocklist:{uid}
```
用于快速判断:
- 是否好友。
- 是否被拉黑。
- 是否允许发起会话。
- 是否允许查看在线状态或最近在线时间。
### 5.3 第三阶段可选接入
- 首页 bootstrap 聚合结果:短 TTL 缓存,减少多表聚合查询。
- 群成员分页快照深度优化:基础 HTTP 结果快照已接入,后续大群可继续补 sorted set、稳定游标和主动失效。
- 未读数深度优化:基础总未读 Redis 热缓存已接入,后续可继续补 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。
### 5.4 万人大群实时推送专项
万人大群的实时推送需要和 Redis 改造一起考虑。核心目标是避免普通小群模式下的全量 fanout 压垮服务端,同时保证在线用户实时可达、离线用户通过极光 Push 提醒、客户端重连后可按 seq 补齐消息。
该部分作为 Redis 改造的近期实现项处理,不再仅作为未实现增强项记录。
#### 数据分层
| 层级 | 承载内容 | 说明 |
| --- | --- | --- |
| 进程内 | 本节点在线连接、本节点 topic 订阅、本节点待发送队列 | 只负责当前节点连接和发送,不保存全局群成员在线状态。 |
| Redis | 群在线成员索引、用户所在节点、群分片、原子 seq 发号器、24 小时热消息、短期去重 key | 用于判断哪些在线用户需要实时推送、原子获取群消息 seq,以及近期高频消息读取加速。 |
| MySQL 租户库 | `im_group_messages` 群消息正文、群消息 seq 结果、消息索引、离线同步游标、审计数据 | 作为群消息最终存储,保存 Redis 分配后的 seq,不负责实时发号。 |
| 极光 Push | 离线提醒 | 只做提醒,不承载消息正文最终一致性。 |
#### 建议 Redis key
```text
im:{tenant_id}:presence:user:{uid}
im:{tenant_id}:topic:{topic}:online_members
im:{tenant_id}:topic:{topic}:node:{node_id}:online_members
im:{tenant_id}:topic:{topic}:shard:{shard_id}:members
im:{tenant_id}:topic:{topic}:last_seq
im:{tenant_id}:topic:{topic}:hot_msg_seqs
im:{tenant_id}:topic:{topic}:hot_msg:{seq}
im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id}
im:{tenant_id}:push:dedup:{topic}:{seq}:{uid}
```
说明:
- `topic:online_members` 保存当前群在线成员集合,可由用户上线、订阅群、退订群、断开连接维护。
- `topic:node:{node_id}:online_members` 用于跨节点推送时按节点拆分,减少广播范围。
- `topic:shard:{shard_id}:members` 可用于万人群成员分片,后续支持分批扫描和补偿。
- `topic:last_seq` 是群消息 seq 发号器,使用 Redis `INCR` 或 Lua 脚本原子获取下一个 seq。
- `topic:hot_msg_seqs` 使用 sorted set 保存 24 小时内热消息 seq,score 为 seq 或发送时间。
- `topic:hot_msg:{seq}` 保存单条热消息正文快照,TTL 建议 24 小时。
- `topic:uniq:msg:{from_uid}:{client_msg_id}` 保存群消息发送唯一 key,用于客户端重试幂等和服务端去重。
- `push:dedup` 用于极光 Push 或实时推送去重,TTL 建议按分钟级设置。
#### 唯一 key 与幂等规则
Redis 唯一 key 用于挡住短时间内的重复提交,MySQL 唯一索引作为最终兜底。不能只依赖 Redis 做永久唯一性,因为 Redis key 会过期,也可能被运维清理。
群消息发送唯一 key:
```text
im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id}
```
建议 value:
```json
{
"status": "pending|done|failed",
"seq": 12345,
"message_id": 987654321,
"created_at": "2026-08-25T12:00:00.000+08:00"
}
```
处理规则:
1. 客户端必须传 `client_msg_id`,同一发送人、同一 Topic 内唯一。
2. 服务端收到群消息后先用 `SET key value NX EX ttl` 抢占唯一 key,初始状态为 `pending`。
3. 抢占成功才继续 Redis `INCR` 获取群 `seq`。
4. 抢占失败时读取 key:
- `done`:直接返回已生成的 `seq/message_id`。
- `pending`:返回处理中或短暂等待后重试,避免重复发号。
- `failed`:允许客户端用同一个 `client_msg_id` 重试,或要求重新生成 `client_msg_id`。
5. 消息写入 `im_group_messages` 成功后,将唯一 key 更新为 `done`,记录 `seq/message_id`。
6. 消息写入失败时,将唯一 key 更新为 `failed` 并设置较短 TTL;已经获取的 `seq` 不回退,允许空洞。
7. `im_group_messages` 仍保留 `UNIQUE(topic, from_user, client_msg_id)`,防止 Redis key 过期后重复落库。
TTL 建议:
- `pending`:30 到 120 秒。
- `done`:24 小时到 7 天,按客户端重试窗口决定。
- `failed`:5 到 30 分钟。
- Push 去重 key:5 到 30 分钟。
#### Seq 发号规则
- 群消息 seq 由 Redis 维护,不从 MySQL 查询最大 seq。
- 每个 Topic 独立一个 `topic:last_seq`,使用 `INCR im:{tenant_id}:topic:{topic}:last_seq` 原子获取。
- 只要求单 Topic 内单调递增,不要求全局连续。
- 如果取到 seq 后消息持久化失败,该 seq 可以形成空洞;客户端按 seq 补拉时允许跳过服务端确认不存在的 seq。
- Topic 创建或 Redis key 丢失时,当前代码使用 topic 内存态 `lastID` 作为 Redis 初始化基线;该值来自 topic 加载结果或当前进程已确认的最新 seq,然后再执行原子 `INCR`。
- Redis 不可用时不能用数据库临时发号继续写大群消息,应返回可重试错误,避免多源发号造成 seq 冲突。
#### 发送流程
1. 服务端收到群消息后,先完成权限校验和禁言校验。
2. 使用 Redis 唯一 key 抢占 `topic + from_uid + client_msg_id`,防止客户端重试重复发送。
3. 抢占成功后,使用 Redis `INCR` 或 Lua 脚本从 `topic:last_seq` 原子获取下一个群 `seq`。
4. 消息正文带着该 `seq` 写入租户库 `im_group_messages`。
5. 落库成功后将唯一 key 更新为 `done`,记录 `seq/message_id`。
6. 对万人大群或高热群,将消息快照写入 `topic:hot_msg:{seq}`,并把 seq 加入 `topic:hot_msg_seqs`,TTL 保持 24 小时。
7. 查询 Redis 中该群在线成员,按 `node_id` 分组。
8. 本节点在线成员直接从进程内连接表推送。
9. 其他节点在线成员通过 Redis Pub/Sub 通知目标节点推送;当前代码使用 `im:0:group_fanout` 广播精简后的 `ServerComMessage`,订阅节点收到后投递到本机 `hub.routeSrv`,只发送给本节点已加载 Topic 的在线连接。
10. 不在线成员不做服务端全量离线消息 fanout,只更新离线游标或依赖消息 seq 补拉,并触发极光 Push。
11. 客户端重连后按 Topic seq 拉取缺失消息,优先读 Redis 热消息,未命中再回源租户库 `im_group_messages`。
#### 读取流程
客户端消息读取仍以 seq 为准,Redis 只优化 24 小时内的热读:
1. 在线实时收到消息后,客户端本地落库并更新本地最大 seq。
2. 重连、切换设备或发现 seq 不连续时,调用历史消息接口按 `topic + after_seq` 补拉。
3. 服务端先按 seq 范围读取 Redis 热消息。
4. Redis 命中的消息直接返回;缺失、过期或不连续的 seq 回源租户库 `im_group_messages`。
5. 回源命中后可按策略重新写入 Redis,保持 24 小时 TTL。
6. 极光 Push 唤醒后,客户端仍走正常登录和 seq 补拉链路。
#### 热消息缓存边界
- 只缓存 24 小时内高频读取的群消息,普通群可不开启或按群规模阈值开启。
- Redis 中的消息快照不作为审计和历史同步最终来源。
- 删除、撤回、编辑消息后,必须同步删除或更新对应 `topic:hot_msg:{seq}`,避免客户端读到旧正文。
- 大消息、文件二进制、音视频内容不直接放 Redis,只缓存消息元数据和 OSS object key。
- Redis 容量按租户和群设置上限,超过阈值时优先淘汰低热群或旧 seq。
- 当前代码只在 Redis 能完整命中一页群消息时直接返回;如果 Redis 缺失、过期、不连续或读取带有用户软删除视角,则回源 MySQL,避免缓存缺口导致消息缺失或读到用户已删除内容。
#### 离线 Push 口子
极光 Push 只用于离线提醒,建议 payload 只放最小必要信息:
```json
{
"type": "group_message",
"topic": "grp_xxx",
"seq": 12345,
"tenant_id": "tenant_xxx"
}
```
不建议在 Push payload 中放完整消息正文,避免隐私泄露、厂商通道截断和多端一致性问题。
#### 一致性边界
- Redis 的在线成员集合允许短暂不准,最终由心跳 TTL 和订阅变更修正。
- Redis 唯一 key 负责短期幂等和去重,MySQL 唯一索引负责最终唯一性兜底。
- seq 是否已分配以 Redis `topic:last_seq` 为准;群消息是否真实存在,以租户库 `im_group_messages` 为准。
- Redis 发号成功但消息持久化失败时,允许 seq 空洞,不回退 `topic:last_seq`。
- 实时推送失败不影响消息持久化,通过客户端 seq 补拉恢复;Redis 热消息缺失时回源租户库 `im_group_messages`。
- 极光 Push 失败不影响消息存储,只影响离线提醒,需要记录失败并可观测。
- Redis Pub/Sub 只负责在线实时投递,不作为可靠消息队列;节点短暂断线错过实时消息时,客户端仍以 Topic seq 补拉恢复。
## 6. MySQL 层设计
### 6.1 必须保留在 MySQL 的数据
- 私聊消息正文、私聊消息索引、离线同步游标。
- 群消息正文、群消息索引、群消息审计数据。
- 文件记录、OSS object key、文件安全审核结果。
- 群邀请、进群申请、审批记录。
- 群公告、公告确认、公告历史。
- 用户认证、KYC 记录、企业注册资料。
- 举报、投诉、内容安全审核任务。
- 操作日志、审计日志、管理后台变更记录。
- 需要事务一致性的业务状态。
### 6.2 消息表拆分
当前 `im_messages` 后续只作为私聊、文件助手等非群聊消息的主存储。群聊消息新增租户库业务表 `im_group_messages`,避免所有消息挤在一张表里,也方便后续针对大群按 `topic + seq`、时间、热度做分区、归档和缓存。
建议表结构:
```sql
CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(64) NOT NULL COMMENT '群 Topic,如 grp_xxx',
group_id BIGINT NOT NULL COMMENT '业务群 ID,便于分区、治理和统计',
seqid BIGINT NOT NULL COMMENT '群内消息序号,由 Redis 原子发号',
from_user BIGINT NOT NULL COMMENT '发送人用户 ID',
client_msg_id VARCHAR(64) NULL COMMENT '客户端幂等消息 ID',
msg_type VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等',
status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked',
content JSON NULL COMMENT '消息正文或 Drafty 内容',
head JSON NULL COMMENT '消息头,包含 @、引用、客户端能力等',
mentioned_user_ids JSON NULL COMMENT '@ 用户列表,便于提醒和检索',
mention_all TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 @ 全体',
reply_seqid BIGINT NULL COMMENT '回复的群消息 seq',
root_seqid BIGINT NULL COMMENT '线程或合并上下文根消息 seq',
file_count INT NOT NULL DEFAULT 0 COMMENT '附件数量',
content_size INT NOT NULL DEFAULT 0 COMMENT '正文 JSON 序列化后的字节数',
has_risk TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否命中风控或内容安全',
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject',
version INT NOT NULL DEFAULT 1 COMMENT '编辑版本',
recalled_at DATETIME(3) NULL,
recalled_by BIGINT NULL,
deleted_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL COMMENT '可选保留期,便于过期归档或清理',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_group_messages_group_seq (group_id, seqid),
UNIQUE KEY uk_im_group_messages_topic_seq (topic, seqid),
UNIQUE KEY uk_im_group_messages_topic_sender_client_mid (topic, from_user, client_msg_id),
KEY idx_im_group_messages_group_created (group_id, created_at),
KEY idx_im_group_messages_group_status_seq (group_id, status, seqid),
KEY idx_im_group_messages_topic_created (topic, created_at),
KEY idx_im_group_messages_topic_from_seq (topic, from_user, seqid),
KEY idx_im_group_messages_audit (audit_status, created_at),
KEY idx_im_group_messages_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
设计说明:
- `seqid` 使用 `BIGINT`,避免大群长期高频消息导致 `INT` 不够用。
- `group_id + seqid` 和 `topic + seqid` 都保持唯一,业务治理优先用 `group_id`,协议补拉仍可用 `topic`。
- `client_msg_id` 用于发送幂等,索引带上 `topic`,避免不同群复用客户端消息 ID 时互相冲突。
- `status` 标记普通、撤回、删除、拦截状态,拉历史时可以返回占位或过滤。
- `mentioned_user_ids`、`mention_all` 单独存,方便 @ 提醒、离线 Push 和后台治理,不必每次解析正文。
- `audit_status`、`has_risk` 为内容安全和风控留字段,高风险消息可先 pending 后再放行。
- `reply_seqid`、`root_seqid` 为引用回复、线程、合并转发上下文预留。
- 文件二进制不进表,表内只保存消息内容和 OSS object key 等元数据;群消息附件会同步登记到 `im_group_files`,并记录 `source_topic/source_seq` 追溯来源消息。
- 超大规模租户后续可按 `group_id` hash 分表,或按 `created_at/expires_at` 做归档分区。
读写规则:
1. 私聊消息继续写 `im_messages`。
2. 群聊消息写 `im_group_messages`。
3. 群消息发号由 Redis `topic:last_seq` 原子递增完成,`im_group_messages.seqid` 只保存结果。
4. Redis 24 小时热消息命中时直接返回;未命中按 `topic + seqid` 回源 `im_group_messages`。
5. 撤回、编辑、删除群消息时,以 `im_group_messages` 为准,并同步更新或删除 Redis 热消息。
### 6.3 消息正文说明
第一阶段不把消息正文作为 Redis 长期存储。万人大群和高热群允许缓存 24 小时内热消息,作为客户端重连补拉、最近历史翻页和多端同步的读加速。
消息正文主要读取场景:
- 新设备登录后的历史消息同步。
- 离线用户重新上线后的消息补拉。
- 客户端本地消息缺失后的重拉。
- 举报、风控、内容安全复查。
- 管理后台审计或客服处理。
这些场景的最终来源仍是 MySQL 的消息表。私聊回源 `im_messages`,群聊回源 `im_group_messages`。Redis 热消息只覆盖短期高频读取,命中则减少数据库压力,未命中必须回源。
## 7. 读写一致性规则
### 7.1 进程内配置
- 进程启动加载配置。
- 定时刷新配置。
- 配置读取失败时使用旧值。
- 配置变更允许短时间延迟生效。
### 7.2 Redis 运行态
- 在线状态以 Redis 为跨节点查询来源。
- 本机内存只用于管理本机连接。
- Redis 写入失败时,在线状态降级为未知或离线,不阻断基础连接能力。
- 心跳持续刷新 TTL,节点异常时依赖 TTL 自动回收。
### 7.3 Redis 缓存 MySQL 热数据
- 读:先 Redis,未命中再 MySQL,最后回填 Redis。
- 写:先 MySQL 事务提交,再删除或更新 Redis。
- 消息 seq:群聊先通过 Redis `INCR` 或 Lua 脚本原子获取 seq,再带 seq 写租户库 `im_group_messages`;私聊仍按现有 `im_messages` 链路处理。
- 消息热缓存:消息持久化成功后,再写 Redis 24 小时热消息;热消息写失败不影响消息发送成功。
- Redis 故障:直接回源 MySQL,记录降级日志和指标。
- 不允许因为 Redis 写失败回滚已经成功的 MySQL 业务事务,除非该业务本身强依赖 Redis 状态。
### 7.4 MySQL 持久化
- 需要审计、事务、历史追溯的数据直接写 MySQL。
- Redis 和进程内缓存只做读取加速或运行态存储。
- 私聊消息使用 `im_messages`,群聊消息使用 `im_group_messages`,避免单表无限膨胀。
## 8. 租户隔离
当前方案是一租户一库,业务表中不再保留 `tenant_id` 字段。但 Redis、进程内配置、日志指标仍属于共享运行环境,必须保留租户维度。
Redis key 统一包含租户标识:
```text
im:{tenant_id}:...
```
如果请求早期只有企业码或租户编码,应先解析到租户 ID 或租户库配置,再进入业务缓存读取。
## 9. 代码改造建议
### 9.1 基础设施
- 增加 Redis 配置:地址、密码、DB、连接池、超时、key 前缀。
- 增加 Redis client 初始化和健康检查。
- 增加统一 key builder,避免业务代码手写 key。
- 增加缓存指标和日志。
当前代码将 Redis 配置挂在 `store_config.redis` 下;不配置则不启用 Redis 群消息发号和唯一 key,群消息仍兼容旧的本机 `lastID + 1` 发号。
配置示例:
```json
{
"store_config": {
"redis": {
"addr": "127.0.0.1:6379",
"username": "",
"password": "",
"db": 0,
"key_prefix": "im",
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
"presence_ttl_seconds": 120,
"cache_ttl_seconds": 300,
"short_state_ttl_seconds": 600,
"group_fanout_enabled": true
}
}
}
```
`group_fanout_enabled` 默认关闭。启用后,群消息本节点投递完成后会发布 Redis Pub/Sub,其他节点订阅后只向本节点已在线的同群连接推送;如果节点没有加载该 Topic,则读取 Redis 本节点群在线成员集合做兜底直投,本地会话订阅表作为兼容兜底,实时投递失败再由客户端 seq 补拉恢复。
当前第二阶段已接入:
- 用户资料单对象缓存:`store.Users.Get` 优先读 Redis,用户更新/删除后失效。
- Topic 单对象缓存:`store.Topics.Get` 优先读 Redis,Topic 更新、转让、删除后失效。
- 单条订阅关系缓存:`store.Subs.Get` 优先读 Redis,订阅更新/删除后 active/any 两种缓存同时失效。
- 群治理资料缓存:群设置 `GroupBusiness.GetSettings`、群成员 `GroupBusiness.GetMember` 优先读 Redis,更新、离群、角色变更后刷新或失效。
- 群成员分页快照:`GET /v1/im/groups/{topic}/members` 按租户、Topic、操作者权限、筛选条件、cursor 和 limit 缓存响应快照,未命中回源订阅列表。
- 用户总未读热缓存:`store.Users.GetUnreadCount` 先读 Redis,未命中回源 MySQL 并回填;在线用户 unread updater 更新内存计数时同步写 Redis。
- 好友/黑名单 Redis Set 热缓存:关系上下文判断先查 `friends:{uid}`、`blocklist:{uid}` 正向 set,命中后减少 MySQL 查询;未命中仍回源 MySQL,新增/删除好友、拉黑/解除拉黑会同步更新 set。
- 验证码短状态缓存:验证码创建、查询、验证、失败、消费会同步 Redis,`GetActive` 使用 `scene + phone_hash` 的 active key 做重发窗口热读。
- Redis 频控:验证码创建按 `tenant + scene + phone_hash` 使用 Redis 原子 `INCR + EXPIRE` 做 1 小时 5 次限制,手机号只使用 hash,不写明文。
- 动作 token 短状态缓存:创建时写 Redis,消费成功后删除缓存;最终消费仍以 MySQL 状态机为准,保留审计一致性。
- SeqId 收口:消息、Topic 游标、订阅读/收游标、消息范围、收藏/举报/群文件引用、通话消息引用、JSON/gRPC 协议结构均改为 `int64`;MySQL 新建表 schema 中对应字段改为 `BIGINT`。
- 进程内配置缓存:租户解析、注册短信开关、第三方配置读取已加本地 TTL 缓存,减少 HotGo 控制库频繁查询;缓存过期后按下一次读取刷新。
### 9.2 接口抽象
建议新增接口:
```go
type PresenceStore interface {
MarkOnline(ctx context.Context, tenantID string, uid string, sessionID string, meta PresenceMeta) error
Refresh(ctx context.Context, tenantID string, uid string, sessionID string) error
MarkOffline(ctx context.Context, tenantID string, uid string, sessionID string) error
Get(ctx context.Context, tenantID string, uid string) (Presence, error)
}
type ConfigCache interface {
GetDict(ctx context.Context, key string) (DictValue, bool)
GetTenantConfig(ctx context.Context, tenantID string) (TenantConfig, bool)
Refresh(ctx context.Context) error
}
type ReadModelCache interface {
GetUserProfile(ctx context.Context, tenantID string, uid string) (UserProfile, bool)
SetUserProfile(ctx context.Context, tenantID string, profile UserProfile) error
DeleteUserProfile(ctx context.Context, tenantID string, uid string) error
}
```
### 9.3 业务接入顺序
1. 先接入 Redis client 和配置。
2. 接入在线状态 PresenceStore。
3. 群消息接入 Redis `SET NX` 唯一 key 与 `INCR` 原子 seq 发号,群消息正文写入 `im_group_messages`。
4. WebSocket 登录、心跳、断开链路写入 PresenceStore。
5. 用户在线状态查询接口改为读 Redis。
6. 配置和字典改为进程内 TTL 缓存刷新。
7. 再逐步接入用户、群、好友关系热数据缓存。
## 10. 故障降级
| 场景 | 降级策略 |
| --- | --- |
| Redis 不可用,查询在线状态 | 返回未知或离线,记录告警 |
| Redis 不可用,读取用户/群缓存 | 回源 MySQL |
| Redis 不可用,验证码或短期 token | 涉及安全的接口应失败关闭 |
| Redis 不可用,万人大群发消息 | 无法原子获取群 seq,应返回可重试错误;不使用 MySQL 临时发号,避免 seq 冲突 |
| 配置刷新失败 | 使用进程内旧配置 |
| MySQL 不可用 | 持久化业务失败,不依赖 Redis 兜底 |
## 11. 监控指标
- Redis 连接状态。
- Redis 命令耗时。
- Redis 缓存命中率。
- 在线用户 key 数量。
- 心跳刷新失败次数。
- Redis 回源 MySQL 次数。
- 大群在线成员集合大小。
- 大群按节点分发耗时。
- 大群实时推送成功率和补拉率。
- 极光 Push 触发数、成功数和失败数。
- 配置刷新成功和失败次数。
- Presence TTL 过期导致的离线数量。
## 12. 第一阶段落地范围
第一阶段建议只做以下内容:
- Redis 基础配置和 client 初始化。
- 用户在线、离线、心跳刷新、跨节点查询。
- 验证码、临时 token、限流计数等短期状态迁移到 Redis。
- 系统配置、字典、租户配置、第三方配置改为进程内 TTL 缓存刷新。
- 万人大群实时推送纳入 Redis 改造近期实现范围,先完成 Redis key、在线成员索引、节点分片和跨节点实时分发链路。
- 明确消息正文不进入 Redis 长期存储;万人大群 24 小时热消息可进入 Redis 做读加速。
- 群成员分页快照和用户总未读 Redis 热缓存已完成基础接入。
后续再逐步推进好友/黑名单全量预热、群成员分页快照、未读数等缓存改造。
+33 -1
View File
@@ -1,6 +1,6 @@
# IM 产品原型需求文档 # IM 产品原型需求文档
> 本文档根据 `/Users/superysh/im/第二项目/` 中的 156 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。 > 本文档根据 `/Users/superysh/im/第二项目/` 中的 160 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。
## 产品总体定位 ## 产品总体定位
@@ -18,6 +18,38 @@
提供企业租户入口。页面展示产品标识、企业码输入框和“进入企业”按钮;提交后应校验企业码是否存在、是否启用,并将用户导向对应企业的登录环境。 提供企业租户入口。页面展示产品标识、企业码输入框和“进入企业”按钮;提交后应校验企业码是否存在、是否启用,并将用户导向对应企业的登录环境。
### 1.1.1 企业注册-登录入口.png
<img src="images/企业注册-登录入口.png" alt="企业注册-登录入口" width="360">
功能说明:
企业码登录页右上角提供“企业注册”入口,底部提供“遇到问题?”反馈入口。点击“企业注册”进入企业资料提交流程;点击“遇到问题?”进入问题反馈页。入口文案应保持可见且易点击,避免与状态栏或背景装饰重叠。
### 1.1.2 企业注册-填写注册信息.png
<img src="images/企业注册-填写注册信息.png" alt="企业注册-填写注册信息" width="360">
功能说明:
企业注册页按步骤展示“填写企业信息、确定信息、提交审核”,当前处于第 1 步。用户需填写 18 位统一社会信用代码,支持扫描识别;可选上传营业执照,并填写企业邮箱用于接收审核结果及企业通知。提交前应校验统一社会信用代码格式、邮箱格式、图片大小和文件类型,并提示资料提交后预计审核时长。
### 1.1.3 企业注册-提交成功提示.png
<img src="images/企业注册-提交成功提示.png" alt="企业注册-提交成功提示" width="360">
功能说明:
企业注册资料提交成功后,在当前页展示明确 Toast 提示“提交成功,我们会尽快联系你”。提交成功状态应防止重复提交,并记录申请单状态;审核结果需通过企业邮箱或后续通知渠道触达。页面底部审核时长文案应与实际运营规则一致。
### 1.1.4 企业注册-问题反馈.png
<img src="images/企业注册-问题反馈.png" alt="企业注册-问题反馈" width="360">
功能说明:
企业入口问题反馈页支持填写问题描述并上传最多 9 张截图,便于定位企业码、注册资料、审核通知等问题。提交时应携带设备、版本、租户线索和可选联系方式;图片上传需显示数量限制、失败重试和大小校验,提交成功后给出明确受理反馈。
### 1.2 登录页面.png ### 1.2 登录页面.png
<img src="images/登录页面.png" alt="登录页面" width="360"> <img src="images/登录页面.png" alt="登录页面" width="360">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+128 -62
View File
@@ -29,7 +29,7 @@ API Key 是应用级接入标识,由部署人员在发布客户端前生成并
1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。
2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。
3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。
生成示意: 生成示意:
@@ -44,7 +44,7 @@ API Key、企业码和用户 Token 是三个不同概念:
| 值 | 取得时间 | 作用 | 是否代表用户身份 | | 值 | 取得时间 | 作用 | 是否代表用户身份 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | | `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 |
| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | | `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 |
| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | | `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 |
@@ -57,11 +57,11 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
| 通道 | 地址 | 认证 | 用途 | | 通道 | 地址 | 认证 | 用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | | WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 |
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | | WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | | 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | | 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
@@ -221,7 +221,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
```http ```http
POST /v0/channels/lp HTTP/1.1 POST /v0/channels/lp HTTP/1.1
Host: im.example.com Host: im.example.com
X-Tinode-APIKey: <预置的普通 API Key> X-MeetingIM-APIKey: <预置的普通 API Key>
Content-Length: 0 Content-Length: 0
``` ```
@@ -235,7 +235,7 @@ Content-Length: 0
```http ```http
POST /v0/channels/lp?sid=session-id HTTP/1.1 POST /v0/channels/lp?sid=session-id HTTP/1.1
X-Tinode-APIKey: <预置的普通 API Key> X-MeetingIM-APIKey: <预置的普通 API Key>
Content-Type: application/json Content-Type: application/json
{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} {"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}}
@@ -245,7 +245,7 @@ POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的
```http ```http
GET /v0/channels/lp?sid=session-id HTTP/1.1 GET /v0/channels/lp?sid=session-id HTTP/1.1
X-Tinode-APIKey: <预置的普通 API Key> X-MeetingIM-APIKey: <预置的普通 API Key>
``` ```
收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。
@@ -270,7 +270,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
本节用于 WebSocket 联调。除特别说明外: 本节用于 WebSocket 联调。除特别说明外:
- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-Tinode-APIKey`。 - WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。
- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 - 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。
- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 - 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。
- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 - 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。
@@ -334,16 +334,60 @@ X-Tinode-APIKey: <预置的普通 API Key>
| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` | | 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` |
| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` | | 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` |
##### 0.5.4.6 系统事件、好友和群业务下行 ##### 0.5.4.6 系统通知、好友和群业务下行
| 事件 | 服务端下行示例 | 客户端动作 | 服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。
| --- | --- | --- |
| 好友申请创建 | `{"data":{"topic":"sys","seq":101,"from":"usr_system","head":{"mime":"application/json","x-im-type":"system_event"},"content":{"event_type":"friend.request.created","request_id":"frq_1","applicant":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","action":{"type":"friend_request","request_id":"frq_1"}}}}` | 系统消息入库/展示,刷新 `friend-requests` 和 `bootstrap.entry_badges` | 用户维度业务事件统一格式:
| 好友关系变化 | `{"data":{"topic":"sys","seq":102,"head":{"x-im-type":"system_event"},"content":{"event_type":"friend_relationship_changed","peer_user_id":"usr_a","relationship":"friend","topic":"p2p..."}}}` | 刷新会话 context,解锁或锁定输入区 |
| 群邀请 | `{"data":{"topic":"sys","seq":103,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_invitation","invitation_id":"ginv_1","group":{"topic":"grp...","name":"项目群"},"inviter":{"user_id":"usr_a","display_name":"张三"},"status":"pending","expires_at":"2026-08-27T09:00:00Z"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 | ```json
| 入群申请 | `{"data":{"topic":"grp...","seq":20,"head":{"x-im-type":"system_event"},"content":{"event_type":"group.join_request.created","request_id":"gjr_1","applicant":{"user_id":"usr_a","display_name":"张三"},"source":"qr"}}}` | 管理员刷新 `join-requests` 和 `summary.pending_join_request_count` | {
| 群成员变化 | `{"data":{"topic":"grp...","seq":21,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_membership_changed","operator":"usr_owner","target_users":["usr_a"],"action":"join","result":"joined"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 | "info": {
| 工作台未读变化 | `{"data":{"topic":"sys","seq":104,"head":{"x-im-type":"system_event"},"content":{"event_type":"workbench.badge.updated","modules":[{"code":"meetings","unread_count":2}],"workbench_total_unread":2}}}` | 更新工作台 badge;必要时重新拉 `GET /v1/im/workbench` | "topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "friend.request.accepted",
"payload": {
"user_id": "usr_actor"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
群维度业务事件统一格式:
```json
{
"info": {
"topic": "grp_xxx",
"from": "usr_actor",
"what": "group",
"event": "group.member.joined",
"payload": {
"topic": "grp_xxx",
"user_id": "usr_xxx"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
| 事件 | 接收方 | 服务端下行示例 | 客户端动作 |
| --- | --- | --- | --- |
| 好友申请创建 | 被申请人 | `{"info":{"topic":"usr_target","from":"usr_applicant","what":"im","event":"friend.request.created","payload":{"user_id":"usr_applicant"}}}` | 刷新 `GET /v1/im/friend-requests` 和 `GET /v1/im/bootstrap` 的新朋友未处理数;系统通知列表也会新增记录 |
| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 |
| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 |
| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 |
| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 |
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
业务事件写入 outbox 后会立即尝试投递;Hub 不可用或队列满时保持 pending,后台 poller 按在线租户重试。`registration.invite_used` 和 `security.password_reset` 是服务端内部消费事件,不作为普通业务事件下发给 App。
##### 0.5.4.7 WebRTC 通话信令 ##### 0.5.4.7 WebRTC 通话信令
@@ -381,8 +425,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | | WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | | WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | | `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-Tinode-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | | `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-Tinode-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | | `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
### 0.6 Topic 与产品消息 ### 0.6 Topic 与产品消息
@@ -437,7 +481,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**企业解析接口:** **企业解析接口:**
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
@@ -486,7 +530,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。 - 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。
- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。 - 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。
- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。 - 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。
- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 - 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体: - 请求体:
```json ```json
@@ -537,7 +581,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。 - 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。
- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。 - 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。
- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。 - 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。
- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 - 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体: - 请求体:
```json ```json
@@ -575,8 +619,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 - 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 - 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 - 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 - Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 - 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
@@ -696,7 +740,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 - 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。
- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 - 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。
- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。 - 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。
- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 - 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。
### 1.9 实名认证-已实名.png ### 1.9 实名认证-已实名.png
@@ -734,7 +778,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -798,7 +842,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:基础消息操作部分已实现;产品权限矩阵需改造。** **状态:基础消息操作部分已实现;产品权限矩阵需改造。**
- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 - 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。
- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 - 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。
- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 - 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。
- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 - 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。
@@ -808,7 +852,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:需改造。** **状态:需改造。**
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 - `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 - 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
@@ -817,7 +861,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -852,7 +896,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 - 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。
- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 - `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。
- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 - 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。
### 3.3 个人对话框(我的状态).png ### 3.3 个人对话框(我的状态).png
@@ -880,7 +924,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增好友审批与 Topic 联动。** **状态:新增好友审批与 Topic 联动。**
- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 - 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。
- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 - 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。
- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 - 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。
@@ -1150,7 +1194,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1174,6 +1218,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` | | `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` |
| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | | `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
## 5. 通讯录、好友关系与黑名单 ## 5. 通讯录、好友关系与黑名单
### 5.1 通讯录.png ### 5.1 通讯录.png
@@ -1196,6 +1242,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png ### 5.3 添加好友.png
@@ -1314,7 +1361,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** **状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。**
- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 - 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
@@ -1322,7 +1369,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1357,11 +1404,11 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/群聊.png" alt="群聊" width="360"> <img src="images/群聊.png" alt="群聊" width="360">
**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** **状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。**
- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 - 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。
- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 - 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。
- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 - 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。
### 6.2 空白页.png ### 6.2 空白页.png
@@ -1378,7 +1425,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增邀请卡片和加入接口。** **状态:新增邀请卡片和加入接口。**
- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 - 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。
- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
@@ -1389,8 +1436,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增群成员事件。** **状态:新增群成员事件。**
- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 - 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。
- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 - 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。
- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 - 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。
### 6.5 邀请进群申请.png ### 6.5 邀请进群申请.png
@@ -1419,7 +1466,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 - 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。
- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 - 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。
- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 - 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
### 6.8 群公告状态/邀请进群申请同时存在.png ### 6.8 群公告状态/邀请进群申请同时存在.png
@@ -1603,7 +1650,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 - `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。
- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 - 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成 `group.owner.transferred` 群业务事件。
- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 - 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。
### 6.27 群文件.png ### 6.27 群文件.png
@@ -1630,7 +1677,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1765,7 +1812,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:底层下载已实现租户校验;资源 ACL 需改造。** **状态:底层下载已实现租户校验;资源 ACL 需改造。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 - 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
@@ -1838,7 +1885,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -1975,7 +2022,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2001,7 +2048,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 - `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。 - `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 - 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
### 9.2 部门组织框架.png ### 9.2 部门组织框架.png
@@ -2012,6 +2059,18 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 - 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。
- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 - 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。
- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 - 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。
- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。
### 9.2.1 打卡考勤
**状态:已实现基础版。**
- 打卡规则、班次、位置/Wi-Fi 约束、补卡窗口、异常判定和统计结果必须以后端为真源;客户端只负责展示、采集设备侧上下文和临时离线队列,不能把正式打卡记录或规则只保存在本地。
- 配置:`GET /v1/im/attendance/config?date=2026-08-31` 返回当前用户当天适用的规则、班次、允许打卡动作和前端展示文案。服务端按租户、部门、人员、节假日和特殊排班裁剪,前端不得自行推导是否迟到/早退/缺卡。
- 记录:`GET /v1/im/attendance/records?from=2026-08-01&to=2026-08-31&scope=mine` 返回当前用户打卡记录、异常状态和补卡状态;管理员用 `scope=managed&department_id=&user_id=&cursor=` 查询权限范围内记录。
- 打卡:`POST /v1/im/attendance/checkins`,Body 带 `date,action,rule_id,shift_id,client_time,location,wifi,device`,写操作必须带 `Idempotency-Key`。服务端校验打卡窗口、地理围栏/Wi-Fi、重复提交和设备风险,成功后返回服务端判定状态。
- 补卡/异常申诉:`POST /v1/im/attendance/appeals` 创建申请,`GET /v1/im/attendance/appeals?scope=mine|managed&status=&cursor=` 查询列表,审批动作由管理端或后续办公审批接口处理。
- 主动下行:规则变更、打卡状态变化、补卡审批变化可通过 `info.what="im"`,事件 `attendance.rule.updated/attendance.record.updated/attendance.appeal.updated` 下发;前端收到后刷新配置、记录或列表。
### 9.3 会议通知-发起人状态.png ### 9.3 会议通知-发起人状态.png
@@ -2022,6 +2081,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 - 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。
- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 - 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。
- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 - 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。
- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。
### 9.4 会议通知-接收人状态.png ### 9.4 会议通知-接收人状态.png
@@ -2030,7 +2090,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增。** **状态:新增。**
- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 - 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。
- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 - 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.5 日报.png ### 9.5 日报.png
@@ -2125,21 +2185,27 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 - 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 9.14.1 工作台、组织、会议、日报、月报、信箱 #### 9.14.1 工作台、组织、打卡、会议、日报、月报、信箱
| 接口 | 输入示例 | 成功 `data` 示例 | | 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- | | --- | --- | --- |
| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` | | `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"},{"code":"attendance","name":"打卡考勤","icon_file_id":"","route":"/attendance","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"attendance":true,"meetings":true}}}` |
| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` | | `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` |
| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` | | `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` | | `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` | | `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/attendance/config?date=2026-08-31` | Query only | `{"date":"2026-08-31","timezone":"Asia/Shanghai","rule":{"rule_id":"att_rule_default","name":"默认考勤规则","workday":true,"location_required":false,"wifi_required":false,"allow_outside_checkin":true,"appeal_allowed":true},"shifts":[{"shift_id":"shift_default","name":"默认班次","checkin_start":"08:30","work_start":"09:00","work_end":"18:00","checkout_end":"20:00"}],"allowed_actions":["check_in","check_out"],"today_record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","checkins":[]},"permissions":{"can_checkin":true,"can_appeal":true,"can_view_managed":true}}` |
| `GET /v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr_me","date":"2026-08-31","rule_id":"att_rule_default","shift_id":"shift_default","status":"normal","checkins":[{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"name":"上海办公室","distance_meters":38},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}],"exceptions":[],"appeal_status":"","version":3}],"next_cursor":"","has_more":false,"statistics":{"expected_count":1,"checked_count":1,"exception_count":0,"not_checked_count":0},"permissions":{"can_checkin":true,"can_view_managed":true,"can_appeal":true}}` |
| `GET /v1/im/attendance/records?scope=managed&department_id=dep_1&date=2026-08-31&status=late&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"late","exceptions":["late"],"appeal_status":"pending","version":2}],"next_cursor":"","has_more":false,"statistics":{"expected_count":20,"checked_count":19,"exception_count":2,"not_checked_count":1}}` |
| `POST /v1/im/attendance/checkins` | Header `Idempotency-Key`;Body `{"date":"2026-08-31","action":"check_in","rule_id":"att_rule_default","shift_id":"shift_default","client_time":"2026-08-31T08:55:00+08:00","location":{"latitude":31.2304,"longitude":121.4737,"accuracy_meters":30},"wifi":{"ssid":"Office-WiFi","bssid":"00:11:22:33:44:55"},"device":{"device_id":"ios-device-1","platform":"ios"}}` | `{"record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","version":2,"checkins":[{"checkin_id":"attc_...","action":"check_in","result":"normal"}]},"checkin":{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"latitude":31.2304,"longitude":121.4737},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}}` |
| `POST /v1/im/attendance/appeals` | Header `Idempotency-Key`;Body `{"record_id":"evt_...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T09:00:00+08:00","attachment_file_ids":["file_1"]}` | `{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T01:00:00Z","attachment_file_ids":["file_1"],"status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}` |
| `GET /v1/im/attendance/appeals?scope=mine&status=pending&cursor=` | Query only | `{"items":[{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` | | `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` |
| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` | | `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` |
| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` | | `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` |
@@ -2147,16 +2213,16 @@ X-Tinode-APIKey: <预置的普通 API Key>
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | | `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | | `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | | `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | | `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | | `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` | | `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | | `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | | `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` |
| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` | | `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` |
| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | | `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` |
| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` | | `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` |
| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` | | `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` |
| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | | `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
@@ -2182,10 +2248,10 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/个人信息.png" alt="个人信息" width="360"> <img src="images/个人信息.png" alt="个人信息" width="360">
**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png ### 10.3 设置昵称.png
@@ -2372,7 +2438,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2410,7 +2476,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 - 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。
- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 - 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。
- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 - 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。
### 11.2 反诈弹窗(二次提示安卓).png ### 11.2 反诈弹窗(二次提示安卓).png
@@ -2457,7 +2523,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
以下示例用于本章 HTTP 联调快速查阅。除特别说明外: 以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。 - 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 - 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 - 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
@@ -2479,7 +2545,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。
2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。
3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。
4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。
5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。
@@ -2489,7 +2555,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | | P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 |
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | | P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 |
| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | | P0 | 普通用户禁止发布系统 Topic;系统通知和业务事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 |
| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | | P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 |
| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | | P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 |
| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | | P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 |
+133 -4
View File
@@ -443,6 +443,19 @@ CREATE TABLE IF NOT EXISTS tinode.im_org_members (
CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_org_visibility_overrides (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
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_org_visibility_user (user_id),
INDEX idx_im_org_visibility_scope (visibility_scope, state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_meetings ( CREATE TABLE IF NOT EXISTS tinode.im_meetings (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL, meeting_id VARCHAR(64) NOT NULL,
@@ -805,6 +818,113 @@ CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions (
INDEX idx_im_feedback_submissions_user (user_id, id DESC) INDEX idx_im_feedback_submissions_user (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Attendance.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_attendance_rules (
id BIGINT NOT NULL AUTO_INCREMENT,
rule_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
workday TINYINT NOT NULL DEFAULT 1,
location_required TINYINT NOT NULL DEFAULT 0,
wifi_required TINYINT NOT NULL DEFAULT 0,
allow_outside_checkin TINYINT NOT NULL DEFAULT 1,
appeal_allowed TINYINT NOT NULL DEFAULT 1,
effective_from DATE,
effective_to DATE,
state VARCHAR(16) NOT NULL DEFAULT 'active',
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_attendance_rules_rule (rule_id),
INDEX idx_im_attendance_rules_state (state, effective_from, effective_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_attendance_shifts (
id BIGINT NOT NULL AUTO_INCREMENT,
shift_id VARCHAR(64) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
work_start TIME NOT NULL,
work_end TIME NOT NULL,
checkin_start TIME NOT NULL,
checkout_end TIME NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
sort_order INT NOT NULL DEFAULT 0,
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_attendance_shifts_shift (shift_id),
INDEX idx_im_attendance_shifts_rule (rule_id, state, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_attendance_records (
id BIGINT NOT NULL AUTO_INCREMENT,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
record_date DATE NOT NULL,
rule_id VARCHAR(64) NOT NULL,
shift_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'not_checked',
exceptions JSON,
appeal_status VARCHAR(32) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
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_attendance_records_record (record_id),
UNIQUE INDEX uk_im_attendance_records_user_date_shift (user_id, record_date, shift_id),
INDEX idx_im_attendance_records_user_id (user_id, id DESC),
INDEX idx_im_attendance_records_date_status (record_date, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_attendance_checkins (
id BIGINT NOT NULL AUTO_INCREMENT,
checkin_id VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
record_date DATE NOT NULL,
action VARCHAR(32) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
shift_id VARCHAR(64) NOT NULL,
client_time DATETIME(3) NOT NULL,
server_time DATETIME(3) NOT NULL,
result VARCHAR(32) NOT NULL,
location JSON,
wifi JSON,
device JSON,
idempotency_key VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_attendance_checkins_checkin (checkin_id),
UNIQUE INDEX uk_im_attendance_checkins_idempotency (user_id, idempotency_key),
INDEX idx_im_attendance_checkins_record (record_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_attendance_appeals (
id BIGINT NOT NULL AUTO_INCREMENT,
appeal_id VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
appeal_type VARCHAR(64) NOT NULL,
reason VARCHAR(512) NOT NULL,
target_action VARCHAR(32) NOT NULL DEFAULT '',
target_time DATETIME(3),
attachment_file_ids JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
submitted_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_attendance_appeals_appeal (appeal_id),
UNIQUE INDEX uk_im_attendance_appeals_idempotency (user_id, idempotency_key),
INDEX idx_im_attendance_appeals_record (record_id),
INDEX idx_im_attendance_appeals_user_status (user_id, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================ -- ============================================================
-- Seed data. -- Seed data.
-- ============================================================ -- ============================================================
@@ -824,10 +944,11 @@ ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_workbench_apps(code, name, route, sort_order, unread_key, enabled, permission) INSERT INTO tinode.im_workbench_apps(code, name, route, sort_order, unread_key, enabled, permission)
VALUES VALUES
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'), ('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 20, 'meetings', 1, 'enabled'), ('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), ('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 40, 'monthly_reports', 1, 'enabled'), ('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'),
('executive_mailbox', '总经理信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled') ('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'),
('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 1, 'enabled')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
name = VALUES(name), name = VALUES(name),
route = VALUES(route), route = VALUES(route),
@@ -835,6 +956,14 @@ ON DUPLICATE KEY UPDATE
unread_key = VALUES(unread_key), unread_key = VALUES(unread_key),
updated_at = CURRENT_TIMESTAMP(3); updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_attendance_rules(rule_id, name, workday, location_required, wifi_required, allow_outside_checkin, appeal_allowed)
VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1)
ON DUPLICATE KEY UPDATE updated_at = updated_at;
INSERT INTO tinode.im_attendance_shifts(shift_id, rule_id, name, work_start, work_end, checkin_start, checkout_end, sort_order)
VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10)
ON DUPLICATE KEY UPDATE updated_at = updated_at;
INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content) INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content)
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at = updated_at; ON DUPLICATE KEY UPDATE updated_at = updated_at;
@@ -0,0 +1,211 @@
# IM 服务端主动推送消息梳理
> 本文先按当前代码实现梳理,后续确认产品口径后再合并到《IM-前后端对接文档》。
## 1. 术语和通道
服务端主动下行分两层:
1. Tinode 原生实时消息:通过 WebSocket/Long Polling 下发 `{data}`、`{info}`、`{pres}`、`{meta}` 等帧,覆盖聊天消息、回执、输入中、在线状态、通话信令。
2. IM 产品业务事件:HTTP 写操作或业务事务写入 `im_business_events_outbox` / 群业务 outbox 后,由服务端投递为 `{info}` 帧,前端据此刷新业务接口或局部状态。
当前产品业务事件不是聊天消息,不占普通会话消息 `seq`。系统通知列表是否出现记录,取决于是否额外写入 `im_system_notifications`。
## 2. 产品业务事件统一帧格式
### 2.1 用户维度事件
来自 `im_business_events_outbox`,投递到用户自己的在线会话。
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "friend.request.accepted",
"payload": {
"user_id": "usr_actor"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `info.topic` | 目标用户 ID,即事件接收人 |
| `info.from` | 操作人用户 ID;系统事件可能为空 |
| `info.what` | 固定为 `im` |
| `info.event` | 产品事件类型 |
| `info.payload` | 事件载荷,按事件类型变化 |
投递机制:
- 写入 outbox 后会立即尝试投递。
- Hub 不可用或队列满时保持 pending,后续 poller 每秒按在线租户重试。
- 投递成功后标记 delivered。
- `registration.invite_used` 与 `security.password_reset` 是内部消费事件,不作为普通 WS 业务事件下发。
### 2.2 群维度事件
来自群业务 outbox,投递到群 Topic,在线群成员收到。
```json
{
"info": {
"topic": "grp_xxx",
"from": "usr_actor",
"what": "group",
"event": "group.member.joined",
"payload": {
"topic": "grp_xxx",
"user_id": "usr_xxx"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
当前群事件投递时,还会给除操作人外的群成员写入一条 `im_system_notifications`,标题为“群聊动态”,summary 暂为事件类型。
## 3. 好友和关系事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 创建好友申请 `POST /v1/im/friend-requests` | 被申请人 | `info.what="im"`, `event="friend.request.created"` | `{"user_id":"申请人"}` | 有,写给被申请人,标题“新的好友申请” |
| 同意好友申请 `POST /v1/im/friend-requests/{id}/accept` | 申请人 | `info.what="im"`, `event="friend.request.accepted"` | `{"user_id":"通过人"}` | 当前有,写给申请人,标题“好友申请已处理” |
| 同意好友申请 | 通过人本人 | `info.what="im"`, `event="friend.request.accepted"` | `{"user_id":"通过人"}` | 当前无 |
| 拒绝好友申请 `POST /v1/im/friend-requests/{id}/reject` | 申请人 | `info.what="im"`, `event="friend.request.rejected"` | `{"user_id":"拒绝人"}` | 当前有,写给申请人,标题“好友申请已处理” |
| 拒绝好友申请 | 拒绝人本人 | `info.what="im"`, `event="friend.request.rejected"` | `{"user_id":"拒绝人"}` | 当前无 |
| 删除好友 `DELETE /v1/im/friends/{user_id}` | 被删除方 | `info.what="im"`, `event="friend.deleted"` | `{"user_id":"操作人"}` | 有,标题“好友关系已解除” |
| 删除好友 | 操作人本人 | `info.what="im"`, `event="friend.deleted"` | `{"user_id":"操作人"}` | 当前无 |
| 修改好友备注 `PATCH /v1/im/friends/{user_id}` | 操作人本人 | `info.what="im"`, `event="friend.remark.updated"` | `{"user_id":"操作人"}` | 当前无 |
| 拉黑用户 `PUT /v1/im/blocks/{user_id}` | 操作人本人 | `info.what="im"`, `event="relationship.blocked"` | `{"user_id":"操作人"}` | 当前无 |
| 取消拉黑 `DELETE /v1/im/blocks/{user_id}` | 操作人本人 | `info.what="im"`, `event="relationship.unblocked"` | `{"user_id":"操作人"}` | 当前无 |
前端建议动作:
- 收到 `friend.request.created`:刷新新朋友列表 `GET /v1/im/friend-requests`,刷新 `GET /v1/im/bootstrap` 的 `entry_badges.new_friends_pending_count`。
- 收到 `friend.request.accepted/rejected`:刷新新朋友列表;accepted 时刷新好友列表、P2P 会话 context。
- 收到 `friend.deleted/relationship.blocked/relationship.unblocked`:刷新好友列表、黑名单列表、会话 context,重新判断输入区 `can_send`。
- 收到 `friend.remark.updated`:刷新好友列表和会话头显示名。
当前待确认点:
- 好友申请通过/拒绝、删除好友等操作人本人是否也要写入系统消息列表。当前代码只给对方或申请人写系统通知,操作人本人主要靠 WS 业务事件刷新。
- 对接文档现有示例写成 `sys` topic `{data}` 的 `system_event`,但当前代码实际实时推送为 `{info what:"im"}`。后续需要统一口径。
## 4. 消息编辑和撤回事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 撤回消息 `POST /v1/im/messages/{topic}/{seq}/recall` | Topic 在线成员 | `info.what="im"`, `event="message.recalled"` | `{"topic":"...","seq":1,"version":2}` | 无 |
| 编辑消息 `POST /v1/im/messages/{topic}/{seq}/revisions` | Topic 在线成员 | `info.what="im"`, `event="message.edited"` | `{"topic":"...","seq":1,"version":2}` | 无 |
前端建议动作:
- 收到 `message.recalled`:按 `topic + seq + version` 重新拉取或本地标记该消息为已撤回。
- 收到 `message.edited`:按 `topic + seq + version` 重新拉取消息详情或修订记录,更新气泡内容。
注意:
- 这两个事件当前 target_type 是 `topic`,投递到 Topic,而不是某一个用户。
- 数据库层和 HTTP 层都可能写入同类 outbox,依赖 `event_id` 幂等避免重复。
## 5. 群业务事件
| 触发点 | 接收方 | WS 事件 | payload 主要字段 | 系统通知 |
| --- | --- | --- | --- | --- |
| 创建群 | 群成员 | `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.announcement.updated"` | `topic`、公告信息 | 除操作人外写入 |
| 群禁言更新 | 群成员 | `event="group.mute.updated"` | `topic`、禁言信息 | 除操作人外写入 |
| 成员离群 | 群成员 | `event="group.member.left"` | `user_id` | 除操作人外写入 |
| 群角色更新 | 群成员 | `event="group.role.updated"` | `user_id`、角色信息 | 除操作人外写入 |
| 群主转让 | 群成员 | `event="group.owner.transferred"` | `previous_owner`、`new_owner` | 除操作人外写入 |
前端建议动作:
- 群成员、角色、禁言、群主变化:刷新群详情、成员列表、当前用户群权限。
- 入群申请变化:管理员刷新入群申请列表和群 summary 待处理数。
- 群公告变化:刷新公告区。
- 成员加入/离开:刷新群成员数、成员列表和会话系统提示。
注意:
- 当前群事件实时帧 `what="group"`,不是 `what="im"`。
- 群系统通知的 summary 当前主要是事件类型,文案还比较粗,需要产品确认后优化。
## 6. 会议事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 创建会议 | 所有参会人 | `info.what="im"`, `event="meeting.created"` | `{"meeting_id":"...","version":1,"state":"scheduled"}` | 除操作者外写入,标题“会议通知” |
| 更新会议 | 所有参会人 | `event="meeting.updated"` | `meeting_id/version/state` | 除操作者外写入 |
| 取消会议 | 所有参会人 | `event="meeting.canceled"` | `meeting_id/version/state` | 除操作者外写入 |
| 发送会议提醒 | 待响应参会人 | `event="meeting.reminded"` | `meeting_id/version/state` | 除操作者外写入 |
| 更新参会响应 | 所有参会人 | `event="meeting.response.updated"` | `meeting_id/version/state` | 除操作者外写入 |
前端建议动作:
- 收到会议事件后刷新会议详情或会议列表。
- `meeting.reminded` 可触发本地提醒 UI。
## 7. 安全事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 风险挑战确认/取消 | 操作人本人 | `info.what="im"`, `event="security.risk.continue"` 或 `security.risk.cancel` | `{"risk_challenge_id":"...","decision":"continue|cancel"}` | 有,标题“安全操作确认” |
| 密码重置成功 | 无普通 WS 事件 | 内部事件 `security.password_reset` | `{"user_id":123}` | 当前用于清退该用户在线 session |
前端建议动作:
- 收到 `security.risk.*`:刷新安全验证页面状态。
- 密码重置后旧 token/session 会被服务端清退,客户端按登录失效流程处理。
## 8. 邀请码统计事件
| 触发点 | 接收方 | WS 事件 | payload | 系统通知 |
| --- | --- | --- | --- | --- |
| 注册成功使用邀请码 | 不下发给普通客户端 | 内部事件 `registration.invite_used` | `{"tenant_id":1,"invite_code_id":123,"user_id":456}` | 无 |
说明:
- 该事件当前由 IM 侧消费,用于把租户库 `im_registration_invite_uses` 聚合回 HotGo 控制库的邀请码统计。
- 普通 App 前端不需要监听。
## 9. Tinode 原生实时下行
这类不是产品 outbox 事件,但也是服务端主动下行,前端需要持续处理。
| 帧类型 | 触发点 | 接收方 | 前端动作 |
| --- | --- | --- | --- |
| `{data}` | 普通聊天消息、文件消息、通话邀请 `webrtc=started`、服务端替换通话结果消息 | Topic 订阅者 | 写入/更新会话时间线、更新未读 |
| `{info what:"read"}` | 对端已读回执 | Topic 订阅者 | 更新消息已读状态 |
| `{info what:"recv"}` | 对端送达回执 | Topic 订阅者 | 更新送达状态 |
| `{info what:"kp"}` | 对端输入中 | Topic 订阅者 | 显示输入中状态,超时自动消失 |
| `{info what:"call"}` | WebRTC `ringing/accept/offer/answer/ice-candidate/hang-up` | 通话对端 | 驱动通话状态机和 SDP/ICE 交换 |
| `{pres}` | 订阅、离线、在线、权限/订阅变化等 presence | 相关订阅者 | 更新在线状态、订阅状态、必要时刷新列表 |
| `{meta}` | 服务端响应 get/sub 中的 desc/sub/data/del/cred 等查询结果 | 请求方 | 合并服务端状态 |
| `{ctrl}` | 客户端请求结果 | 请求方 | 更新请求状态、错误提示、幂等结果 |
## 10. 当前文档与实现差异
当前《IM-前后端对接文档》部分位置把好友关系变化描述为 `sys` topic 的 `{data head.x-im-type="system_event"}`。代码当前更准确的模型是:
- 实时刷新事件:`{info what:"im"}` 或 `{info what:"group"}`。
- 系统通知列表:通过 `im_system_notifications` 暴露给 `GET /v1/im/system-notifications`,并不是所有实时事件都会写入。
- 聊天/系统 Topic `{data}` 仍用于普通消息时间线和 Tinode 原生 Topic 消息,不等同于业务 outbox 实时事件。
建议后续合并到对接文档时明确:
1. 前端必须监听所有 `{info}`,按 `what + event` 分发业务刷新。
2. 系统消息入口的未读数来自 `im_system_notifications`,不是 `{info}` 事件数量。
3. 好友申请通过后是否双方都写系统通知,需要产品确认;若确认双方都有,需要同步补代码。
+54 -35
View File
@@ -1,6 +1,6 @@
# IM 第一至十一章后续增强项 # IM 第一至十一章后续增强项
> 更新日期:2026-08-21 > 更新日期:2026-08-25
> 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。 > 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。 > 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。
> 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。 > 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。
@@ -14,42 +14,38 @@
3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。 3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。
4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。 4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。
5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。 5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。
6. `GET /v1/im/groups/{topic}/files` 和 `/announcements` 不再误落到群详情;未实现路径返回 404。 6. 群文件和群公告路径已从群详情兜底中拆出,群文件基础接口已落地,未实现的子路径会返回明确错误。
7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。 7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。
8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。 8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。
9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。 9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。
10. 第一批增强中的群文件列表/登记/删除、群邀请外部接受、实名更正申诉、组织全局成员接口、月报 reopen 已落地。
11. 第一批增强中的主动创建/获取 P2P 会话、会话设置、多端云草稿已落地。
### 0.2 当前仍明确缺口及分流状态 ### 0.2 当前仍明确缺口及分流状态
| 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 | | 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 | | P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 |
| P0 | 群文件完整接口 | 6.27、6.28 | `/groups/{topic}/files` 已不误兜底,但列表/登记/删除未实现 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` | | P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 登出与会话撤销 | 10.7 | `POST /v1/im/auth/logout`、`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | | P0 | 登出与会话撤销 | 10.7 | `POST /v1/im/auth/logout`、`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 单聊主动建会话/会话设置/云草稿 | 3.2、4.5、4.21 | `GET /conversations/{topic}/context` 已有,`POST /conversations/p2p`、`/settings`、`PUT /topics/{topic}/draft` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 群邀请外部接受接口 | 6.8 | 群内 `/groups/{topic}/invitations` 已有,`POST /v1/im/group-invitations/{id}/accept` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 实名更正申诉 | 1.9、10.22 | `POST /v1/im/security/realname/appeals` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 组织全局成员接口 | 5.1、9.2 | 部门成员与组织搜索已具备,`GET /v1/im/organization/members` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 月报 reopen | 9.10 | 月报草稿/提交/详情已具备,`POST /reports/monthly/{id}/reopen` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
### 0.3 已抽入第一批开发的项目 ### 0.3 已抽入第一批开发的项目
以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。它们当前代码上仍未完全落地,但不再按“未分流的未实现项”处理,后续应以第一批开发文档作为实现、验收和同步更新依据。 以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。其中已落地项已移入各章“当前已完成基线”;仍未完全落地的项目继续以第一批开发文档作为实现、验收和同步更新依据。
1. 登出与指定会话撤销。 1. 仍待落地:登出与指定会话撤销。
2. 普通消息转发。 2. 仍待落地:普通消息转发。
3. 合并转发 Bundle 详情。 3. 仍待落地:合并转发 Bundle 详情。
4. 群文件列表、登记和删除。 4. 已落地:群文件列表、登记和删除。
5. 主动创建/获取 P2P 会话。 5. 已落地:主动创建/获取 P2P 会话。
6. 会话设置。 6. 已落地:会话设置。
7. 多端云草稿。 7. 已落地:多端云草稿。
8. 群邀请外部接受。 8. 已落地:群邀请外部接受。
9. 实名更正申诉。 9. 已落地:实名更正申诉。
10. 组织全局成员接口。 10. 已落地:组织全局成员接口。
11. 月报 reopen。 11. 已落地:月报 reopen。
12. 列表 `has_more` 统一。 12. 仍待统一回归:列表 `has_more` 统一。
13. 风控 token 单次消费接入业务接口。 13. 仍待落地:风控 token 单次消费接入业务接口。
## 1. 第一章:登录、注册与安全 ## 1. 第一章:登录、注册与安全
@@ -70,6 +66,7 @@
13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。 13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。
14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。 14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。
15. 风险确认可生成安全事件和系统通知。 15. 风险确认可生成安全事件和系统通知。
16. 实名更正申诉 `POST /v1/im/security/realname/appeals` 已具备,申诉记录进入 pending 状态。
### 1.2 后续增强项 ### 1.2 后续增强项
@@ -78,7 +75,7 @@
| P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 | | P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 |
| P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | | P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | | P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P1 | 实名审核与申诉 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过状态变更和 `realname/appeals`。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有工单状态。 | | P1 | 实名后台审核流转 | 当前支持实名提交、状态查询和申诉提交,后续需要后台审核、驳回原因、通过状态变更和申诉处理工单。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有处理状态。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 | | P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 | | P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
| P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 | | P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 |
@@ -103,12 +100,15 @@
| 优先级 | 增强项 | 说明 | 建议验收点 | | 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 | | P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P0 | 万人大群离线提醒与重连补偿 | 离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿。 | 离线用户收到极光提醒;重连后按 seq 补齐且不丢不重。 |
| P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 | | P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 |
| P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 | | P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | | P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 | | P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 | | P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 | | P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P1 | 群成员分页快照深度优化 | 当前已接入 Redis 短 TTL 响应快照,后续大群需要 sorted set、稳定游标和成员变更主动失效。 | 万人大群成员翻页不重复不漏,角色/昵称/退群变更后缓存能及时失效。 |
| P1 | 未读数 Redis 深度优化 | 当前已接入用户总未读 Redis 热缓存,后续需要 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。 | 首页红点、会话未读和多端 read/recv 后的未读数实时一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | | P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
## 3. 第三章:单聊关系、权限与会话上下文 ## 3. 第三章:单聊关系、权限与会话上下文
@@ -121,15 +121,16 @@
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。 5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
6. 非好友资料查询已执行 `profile_visibility`。 6. 非好友资料查询已执行 `profile_visibility`。
7. 主动创建/获取 P2P 会话 `POST /v1/im/conversations/p2p` 已具备。
8. 会话设置 `GET/PATCH /v1/im/conversations/{topic}/settings` 已具备。
9. 多端云草稿 `GET/PUT /v1/im/topics/{topic}/draft` 已具备。
### 3.2 后续增强项 ### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 | | 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | | P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P1 | 主动建立 P2P 会话接口 | 对接文档有 `POST /v1/im/conversations/p2p`,当前未见路由。 | 好友关系成立时可显式获取/创建 P2P topic。 | | P1 | 会话策略与草稿联调回归 | P2P 会话、会话设置和云草稿接口已具备,仍需覆盖多端、冲突、非好友/黑名单状态下的联调回归。 | 多端草稿同步、版本冲突和权限拒绝都符合协议。 |
| P1 | 会话设置接口 | 对接文档有 `GET/PATCH /v1/im/conversations/{topic}/settings`,当前未见路由。 | 置顶、免打扰、备注、保留策略跨端同步。 |
| P1 | 多端草稿 | 对接文档有 `PUT /v1/im/topics/{topic}/draft`,当前仍建议本地加密保存。 | 同账号多端草稿同步,版本冲突可处理。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 | | P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 |
| P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | | P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | | P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
@@ -152,6 +153,7 @@
| P0 | 消息转发接口 | 对接文档有 `POST /v1/im/messages/forward`,当前未见路由;收藏转发不等同普通消息转发。 | 单条/多条消息可分别转发到好友、群和文件助手。 | | P0 | 消息转发接口 | 对接文档有 `POST /v1/im/messages/forward`,当前未见路由;收藏转发不等同普通消息转发。 | 单条/多条消息可分别转发到好友、群和文件助手。 |
| P0 | 合并转发 Bundle | 对接文档有 `GET /v1/im/forward-bundles/{id}`,当前未见路由。 | 合并转发卡片可分页查看,权限隔离原会话。 | | P0 | 合并转发 Bundle | 对接文档有 `GET /v1/im/forward-bundles/{id}`,当前未见路由。 | 合并转发卡片可分页查看,权限隔离原会话。 |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在线、离线、多端场景均收到一次提醒。 | | P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在线、离线、多端场景均收到一次提醒。 |
| P0 | 离线 Push Kafka 化 | `im.message.push` 已纳入 Kafka 方案,但本轮暂不实现;离线提醒仍按后续极光 Push 统一接入。 | 消息、@、通话、系统通知等离线场景统一投递 Kafka Push 任务,失败可重试和补偿。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频。 | 超限返回稳定错误码,管理员可配置限额。 | | P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频。 | 超限返回稳定错误码,管理员可配置限额。 |
| P1 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 | | P1 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 | | P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
@@ -199,14 +201,15 @@
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。 9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。
10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。 10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。
11. 群文件和群公告未实现路径已不再误返回群详情。 11. 群文件列表、登记和删除接口 `GET/POST/DELETE /v1/im/groups/{topic}/files` 已具备。
12. 群邀请外部接受 `POST /v1/im/group-invitations/{id}/accept` 已具备。
### 6.2 后续增强项 ### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 | | 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | 群文件索引与管理 | 对接文档要求 `GET/POST/DELETE /v1/im/groups/{topic}/files`,当前未实现,只修复了错误兜底。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 | | P0 | 群文件权限与下载闭环 | 群文件基础接口已具备,后续需与文件 ACL、退群后下载阻断、审核状态、OSS 短链失效联动。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 |
| P1 | 群邀请外部接受接口 | 对接文档有 `POST /v1/im/group-invitations/{id}/accept`,当前未见路由。 | 被邀请人可通过邀请卡入群或进入审批。 | | P1 | 群邀请外部接受体验回归 | 外部接受接口已具备,仍需覆盖邀请卡、多端重复接受、过期和审批模式切换。 | 被邀请人可通过邀请卡入群或进入审批;重复接受幂等返回稳定结果。 |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | | P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | | P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | | P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
@@ -275,6 +278,9 @@
8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。 10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
11. 组织全局成员接口 `GET /v1/im/organization/members` 已具备。
12. 月报 reopen `POST /v1/im/reports/monthly/{id}/reopen` 已具备。
13. 组织架构可见范围已按账号来源裁剪:邀请码注册默认仅本人,手机号注册默认本人和好友组织路径,上架测试账号可通过 `im_org_visibility_overrides.visibility_scope=all` 放开全量。
### 9.2 后续增强项 ### 9.2 后续增强项
@@ -283,10 +289,10 @@
| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 | | P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 |
| P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | | P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | | P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | | P0 | 组织字段级隐私策略 | 组织架构范围裁剪已具备,成员手机号、邮箱、职位等字段后续仍需按组织角色、隐私和企业策略进一步细化。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织全局成员接口 | 对接文档提到 `GET /v1/im/organization/members`,当前可由部门成员和组织搜索替代,未见独立路由。 | 前端如需要全局成员列表,可直接分页查询并按部门过滤。 | | P1 | 组织全局成员接口深度联调 | `GET /v1/im/organization/members` 已具备,仍需按部门、关键词、权限和分页做测试环境回归。 | 前端可直接分页查询并按部门过滤,字段裁剪符合权限。 |
| P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 | | P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 |
| P1 | 月报 reopen | 对接文档有 `POST /v1/im/reports/monthly/{id}/reopen`,当前未见路由。 | 已提交月报可按权限退回草稿并保留审计。 | | P1 | 月报 reopen 审计与权限扩展 | reopen 接口已具备,后续需要后台权限、原因记录、审计和通知闭环。 | 已提交月报可按权限退回草稿并保留审计。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | | P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | | P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | | P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
@@ -341,7 +347,20 @@
## 12. 统一处理建议 ## 12. 统一处理建议
1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、群文件、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。 1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。
2. 第二优先级补齐前端文档缺口:`conversations/p2p`、会话设置、云草稿、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。 2. 第二优先级补齐已落地接口的联调闭环:P2P 会话、会话设置、云草稿、群文件、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。
3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。 3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。
4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。 4. 万人大群未实现增强项仅保留离线能力:离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿;实时推送能力归入 `IM-Redis三层数据分层改造方案.md` 后续直接实现。
5. Redis 读性能深度优化继续保留:群成员分页 sorted set/稳定游标/主动失效、Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。
6. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。
## 13. Kafka 联调后续增强项
来源:`IM-Kafka接入联调测试文档.md` 10.2。本轮已完成后端 Kafka 主链路联调,但端到端页面与真实客户端仍需专项补测;同时 Kafka 断线恢复暴露出进程稳定性问题。
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | Admin Kafka 断线自动恢复 | 本地联调中停止 Kafka 后,Admin consumer 报 `client has run out of available brokers` 并 fatal 退出。 | Kafka 停止/恢复期间 Admin 进程不退出,consumer 退避重连,恢复后继续消费积压消息。 |
| P0 | IM Kafka consumer 自动恢复 | Kafka 恢复后 IM 仍 healthy,但 consumer 未立即消费新事件,重启 IM 后才补偿消费。 | Kafka broker 重启后无需重启 IM,`im.admin.command`、`im.message.persist`、`im.cache.invalidate` 等 Topic 自动恢复消费。 |
| P1 | Admin 前端页面人工验收 | 后端接口、Kafka、数据库状态已测;仍需在浏览器逐个菜单验收操作单、死信、审计和状态流转展示。 | 后台页面可展示 pending/running/succeeded/failed/dead,死信可查询、忽略、解决、重放,审计列表与详情可查看。 |
| P1 | 真实 C 端 WebSocket 联调 | 后台命令已生成业务事件/系统通知;仍需真实客户端在线观察 WS 实时刷新。 | 在线客户端收到群治理、用户治理、工作台、系统通知等事件;断线重连后可按 seq/bootstrap 补偿。 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+81
View File
@@ -0,0 +1,81 @@
-- IM Kafka foundation tables for tenant databases.
-- 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.
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;
CREATE TABLE IF NOT EXISTS im_kafka_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(96) NOT NULL,
topic VARCHAR(128) NOT NULL,
event_type VARCHAR(96) NOT NULL,
biz_type VARCHAR(64) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
key_value VARCHAR(256) NOT NULL DEFAULT '',
envelope JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
sent_at DATETIME(3),
last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) NOT NULL DEFAULT '',
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_outbox_event(event_id),
INDEX idx_im_kafka_outbox_pending(status, next_retry_at, id),
INDEX idx_im_kafka_outbox_biz(biz_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT,
dead_id VARCHAR(96) NOT NULL,
event_id VARCHAR(96) NOT NULL,
parent_event_id VARCHAR(96) NOT NULL DEFAULT '',
source_topic VARCHAR(128) NOT NULL,
consumer_group 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 '',
envelope JSON NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(24) NOT NULL DEFAULT 'dead',
replay_count INT NOT NULL DEFAULT 0,
resolved_by VARCHAR(64) NOT NULL DEFAULT '',
resolved_at DATETIME(3),
resolve_note VARCHAR(1000) NOT NULL DEFAULT '',
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_dead_letters_dead(dead_id),
INDEX idx_im_kafka_dead_letters_event(event_id),
INDEX idx_im_kafka_dead_letters_status(status, created_at),
INDEX idx_im_kafka_dead_letters_biz(biz_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,12 @@
-- IM SeqId BIGINT upgrade for existing MySQL tenant databases.
-- New schemas have been updated in server/db/mysql/adapter.go and group_schema.go.
ALTER TABLE im_topics MODIFY seqid BIGINT NOT NULL DEFAULT 0;
ALTER TABLE im_subscriptions MODIFY recvseqid BIGINT DEFAULT 0;
ALTER TABLE im_subscriptions MODIFY readseqid BIGINT DEFAULT 0;
ALTER TABLE im_messages MODIFY seqid BIGINT NOT NULL;
ALTER TABLE im_message_revisions MODIFY seqid BIGINT NOT NULL;
ALTER TABLE im_favorites MODIFY source_seq BIGINT NOT NULL;
ALTER TABLE im_abuse_reports MODIFY target_seq BIGINT NOT NULL DEFAULT 0;
ALTER TABLE im_call_records MODIFY seq_id BIGINT NOT NULL;
ALTER TABLE im_group_files MODIFY source_seq BIGINT NOT NULL DEFAULT 0;
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(64) NOT NULL COMMENT '群 Topic,如 grp_xxx',
group_id BIGINT NOT NULL COMMENT '业务群 ID,便于分区、治理和统计',
seqid BIGINT NOT NULL COMMENT '群内消息序号,由 Redis 原子发号',
from_user BIGINT NOT NULL COMMENT '发送人用户 ID',
client_msg_id VARCHAR(64) NULL COMMENT '客户端幂等消息 ID',
msg_type VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等',
status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked',
content JSON NULL COMMENT '消息正文或 Drafty 内容',
head JSON NULL COMMENT '消息头,包含 @、引用、客户端能力等',
mentioned_user_ids JSON NULL COMMENT '@ 用户列表,便于提醒和检索',
mention_all TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 @ 全体',
reply_seqid BIGINT NULL COMMENT '回复的群消息 seq',
root_seqid BIGINT NULL COMMENT '线程或合并上下文根消息 seq',
file_count INT NOT NULL DEFAULT 0 COMMENT '附件数量',
content_size INT NOT NULL DEFAULT 0 COMMENT '正文 JSON 序列化后的字节数',
has_risk TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否命中风控或内容安全',
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject',
version INT NOT NULL DEFAULT 1 COMMENT '编辑版本',
recalled_at DATETIME(3) NULL,
recalled_by BIGINT NULL,
deleted_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL COMMENT '可选保留期,便于过期归档或清理',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_group_messages_group_seq (group_id, seqid),
UNIQUE KEY uk_im_group_messages_topic_seq (topic, seqid),
UNIQUE KEY uk_im_group_messages_topic_sender_client_mid (topic, from_user, client_msg_id),
KEY idx_im_group_messages_group_created (group_id, created_at),
KEY idx_im_group_messages_group_status_seq (group_id, status, seqid),
KEY idx_im_group_messages_topic_created (topic, created_at),
KEY idx_im_group_messages_topic_from_seq (topic, from_user, seqid),
KEY idx_im_group_messages_audit (audit_status, created_at),
KEY idx_im_group_messages_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,110 @@
# IM 第三方接入本地测试结果
测试日期:2026-08-24
测试环境:本地环境,MySQL `127.0.0.1:3306`,控制库 `hotgo`,业务库 `tinode`。
## 1. 测试结论
| 项 | 结果 | 说明 |
| --- | --- | --- |
| 第三方相关普通单元测试 | 通过 | OSS、文件安全配置、阿里云内容安全封装测试通过 |
| MySQL build tag 单元测试 | 通过 | 文件审核表结构、MySQL 适配相关测试通过 |
| 本地 API handler 测试 | 通过 | 群文件、群邀请接受、实名申诉、组织成员、月报 reopen、反馈/举报、信箱、会话设置、云草稿等接口级测试通过 |
| 本地 MySQL 连通性 | 通过 | 可连接 `hotgo` 与 `tinode` |
| 第三方配置读取 | 通过 | `im_third_party` 配置存在 18 条,已按本地第三方依赖配置启用 OSS、内容审核和恶意文件检测 |
| 文件审核字典 | 通过 | provider 2 条、task type 2 条、status 5 条 |
| 文件审核任务表 | 通过 | `tinode.im_file_review_tasks` 存在,且不包含 `tenant_id` |
| 真实 OSS 上传/下载 | 通过 | 使用普通 Bucket endpoint 完成 put/get/sign URL,并清理测试对象 |
| 真实内容安全 | 通过 | 使用阿里云内容安全增强版 `FileModeration + document_detection` 提交并轮询,返回 `clean/none` |
| 真实恶意文件检测 | 阻塞 | SDK 请求 `tds.aliyuncs.com` 时本地网络连接被 reset,`curl` 直连同样失败 |
## 2. 执行命令
```bash
go test -count=1 ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql
go test -count=1 -tags mysql ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql
go test -count=1 ./server -run 'TestServeIM(GroupFiles|GroupInvitationAccept|RealNameAppeal|OrganizationMembers|MonthlyReportReopen|DailyReport|MonthlyReport|Mailbox|Feedback|Abuse|ConversationSettings|TopicDraft|P2PConversation|ForwardTargets)'
```
## 3. 命令结果
| 命令 | 结果 |
| --- | --- |
| `go test -count=1 ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql` | 通过 |
| `go test -count=1 -tags mysql ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql` | 通过 |
| `go test -count=1 ./server -run ...` | 通过 |
## 4. 本地数据库核验
| 核验项 | 结果 |
| --- | --- |
| MySQL ping | 通过 |
| `hotgo` 数据库 | 存在 |
| `tinode` 数据库 | 存在 |
| `hotgo.hg_sys_config` 中 `im_third_party` 配置数量 | 18 |
| `oss_enabled` | 1 |
| `file_review_content_enabled` | 1 |
| `file_review_malware_enabled` | 1 |
| OSS 敏感配置 | 已配置,报告不输出具体值 |
| 阿里云审核敏感配置 | 已配置,报告不输出具体值 |
| `im_file_review_provider` 字典 | 2 条 |
| `im_file_review_task_type` 字典 | 2 条 |
| `im_file_review_status` 字典 | 5 条 |
| `tinode.im_file_review_tasks` | 存在 |
| `im_file_review_tasks.tenant_id` | 不存在,符合租户分库方案 |
| `im_file_review_tasks` 当前记录数 | 0 |
## 5. 覆盖情况
| 测试范围 | 本次覆盖 | 结果 |
| --- | --- | --- |
| OSS 配置读取与对象路径规则 | 已覆盖单元测试 | 通过 |
| `im_third_party` 配置读取 | 已覆盖单元测试和本地库只读核验 | 通过 |
| `im_file_review_tasks` 表结构 | 已覆盖 MySQL tag 测试和本地库只读核验 | 通过 |
| 上传完成创建审核任务 | 已覆盖代码路径和表结构测试;本地无真实上传任务 | 部分通过 |
| 群文件 API handler | 已覆盖本地 handler 测试 | 通过 |
| 举报/反馈/信箱附件相关 API handler | 已覆盖本地 handler 测试 | 通过 |
| 日报/月报附件相关 API handler | 已覆盖本地 handler 测试 | 通过 |
| P2P 会话、会话设置、云草稿 | 已覆盖本地 handler 测试 | 通过 |
| 真实 OSS API | 已上传、读取、生成签名 URL、清理测试对象 | 通过 |
| 真实内容安全 API | 已提交 OSS 文件审核并轮询结果 | 通过 |
| 真实恶意文件检测 API | 已使用官方 SAS SDK 提交检测,受本地网络限制未到达服务端 | 阻塞 |
| 风险审核结果影响下载/展示 | worker 和策略闭环未完成 | 未执行 |
## 6. 真实第三方调用补测
补测时间:2026-08-24
### 6.1 补测操作
1. 从本地第三方依赖文档读取测试账号、OSS Bucket 和地域信息。
2. 将本地 `hotgo.hg_sys_config` 的 `im_third_party` 配置更新为启用状态。
3. 使用普通 Bucket endpoint `oss-cn-shenzhen.aliyuncs.com` 上传测试 TXT 文件。
4. 对同一 OSS 对象执行读取、签名 URL 生成和测试对象清理。
5. 使用阿里云内容安全增强版 `FileModeration + document_detection` 提交该 OSS 对象并轮询结果。
6. 使用阿里云云安全中心官方 SAS SDK 调用 `CreateFileDetect` 和 `GetFileDetectResult`。
### 6.2 补测结果
| 项 | 结果 | 说明 |
| --- | --- | --- |
| 本地配置启用 | 通过 | `oss_enabled`、`file_review_content_enabled`、`file_review_malware_enabled` 已置为 1 |
| OSS `PutObject` | 通过 | 测试文件成功写入 OSS |
| OSS `GetObject` | 通过 | 测试文件可读取且内容一致 |
| OSS `SignURL` | 通过 | 可生成短时下载 URL |
| OSS 测试对象清理 | 通过 | `codex-live/` 测试前缀已清理 |
| 内容安全提交 | 通过 | `FileModeration` 返回任务 ID |
| 内容安全轮询 | 通过 | 轮询结果为 `status=clean,risk=none` |
| 云安全中心提交 | 阻塞 | 本地网络访问 `https://tds.aliyuncs.com` 被 reset,未到达业务响应 |
### 6.3 SAS 阻塞说明
云安全中心 SDK 内置中国区 endpoint 为 `tds.aliyuncs.com`。本地环境中该域名解析到 `198.18.1.113`,无论 SDK 调用还是 `curl -I https://tds.aliyuncs.com/` 都返回 connection reset。备用 endpoint `tds.ap-southeast-1.aliyuncs.com` 在当前网络下同样 reset。因此本轮无法判断 SAS 账号权限或检测结果,只能确认本地网络到 SAS endpoint 不通。
## 7. 遗留问题
1. 本地网络无法访问云安全中心恶意文件检测 endpoint `tds.aliyuncs.com`,需要换网络、服务器环境或排查网络策略后复测。
2. 文件安全 worker 尚未完成,pending 审核任务不会自动提交第三方、轮询或回写状态。
3. 下载和业务展示侧尚未完整按 `im_file_review_tasks` 风险状态阻断或告警。
4. 纯文本内容安全不属于文件审核链路,群简介、签名、举报/反馈正文、日报/月报正文仍需独立文本审核方案。
@@ -2,46 +2,46 @@
## 1. 测试范围 ## 1. 测试范围
- 测试时间:`2026-08-21 00:39:01 +0800` - 测试时间:`2026-08-24 11:15:16 +0800`
- HTTP:`https://api.118im.com` - HTTP:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels` - WebSocket:`wss://api.118im.com/v0/channels`
- 租户:`default` - 租户:`default`
- 用户 A:`usr7-zA3UMrw7c` / `wsa459000` / `15794459000` - 用户 A:`usrO4KoysjOOVE` / `wsa043000` / `15757043000`
- 用户 B:`usrnDXQ3A2oQP0` / `wsb459000` / `15894459000` - 用户 B:`usricjD1vg9CXU` / `wsb043000` / `15857043000`
- API Key 与 Token 已脱敏,不在文档中展开。 - API Key 与 Token 已脱敏,不在文档中展开。
## 2. 总体结论 ## 2. 总体结论
本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `27` 项,未通过 `2` 项。 本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `28` 项,未通过 `1` 项。
| ID | 场景 | 方法 | 目标 | 结果 | 备注 | | ID | 场景 | 方法 | 目标 | 结果 | 备注 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `WS-01` | 注册用户 A: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usr7-zA3UMrw7c token=AwEA...18Om | | `WS-01` | 注册用户 A: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usrO4KoysjOOVE token=AwEA...S/Xa |
| `WS-02` | 注册用户 B: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usrnDXQ3A2oQP0 token=AwEA...NX6b | | `WS-02` | 注册用户 B: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usricjD1vg9CXU token=AwEA...Reda |
| `WS-03` | HTTP 建立好友关系生成 P2P Topic | `HTTP` | `/v1/im/friend-requests` | 通过 | request_id=frq_KAOkEfarCOA topic=p2p7-zA3UMrw7ecNdDcDahA_Q | | `WS-03` | HTTP 建立好友关系生成 P2P Topic | `HTTP` | `/v1/im/friend-requests` | 通过 | request_id=frq_xYKajMBbqQM topic=p2pO4KoysjOOVGJyMPW-D0JdQ |
| `WS-04` | A token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usr7-zA3UMrw7c | | `WS-04` | A token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usrO4KoysjOOVE |
| `WS-05` | B token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usrnDXQ3A2oQP0 | | `WS-05` | B token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usricjD1vg9CXU |
| `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | topic=p2p7-zA3UMrw7ecNdDcDahA_Q | | `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | topic=p2pO4KoysjOOVGJyMPW-D0JdQ |
| `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=1 bob_topic=usr7-zA3UMrw7c | | `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=1 bob_topic=usrO4KoysjOOVE |
| `WS-08` | B 发送 recv/read 回执 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | what=read seq=1 | | `WS-08` | B 发送 recv/read 回执 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | what=read seq=1 |
| `WS-09` | basic login + sub me | `WS` | `/v0/channels` | 通过 | basic login ok | | `WS-09` | basic login + sub me | `WS` | `/v0/channels` | 通过 | basic login ok |
| `WS-10` | 重复 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=already authenticated | | `WS-10` | 重复 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=already authenticated |
| `WS-11` | 订阅 fnd 搜索 Topic | `WS` | `fnd` | 通过 | sub fnd ok | | `WS-11` | 订阅 fnd 搜索 Topic | `WS` | `fnd` | 通过 | sub fnd ok |
| `WS-12` | get me desc/sub/tags/cred | `WS` | `me` | 通过 | get me ok | | `WS-12` | get me desc/sub/tags/cred | `WS` | `me` | 通过 | get me ok |
| `WS-13` | get P2P desc/sub/data/del | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | get p2p ok | | `WS-13` | get P2P desc/sub/data/del | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | get p2p ok |
| `WS-14` | set P2P private 设置 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | set private ok | | `WS-14` | set P2P private 设置 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | set private ok |
| `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=2 bob_topic=usr7-zA3UMrw7c | | `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=2 bob_topic=usrO4KoysjOOVE |
| `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003857.244320000 code=500 text=internal error | | `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=3 bob_topic=usrO4KoysjOOVE |
| `WS-17` | note kp 输入中通知 | `WS` | `usr7-zA3UMrw7c` | 通过 | what=kp | | `WS-17` | note kp 输入中通知 | `WS` | `usrO4KoysjOOVE` | 通过 | what=kp |
| `WS-18` | del 当前用户消息视图 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | deleted seq=1 | | `WS-18` | del 当前用户消息视图 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | deleted seq=1 |
| `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | leave/resub ok | | `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | leave/resub ok |
| `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpURzKYanXB1Y | | `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpNJqhCtAZwYM |
| `WS-21` | A/B 订阅群 Topic | `WS` | `grpURzKYanXB1Y` | 通过 | sub group ok | | `WS-21` | A/B 订阅群 Topic | `WS` | `grpNJqhCtAZwYM` | 通过 | sub group ok |
| `WS-22` | 群 pub 文本广播 | `WS` | `grpURzKYanXB1Y` | 通过 | seq=1 topic=grpURzKYanXB1Y | | `WS-22` | 群 pub 文本广播 | `WS` | `grpNJqhCtAZwYM` | 通过 | seq=1 topic=grpNJqhCtAZwYM |
| `WS-23` | get 群 desc/sub/data | `WS` | `grpURzKYanXB1Y` | 通过 | get group ok | | `WS-23` | get 群 desc/sub/data | `WS` | `grpNJqhCtAZwYM` | 通过 | get group ok |
| `WS-24` | 文件上传 /v0/file/u | `HTTP` | `/v0/file/u/` | 通过 | url=/v0/....bin | | `WS-24` | 文件上传 /v0/file/u | `HTTP` | `/v0/file/u/` | 通过 | url=/v0/....bin |
| `WS-25` | WS 文件消息下发 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=3 bob_topic=usr7-zA3UMrw7c | | `WS-25` | WS 文件消息下发 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=4 bob_topic=usrO4KoysjOOVE |
| `WS-26` | WebRTC 通话 invite pub | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003859.867810000 code=501 text=not implemented | | `WS-26` | WebRTC 通话 invite pub | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 未通过 | pub ctrl: ctrl id=pub-111508.550364000 code=501 text=not implemented |
| `WS-27` | 未 hi 直接 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence | | `WS-27` | 未 hi 直接 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence |
| `WS-28` | hi 后切换 tenant 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence | | `WS-28` | hi 后切换 tenant 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence |
| `WS-29` | 非法 API Key 拒绝建连 | `WS` | `/v0/channels` | 通过 | http_status=403 | | `WS-29` | 非法 API Key 拒绝建连 | `WS` | `/v0/channels` | 通过 | http_status=403 |
@@ -53,6 +53,6 @@
## 4. 测试产生的数据 ## 4. 测试产生的数据
- P2P Topic:`p2p7-zA3UMrw7ecNdDcDahA_Q` - P2P Topic:`p2pO4KoysjOOVGJyMPW-D0JdQ`
- Bob 收到的 Topic:`usr7-zA3UMrw7c` - Bob 收到的 Topic:`usrO4KoysjOOVE`
- 消息 Seq:`1` - 消息 Seq:`1`
+4
View File
@@ -24,6 +24,7 @@ require (
github.com/nyaruka/phonenumbers v1.8.0 github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.69.0 github.com/prometheus/common v0.69.0
github.com/redis/go-redis/v9 v9.12.1
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
github.com/tinode/jsonco v1.0.0 github.com/tinode/jsonco v1.0.0
github.com/tinode/snowflake v1.0.0 github.com/tinode/snowflake v1.0.0
@@ -65,6 +66,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -76,8 +78,10 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/segmentio/kafka-go v0.4.49 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/objx v0.5.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
+20
View File
@@ -37,24 +37,32 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk= github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ= github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
@@ -121,6 +129,10 @@ github.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2io
github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -135,6 +147,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
@@ -261,6 +275,8 @@ github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoT
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -277,10 +293,14 @@ github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85Cu
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+1 -1
View File
@@ -24,7 +24,7 @@
<request subst="true"> <request subst="true">
<websocket type="connect" path="/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"> <websocket type="connect" path="/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K">
<!--http_header name="X-Tinode-APIKey" value="AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"/--> <!--http_header name="X-MeetingIM-APIKey" value="AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"/-->
</websocket> </websocket>
</request> </request>
+32 -32
View File
@@ -810,8 +810,8 @@ type SeqRange struct {
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
Low int32 `protobuf:"varint,1,opt,name=low,proto3" json:"low,omitempty"` Low int64 `protobuf:"varint,1,opt,name=low,proto3" json:"low,omitempty"`
Hi int32 `protobuf:"varint,2,opt,name=hi,proto3" json:"hi,omitempty"` Hi int64 `protobuf:"varint,2,opt,name=hi,proto3" json:"hi,omitempty"`
} }
func (x *SeqRange) Reset() { func (x *SeqRange) Reset() {
@@ -846,14 +846,14 @@ func (*SeqRange) Descriptor() ([]byte, []int) {
return file_model_proto_rawDescGZIP(), []int{6} return file_model_proto_rawDescGZIP(), []int{6}
} }
func (x *SeqRange) GetLow() int32 { func (x *SeqRange) GetLow() int64 {
if x != nil { if x != nil {
return x.Low return x.Low
} }
return 0 return 0
} }
func (x *SeqRange) GetHi() int32 { func (x *SeqRange) GetHi() int64 {
if x != nil { if x != nil {
return x.Hi return x.Hi
} }
@@ -872,9 +872,9 @@ type GetOpts struct {
// Limit search results to one topic; // Limit search results to one topic;
Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"` Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"`
// Load messages with seq id equal or greater than this // Load messages with seq id equal or greater than this
SinceId int32 `protobuf:"varint,4,opt,name=since_id,json=sinceId,proto3" json:"since_id,omitempty"` SinceId int64 `protobuf:"varint,4,opt,name=since_id,json=sinceId,proto3" json:"since_id,omitempty"`
// Load messages with seq id lower than this // Load messages with seq id lower than this
BeforeId int32 `protobuf:"varint,5,opt,name=before_id,json=beforeId,proto3" json:"before_id,omitempty"` BeforeId int64 `protobuf:"varint,5,opt,name=before_id,json=beforeId,proto3" json:"before_id,omitempty"`
// Maximum number of results to return // Maximum number of results to return
Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"`
// Load messages by id or ranges of ids // Load messages by id or ranges of ids
@@ -934,14 +934,14 @@ func (x *GetOpts) GetTopic() string {
return "" return ""
} }
func (x *GetOpts) GetSinceId() int32 { func (x *GetOpts) GetSinceId() int64 {
if x != nil { if x != nil {
return x.SinceId return x.SinceId
} }
return 0 return 0
} }
func (x *GetOpts) GetBeforeId() int32 { func (x *GetOpts) GetBeforeId() int64 {
if x != nil { if x != nil {
return x.BeforeId return x.BeforeId
} }
@@ -1911,7 +1911,7 @@ type ClientNote struct {
// "kp" - typing notification, "call" - voice/video call // "kp" - typing notification, "call" - voice/video call
What InfoNote `protobuf:"varint,2,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"` What InfoNote `protobuf:"varint,2,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"`
// Server-issued message ID being reported // Server-issued message ID being reported
SeqId int32 `protobuf:"varint,3,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` SeqId int64 `protobuf:"varint,3,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"`
// Client's count of unread messages to report back to the server. Used in push notifications on iOS. // Client's count of unread messages to report back to the server. Used in push notifications on iOS.
Unread int32 `protobuf:"varint,4,opt,name=unread,proto3" json:"unread,omitempty"` Unread int32 `protobuf:"varint,4,opt,name=unread,proto3" json:"unread,omitempty"`
// Call event. // Call event.
@@ -1966,7 +1966,7 @@ func (x *ClientNote) GetWhat() InfoNote {
return InfoNote_X1 return InfoNote_X1
} }
func (x *ClientNote) GetSeqId() int32 { func (x *ClientNote) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -2338,9 +2338,9 @@ type TopicDesc struct {
TouchedAt int64 `protobuf:"varint,3,opt,name=touched_at,json=touchedAt,proto3" json:"touched_at,omitempty"` TouchedAt int64 `protobuf:"varint,3,opt,name=touched_at,json=touchedAt,proto3" json:"touched_at,omitempty"`
Defacs *DefaultAcsMode `protobuf:"bytes,4,opt,name=defacs,proto3" json:"defacs,omitempty"` Defacs *DefaultAcsMode `protobuf:"bytes,4,opt,name=defacs,proto3" json:"defacs,omitempty"`
Acs *AccessMode `protobuf:"bytes,5,opt,name=acs,proto3" json:"acs,omitempty"` Acs *AccessMode `protobuf:"bytes,5,opt,name=acs,proto3" json:"acs,omitempty"`
SeqId int32 `protobuf:"varint,6,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` SeqId int64 `protobuf:"varint,6,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"`
ReadId int32 `protobuf:"varint,7,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` ReadId int64 `protobuf:"varint,7,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"`
RecvId int32 `protobuf:"varint,8,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` RecvId int64 `protobuf:"varint,8,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"`
DelId int32 `protobuf:"varint,9,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` DelId int32 `protobuf:"varint,9,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"`
Public []byte `protobuf:"bytes,10,opt,name=public,proto3" json:"public,omitempty"` Public []byte `protobuf:"bytes,10,opt,name=public,proto3" json:"public,omitempty"`
Private []byte `protobuf:"bytes,11,opt,name=private,proto3" json:"private,omitempty"` Private []byte `protobuf:"bytes,11,opt,name=private,proto3" json:"private,omitempty"`
@@ -2421,21 +2421,21 @@ func (x *TopicDesc) GetAcs() *AccessMode {
return nil return nil
} }
func (x *TopicDesc) GetSeqId() int32 { func (x *TopicDesc) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
return 0 return 0
} }
func (x *TopicDesc) GetReadId() int32 { func (x *TopicDesc) GetReadId() int64 {
if x != nil { if x != nil {
return x.ReadId return x.ReadId
} }
return 0 return 0
} }
func (x *TopicDesc) GetRecvId() int32 { func (x *TopicDesc) GetRecvId() int64 {
if x != nil { if x != nil {
return x.RecvId return x.RecvId
} }
@@ -2522,8 +2522,8 @@ type TopicSub struct {
DeletedAt int64 `protobuf:"varint,2,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` DeletedAt int64 `protobuf:"varint,2,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"`
Online bool `protobuf:"varint,3,opt,name=online,proto3" json:"online,omitempty"` Online bool `protobuf:"varint,3,opt,name=online,proto3" json:"online,omitempty"`
Acs *AccessMode `protobuf:"bytes,4,opt,name=acs,proto3" json:"acs,omitempty"` Acs *AccessMode `protobuf:"bytes,4,opt,name=acs,proto3" json:"acs,omitempty"`
ReadId int32 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` ReadId int64 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"`
RecvId int32 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` RecvId int64 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"`
Public []byte `protobuf:"bytes,7,opt,name=public,proto3" json:"public,omitempty"` Public []byte `protobuf:"bytes,7,opt,name=public,proto3" json:"public,omitempty"`
Trusted []byte `protobuf:"bytes,16,opt,name=trusted,proto3" json:"trusted,omitempty"` // 16! Trusted []byte `protobuf:"bytes,16,opt,name=trusted,proto3" json:"trusted,omitempty"` // 16!
Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"` Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"`
@@ -2533,7 +2533,7 @@ type TopicSub struct {
Topic string `protobuf:"bytes,10,opt,name=topic,proto3" json:"topic,omitempty"` Topic string `protobuf:"bytes,10,opt,name=topic,proto3" json:"topic,omitempty"`
TouchedAt int64 `protobuf:"varint,11,opt,name=touched_at,json=touchedAt,proto3" json:"touched_at,omitempty"` TouchedAt int64 `protobuf:"varint,11,opt,name=touched_at,json=touchedAt,proto3" json:"touched_at,omitempty"`
// ID of the last {data} message in a topic // ID of the last {data} message in a topic
SeqId int32 `protobuf:"varint,12,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` SeqId int64 `protobuf:"varint,12,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"`
// Messages are deleted up to this ID // Messages are deleted up to this ID
DelId int32 `protobuf:"varint,13,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` DelId int32 `protobuf:"varint,13,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"`
// Other user's last online timestamp & user agent // Other user's last online timestamp & user agent
@@ -2601,14 +2601,14 @@ func (x *TopicSub) GetAcs() *AccessMode {
return nil return nil
} }
func (x *TopicSub) GetReadId() int32 { func (x *TopicSub) GetReadId() int64 {
if x != nil { if x != nil {
return x.ReadId return x.ReadId
} }
return 0 return 0
} }
func (x *TopicSub) GetRecvId() int32 { func (x *TopicSub) GetRecvId() int64 {
if x != nil { if x != nil {
return x.RecvId return x.RecvId
} }
@@ -2657,7 +2657,7 @@ func (x *TopicSub) GetTouchedAt() int64 {
return 0 return 0
} }
func (x *TopicSub) GetSeqId() int32 { func (x *TopicSub) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -2833,7 +2833,7 @@ type ServerData struct {
Timestamp int64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"` Timestamp int64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
// Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970 // Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970
DeletedAt int64 `protobuf:"varint,3,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` DeletedAt int64 `protobuf:"varint,3,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"`
SeqId int32 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` SeqId int64 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"`
Head map[string][]byte `protobuf:"bytes,5,rep,name=head,proto3" json:"head,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` Head map[string][]byte `protobuf:"bytes,5,rep,name=head,proto3" json:"head,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
Content []byte `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"` Content []byte `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"`
} }
@@ -2898,7 +2898,7 @@ func (x *ServerData) GetDeletedAt() int64 {
return 0 return 0
} }
func (x *ServerData) GetSeqId() int32 { func (x *ServerData) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -2929,7 +2929,7 @@ type ServerPres struct {
Src string `protobuf:"bytes,2,opt,name=src,proto3" json:"src,omitempty"` Src string `protobuf:"bytes,2,opt,name=src,proto3" json:"src,omitempty"`
What ServerPres_What `protobuf:"varint,3,opt,name=what,proto3,enum=pbx.ServerPres_What" json:"what,omitempty"` What ServerPres_What `protobuf:"varint,3,opt,name=what,proto3,enum=pbx.ServerPres_What" json:"what,omitempty"`
UserAgent string `protobuf:"bytes,4,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"` UserAgent string `protobuf:"bytes,4,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"`
SeqId int32 `protobuf:"varint,5,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` SeqId int64 `protobuf:"varint,5,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"`
DelId int32 `protobuf:"varint,6,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` DelId int32 `protobuf:"varint,6,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"`
DelSeq []*SeqRange `protobuf:"bytes,7,rep,name=del_seq,json=delSeq,proto3" json:"del_seq,omitempty"` DelSeq []*SeqRange `protobuf:"bytes,7,rep,name=del_seq,json=delSeq,proto3" json:"del_seq,omitempty"`
TargetUserId string `protobuf:"bytes,8,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` TargetUserId string `protobuf:"bytes,8,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
@@ -2997,7 +2997,7 @@ func (x *ServerPres) GetUserAgent() string {
return "" return ""
} }
func (x *ServerPres) GetSeqId() int32 { func (x *ServerPres) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -3152,7 +3152,7 @@ type ServerInfo struct {
Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"`
FromUserId string `protobuf:"bytes,2,opt,name=from_user_id,json=fromUserId,proto3" json:"from_user_id,omitempty"` FromUserId string `protobuf:"bytes,2,opt,name=from_user_id,json=fromUserId,proto3" json:"from_user_id,omitempty"`
What InfoNote `protobuf:"varint,3,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"` What InfoNote `protobuf:"varint,3,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"`
SeqId int32 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` SeqId int64 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"`
Src string `protobuf:"bytes,5,opt,name=src,proto3" json:"src,omitempty"` Src string `protobuf:"bytes,5,opt,name=src,proto3" json:"src,omitempty"`
Event CallEvent `protobuf:"varint,6,opt,name=event,proto3,enum=pbx.CallEvent" json:"event,omitempty"` Event CallEvent `protobuf:"varint,6,opt,name=event,proto3,enum=pbx.CallEvent" json:"event,omitempty"`
Payload []byte `protobuf:"bytes,7,opt,name=payload,proto3" json:"payload,omitempty"` Payload []byte `protobuf:"bytes,7,opt,name=payload,proto3" json:"payload,omitempty"`
@@ -3211,7 +3211,7 @@ func (x *ServerInfo) GetWhat() InfoNote {
return InfoNote_X1 return InfoNote_X1
} }
func (x *ServerInfo) GetSeqId() int32 { func (x *ServerInfo) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -3862,8 +3862,8 @@ type SubscriptionEvent struct {
Topic string `protobuf:"bytes,2,opt,name=topic,proto3" json:"topic,omitempty"` Topic string `protobuf:"bytes,2,opt,name=topic,proto3" json:"topic,omitempty"`
UserId string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` UserId string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
DelId int32 `protobuf:"varint,4,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` DelId int32 `protobuf:"varint,4,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"`
ReadId int32 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` ReadId int64 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"`
RecvId int32 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` RecvId int64 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"`
Mode *AccessMode `protobuf:"bytes,7,opt,name=mode,proto3" json:"mode,omitempty"` Mode *AccessMode `protobuf:"bytes,7,opt,name=mode,proto3" json:"mode,omitempty"`
Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"` Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"`
} }
@@ -3928,14 +3928,14 @@ func (x *SubscriptionEvent) GetDelId() int32 {
return 0 return 0
} }
func (x *SubscriptionEvent) GetReadId() int32 { func (x *SubscriptionEvent) GetReadId() int64 {
if x != nil { if x != nil {
return x.ReadId return x.ReadId
} }
return 0 return 0
} }
func (x *SubscriptionEvent) GetRecvId() int32 { func (x *SubscriptionEvent) GetRecvId() int64 {
if x != nil { if x != nil {
return x.RecvId return x.RecvId
} }
+16 -16
View File
@@ -100,8 +100,8 @@ message SetDesc {
} }
message SeqRange { message SeqRange {
int32 low = 1; int64 low = 1;
int32 hi = 2; int64 hi = 2;
} }
message GetOpts { message GetOpts {
@@ -112,9 +112,9 @@ message GetOpts {
// Limit search results to one topic; // Limit search results to one topic;
string topic = 3; string topic = 3;
// Load messages with seq id equal or greater than this // Load messages with seq id equal or greater than this
int32 since_id = 4; int64 since_id = 4;
// Load messages with seq id lower than this // Load messages with seq id lower than this
int32 before_id = 5; int64 before_id = 5;
// Maximum number of results to return // Maximum number of results to return
int32 limit = 6; int32 limit = 6;
// Load messages by id or ranges of ids // Load messages by id or ranges of ids
@@ -292,7 +292,7 @@ message ClientNote {
// "kp" - typing notification, "call" - voice/video call // "kp" - typing notification, "call" - voice/video call
InfoNote what = 2; InfoNote what = 2;
// Server-issued message ID being reported // Server-issued message ID being reported
int32 seq_id = 3; int64 seq_id = 3;
// Client's count of unread messages to report back to the server. Used in push notifications on iOS. // Client's count of unread messages to report back to the server. Used in push notifications on iOS.
int32 unread = 4; int32 unread = 4;
// Call event. // Call event.
@@ -345,9 +345,9 @@ message TopicDesc {
int64 touched_at = 3; int64 touched_at = 3;
DefaultAcsMode defacs = 4; DefaultAcsMode defacs = 4;
AccessMode acs = 5; AccessMode acs = 5;
int32 seq_id = 6; int64 seq_id = 6;
int32 read_id = 7; int64 read_id = 7;
int32 recv_id = 8; int64 recv_id = 8;
int32 del_id = 9; int32 del_id = 9;
bytes public = 10; bytes public = 10;
bytes private = 11; bytes private = 11;
@@ -370,8 +370,8 @@ message TopicSub {
bool online = 3; bool online = 3;
AccessMode acs = 4; AccessMode acs = 4;
int32 read_id = 5; int64 read_id = 5;
int32 recv_id = 6; int64 recv_id = 6;
bytes public = 7; bytes public = 7;
bytes trusted = 16; // 16! bytes trusted = 16; // 16!
bytes private = 8; bytes private = 8;
@@ -387,7 +387,7 @@ message TopicSub {
string topic = 10; string topic = 10;
int64 touched_at = 11; int64 touched_at = 11;
// ID of the last {data} message in a topic // ID of the last {data} message in a topic
int32 seq_id = 12; int64 seq_id = 12;
// Messages are deleted up to this ID // Messages are deleted up to this ID
int32 del_id = 13; int32 del_id = 13;
@@ -421,7 +421,7 @@ message ServerData {
int64 timestamp = 7; int64 timestamp = 7;
// Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970 // Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970
int64 deleted_at = 3; int64 deleted_at = 3;
int32 seq_id = 4; int64 seq_id = 4;
map<string, bytes> head = 5; map<string, bytes> head = 5;
bytes content = 6; bytes content = 6;
} }
@@ -449,7 +449,7 @@ message ServerPres {
} }
What what = 3; What what = 3;
string user_agent = 4; string user_agent = 4;
int32 seq_id = 5; int64 seq_id = 5;
int32 del_id = 6; int32 del_id = 6;
repeated SeqRange del_seq = 7; repeated SeqRange del_seq = 7;
string target_user_id = 8; string target_user_id = 8;
@@ -475,7 +475,7 @@ message ServerInfo {
string topic = 1; string topic = 1;
string from_user_id = 2; string from_user_id = 2;
InfoNote what = 3; InfoNote what = 3;
int32 seq_id = 4; int64 seq_id = 4;
string src = 5; string src = 5;
CallEvent event = 6; CallEvent event = 6;
bytes payload = 7; bytes payload = 7;
@@ -577,8 +577,8 @@ message SubscriptionEvent {
string user_id = 3; string user_id = 3;
int32 del_id = 4; int32 del_id = 4;
int32 read_id = 5; int64 read_id = 5;
int32 recv_id = 6; int64 recv_id = 6;
AccessMode mode = 7; AccessMode mode = 7;
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type adminInternalAPIConfig struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
ServiceName string `json:"service_name"`
Secret string `json:"secret"`
TimeoutMS int `json:"timeout_ms"`
}
type adminInternalClient struct {
config adminInternalAPIConfig
http *http.Client
}
type adminInternalTenantDTO struct {
ID types.TenantID `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TenantDesc *string `json:"tenant_desc,omitempty"`
DBName string `json:"db_name"`
DBDSN string `json:"db_dsn"`
State types.TenantState `json:"state"`
CreatedAt time.Time `json:"created_at"`
CreatedBy int64 `json:"created_by"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy int64 `json:"updated_by"`
}
func initAdminInternalAPI(raw json.RawMessage) error {
store.SetAdminInternalConfigProvider(nil)
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var cfg adminInternalAPIConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "im-server"
}
if cfg.TimeoutMS <= 0 {
cfg.TimeoutMS = 3000
}
if !cfg.Enabled {
return nil
}
if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" {
return errors.New("admin_internal_api enabled but base_url or secret is empty")
}
store.SetAdminInternalConfigProvider(&adminInternalClient{
config: cfg,
http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond},
})
logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName)
return nil
}
func (c *adminInternalClient) GetTenantByCode(code string) (*types.Tenant, error) {
var tenant adminInternalTenantDTO
if err := c.getJSON("/internal/im-config/tenants/by-code/"+url.PathEscape(strings.TrimSpace(code)), nil, &tenant); err != nil {
return nil, err
}
return tenant.toStoreTenant(), nil
}
func (c *adminInternalClient) GetTenantByID(id types.TenantID) (*types.Tenant, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
var tenant adminInternalTenantDTO
if err := c.getJSON("/internal/im-config/tenants/by-id/"+strconv.FormatInt(int64(id), 10), nil, &tenant); err != nil {
return nil, err
}
return tenant.toStoreTenant(), nil
}
func (c *adminInternalClient) ListActiveTenants(limit int) ([]types.Tenant, error) {
query := url.Values{}
query.Set("state", "active")
if limit > 0 {
query.Set("limit", strconv.Itoa(limit))
}
var wrapper struct {
Items []adminInternalTenantDTO `json:"items"`
}
if err := c.getJSON("/internal/im-config/tenants", query, &wrapper); err != nil {
return nil, err
}
tenants := make([]types.Tenant, 0, len(wrapper.Items))
for _, item := range wrapper.Items {
if tenant := item.toStoreTenant(); tenant != nil {
tenants = append(tenants, *tenant)
}
}
return tenants, nil
}
func (c *adminInternalClient) RegistrationSMSEnabled() (bool, error) {
var wrapper struct {
Enabled bool `json:"enabled"`
}
if err := c.getJSON("/internal/im-config/registration/sms", nil, &wrapper); err != nil {
return true, err
}
return wrapper.Enabled, nil
}
func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) {
query := url.Values{}
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if key = strings.TrimSpace(key); key != "" {
normalized = append(normalized, key)
}
}
if len(normalized) > 0 {
query.Set("keys", strings.Join(normalized, ","))
}
var wrapper struct {
Values map[string]string `json:"values"`
}
if err := c.getJSON("/internal/im-config/third-party/"+url.PathEscape(strings.TrimSpace(group)), query, &wrapper); err != nil {
return nil, err
}
if wrapper.Values == nil {
wrapper.Values = map[string]string{}
}
return wrapper.Values, nil
}
func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
if c == nil || c.http == nil {
return types.ErrUnsupported
}
path = "/" + strings.TrimLeft(path, "/")
u, err := url.Parse(c.config.BaseURL + path)
if err != nil {
return err
}
if len(query) > 0 {
u.RawQuery = query.Encode()
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return err
}
if err = c.signRequest(req, nil); err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("admin internal api status=%d body=%s", resp.StatusCode, string(body))
}
var parsed struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
}
if err = json.Unmarshal(body, &parsed); err != nil {
return err
}
if parsed.Code != 0 {
return fmt.Errorf("admin internal api code=%d", parsed.Code)
}
if out == nil || len(parsed.Data) == 0 || string(parsed.Data) == "null" {
return nil
}
return json.Unmarshal(parsed.Data, out)
}
func (c *adminInternalClient) signRequest(req *http.Request, body []byte) error {
nonce, err := randomInternalNonce()
if err != nil {
return err
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
bodyHash := sha256.Sum256(body)
signingText := strings.Join([]string{
req.Method,
req.URL.RequestURI(),
hex.EncodeToString(bodyHash[:]),
timestamp,
nonce,
}, "\n")
req.Header.Set(internalHeaderService, c.config.ServiceName)
req.Header.Set(internalHeaderTimestamp, timestamp)
req.Header.Set(internalHeaderNonce, nonce)
req.Header.Set(internalHeaderSignature, signInternalRequest(c.config.Secret, signingText))
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
req.Header.Set("Content-Type", "application/json")
}
return nil
}
func randomInternalNonce() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
func signInternalRequest(secret, signingText string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingText))
return hex.EncodeToString(mac.Sum(nil))
}
func (t adminInternalTenantDTO) toStoreTenant() *types.Tenant {
if t.ID.IsZero() && strings.TrimSpace(t.Code) == "" {
return nil
}
return &types.Tenant{
ID: t.ID,
Code: strings.TrimSpace(t.Code),
Name: t.Name,
TenantDesc: t.TenantDesc,
DBName: t.DBName,
DBDSN: t.DBDSN,
State: t.State,
CreatedAt: t.CreatedAt,
CreatedBy: t.CreatedBy,
UpdatedAt: t.UpdatedAt,
UpdatedBy: t.UpdatedBy,
}
}
+2 -2
View File
@@ -86,7 +86,7 @@ type videoCall struct {
// Call participants (session sid -> callPartyData). // Call participants (session sid -> callPartyData).
parties map[string]callPartyData parties map[string]callPartyData
// Call message seq ID. // Call message seq ID.
seq int seq int64
// Call message content. // Call message content.
content any content any
// Call message content mime type. // Call message content mime type.
@@ -300,7 +300,7 @@ func (call *videoCall) messageHead(head map[string]any, newState string, duratio
head = map[string]any{} head = map[string]any{}
} }
head["replace"] = ":" + strconv.Itoa(call.seq) head["replace"] = ":" + strconv.FormatInt(call.seq, 10)
head["webrtc"] = newState head["webrtc"] = newState
if duration > 0 { if duration > 0 {
+23 -23
View File
@@ -27,9 +27,9 @@ type MsgGetOpts struct {
// Return results modified since this timespamp. // Return results modified since this timespamp.
IfModifiedSince *time.Time `json:"ims,omitempty"` IfModifiedSince *time.Time `json:"ims,omitempty"`
// Load messages/ranges with IDs equal or greater than this (inclusive or closed). // Load messages/ranges with IDs equal or greater than this (inclusive or closed).
SinceId int `json:"since,omitempty"` SinceId int64 `json:"since,omitempty"`
// Load messages/ranges with IDs lower than this (exclusive or open). // Load messages/ranges with IDs lower than this (exclusive or open).
BeforeId int `json:"before,omitempty"` BeforeId int64 `json:"before,omitempty"`
// Limit the number of messages loaded. // Limit the number of messages loaded.
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
// Fetch messages with IDs in these ranges. // Fetch messages with IDs in these ranges.
@@ -100,8 +100,8 @@ type MsgSetQuery struct {
// MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed), // MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed),
// high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4. // high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4.
type MsgRange struct { type MsgRange struct {
LowId int `json:"low,omitempty"` LowId int64 `json:"low,omitempty"`
HiId int `json:"hi,omitempty"` HiId int64 `json:"hi,omitempty"`
} }
/**************************************************************** /****************************************************************
@@ -321,7 +321,7 @@ type MsgClientNote struct {
// what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification // what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification
What string `json:"what"` What string `json:"what"`
// Server-issued message ID being reported // Server-issued message ID being reported
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
// Client's count of unread messages to report back to the server. Used in push notifications on iOS. // Client's count of unread messages to report back to the server. Used in push notifications on iOS.
Unread int `json:"unread,omitempty"` Unread int `json:"unread,omitempty"`
// Call event. // Call event.
@@ -453,9 +453,9 @@ type MsgTopicDesc struct {
// Actual access mode // Actual access mode
Acs *MsgAccessMode `json:"acs,omitempty"` Acs *MsgAccessMode `json:"acs,omitempty"`
// Max message ID // Max message ID
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
ReadSeqId int `json:"read,omitempty"` ReadSeqId int64 `json:"read,omitempty"`
RecvSeqId int `json:"recv,omitempty"` RecvSeqId int64 `json:"recv,omitempty"`
// Id of the last delete operation as seen by the requesting user // Id of the last delete operation as seen by the requesting user
DelId int `json:"clear,omitempty"` DelId int `json:"clear,omitempty"`
SubCnt int `json:"subcnt,omitempty"` SubCnt int `json:"subcnt,omitempty"`
@@ -475,13 +475,13 @@ func (src *MsgTopicDesc) describe() string {
s += " acs={" + src.Acs.describe() + "}" s += " acs={" + src.Acs.describe() + "}"
} }
if src.SeqId != 0 { if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if src.ReadSeqId != 0 { if src.ReadSeqId != 0 {
s += " read=" + strconv.Itoa(src.ReadSeqId) s += " read=" + strconv.FormatInt(src.ReadSeqId, 10)
} }
if src.RecvSeqId != 0 { if src.RecvSeqId != 0 {
s += " recv=" + strconv.Itoa(src.RecvSeqId) s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10)
} }
if src.DelId != 0 { if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId) s += " clear=" + strconv.Itoa(src.DelId)
@@ -517,9 +517,9 @@ type MsgTopicSub struct {
// Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned. // Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned.
Acs MsgAccessMode `json:"acs,omitempty"` Acs MsgAccessMode `json:"acs,omitempty"`
// ID of the message reported by the given user as read // ID of the message reported by the given user as read
ReadSeqId int `json:"read,omitempty"` ReadSeqId int64 `json:"read,omitempty"`
// ID of the message reported by the given user as received // ID of the message reported by the given user as received
RecvSeqId int `json:"recv,omitempty"` RecvSeqId int64 `json:"recv,omitempty"`
// Topic's public data // Topic's public data
Public any `json:"public,omitempty"` Public any `json:"public,omitempty"`
// Topic's trusted public data // Topic's trusted public data
@@ -540,7 +540,7 @@ type MsgTopicSub struct {
// Timestamp of the last message in the topic. // Timestamp of the last message in the topic.
TouchedAt *time.Time `json:"touched,omitempty"` TouchedAt *time.Time `json:"touched,omitempty"`
// ID of the last {data} message in a topic // ID of the last {data} message in a topic
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
// Id of the latest Delete operation // Id of the latest Delete operation
DelId int `json:"clear,omitempty"` DelId int `json:"clear,omitempty"`
// Number of subscribers, group topics only. // Number of subscribers, group topics only.
@@ -555,13 +555,13 @@ func (src *MsgTopicSub) describe() string {
s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe() s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe()
if src.SeqId != 0 { if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if src.ReadSeqId != 0 { if src.ReadSeqId != 0 {
s += " read=" + strconv.Itoa(src.ReadSeqId) s += " read=" + strconv.FormatInt(src.ReadSeqId, 10)
} }
if src.RecvSeqId != 0 { if src.RecvSeqId != 0 {
s += " recv=" + strconv.Itoa(src.RecvSeqId) s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10)
} }
if src.DelId != 0 { if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId) s += " clear=" + strconv.Itoa(src.DelId)
@@ -621,7 +621,7 @@ type MsgServerData struct {
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
Timestamp time.Time `json:"ts"` Timestamp time.Time `json:"ts"`
DeletedAt *time.Time `json:"deleted,omitempty"` DeletedAt *time.Time `json:"deleted,omitempty"`
SeqId int `json:"seq"` SeqId int64 `json:"seq"`
Head map[string]any `json:"head,omitempty"` Head map[string]any `json:"head,omitempty"`
Content any `json:"content"` Content any `json:"content"`
} }
@@ -636,7 +636,7 @@ func (src *MsgServerData) copy() *MsgServerData {
} }
func (src *MsgServerData) describe() string { func (src *MsgServerData) describe() string {
s := src.Topic + " from=" + src.From + " seq=" + strconv.Itoa(src.SeqId) s := src.Topic + " from=" + src.From + " seq=" + strconv.FormatInt(src.SeqId, 10)
if src.DeletedAt != nil { if src.DeletedAt != nil {
s += " deleted" s += " deleted"
} else { } else {
@@ -654,7 +654,7 @@ type MsgServerPres struct {
Src string `json:"src,omitempty"` Src string `json:"src,omitempty"`
What string `json:"what"` What string `json:"what"`
UserAgent string `json:"ua,omitempty"` UserAgent string `json:"ua,omitempty"`
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
DelId int `json:"clear,omitempty"` DelId int `json:"clear,omitempty"`
DelSeq []MsgRange `json:"delseq,omitempty"` DelSeq []MsgRange `json:"delseq,omitempty"`
AcsTarget string `json:"tgt,omitempty"` AcsTarget string `json:"tgt,omitempty"`
@@ -706,7 +706,7 @@ func (src *MsgServerPres) describe() string {
s += " ua=" + src.UserAgent s += " ua=" + src.UserAgent
} }
if src.SeqId != 0 { if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if src.DelId != 0 { if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId) s += " clear=" + strconv.Itoa(src.DelId)
@@ -799,7 +799,7 @@ type MsgServerInfo struct {
// The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call. // The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call.
What string `json:"what"` What string `json:"what"`
// Server-issued message ID being reported. // Server-issued message ID being reported.
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
// Call event. // Call event.
Event string `json:"event,omitempty"` Event string `json:"event,omitempty"`
// Arbitrary json payload (used by video calls). // Arbitrary json payload (used by video calls).
@@ -829,7 +829,7 @@ func (src *MsgServerInfo) describe() string {
} }
s += " what=" + src.What + " from=" + src.From s += " what=" + src.What + " from=" + src.From
if src.SeqId > 0 { if src.SeqId > 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if len(src.Payload) > 0 { if len(src.Payload) > 0 {
s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>" s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>"
+36 -5
View File
@@ -214,6 +214,7 @@ type Adapter interface {
type TenantAdapter interface { type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error) TenantGetByCode(code string) (*t.Tenant, error)
TenantGetByID(id t.TenantID) (*t.Tenant, error) TenantGetByID(id t.TenantID) (*t.Tenant, error)
TenantListActive(limit int) ([]t.Tenant, error)
} }
// RegistrationConfigAdapter reads global registration policy from the control database. // RegistrationConfigAdapter reads global registration policy from the control database.
@@ -234,6 +235,11 @@ type TenantEntryAdapter interface {
TenantEntryFeedbackCreate(*t.TenantEntryFeedback) (*t.TenantEntryFeedback, error) TenantEntryFeedbackCreate(*t.TenantEntryFeedback) (*t.TenantEntryFeedback, error)
} }
type TenantInternalAdminQueryAdapter interface {
TenantInternalAdminQuery(tenantID t.TenantID, query string, countQuery string, args []any, page, perPage int) (*t.InternalAdminQueryResult, error)
TenantInternalAdminQueryOne(tenantID t.TenantID, query string, args []any) (map[string]any, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the // TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model. // "one tenant, one database" deployment model.
type TenantRoutedAdapter interface { type TenantRoutedAdapter interface {
@@ -249,6 +255,18 @@ type TenantHTTPInfrastructureAdapter interface {
TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error
TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error) TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error)
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
TenantKafkaConsumeBegin(t.TenantID, *t.KafkaConsumeRecord, time.Time) (*t.KafkaConsumeRecord, bool, error)
TenantKafkaConsumeSucceed(t.TenantID, string, string, time.Time) error
TenantKafkaConsumeFail(t.TenantID, string, string, string, string, time.Time) (*t.KafkaConsumeRecord, error)
TenantKafkaConsumeDead(t.TenantID, *t.KafkaDeadLetter, time.Time) error
TenantKafkaDeadLetterList(t.TenantID, t.KafkaDeadLetterListOptions) (*t.KafkaDeadLetterPage, error)
TenantKafkaDeadLetterGet(t.TenantID, string) (*t.KafkaDeadLetter, error)
TenantKafkaDeadLetterMark(t.TenantID, string, string, string, string, time.Time) error
TenantKafkaDeadLetterMarkReplay(t.TenantID, string, string, string, time.Time) (*t.KafkaDeadLetter, error)
TenantKafkaOutboxCreate(t.TenantID, *t.KafkaOutboxRecord) error
TenantKafkaOutboxListPending(t.TenantID, int, time.Time) ([]t.KafkaOutboxRecord, error)
TenantKafkaOutboxMarkSent(t.TenantID, string, time.Time) error
TenantKafkaOutboxMarkFailed(t.TenantID, string, string, string, time.Time, time.Time) error
} }
// TenantRegistrationInviteAdapter stores invitation definitions in the // TenantRegistrationInviteAdapter stores invitation definitions in the
@@ -326,13 +344,20 @@ type TenantBusinessAdapter interface {
TenantMessageSave(t.TenantID, *t.Message) error TenantMessageSave(t.TenantID, *t.Message) error
TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error) TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error)
TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error) TenantMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error) TenantMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error)
TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error) TenantMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error) TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error) TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error) TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantGroupMessageSave(t.TenantID, *t.Message) error
TenantGroupMessageGetByClientID(t.TenantID, string, t.Uid, string) (*t.Message, error)
TenantGroupMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
TenantGroupMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error)
TenantGroupMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantGroupMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantGroupMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error) TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error)
TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error) TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error)
@@ -340,8 +365,8 @@ type TenantBusinessAdapter interface {
TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error) TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error)
TenantCallCreate(t.TenantID, *t.CallRecord) error TenantCallCreate(t.TenantID, *t.CallRecord) error
TenantCallAccept(t.TenantID, string, int, time.Time) error TenantCallAccept(t.TenantID, string, int64, time.Time) error
TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error TenantCallFinish(t.TenantID, string, int64, string, int64, time.Time) error
TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error) TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error)
TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error) TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error)
@@ -379,7 +404,13 @@ type TenantBusinessAdapter interface {
TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error) TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error)
TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error) TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error)
TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error) TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error)
TenantAttendanceConfig(t.TenantID, t.Uid, time.Time) (*t.AttendanceConfig, error)
TenantAttendanceRecordList(t.TenantID, t.Uid, t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error)
TenantAttendanceCheckin(t.TenantID, t.Uid, string, t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error)
TenantAttendanceAppealCreate(t.TenantID, t.Uid, *t.AttendanceAppeal, string) (*t.AttendanceAppeal, error)
TenantAttendanceAppealList(t.TenantID, t.Uid, t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error)
TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error) TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error)
TenantMeetingList(t.TenantID, t.Uid, string, int) ([]t.Meeting, string, error)
TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error) TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error)
TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error) TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error)
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error) TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
+3 -3
View File
@@ -126,7 +126,7 @@ func TestRangesToSql(t *testing.T) {
ranges := []types.Range{{Low: 5, Hi: 0}} ranges := []types.Range{{Low: 5, Hi: 0}}
sql, args := RangesToSql(ranges) sql, args := RangesToSql(ranges)
expectedSql := "IN (?)" expectedSql := "IN (?)"
expectedArgs := []any{5} expectedArgs := []any{int64(5)}
if sql != expectedSql { if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
} }
@@ -138,7 +138,7 @@ func TestRangesToSql(t *testing.T) {
ranges = []types.Range{{Low: 5, Hi: 8}} ranges = []types.Range{{Low: 5, Hi: 8}}
sql, args = RangesToSql(ranges) sql, args = RangesToSql(ranges)
expectedSql = "BETWEEN ? AND ?" expectedSql = "BETWEEN ? AND ?"
expectedArgs = []any{5, 7} // Hi-1 for BETWEEN expectedArgs = []any{int64(5), int64(7)} // Hi-1 for BETWEEN
if sql != expectedSql { if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
} }
@@ -150,7 +150,7 @@ func TestRangesToSql(t *testing.T) {
ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}} ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}}
sql, args = RangesToSql(ranges) sql, args = RangesToSql(ranges)
expectedSql = "IN (?,?,?,?,?)" expectedSql = "IN (?,?,?,?,?)"
expectedArgs = []any{1, 2, 5, 8, 9} expectedArgs = []any{int64(1), int64(2), int64(5), int64(8), int64(9)}
if sql != expectedSql { if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
} }
+280 -24
View File
@@ -113,9 +113,23 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
return context.Background(), nil return context.Background(), nil
} }
func normalizeMySQLDSN(dsn string) string {
dsn = strings.TrimSpace(dsn)
if strings.HasPrefix(dsn, "mysql://") {
return strings.TrimPrefix(dsn, "mysql://")
}
if strings.HasPrefix(dsn, "mysql:") {
credentials, _, _ := strings.Cut(strings.TrimPrefix(dsn, "mysql:"), "@")
if strings.Count(credentials, ":") >= 1 {
return strings.TrimPrefix(dsn, "mysql:")
}
}
return dsn
}
func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) { func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) {
if strings.TrimSpace(dsn) != "" { if strings.TrimSpace(dsn) != "" {
cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://")) cfg, err := ms.ParseDSN(normalizeMySQLDSN(dsn))
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -180,7 +194,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
// Note: this method is deprecated. // Note: this method is deprecated.
if config.DSN != "" { if config.DSN != "" {
// Remove optional schema. // Remove optional schema.
a.dsn = strings.TrimPrefix(config.DSN, "mysql://") a.dsn = normalizeMySQLDSN(config.DSN)
} else { } else {
a.dsn = defaultDSN a.dsn = defaultDSN
} }
@@ -261,6 +275,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMFileReviewTables(a.db); err != nil { if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error()) return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
} }
if err = ensureIMOrganizationVisibilityTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure organization visibility schema: " + err.Error())
}
if err = ensureSnowflakePrimaryKeys(a.db); err != nil { if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
} }
@@ -428,6 +445,31 @@ func (a *adapter) TenantGetByID(id t.TenantID) (*t.Tenant, error) {
return a.tenantByID(id) return a.tenantByID(id)
} }
func (a *adapter) TenantListActive(limit int) ([]t.Tenant, error) {
if limit <= 0 {
limit = 1000
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
db := a.db
if a.registryDB != nil {
db = a.registryDB
}
var tenants []t.Tenant
err := db.SelectContext(ctx, &tenants,
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE state=? ORDER BY id ASC LIMIT ?`, t.TenantStateActive, limit)
if isUnknownColumn(err) {
err = db.SelectContext(ctx, &tenants,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE state=? ORDER BY id ASC LIMIT ?`, t.TenantStateActive, limit)
}
return tenants, err
}
func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) { func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) {
if !a.tenantDBRouting { if !a.tenantDBRouting {
return a, a, nil return a, a, nil
@@ -468,6 +510,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close() db.Close()
return nil, nil, err return nil, nil, err
} }
if err = ensureIMOrganizationVisibilityTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil { if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close() db.Close()
return nil, nil, err return nil, nil, err
@@ -754,7 +800,7 @@ func (a *adapter) CreateDb(reset bool) error {
usebt TINYINT DEFAULT 0, usebt TINYINT DEFAULT 0,
owner BIGINT NOT NULL DEFAULT 0, owner BIGINT NOT NULL DEFAULT 0,
access JSON, access JSON,
seqid INT NOT NULL DEFAULT 0, seqid BIGINT NOT NULL DEFAULT 0,
delid INT DEFAULT 0, delid INT DEFAULT 0,
subcnt INT DEFAULT 0, subcnt INT DEFAULT 0,
public JSON, public JSON,
@@ -799,8 +845,8 @@ func (a *adapter) CreateDb(reset bool) error {
userid BIGINT NOT NULL, userid BIGINT NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
delid INT DEFAULT 0, delid INT DEFAULT 0,
recvseqid INT DEFAULT 0, recvseqid BIGINT DEFAULT 0,
readseqid INT DEFAULT 0, readseqid BIGINT DEFAULT 0,
modewant CHAR(8), modewant CHAR(8),
modegiven CHAR(8), modegiven CHAR(8),
private JSON, private JSON,
@@ -822,7 +868,7 @@ func (a *adapter) CreateDb(reset bool) error {
updatedat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3), deletedat DATETIME(3),
delid INT DEFAULT 0, delid INT DEFAULT 0,
seqid INT NOT NULL, seqid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,` + topic CHAR(25) NOT NULL,` +
"`from` BIGINT NOT NULL," + "`from` BIGINT NOT NULL," +
`client_msg_id VARCHAR(64), `client_msg_id VARCHAR(64),
@@ -845,7 +891,7 @@ func (a *adapter) CreateDb(reset bool) error {
createdat DATETIME(3) NOT NULL, createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
seqid INT NOT NULL, seqid BIGINT NOT NULL,
version INT NOT NULL, version INT NOT NULL,
actor BIGINT NOT NULL, actor BIGINT NOT NULL,
head JSON, head JSON,
@@ -869,6 +915,9 @@ func (a *adapter) CreateDb(reset bool) error {
if err = createIMOrganizationTables(tx); err != nil { if err = createIMOrganizationTables(tx); err != nil {
return err return err
} }
if err = createIMAttendanceTables(tx); err != nil {
return err
}
if err = createIMMeetingTables(tx); err != nil { if err = createIMMeetingTables(tx); err != nil {
return err return err
} }
@@ -996,6 +1045,10 @@ func (a *adapter) UpgradeDb() error {
return err return err
} }
if err := a.ensureMessageTopicSeqIndex(); err != nil {
return err
}
if a.version == adpVersion { if a.version == adpVersion {
return nil return nil
} }
@@ -1004,6 +1057,22 @@ func (a *adapter) UpgradeDb() error {
strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion) + " is not supported") strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion) + " is not supported")
} }
func (a *adapter) ensureMessageTopicSeqIndex() error {
const indexName = "messages_topic_seqid"
var count int
if err := a.db.QueryRow(
`SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='im_messages' AND index_name=?`,
indexName).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
_, err := a.db.Exec("ALTER TABLE im_messages ADD UNIQUE INDEX messages_topic_seqid(topic, seqid)")
return err
}
func createIMNotificationTables(tx *sql.Tx) error { func createIMNotificationTables(tx *sql.Tx) error {
_, err := tx.Exec( _, err := tx.Exec(
`CREATE TABLE im_system_notifications( `CREATE TABLE im_system_notifications(
@@ -1030,7 +1099,7 @@ func createIMCallTables(tx *sql.Tx) error {
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL, call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
seq_id INT NOT NULL, seq_id BIGINT NOT NULL,
caller_id BIGINT NOT NULL, caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL, callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL, call_type VARCHAR(16) NOT NULL,
@@ -1076,10 +1145,11 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission) `INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES VALUES
('organization','组织架构','/organization',10,'organization',1,'enabled'), ('organization','组织架构','/organization',10,'organization',1,'enabled'),
('meetings','会议通知','/meetings',20,'meetings',1,'enabled'), ('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'), ('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
('monthly_reports','月报','/reports/monthly',40,'monthly_reports',1,'enabled'), ('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
('executive_mailbox','总经理信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled') ('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
name=VALUES(name), name=VALUES(name),
route=VALUES(route), route=VALUES(route),
@@ -1128,6 +1198,18 @@ func createIMOrganizationTables(tx *sql.Tx) error {
INDEX idx_im_org_members_display_name(display_name), INDEX idx_im_org_members_display_name(display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE
)`, )`,
`CREATE TABLE im_org_visibility_overrides(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
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_org_visibility_user(user_id),
INDEX idx_im_org_visibility_scope(visibility_scope,state)
)`,
} }
for _, stmt := range statements { for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil { if _, err := tx.Exec(stmt); err != nil {
@@ -1137,6 +1219,139 @@ func createIMOrganizationTables(tx *sql.Tx) error {
return nil return nil
} }
func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error {
_, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_org_visibility_overrides(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
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_org_visibility_user(user_id),
INDEX idx_im_org_visibility_scope(visibility_scope,state)
)`)
return err
}
func createIMAttendanceTables(tx *sql.Tx) error {
statements := []string{
`CREATE TABLE im_attendance_rules(
id BIGINT NOT NULL AUTO_INCREMENT,
rule_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
workday TINYINT NOT NULL DEFAULT 1,
location_required TINYINT NOT NULL DEFAULT 0,
wifi_required TINYINT NOT NULL DEFAULT 0,
allow_outside_checkin TINYINT NOT NULL DEFAULT 1,
appeal_allowed TINYINT NOT NULL DEFAULT 1,
effective_from DATE,
effective_to DATE,
state VARCHAR(16) NOT NULL DEFAULT 'active',
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_attendance_rules_rule(rule_id),
INDEX idx_im_attendance_rules_state(state,effective_from,effective_to)
)`,
`CREATE TABLE im_attendance_shifts(
id BIGINT NOT NULL AUTO_INCREMENT,
shift_id VARCHAR(64) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
work_start TIME NOT NULL,
work_end TIME NOT NULL,
checkin_start TIME NOT NULL,
checkout_end TIME NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
sort_order INT NOT NULL DEFAULT 0,
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_attendance_shifts_shift(shift_id),
INDEX idx_im_attendance_shifts_rule(rule_id,state,sort_order)
)`,
`CREATE TABLE im_attendance_records(
id BIGINT NOT NULL AUTO_INCREMENT,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
record_date DATE NOT NULL,
rule_id VARCHAR(64) NOT NULL,
shift_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'not_checked',
exceptions JSON,
appeal_status VARCHAR(32) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
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_attendance_records_record(record_id),
UNIQUE INDEX uk_im_attendance_records_user_date_shift(user_id,record_date,shift_id),
INDEX idx_im_attendance_records_user_id(user_id,id DESC),
INDEX idx_im_attendance_records_date_status(record_date,status,id DESC)
)`,
`CREATE TABLE im_attendance_checkins(
id BIGINT NOT NULL AUTO_INCREMENT,
checkin_id VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
record_date DATE NOT NULL,
action VARCHAR(32) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
shift_id VARCHAR(64) NOT NULL,
client_time DATETIME(3) NOT NULL,
server_time DATETIME(3) NOT NULL,
result VARCHAR(32) NOT NULL,
location JSON,
wifi JSON,
device JSON,
idempotency_key VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_attendance_checkins_checkin(checkin_id),
UNIQUE INDEX uk_im_attendance_checkins_idempotency(user_id,idempotency_key),
INDEX idx_im_attendance_checkins_record(record_id,id)
)`,
`CREATE TABLE im_attendance_appeals(
id BIGINT NOT NULL AUTO_INCREMENT,
appeal_id VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
appeal_type VARCHAR(64) NOT NULL,
reason VARCHAR(512) NOT NULL,
target_action VARCHAR(32) NOT NULL DEFAULT '',
target_time DATETIME(3),
attachment_file_ids JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
submitted_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_attendance_appeals_appeal(appeal_id),
UNIQUE INDEX uk_im_attendance_appeals_idempotency(user_id,idempotency_key),
INDEX idx_im_attendance_appeals_record(record_id),
INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC)
)`,
}
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
if _, err := tx.Exec(`INSERT INTO im_attendance_rules(rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed)
VALUES('att_rule_default','默认考勤规则',1,0,0,1,1)
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO im_attendance_shifts(shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,sort_order)
VALUES('shift_default','att_rule_default','默认班次','09:00:00','18:00:00','08:30:00','20:00:00',10)
ON DUPLICATE KEY UPDATE updated_at=updated_at`)
return err
}
func createIMMeetingTables(tx *sql.Tx) error { func createIMMeetingTables(tx *sql.Tx) error {
statements := []string{ statements := []string{
`CREATE TABLE im_meetings( `CREATE TABLE im_meetings(
@@ -1383,7 +1598,7 @@ func createIMUserProductTables(tx *sql.Tx) error {
target_type VARCHAR(16) NOT NULL, target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0, target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '', target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0, target_seq BIGINT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL, reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
evidence_file_ids JSON, evidence_file_ids JSON,
@@ -1461,7 +1676,7 @@ func upgradeIMMessageActions(a *adapter) error {
createdat DATETIME(3) NOT NULL, createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
seqid INT NOT NULL, seqid BIGINT NOT NULL,
version INT NOT NULL, version INT NOT NULL,
actor BIGINT NOT NULL, actor BIGINT NOT NULL,
head JSON, head JSON,
@@ -1534,7 +1749,7 @@ func createIMRelationTables(exec sqlExecutor) error {
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL, item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL, source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL, source_seq BIGINT NOT NULL,
snapshot JSON, snapshot JSON,
attachment_refs JSON, attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available', availability VARCHAR(16) NOT NULL DEFAULT 'available',
@@ -2521,7 +2736,35 @@ func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error {
jpriv := common.ToJSON(sub.Private) jpriv := common.ToJSON(sub.Private)
decoded_uid := store.DecodeUid(t.ParseUid(sub.User)) decoded_uid := store.DecodeUid(t.ParseUid(sub.User))
_, err := tx.Exec(
existing := struct {
ID int64 `db:"id"`
DeletedAt sql.NullTime `db:"deletedat"`
}{}
err := tx.Get(&existing, "SELECT id,deletedat FROM im_subscriptions WHERE topic=? AND userid=? ORDER BY deletedat IS NULL DESC,id ASC LIMIT 1",
sub.Topic, decoded_uid)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil {
if existing.DeletedAt.Valid {
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
"delid=0,recvseqid=0,readseqid=0,private=? WHERE id=?",
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, existing.ID)
} else {
_, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,modeWant=?,modeGiven=?,private=? WHERE id=?",
sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, existing.ID)
}
if err != nil {
return err
}
if isOwner {
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE name=?", decoded_uid, sub.Topic)
}
return err
}
_, err = tx.Exec(
"INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ "INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
"VALUES(?,?,NULL,?,?,?,?,?)", "VALUES(?,?,NULL,?,?,?,?,?)",
sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
@@ -2576,8 +2819,18 @@ func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {
topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}} topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}
topic.ObjHeader.MergeTimes(&initiator.ObjHeader) topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
topic.TouchedAt = initiator.GetTouchedAt() topic.TouchedAt = initiator.GetTouchedAt()
err = a.topicCreate(tx, topic) var existingTopicID int64
if err != nil { err = tx.Get(&existingTopicID, "SELECT id FROM im_topics WHERE name=? ORDER BY id ASC LIMIT 1", topic.Id)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == sql.ErrNoRows {
err = a.topicCreate(tx, topic)
if err != nil {
return err
}
} else if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE id=?",
topic.UpdatedAt, topic.TouchedAt, t.StateOK, existingTopicID); err != nil {
return err return err
} }
@@ -3604,6 +3857,9 @@ func (a *adapter) MessageSave(msg *t.Message) error {
"INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?)", "INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?)",
msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content)) store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content))
if isDupe(err) {
return t.ErrDuplicate
}
if err == nil { if err == nil {
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
// Replacing ID given by store by ID given by the DB. // Replacing ID given by store by ID given by the DB.
@@ -3684,8 +3940,8 @@ func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) (
// Get ranges of deleted messages // Get ranges of deleted messages
func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
var limit = a.maxResults var limit = a.maxResults
var lower = 0 var lower int64 = 0
var upper = 1<<31 - 1 var upper int64 = 1<<31 - 1
if opts != nil { if opts != nil {
if opts.Since > 0 { if opts.Since > 0 {
@@ -3718,8 +3974,8 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp
Topic string Topic string
Deletedfor int64 Deletedfor int64
Delid int Delid int
Low int Low int64
Hi int Hi int64
} }
var dmsgs []t.DelMessage var dmsgs []t.DelMessage
var dmsg t.DelMessage var dmsg t.DelMessage
@@ -3797,7 +4053,7 @@ func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
} }
// Find the actual IDs still present in the database. // Find the actual IDs still present in the database.
var seqIDs []int var seqIDs []int64
err = tx.Select(&seqIDs, "SELECT seqid FROM im_messages AS m WHERE "+where, args...) err = tx.Select(&seqIDs, "SELECT seqid FROM im_messages AS m WHERE "+where, args...)
if err != nil { if err != nil {
return err return err
@@ -3809,7 +4065,7 @@ func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
} }
// Recalculate the actual ranges to delete. // Recalculate the actual ranges to delete.
sort.Ints(seqIDs) sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
delRanges = t.SliceToRanges(seqIDs) delRanges = t.SliceToRanges(seqIDs)
// Compose a new query with the new ranges. // Compose a new query with the new ranges.
+45
View File
@@ -0,0 +1,45 @@
//go:build mysql
// +build mysql
package mysql
import (
"testing"
ms "github.com/go-sql-driver/mysql"
)
func TestNormalizeMySQLDSN(t *testing.T) {
tests := []struct {
name string
dsn string
user string
}{
{
name: "go driver dsn",
dsn: "root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true",
user: "root",
},
{
name: "url scheme dsn",
dsn: "mysql://root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true",
user: "root",
},
{
name: "legacy scheme dsn",
dsn: "mysql:root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true",
user: "root",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg, err := ms.ParseDSN(normalizeMySQLDSN(tc.dsn))
if err != nil {
t.Fatalf("ParseDSN failed: %v", err)
}
if cfg.User != tc.user {
t.Fatalf("user=%q, want %q", cfg.User, tc.user)
}
})
}
}
+21 -1
View File
@@ -23,6 +23,26 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_settings_owner(owner_user_id, group_status), INDEX idx_im_group_settings_owner(owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at) INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT, root_seqid BIGINT, 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), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_messages_group_seq(group_id, seqid),
UNIQUE INDEX uk_im_group_messages_topic_seq(topic, seqid),
UNIQUE INDEX uk_im_group_messages_topic_sender_client_mid(topic, from_user, client_msg_id),
INDEX idx_im_group_messages_group_created(group_id, created_at),
INDEX idx_im_group_messages_group_status_seq(group_id, status, seqid),
INDEX idx_im_group_messages_topic_created(topic, created_at),
INDEX idx_im_group_messages_topic_from_seq(topic, from_user, seqid),
INDEX idx_im_group_messages_audit(audit_status, created_at),
INDEX idx_im_group_messages_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_members ( `CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active', role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active',
@@ -105,7 +125,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_files ( `CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq INT NOT NULL DEFAULT 0, file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq BIGINT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '', uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0, file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3), status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3),
+46
View File
@@ -29,6 +29,52 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target(target_type, target, id) INDEX idx_im_business_events_outbox_target(target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`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`,
`CREATE TABLE IF NOT EXISTS im_kafka_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(96) NOT NULL,
topic VARCHAR(128) NOT NULL, event_type VARCHAR(96) NOT NULL,
biz_type VARCHAR(64) NOT NULL DEFAULT '', biz_id VARCHAR(128) NOT NULL DEFAULT '',
key_value VARCHAR(256) NOT NULL DEFAULT '', envelope JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), sent_at DATETIME(3), last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) NOT NULL DEFAULT '',
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_outbox_event(event_id),
INDEX idx_im_kafka_outbox_pending(status, next_retry_at, id),
INDEX idx_im_kafka_outbox_biz(biz_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT, dead_id VARCHAR(96) NOT NULL,
event_id VARCHAR(96) NOT NULL, parent_event_id VARCHAR(96) NOT NULL DEFAULT '',
source_topic VARCHAR(128) NOT NULL, consumer_group 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 '', envelope JSON NOT NULL,
retry_count INT NOT NULL DEFAULT 0, last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) NOT NULL DEFAULT '', status VARCHAR(24) NOT NULL DEFAULT 'dead',
replay_count INT NOT NULL DEFAULT 0, resolved_by VARCHAR(64) NOT NULL DEFAULT '',
resolved_at DATETIME(3), resolve_note VARCHAR(1000) NOT NULL DEFAULT '',
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_dead_letters_dead(dead_id),
INDEX idx_im_kafka_dead_letters_event(event_id),
INDEX idx_im_kafka_dead_letters_status(status, created_at),
INDEX idx_im_kafka_dead_letters_biz(biz_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_realname_appeals ( `CREATE TABLE IF NOT EXISTS im_realname_appeals (
id BIGINT NOT NULL, appeal_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, id BIGINT NOT NULL, appeal_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '', attachments_json JSON, reason VARCHAR(500) NOT NULL DEFAULT '', attachments_json JSON,
@@ -12,13 +12,16 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
if err := ensureIMHTTPInfrastructureTables(exec); err != nil { if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 5 { if len(exec.statements) != 8 {
t.Fatalf("created %d infrastructure tables, want 5", len(exec.statements)) t.Fatalf("created %d infrastructure tables, want 8", len(exec.statements))
} }
joined := strings.ToLower(strings.Join(exec.statements, "\n")) joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{ for _, table := range []string{
"im_http_idempotency", "im_http_idempotency",
"im_business_events_outbox", "im_business_events_outbox",
"im_kafka_consume_log",
"im_kafka_outbox",
"im_kafka_dead_letters",
"im_realname_appeals", "im_realname_appeals",
"im_conversation_settings", "im_conversation_settings",
"im_topic_drafts", "im_topic_drafts",
File diff suppressed because it is too large Load Diff
+509
View File
@@ -4,8 +4,11 @@
package mysql package mysql
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"strconv"
"strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -225,6 +228,512 @@ func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string
return nil return nil
} }
func (a *adapter) TenantKafkaConsumeBegin(id t.TenantID, record *t.KafkaConsumeRecord, deadline time.Time) (*t.KafkaConsumeRecord, bool, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, false, err
}
if record == nil || record.EventID == "" || record.ConsumerGroup == "" || record.SourceTopic == "" {
return nil, false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaConsumeBegin(id, record, deadline)
}
now := t.TimeNow()
_, err = a.db.Exec(`INSERT INTO im_kafka_consume_log
(event_id,consumer_group,source_topic,event_type,biz_type,biz_id,status,processing_deadline,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, record.EventID, record.ConsumerGroup, record.SourceTopic, record.EventType,
record.BizType, record.BizID, t.KafkaConsumeProcessing, deadline, now, now)
if err == nil {
record.Status = t.KafkaConsumeProcessing
record.ProcessingDeadline = &deadline
record.CreatedAt = now
record.UpdatedAt = now
return record, true, nil
}
if !isDupe(err) {
return nil, false, err
}
existing, err := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup)
if err != nil {
return nil, false, err
}
if existing.Status == t.KafkaConsumeSucceeded || existing.Status == t.KafkaConsumeDead {
return existing, false, nil
}
if existing.Status == t.KafkaConsumeProcessing && existing.ProcessingDeadline != nil && existing.ProcessingDeadline.After(now) {
return existing, false, nil
}
res, err := a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,source_topic=?,event_type=?,biz_type=?,biz_id=?,
processing_deadline=?,updated_at=? WHERE event_id=? AND consumer_group=? AND status<>?`,
t.KafkaConsumeProcessing, record.SourceTopic, record.EventType, record.BizType, record.BizID,
deadline, now, record.EventID, record.ConsumerGroup, t.KafkaConsumeSucceeded)
if err != nil {
return nil, false, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
current, getErr := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup)
return current, false, getErr
}
current, err := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup)
return current, err == nil, err
}
func (a *adapter) TenantKafkaConsumeSucceed(id t.TenantID, eventID, consumerGroup string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" || consumerGroup == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaConsumeSucceed(id, eventID, consumerGroup, now)
}
res, err := a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,succeeded_at=?,processing_deadline=NULL,updated_at=?
WHERE event_id=? AND consumer_group=?`, t.KafkaConsumeSucceeded, now, now, eventID, consumerGroup)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantKafkaConsumeFail(id t.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*t.KafkaConsumeRecord, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if eventID == "" || consumerGroup == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaConsumeFail(id, eventID, consumerGroup, reason, message, now)
}
_, err = a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,retry_count=retry_count+1,last_reason=?,
last_error=?,processing_deadline=NULL,updated_at=? WHERE event_id=? AND consumer_group=?`,
t.KafkaConsumeFailed, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000), now, eventID, consumerGroup)
if err != nil {
return nil, err
}
return a.kafkaConsumeGet(eventID, consumerGroup)
}
func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if dead == nil || dead.DeadID == "" || dead.EventID == "" || dead.ConsumerGroup == "" || dead.SourceTopic == "" || len(dead.Envelope) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaConsumeDead(id, dead, now)
}
tx, err := a.db.BeginTxx(context.Background(), nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`INSERT INTO im_kafka_dead_letters
(dead_id,event_id,parent_event_id,source_topic,consumer_group,event_type,biz_type,biz_id,envelope,
retry_count,last_reason,last_error,status,replay_count,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE last_reason=VALUES(last_reason),last_error=VALUES(last_error),updated_at=VALUES(updated_at)`,
dead.DeadID, dead.EventID, dead.ParentEventID, dead.SourceTopic, dead.ConsumerGroup, dead.EventType,
dead.BizType, dead.BizID, dead.Envelope, dead.RetryCount, truncateKafkaString(dead.LastReason, 64),
truncateKafkaString(dead.LastError, 1000), t.KafkaDeadStatusDead, dead.ReplayCount, now, now); err != nil {
return err
}
if _, err = tx.Exec(`UPDATE im_kafka_consume_log SET status=?,dead_at=?,processing_deadline=NULL,last_reason=?,
last_error=?,updated_at=? WHERE event_id=? AND consumer_group=?`,
t.KafkaConsumeDead, now, truncateKafkaString(dead.LastReason, 64), truncateKafkaString(dead.LastError, 1000),
now, dead.EventID, dead.ConsumerGroup); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantKafkaDeadLetterList(id t.TenantID, opts t.KafkaDeadLetterListOptions) (*t.KafkaDeadLetterPage, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantKafkaDeadLetterList(id, opts)
}
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
where := []string{"1=1"}
args := []any{}
if opts.Status != "" {
where = append(where, "status=?")
args = append(args, opts.Status)
}
if opts.BizType != "" {
where = append(where, "biz_type=?")
args = append(args, opts.BizType)
}
if opts.BizID != "" {
where = append(where, "biz_id=?")
args = append(args, opts.BizID)
}
if opts.EventType != "" {
where = append(where, "event_type=?")
args = append(args, opts.EventType)
}
if opts.Cursor != "" {
where = append(where, "id<?")
args = append(args, opts.Cursor)
}
args = append(args, limit+1)
rows, err := a.db.Queryx(`SELECT id,dead_id,event_id,parent_event_id,source_topic,consumer_group,event_type,biz_type,biz_id,
envelope,retry_count,last_reason,last_error,status,replay_count,created_at,updated_at
FROM im_kafka_dead_letters WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
page := &t.KafkaDeadLetterPage{Items: make([]t.KafkaDeadLetter, 0, limit)}
for rows.Next() {
dead, err := scanKafkaDeadLetter(rows)
if err != nil {
return nil, err
}
if len(page.Items) == limit {
page.NextCursor = strconv.FormatInt(dead.ID, 10)
continue
}
page.Items = append(page.Items, *dead)
}
return page, rows.Err()
}
func (a *adapter) TenantKafkaDeadLetterGet(id t.TenantID, deadID string) (*t.KafkaDeadLetter, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if strings.TrimSpace(deadID) == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaDeadLetterGet(id, deadID)
}
rows, err := a.db.Queryx(`SELECT id,dead_id,event_id,parent_event_id,source_topic,consumer_group,event_type,biz_type,biz_id,
envelope,retry_count,last_reason,last_error,status,replay_count,created_at,updated_at
FROM im_kafka_dead_letters WHERE dead_id=?`, strings.TrimSpace(deadID))
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanKafkaDeadLetter(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
return nil, t.ErrNotFound
}
func (a *adapter) TenantKafkaDeadLetterMark(id t.TenantID, deadID, status, resolvedBy, note string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
deadID = strings.TrimSpace(deadID)
status = strings.TrimSpace(status)
if deadID == "" || (status != t.KafkaDeadStatusIgnored && status != t.KafkaDeadStatusResolved && status != t.KafkaDeadStatusReplayed) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaDeadLetterMark(id, deadID, status, resolvedBy, note, now)
}
res, err := a.db.Exec(`UPDATE im_kafka_dead_letters SET status=?,resolved_by=?,resolved_at=?,resolve_note=?,updated_at=?
WHERE dead_id=? AND status IN (?,?,?)`, status, truncateKafkaString(resolvedBy, 64), now,
truncateKafkaString(note, 1000), now, deadID, t.KafkaDeadStatusDead, t.KafkaDeadStatusPendingReplay, t.KafkaDeadStatusReplayed)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolvedBy, note string, now time.Time) (*t.KafkaDeadLetter, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
deadID = strings.TrimSpace(deadID)
if deadID == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaDeadLetterMarkReplay(id, deadID, resolvedBy, note, now)
}
tx, err := a.db.BeginTxx(context.Background(), nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var row struct {
EventID string `db:"event_id"`
ConsumerGroup string `db:"consumer_group"`
}
if err = tx.Get(&row, `SELECT event_id,consumer_group FROM im_kafka_dead_letters WHERE dead_id=? FOR UPDATE`, deadID); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
res, err := tx.Exec(`UPDATE im_kafka_dead_letters SET status=?,replay_count=replay_count+1,resolved_by=?,
resolved_at=?,resolve_note=?,updated_at=? WHERE dead_id=? AND status IN (?,?)`,
t.KafkaDeadStatusPendingReplay, truncateKafkaString(resolvedBy, 64), now, truncateKafkaString(note, 1000),
now, deadID, t.KafkaDeadStatusDead, t.KafkaDeadStatusPendingReplay)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrNotFound
}
if _, err = tx.Exec(`DELETE FROM im_kafka_consume_log WHERE event_id=? AND consumer_group=?`,
row.EventID, row.ConsumerGroup); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantKafkaDeadLetterGet(id, deadID)
}
func (a *adapter) TenantKafkaOutboxCreate(id t.TenantID, record *t.KafkaOutboxRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.EventID == "" || record.Topic == "" || record.EventType == "" || len(record.Envelope) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxCreate(id, record)
}
now := t.TimeNow()
_, err = a.db.Exec(`INSERT INTO im_kafka_outbox
(event_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,next_retry_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
record.EventID, record.Topic, record.EventType, record.BizType, record.BizID, record.Key,
record.Envelope, t.KafkaOutboxPending, record.NextRetryAt, now, now)
return err
}
func (a *adapter) TenantKafkaOutboxListPending(id t.TenantID, limit int, now time.Time) ([]t.KafkaOutboxRecord, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 100
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxListPending(id, limit, now)
}
rows := []struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
Topic string `db:"topic"`
EventType string `db:"event_type"`
BizType string `db:"biz_type"`
BizID string `db:"biz_id"`
Key string `db:"key_value"`
Envelope []byte `db:"envelope"`
Status string `db:"status"`
Attempts int `db:"attempts"`
NextRetryAt sql.NullTime `db:"next_retry_at"`
SentAt sql.NullTime `db:"sent_at"`
LastReason string `db:"last_reason"`
LastError string `db:"last_error"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err = a.db.Select(&rows, `SELECT id,event_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,attempts,
next_retry_at,sent_at,last_reason,last_error,created_at,updated_at
FROM im_kafka_outbox
WHERE status IN (?,?) AND (next_retry_at IS NULL OR next_retry_at<=?)
ORDER BY id ASC LIMIT ?`, t.KafkaOutboxPending, t.KafkaOutboxFailed, now, limit)
if err != nil {
return nil, err
}
out := make([]t.KafkaOutboxRecord, 0, len(rows))
for _, row := range rows {
rec := t.KafkaOutboxRecord{
ID: row.ID,
EventID: row.EventID,
Topic: row.Topic,
EventType: row.EventType,
BizType: row.BizType,
BizID: row.BizID,
Key: row.Key,
Envelope: row.Envelope,
Status: row.Status,
Attempts: row.Attempts,
LastReason: row.LastReason,
LastError: row.LastError,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.NextRetryAt.Valid {
rec.NextRetryAt = &row.NextRetryAt.Time
}
if row.SentAt.Valid {
rec.SentAt = &row.SentAt.Time
}
out = append(out, rec)
}
return out, nil
}
func (a *adapter) TenantKafkaOutboxMarkSent(id t.TenantID, eventID string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxMarkSent(id, eventID, now)
}
res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,sent_at=?,attempts=attempts+1,last_reason='',
last_error='',updated_at=? WHERE event_id=? AND status<>?`,
t.KafkaOutboxSent, now, now, eventID, t.KafkaOutboxSent)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantKafkaOutboxMarkFailed(id t.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantKafkaOutboxMarkFailed(id, eventID, reason, message, nextRetryAt, now)
}
res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,attempts=attempts+1,next_retry_at=?,
last_reason=?,last_error=?,updated_at=? WHERE event_id=? AND status<>?`,
t.KafkaOutboxFailed, nextRetryAt, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000),
now, eventID, t.KafkaOutboxSent)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
var row struct {
ID int64 `db:"id"`
DeadID string `db:"dead_id"`
EventID string `db:"event_id"`
ParentEventID string `db:"parent_event_id"`
SourceTopic string `db:"source_topic"`
ConsumerGroup string `db:"consumer_group"`
EventType string `db:"event_type"`
BizType string `db:"biz_type"`
BizID string `db:"biz_id"`
Envelope []byte `db:"envelope"`
RetryCount int `db:"retry_count"`
LastReason string `db:"last_reason"`
LastError string `db:"last_error"`
Status string `db:"status"`
ReplayCount int `db:"replay_count"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
if err := rows.StructScan(&row); err != nil {
return nil, err
}
return &t.KafkaDeadLetter{
ID: row.ID,
DeadID: row.DeadID,
EventID: row.EventID,
ParentEventID: row.ParentEventID,
SourceTopic: row.SourceTopic,
ConsumerGroup: row.ConsumerGroup,
EventType: row.EventType,
BizType: row.BizType,
BizID: row.BizID,
Envelope: row.Envelope,
RetryCount: row.RetryCount,
LastReason: row.LastReason,
LastError: row.LastError,
Status: row.Status,
ReplayCount: row.ReplayCount,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) kafkaConsumeGet(eventID, consumerGroup string) (*t.KafkaConsumeRecord, error) {
row := struct {
EventID string `db:"event_id"`
ConsumerGroup string `db:"consumer_group"`
SourceTopic string `db:"source_topic"`
EventType string `db:"event_type"`
BizType string `db:"biz_type"`
BizID string `db:"biz_id"`
Status string `db:"status"`
RetryCount int `db:"retry_count"`
LastReason string `db:"last_reason"`
LastError string `db:"last_error"`
ProcessingDeadline *time.Time `db:"processing_deadline"`
SucceededAt *time.Time `db:"succeeded_at"`
DeadAt *time.Time `db:"dead_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.Get(&row, `SELECT event_id,consumer_group,source_topic,event_type,biz_type,biz_id,status,retry_count,
last_reason,last_error,processing_deadline,succeeded_at,dead_at,created_at,updated_at
FROM im_kafka_consume_log WHERE event_id=? AND consumer_group=?`, eventID, consumerGroup)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &t.KafkaConsumeRecord{EventID: row.EventID, ConsumerGroup: row.ConsumerGroup, SourceTopic: row.SourceTopic,
EventType: row.EventType, BizType: row.BizType, BizID: row.BizID, Status: row.Status, RetryCount: row.RetryCount,
LastReason: row.LastReason, LastError: row.LastError, ProcessingDeadline: row.ProcessingDeadline,
SucceededAt: row.SucceededAt, DeadAt: row.DeadAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func truncateKafkaString(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error { func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error {
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
if err != nil { if err != nil {
@@ -0,0 +1,126 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"encoding/json"
"strings"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) TenantInternalAdminQuery(id t.TenantID, query string, countQuery string, args []any, page, perPage int) (*t.InternalAdminQueryResult, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantInternalAdminQuery(id, query, countQuery, args, page, perPage)
}
if strings.TrimSpace(query) == "" || strings.TrimSpace(countQuery) == "" {
return nil, t.ErrMalformed
}
if page <= 0 {
page = 1
}
if perPage <= 0 || perPage > 200 {
perPage = 20
}
offset := (page - 1) * perPage
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var total int64
if err = a.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, err
}
queryArgs := append(append([]any{}, args...), perPage, offset)
rows, err := a.db.QueryxContext(ctx, query+" LIMIT ? OFFSET ?", queryArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanInternalAdminRows(rows)
if err != nil {
return nil, err
}
return &t.InternalAdminQueryResult{Items: items, Total: total, Page: page, PerPage: perPage}, nil
}
func (a *adapter) TenantInternalAdminQueryOne(id t.TenantID, query string, args []any) (map[string]any, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantInternalAdminQueryOne(id, query, args)
}
if strings.TrimSpace(query) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query+" LIMIT 1", args...)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanInternalAdminRows(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, t.ErrNotFound
}
return items[0], nil
}
func scanInternalAdminRows(rows *sqlx.Rows) ([]map[string]any, error) {
items := []map[string]any{}
for rows.Next() {
row := map[string]any{}
if err := rows.MapScan(row); err != nil {
return nil, err
}
for key, value := range row {
row[key] = normalizeInternalAdminValue(value)
}
items = append(items, row)
}
return items, rows.Err()
}
func normalizeInternalAdminValue(value any) any {
switch v := value.(type) {
case nil:
return nil
case []byte:
raw := strings.TrimSpace(string(v))
if raw == "" {
return ""
}
if (strings.HasPrefix(raw, "{") && strings.HasSuffix(raw, "}")) || (strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]")) {
var parsed any
if json.Unmarshal([]byte(raw), &parsed) == nil {
return parsed
}
}
return raw
case time.Time:
return v.UTC().Format(time.RFC3339Nano)
case sql.NullTime:
if v.Valid {
return v.Time.UTC().Format(time.RFC3339Nano)
}
return nil
default:
return value
}
}
+1 -1
View File
@@ -1201,7 +1201,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
} }
var got struct { var got struct {
TouchedAt time.Time TouchedAt time.Time
SeqId int SeqId int64
} }
err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id). err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId) Scan(&got.TouchedAt, &got.SeqId)
+1 -1
View File
@@ -830,7 +830,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
} }
var got struct { var got struct {
TouchedAt time.Time TouchedAt time.Time
SeqId int SeqId int64
} }
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id). err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId) Scan(&got.TouchedAt, &got.SeqId)
+1 -1
View File
@@ -844,7 +844,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
var got struct { var got struct {
TouchedAt time.Time TouchedAt time.Time
SeqId int SeqId int64
} }
if err = cursor.One(&got); err != nil { if err = cursor.One(&got); err != nil {
t.Fatal(err) t.Fatal(err)
+13
View File
@@ -10,6 +10,7 @@
package main package main
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -349,6 +350,9 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return return
} }
if err = enqueueKafkaFileUploaded(req.Context(), principal.TenantID, fdef); err != nil {
logs.Warn.Printf("media upload: failed to enqueue file lifecycle event file=%s: %v", fdef.Id, err)
}
params := map[string]string{"url": url} params := map[string]string{"url": url}
if globals.mediaGcPeriod > 0 { if globals.mediaGcPeriod > 0 {
@@ -628,6 +632,15 @@ func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer)
Size: size, Size: size,
}, },
}) })
if err == nil {
if fdef, finishErr := store.Files.FinishUpload(principal.TenantID, fdef, true, size); finishErr == nil {
if lifecycleErr := enqueueKafkaFileUploaded(context.Background(), principal.TenantID, fdef); lifecycleErr != nil {
logs.Warn.Printf("media upload: failed to enqueue grpc file lifecycle event file=%s: %v", fdef.Id, lifecycleErr)
}
} else {
err = finishErr
}
}
logs.Info.Println("media upload: ok", fdef.Id, fdef.Location, err) logs.Info.Println("media upload: ok", fdef.Id, fdef.Location, err)
return err return err
} }
+3 -3
View File
@@ -300,7 +300,7 @@ func cacheControlHandler(maxAge int, handler http.Handler) http.Handler {
// Get API key from an HTTP request. // Get API key from an HTTP request.
func getAPIKey(req *http.Request) string { func getAPIKey(req *http.Request) string {
// Check header. // Check header.
apikey := req.Header.Get("X-Tinode-APIKey") apikey := req.Header.Get("X-MeetingIM-APIKey")
if apikey != "" { if apikey != "" {
return apikey return apikey
} }
@@ -328,8 +328,8 @@ func getAPIKey(req *http.Request) string {
// Extracts authorization credentials from an HTTP request. // Extracts authorization credentials from an HTTP request.
// Returns authentication method and secret. // Returns authentication method and secret.
func getHttpAuth(req *http.Request) (method, secret string) { func getHttpAuth(req *http.Request) (method, secret string) {
// Check X-Tinode-Auth header. // Check MeetingIM product auth header.
if parts := strings.Split(req.Header.Get("X-Tinode-Auth"), " "); len(parts) == 2 { if parts := strings.Split(req.Header.Get("X-MeetingIM-Auth"), " "); len(parts) == 2 {
method, secret = parts[0], parts[1] method, secret = parts[0], parts[1]
return return
} }
+28
View File
@@ -149,6 +149,13 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt, TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt,
Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload}, Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload},
} }
if deliverIMBusinessEventToUserSessions(tenantID, event, message) {
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
continue
}
select { select {
case globals.hub.routeSrv <- message: case globals.hub.routeSrv <- message:
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil { if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
@@ -192,3 +199,24 @@ func startIMExternalBusinessEventPoller() func() {
}() }()
return func() { close(stop) } return func() { close(stop) }
} }
func deliverIMBusinessEventToUserSessions(tenantID types.TenantID, event types.BusinessEvent, message *ServerComMessage) bool {
if event.TargetType != "user" || globals.sessionStore == nil || message == nil {
return false
}
uid := types.ParseUserId(event.Target)
if uid.IsZero() {
return false
}
delivered := false
globals.sessionStore.Range(func(_ string, session *Session) bool {
if session == nil || session.uid != uid || session.tenantID != tenantID {
return true
}
if session.queueOut(message.copy()) {
delivered = true
}
return true
})
return delivered
}
+43
View File
@@ -75,3 +75,46 @@ func TestDispatchRegistrationInviteUsageEvent(t *testing.T) {
t.Fatal("invite usage event was routed as a regular client event") t.Fatal("invite usage event was routed as a regular client event")
} }
} }
func TestDispatchUserBusinessEventToOnlineSession(t *testing.T) {
oldInfrastructure := store.HTTPInfrastructure
oldHub, oldSessions := globals.hub, globals.sessionStore
defer func() {
store.HTTPInfrastructure = oldInfrastructure
globals.hub, globals.sessionStore = oldHub, oldSessions
}()
uid := types.ParseUserId("usrv97xq86jw3c")
infrastructure := &passwordResetEventStore{}
infrastructure.events = []types.BusinessEvent{{
EventID: "friend-request-1", EventType: "friend.request.created",
TargetType: "user", Target: uid.UserId(), Payload: []byte(`{"request_id":"frq_1","user_id":"usrSrc"}`),
CreatedAt: time.Now().UTC(),
}}
store.HTTPInfrastructure = infrastructure
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
globals.sessionStore = NewSessionStore(time.Minute)
session := &Session{sid: "s1", proto: WEBSOCK, uid: uid, tenantID: types.TenantID(1), send: make(chan any, 1)}
globals.sessionStore.sessCache[session.sid] = session
dispatchIMBusinessEvents(types.TenantID(1))
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "friend-request-1" {
t.Fatalf("business event was not marked delivered: %v", infrastructure.delivered)
}
if len(globals.hub.routeSrv) != 0 {
t.Fatal("online user event should be delivered directly to session without topic routing")
}
select {
case raw := <-session.send:
msg, ok := raw.(*ServerComMessage)
if !ok || msg.Info == nil {
t.Fatalf("unexpected outbound message: %#v", raw)
}
if msg.Info.What != "im" || msg.Info.Event != "friend.request.created" || msg.Info.Topic != uid.UserId() {
t.Fatalf("unexpected info event: %#v", msg.Info)
}
default:
t.Fatal("online user session did not receive business info event")
}
}
+865 -48
View File
File diff suppressed because it is too large Load Diff
+586 -24
View File
@@ -40,6 +40,7 @@ type testVerificationStore struct {
type testHTTPInfrastructureStore struct { type testHTTPInfrastructureStore struct {
records map[string]*types.HTTPIdempotencyRecord records map[string]*types.HTTPIdempotencyRecord
events []types.BusinessEvent events []types.BusinessEvent
outbox []types.KafkaOutboxRecord
} }
type testTenantEntryStore struct { type testTenantEntryStore struct {
@@ -147,6 +148,57 @@ func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ str
return nil return nil
} }
func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record *types.KafkaConsumeRecord, _ time.Time) (*types.KafkaConsumeRecord, bool, error) {
copy := *record
copy.Status = types.KafkaConsumeProcessing
return &copy, true, nil
}
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeSucceeded(_ types.TenantID, _, _ string, _ time.Time) error {
return nil
}
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeFailed(_ types.TenantID, eventID, group, reason, message string, _ time.Time) (*types.KafkaConsumeRecord, error) {
return &types.KafkaConsumeRecord{EventID: eventID, ConsumerGroup: group, Status: types.KafkaConsumeFailed, RetryCount: 1, LastReason: reason, LastError: message}, nil
}
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeDead(_ types.TenantID, _ *types.KafkaDeadLetter, _ time.Time) error {
return nil
}
func (s *testHTTPInfrastructureStore) ListKafkaDeadLetters(_ types.TenantID, _ types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) {
return &types.KafkaDeadLetterPage{}, nil
}
func (s *testHTTPInfrastructureStore) GetKafkaDeadLetter(_ types.TenantID, _ string) (*types.KafkaDeadLetter, error) {
return nil, types.ErrNotFound
}
func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetter(_ types.TenantID, _, _, _, _ string, _ time.Time) error {
return nil
}
func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID, deadID, _, _ string, _ time.Time) (*types.KafkaDeadLetter, error) {
return &types.KafkaDeadLetter{DeadID: deadID, Status: types.KafkaDeadStatusPendingReplay}, nil
}
func (s *testHTTPInfrastructureStore) CreateKafkaOutbox(_ types.TenantID, record *types.KafkaOutboxRecord) error {
s.outbox = append(s.outbox, *record)
return nil
}
func (s *testHTTPInfrastructureStore) ListPendingKafkaOutbox(_ types.TenantID, _ int, _ time.Time) ([]types.KafkaOutboxRecord, error) {
return append([]types.KafkaOutboxRecord(nil), s.outbox...), nil
}
func (s *testHTTPInfrastructureStore) MarkKafkaOutboxSent(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
func (s *testHTTPInfrastructureStore) MarkKafkaOutboxFailed(_ types.TenantID, _, _, _ string, _, _ time.Time) error {
return nil
}
type testRelationsStore struct { type testRelationsStore struct {
context *types.RelationshipContext context *types.RelationshipContext
created *types.FriendRequest created *types.FriendRequest
@@ -211,6 +263,9 @@ type testOrganizationStore struct {
type testMeetingsStore struct { type testMeetingsStore struct {
meeting *types.Meeting meeting *types.Meeting
list []types.Meeting
listCursor string
listLimit int
attendeeIDs []types.Uid attendeeIDs []types.Uid
created *types.Meeting created *types.Meeting
response string response string
@@ -224,6 +279,14 @@ type testDailyReportsStore struct {
remindKeys string remindKeys string
} }
type testAttendanceStore struct {
config *types.AttendanceConfig
records []types.AttendanceRecord
checkinRequest types.AttendanceCheckinRequest
appeal *types.AttendanceAppeal
idempotencyKey string
}
type testMonthlyReportsStore struct { type testMonthlyReportsStore struct {
report *types.MonthlyReport report *types.MonthlyReport
saved bool saved bool
@@ -252,6 +315,7 @@ type testDevicePushTokenStore struct {
type testSafetyStore struct { type testSafetyStore struct {
riskChallenge *types.RiskChallenge riskChallenge *types.RiskChallenge
riskErr error
reasons []types.AbuseReason reasons []types.AbuseReason
abuseReport *types.AbuseReport abuseReport *types.AbuseReport
feedback *types.FeedbackSubmission feedback *types.FeedbackSubmission
@@ -552,6 +616,12 @@ func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *typ
return meeting, nil return meeting, nil
} }
func (s *testMeetingsStore) List(_ types.TenantID, _ types.Uid, cursor string, limit int) ([]types.Meeting, string, error) {
s.listCursor = cursor
s.listLimit = limit
return s.list, "next-1", nil
}
func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) { func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) {
return s.meeting, nil return s.meeting, nil
} }
@@ -633,6 +703,78 @@ func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Tim
return userIDs, nil return userIDs, nil
} }
func (s *testAttendanceStore) Config(_ types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) {
if s.config != nil {
return s.config, nil
}
now := time.Now().UTC().Round(time.Millisecond)
return &types.AttendanceConfig{
Date: date,
Timezone: "Asia/Shanghai",
Rule: types.AttendanceRule{
RuleID: "att_rule_default",
Name: "默认考勤规则",
Workday: true,
AllowOutsideCheckin: true,
AppealAllowed: true,
CreatedAt: now,
UpdatedAt: now,
},
Shifts: []types.AttendanceShift{{
ShiftID: "shift_default", RuleID: "att_rule_default", Name: "默认班次",
WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00",
CreatedAt: now, UpdatedAt: now,
}},
AllowedActions: []string{types.AttendanceActionCheckIn, types.AttendanceActionCheckOut},
TodayRecord: &types.AttendanceRecord{
RecordID: "evt_record", UserID: uid, Date: date, RuleID: "att_rule_default", ShiftID: "shift_default",
Status: types.AttendanceStatusNotChecked, Version: 1, CreatedAt: now, UpdatedAt: now,
},
CanViewManaged: true,
}, nil
}
func (s *testAttendanceStore) ListRecords(_ types.TenantID, _ types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) {
return s.records, opts.Cursor, &types.AttendanceRecordStats{ExpectedCount: 1, CheckedCount: len(s.records)}, nil
}
func (s *testAttendanceStore) Checkin(_ types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) {
s.checkinRequest = req
now := time.Now().UTC().Round(time.Millisecond)
record := &types.AttendanceRecord{
RecordID: "evt_record", UserID: uid, Date: req.Date, RuleID: "att_rule_default", ShiftID: "shift_default",
Status: types.AttendanceStatusCheckedIn, Version: 2, CreatedAt: now, UpdatedAt: now,
}
checkin := &types.AttendanceCheckin{
CheckinID: checkinID, RecordID: record.RecordID, UserID: uid, Date: req.Date, Action: req.Action,
RuleID: record.RuleID, ShiftID: record.ShiftID, ClientTime: req.ClientTime, ServerTime: now,
Result: types.AttendanceCheckinNormal, Location: req.Location, Wifi: req.Wifi, Device: req.Device,
IdempotencyKey: req.IdempotencyKey, CreatedAt: now,
}
record.Checkins = []types.AttendanceCheckin{*checkin}
return record, checkin, nil
}
func (s *testAttendanceStore) CreateAppeal(_ types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) {
s.appeal = appeal
s.idempotencyKey = idempotencyKey
now := time.Now().UTC().Round(time.Millisecond)
appeal.UserID = uid
appeal.Status = types.AttendanceAppealPending
appeal.Version = 1
appeal.SubmittedAt = now
appeal.CreatedAt = now
appeal.UpdatedAt = now
return appeal, nil
}
func (s *testAttendanceStore) ListAppeals(_ types.TenantID, _ types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) {
if s.appeal == nil {
return nil, opts.Cursor, nil
}
return []types.AttendanceAppeal{*s.appeal}, opts.Cursor, nil
}
func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) { func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) {
if s.report == nil { if s.report == nil {
return nil, "", nil return nil, "", nil
@@ -795,6 +937,9 @@ func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge
} }
func (s *testSafetyStore) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) { func (s *testSafetyStore) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) {
if s.riskErr != nil {
return nil, s.riskErr
}
s.tenantID = tenantID s.tenantID = tenantID
s.uid = uid s.uid = uid
s.decision = decision s.decision = decision
@@ -861,11 +1006,11 @@ func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error {
return nil return nil
} }
func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int, _ time.Time) error { func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int64, _ time.Time) error {
return nil return nil
} }
func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ int64, _ time.Time) error { func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int64, _ string, _ int64, _ time.Time) error {
return nil return nil
} }
@@ -885,6 +1030,7 @@ func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid
} }
func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) { func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) {
s.topic = topic
if s.settings == nil { if s.settings == nil {
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now} s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now}
@@ -1160,7 +1306,7 @@ func TestIMHTTPProtocolHeaders(t *testing.T) {
} }
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey) req.Header.Set("X-MeetingIM-APIKey", apiKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported { if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported {
@@ -1168,7 +1314,7 @@ func TestIMHTTPProtocolHeaders(t *testing.T) {
} }
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey) req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "request-1") req.Header.Set("X-Request-ID", "request-1")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@@ -1176,6 +1322,29 @@ func TestIMHTTPProtocolHeaders(t *testing.T) {
if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" { if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" {
t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID")) t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID"))
} }
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || called != 2 {
t.Fatalf("legacy protocol request: status=%d called=%d", rec.Code, called)
}
}
func TestMeetingIMHTTPAuthHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v0/file/s/file1", nil)
req.Header.Set("X-MeetingIM-APIKey", "new-api-key")
req.Header.Set("X-MeetingIM-Auth", "token new-token")
if got := getAPIKey(req); got != "new-api-key" {
t.Fatalf("getAPIKey new header=%q", got)
}
method, secret := getHttpAuth(req)
if method != "token" || secret != "new-token" {
t.Fatalf("getHttpAuth new header method=%q secret=%q", method, secret)
}
} }
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) { func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
@@ -1197,7 +1366,7 @@ func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
request := func(body string) *httptest.ResponseRecorder { request := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("X-Tinode-APIKey", apiKey) req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Idempotency-Key", "same-key") req.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1232,7 +1401,7 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
globals.servingAt = "https://im.example.com/" globals.servingAt = "https://im.example.com/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", " acme ") req.Header.Set("X-Tenant-Code", " acme ")
req.Header.Set("X-Request-ID", "req-1") req.Header.Set("X-Request-ID", "req-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1260,6 +1429,23 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
} }
} }
func TestIMHTTPProtocolDoesNotRequireIdempotencyForTenantResolve(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
writeIMSuccess(w, req, map[string]any{"ok": true})
})
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
withIMHTTPProtocol("/v1/im/", next).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) { func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
oldTenants := store.Tenants oldTenants := store.Tenants
oldServingAt := globals.servingAt oldServingAt := globals.servingAt
@@ -1279,7 +1465,7 @@ func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
req.Host = "internal:6060" req.Host = "internal:6060"
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.118im.com") req.Header.Set("X-Forwarded-Host", "api.118im.com")
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1310,7 +1496,7 @@ func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`)) bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1358,7 +1544,7 @@ func TestServeIMTenantResolveRequiresAPIKey(t *testing.T) {
func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) { func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`)) bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1384,7 +1570,7 @@ func TestServeIMTenantResolveHidesUnavailableTenantState(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`)) bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1405,7 +1591,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`)) bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req) serveIMTenantResolve(rec, req)
@@ -1556,7 +1742,7 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1606,7 +1792,7 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1646,7 +1832,7 @@ func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`)) bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1685,7 +1871,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"reset_password","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"reset_password","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1703,7 +1889,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) { func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req) serveIMVerificationCodes(rec, req)
@@ -1742,7 +1928,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1785,7 +1971,7 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`)) bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -1830,7 +2016,7 @@ func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"654321"}`)) bytes.NewBufferString(`{"code":"654321"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme") req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -2257,6 +2443,82 @@ func TestServeIMOrganizationSearch(t *testing.T) {
} }
} }
func TestServeIMAttendanceConfigAndRecords(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldAttendance := store.Attendance
now := time.Now().UTC().Round(time.Millisecond)
attendance := &testAttendanceStore{records: []types.AttendanceRecord{{
RecordID: "evt_record", UserID: 42, Date: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC),
RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusCheckedIn,
Version: 2, CreatedAt: now, UpdatedAt: now,
}}}
store.Attendance = attendance
defer func() { store.Attendance = oldAttendance }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/attendance/config?date=2026-08-31", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMAttendanceConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["date"] != "2026-08-31" || data["timezone"] != "Asia/Shanghai" {
t.Fatalf("unexpected config: %#v", data)
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMAttendanceRecords(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("records status=%d body=%s", rec.Code, rec.Body.String())
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["record_id"] != "evt_record" {
t.Fatalf("unexpected records: %#v", data)
}
}
func TestServeIMAttendanceCheckinAndAppeal(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldAttendance := store.Attendance
attendance := &testAttendanceStore{}
store.Attendance = attendance
defer func() { store.Attendance = oldAttendance }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/attendance/checkins", strings.NewReader(`{"date":"2026-08-31","action":"check_in","client_time":"2026-08-31T01:00:00Z","location":{"name":"总部"}}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "att-1")
rec := httptest.NewRecorder()
serveIMAttendanceCheckins(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("checkin status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if attendance.checkinRequest.Action != types.AttendanceActionCheckIn || data["record"] == nil || data["checkin"] == nil {
t.Fatalf("unexpected checkin: data=%#v req=%#v", data, attendance.checkinRequest)
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/attendance/appeals", strings.NewReader(`{"record_id":"evt_record","appeal_type":"late","reason":"地铁延误","target_action":"check_in","target_time":"2026-08-31T00:58:00Z"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "appeal-1")
rec = httptest.NewRecorder()
serveIMAttendanceAppeals(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("appeal status=%d body=%s", rec.Code, rec.Body.String())
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if attendance.idempotencyKey != "appeal-1" || data["status"] != types.AttendanceAppealPending {
t.Fatalf("unexpected appeal: data=%#v key=%q", data, attendance.idempotencyKey)
}
}
func TestServeIMMeetingsCreate(t *testing.T) { func TestServeIMMeetingsCreate(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -2285,6 +2547,66 @@ func TestServeIMMeetingsCreate(t *testing.T) {
} }
} }
func TestServeIMMeetingsList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
viewer := types.Uid(42)
expectIMBearer(t, ctrl, "token-1", 7, viewer)
oldMeetings := store.Meetings
oldUsers := store.Users
now := time.Now().UTC().Round(time.Millisecond)
meetings := &testMeetingsStore{list: []types.Meeting{{
MeetingID: "mtg_test",
OrganizerID: viewer,
Title: "周会",
StartAt: now.Add(time.Hour),
EndAt: now.Add(2 * time.Hour),
Location: "A101",
State: types.MeetingStateScheduled,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
Participants: []types.MeetingParticipant{
{MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted},
},
}}}
store.Meetings = meetings
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
organizer := types.User{Public: map[string]any{"fn": "mzb"}}
organizer.SetUid(viewer)
users.EXPECT().GetAll(types.TenantID(7), viewer).Return([]types.User{organizer}, nil)
store.Users = users
defer func() {
store.Meetings = oldMeetings
store.Users = oldUsers
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings?limit=20&cursor=c1", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMeetings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || meetings.listCursor != "c1" || meetings.listLimit != 20 {
t.Fatalf("unexpected list response: data=%#v cursor=%q limit=%d", data, meetings.listCursor, meetings.listLimit)
}
if items[0].(map[string]any)["meeting_id"] != "mtg_test" || data["has_more"] != true {
t.Fatalf("unexpected meeting payload: %#v", data)
}
item := items[0].(map[string]any)
if item["organizer_display_name"] != "mzb" || item["participant_count"].(float64) != 1 {
t.Fatalf("missing meeting display fields: %#v", item)
}
participants := item["participants"].([]any)
if participants[0].(map[string]any)["display_name"] != "mzb" {
t.Fatalf("missing participant display name: %#v", participants)
}
}
func TestServeIMMeetingMyResponse(t *testing.T) { func TestServeIMMeetingMyResponse(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -2347,6 +2669,42 @@ func TestServeIMDailyReportsCreateAndList(t *testing.T) {
} }
} }
func TestServeIMDailyReportsListIncludesContentPreview(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldDailyReports := store.DailyReports
now := time.Now().UTC().Round(time.Millisecond)
reports := &testDailyReportsStore{report: &types.DailyReport{
ReportID: "dr_test",
AuthorID: 42,
ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC),
Content: strings.Repeat("日报", 80),
Status: types.DailyReportStatusDraft,
ReadState: "unread",
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}}
store.DailyReports = reports
defer func() { store.DailyReports = oldDailyReports }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily?scope=mine", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMDailyReports(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
item := items[0].(map[string]any)
if item["content_short"] != strings.Repeat("日报", 60)+"..." {
t.Fatalf("unexpected daily report preview: %#v", item["content_short"])
}
}
func TestServeIMDailyReportDraftAndSubmit(t *testing.T) { func TestServeIMDailyReportDraftAndSubmit(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -2441,6 +2799,46 @@ func TestServeIMMonthlyReportsCreate(t *testing.T) {
} }
} }
func TestServeIMMonthlyReportsListIncludesPreviews(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMonthlyReports := store.MonthlyReports
now := time.Now().UTC().Round(time.Millisecond)
reports := &testMonthlyReportsStore{report: &types.MonthlyReport{
ReportID: "mr_test",
AuthorID: 42,
Month: "2026-08",
WorkSummary: strings.Repeat("工作", 80),
NextPlan: " 下月计划一 \n 下月计划二 ",
Status: types.DailyReportStatusDraft,
ReadState: "unread",
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}}
store.MonthlyReports = reports
defer func() { store.MonthlyReports = oldMonthlyReports }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly?scope=mine", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMonthlyReports(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
item := items[0].(map[string]any)
if item["work_summary_short"] != strings.Repeat("工作", 60)+"..." {
t.Fatalf("unexpected work summary preview: %#v", item["work_summary_short"])
}
if item["next_plan_short"] != "下月计划一 下月计划二" {
t.Fatalf("unexpected next plan preview: %#v", item["next_plan_short"])
}
}
func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) { func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -3770,7 +4168,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
store.Messages = oldMessages store.Messages = oldMessages
}) })
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", 3).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7, TenantID: 7,
Topic: "grpAlpha", Topic: "grpAlpha",
SeqId: 3, SeqId: 3,
@@ -3911,6 +4309,83 @@ func TestServeIMFavoriteForwardRejectsUnauthorizedTarget(t *testing.T) {
} }
} }
func TestServeIMMessageForwardSeparateSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("mid-1")
oldSubs := store.Subs
oldMessages := store.Messages
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
t.Cleanup(func() {
store.Subs = oldSubs
store.Messages = oldMessages
})
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpTarget",
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpSource",
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(3)).Return(&types.Message{
TenantID: 7,
Topic: "grpSource",
SeqId: 3,
From: types.Uid(99).String(),
Head: types.KVMap{"x-im-type": "text"},
Content: map[string]any{"text": "hello"},
ObjHeader: types.ObjHeader{
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
},
}, nil)
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn(
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
if msg.Topic != "grpTarget" || msg.From != types.Uid(42).String() {
t.Fatalf("unexpected forwarded message: %#v", msg)
}
if msg.Head["x-im-forwarded"] != true || msg.Head["x-im-client-mid"] != "fwd_mid-1" {
t.Fatalf("forward metadata not set: %#v", msg.Head)
}
if msg.Content.(map[string]any)["text"] != "hello" {
t.Fatalf("unexpected content: %#v", msg.Content)
}
return &types.MessageSaveResult{SeqId: 21, MarkedReadBySender: true}, nil
})
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":"3"}],"target_topics":["grpTarget"]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMessageForward(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 {
t.Fatalf("unexpected forward response: %#v", data)
}
got := items[0].(map[string]any)
if got["status"] != "sent" || got["seq"].(float64) != 21 || got["client_mid"] != "fwd_mid-1" {
t.Fatalf("unexpected forward item: %#v", got)
}
if failed := data["failed"].([]any); len(failed) != 0 {
t.Fatalf("unexpected failed items: %#v", failed)
}
}
func TestServeIMSecurityURLCheck(t *testing.T) { func TestServeIMSecurityURLCheck(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -3986,6 +4461,28 @@ func TestServeIMRiskChallengeConfirm(t *testing.T) {
} }
} }
func TestServeIMRiskChallengeConfirmNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSafety := store.Safety
store.Safety = &testSafetyStore{riskErr: types.ErrNotFound}
defer func() { store.Safety = oldSafety }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk_fake/confirm", bytes.NewBufferString(`{"decision":"cancel"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMRiskChallengeConfirm(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d want %d body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonResourceNotFound {
t.Fatalf("reason: got %q want %q", resp.Reason, imReasonResourceNotFound)
}
}
func TestServeIMAbuseReportsAndReasons(t *testing.T) { func TestServeIMAbuseReportsAndReasons(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -4307,7 +4804,7 @@ func TestServeIMMessageRecall(t *testing.T) {
ModeWant: types.ModeCFull, ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 9, SeqId: 9,
Topic: "grpRecall", Topic: "grpRecall",
@@ -4315,7 +4812,7 @@ func TestServeIMMessageRecall(t *testing.T) {
Version: 1, Version: 1,
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil) }, nil)
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{ messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), types.Uid(42), 1, gomock.Any()).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 9, SeqId: 9,
Topic: "grpRecall", Topic: "grpRecall",
@@ -4363,7 +4860,7 @@ func TestServeIMMessageRevision(t *testing.T) {
ModeWant: types.ModeCFull, ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", int64(3)).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 3, SeqId: 3,
Topic: "grpEdit", Topic: "grpEdit",
@@ -4373,7 +4870,7 @@ func TestServeIMMessageRevision(t *testing.T) {
Content: "old", Content: "old",
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil) }, nil)
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ messages.EXPECT().Edit(types.TenantID(7), "grpEdit", int64(3), types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 3, SeqId: 3,
Topic: "grpEdit", Topic: "grpEdit",
@@ -4431,6 +4928,43 @@ func TestServeIMConversationContext(t *testing.T) {
} }
} }
func TestServeIMConversationContextAcceptsUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
store.Relations = &testRelationsStore{}
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/context", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != topic || data["peer_user_id"] != peer.UserId() {
t.Fatalf("unexpected context response: %#v", data)
}
}
func TestNormalizeP2PConversationTopicParsesTinodeTopic(t *testing.T) {
user := types.Uid(1)
peer := types.Uid(2)
topic := user.P2PName(peer)
normalized, gotPeer := normalizeP2PConversationTopic(topic, user)
if normalized != topic || gotPeer != peer {
t.Fatalf("normalize p2p: got topic=%q peer=%s, want topic=%q peer=%s", normalized, gotPeer.UserId(), topic, peer.UserId())
}
}
func TestServeIMP2PConversation(t *testing.T) { func TestServeIMP2PConversation(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -4487,6 +5021,34 @@ func TestServeIMConversationSettings(t *testing.T) {
} }
} }
func TestServeIMConversationSettingsNormalizesUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldConversations := store.Conversations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
conversations := &testConversationsStore{}
store.Conversations = conversations
defer func() { store.Conversations = oldConversations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/settings", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if conversations.topic != topic {
t.Fatalf("settings topic: got %q, want %q", conversations.topic, topic)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != topic {
t.Fatalf("unexpected settings response: %#v", data)
}
}
func TestServeIMTopicDraftPutAndGet(t *testing.T) { func TestServeIMTopicDraftPutAndGet(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+15
View File
@@ -0,0 +1,15 @@
package main
import "testing"
func TestInternalAdminDBIDKeepsNumericID(t *testing.T) {
const rawID int64 = 1559676214946304000
id, ok := parseInternalAdminDBID("1559676214946304000")
if !ok {
t.Fatal("numeric admin id was rejected")
}
if id != rawID {
t.Fatalf("numeric admin id = %d, want %d", id, rawID)
}
}
+413
View File
@@ -0,0 +1,413 @@
package main
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type internalQueryFilter struct {
Param string
Column string
Op string
Kind string
}
type internalQuerySpec struct {
Select string
From string
DefaultSort string
Filters []internalQueryFilter
DetailKey string
ScopeColumn string
}
var internalGroupResourceSpecs = map[string]*internalQuerySpec{
"join-requests": {
Select: "SELECT id,request_id,topic,applicant_user_id,inviter_user_id,source,message,status,reject_reason,handled_by,handled_at,expires_at,version,created_at,updated_at",
From: "FROM im_group_join_requests", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"applicant_user_id", "applicant_user_id", "=", "uid"}},
},
"invitations": {
Select: "SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,expires_at,handled_at,created_at,updated_at",
From: "FROM im_group_invitations", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"target_user_id", "target_user_id", "=", "uid"}, {"inviter_user_id", "inviter_user_id", "=", "uid"}},
},
"announcements": {
Select: "SELECT id,announcement_id,topic,author_user_id,content,status,version,published_at,deleted_by,deleted_at,created_at,updated_at",
From: "FROM im_group_announcements", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}},
},
"mutes": {
Select: "SELECT id,topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at",
From: "FROM im_group_mutes", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"user_id", "user_id", "=", "uid"}},
},
"admin-logs": {
Select: "SELECT id,log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key,created_at",
From: "FROM im_group_admin_logs", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic",
Filters: []internalQueryFilter{{"action", "action", "=", "string"}, {"operator_id", "operator_id", "=", "uid"}},
},
"files": {
Select: "SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,created_at,updated_at",
From: "FROM im_group_files", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"file_type", "file_type", "=", "string"}, {"file_name", "file_name", "LIKE", "string"}, {"uploader_id", "uploader_id", "=", "uid"}},
},
}
var internalTenantResourceSpecs = map[string]*internalQuerySpec{
"audit-logs": {
Select: "SELECT id,request_id,actor_user_id,action,target_type,target_id,ip,user_agent,detail,created_at",
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"actor_user_id", "actor_user_id", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"realname-verifications": {
Select: "SELECT id,user_id,submission_id,status,masked_name AS real_name,masked_id_number AS id_card_no_masked,review_reason AS reject_reason,verified_at AS reviewed_at,created_at,updated_at",
From: "FROM im_realname_verifications", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"status", "status", "=", "string"}, {"real_name", "masked_name", "LIKE", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"abuse-reports": {
Select: "SELECT id,report_id,reporter_id AS reporter_user_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at",
From: "FROM im_abuse_reports", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"reporter_user_id", "reporter_id", "=", "uid"}, {"target_type", "target_type", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"feedback-submissions": {
Select: "SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids AS file_ids,client_context,include_diagnostics,status,created_at,updated_at",
From: "FROM im_feedback_submissions", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"category", "category", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"feedback-config": {
Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at",
From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
},
"mailbox-config": {
Select: "SELECT id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at",
From: "FROM im_mailbox_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
},
"abuse-reasons": {
Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at",
From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id",
Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}},
},
"app-releases": {
Select: "SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at",
From: "FROM im_app_releases", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
},
"workbench/apps": {
Select: "SELECT id,code AS app_code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at",
From: "FROM im_workbench_apps", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id",
},
}
var internalOutboxResourceSpecs = map[string]*internalQuerySpec{
"business-events": {
Select: "SELECT id,event_id,target_type,target,actor_user_id,event_type,payload,status,attempts,next_retry_at,delivered_at,created_at,updated_at",
From: "FROM im_business_events_outbox", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"event_type", "event_type", "=", "string"}, {"status", "status", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target", "target", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
"group-events": {
Select: "SELECT id,event_id,topic,actor_user_id,event_type,payload,status,attempts,next_retry_at,delivered_at,created_at,updated_at",
From: "FROM im_group_events_outbox", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"event_type", "event_type", "=", "string"}, {"status", "status", "=", "string"}, {"topic", "topic", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
},
}
var internalOrgResourceSpecs = map[string]*internalQuerySpec{
"departments": {
Select: "SELECT id,department_id,parent_id,name,sort_order,state,created_at,updated_at",
From: "FROM im_org_departments", DefaultSort: "ORDER BY sort_order ASC,id ASC",
Filters: []internalQueryFilter{{"parent_id", "parent_id", "=", "string"}, {"state", "state", "=", "string"}},
},
"members": {
Select: "SELECT id,department_id,user_id,display_name,title,mobile,email,employment_state,sort_order,created_at,updated_at",
From: "FROM im_org_members", DefaultSort: "ORDER BY sort_order ASC,id ASC",
Filters: []internalQueryFilter{{"department_id", "department_id", "=", "string"}, {"user_id", "user_id", "=", "uid"}, {"employment_state", "employment_state", "=", "string"}},
},
}
func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, scope string) {
spec, ok := internalAdminQuerySpec(resource)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, scope)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
result, err := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"items": normalizeInternalAdminRowsForAPI(result.Items),
"total": result.Total,
"page": result.Page,
"per_page": result.PerPage,
})
}
func serveInternalAdminResourceDetail(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, id string) {
spec, ok := internalAdminQuerySpec(resource)
if !ok || spec.DetailKey == "" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
query := spec.Select + " " + spec.From + " WHERE " + spec.DetailKey + "=?"
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{id})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalDashboardSummary(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
summary := map[string]any{}
queries := map[string]string{
"dead_kafka": "SELECT COUNT(*) AS count FROM im_kafka_dead_letters WHERE status='dead'",
"pending_business": "SELECT COUNT(*) AS count FROM im_business_events_outbox WHERE status='pending'",
"pending_group": "SELECT COUNT(*) AS count FROM im_group_events_outbox WHERE status='pending'",
"pending_realname": "SELECT COUNT(*) AS count FROM im_realname_verifications WHERE status='pending'",
"received_abuse": "SELECT COUNT(*) AS count FROM im_abuse_reports WHERE status='received'",
"received_feedback": "SELECT COUNT(*) AS count FROM im_feedback_submissions WHERE status='received'",
"active_groups": "SELECT COUNT(*) AS count FROM im_group_settings WHERE group_status='active'",
"suspended_users": "SELECT COUNT(*) AS count FROM im_users WHERE state=10",
}
for key, query := range queries {
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, nil)
if err != nil {
summary[key] = nil
continue
}
summary[key] = row["count"]
}
writeIMSuccess(wrt, req, summary)
}
func serveInternalAbuseTargetQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, reportID string) {
row, err := store.InternalAdminQueries.QueryOne(tenantID,
"SELECT id,report_id,target_type,target_user_id,target_topic,target_seq,snapshot FROM im_abuse_reports WHERE report_id=?",
[]any{strings.TrimSpace(reportID)})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
data := normalizeInternalAdminRowForAPI(row)
switch fmt.Sprint(row["target_type"]) {
case "user":
if id, ok := numericID(row["target_user_id"]); ok && id > 0 {
if user, getErr := store.InternalAdminQueries.QueryOne(tenantID, usersInternalQuerySpec().Select+" "+usersInternalQuerySpec().From+" WHERE u.id=?", []any{id}); getErr == nil {
data["target"] = normalizeInternalAdminRowForAPI(user)
}
}
case "message":
topic := fmt.Sprint(row["target_topic"])
seq, _ := numericID(row["target_seq"])
if topic != "" && seq > 0 {
if msg, getErr := store.Messages.GetOne(tenantID, topic, seq); getErr == nil {
data["target"] = map[string]any{"topic": msg.Topic, "seq_id": msg.SeqId, "from": msg.From, "head": msg.Head, "content": msg.Content, "created_at": msg.CreatedAt}
}
}
case "group":
topic := fmt.Sprint(row["target_topic"])
if topic != "" {
if group, getErr := store.InternalAdminQueries.QueryOne(tenantID, groupsInternalQuerySpec().Select+" "+groupsInternalQuerySpec().From+" WHERE t.name=?", []any{topic}); getErr == nil {
data["target"] = normalizeInternalAdminRowForAPI(group)
}
}
}
writeIMSuccess(wrt, req, data)
}
func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
switch resource {
case "users":
return usersInternalQuerySpec(), true
case "user_devices":
return &internalQuerySpec{Select: "SELECT id,deviceid AS device_id,platform,lastseen AS last_seen,lang", From: "FROM im_devices", DefaultSort: "ORDER BY lastseen DESC", ScopeColumn: "userid", Filters: []internalQueryFilter{{"device_id", "deviceid", "LIKE", "string"}, {"platform", "platform", "=", "string"}}}, true
case "groups":
return groupsInternalQuerySpec(), true
case "file_links":
return &internalQuerySpec{Select: "SELECT id,topic,msgid AS seq_id,userid AS user_id,createdat AS created_at", From: "FROM im_filemsglinks", DefaultSort: "ORDER BY id DESC", ScopeColumn: "fileid"}, true
case "files":
return &internalQuerySpec{Select: "SELECT id,id AS file_id,userid AS user_id,mimetype AS mime_type,size AS file_size,location,etag,status,createdat AS created_at,updatedat AS updated_at", From: "FROM im_fileuploads", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"file_id", "id", "=", "int"}, {"user_id", "userid", "=", "uid"}, {"status", "status", "=", "int"}, {"mime_type", "mimetype", "LIKE", "string"}, {"created_at_start", "createdat", ">=", "time"}, {"created_at_end", "createdat", "<=", "time"}}}, true
}
if strings.HasPrefix(resource, "group_") {
spec := internalGroupResourceSpecs[strings.TrimPrefix(resource, "group_")]
return spec, spec != nil
}
if strings.HasPrefix(resource, "outbox_") {
spec := internalOutboxResourceSpecs[strings.TrimPrefix(resource, "outbox_")]
return spec, spec != nil
}
if strings.HasPrefix(resource, "org_") {
spec := internalOrgResourceSpecs[strings.TrimPrefix(resource, "org_")]
return spec, spec != nil
}
spec := internalTenantResourceSpecs[resource]
return spec, spec != nil
}
func usersInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`,
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id",
DefaultSort: "ORDER BY u.id DESC",
DetailKey: "u.id",
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
}
}
func groupsInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT t.name AS topic,t.state,t.owner AS owner_id,t.seqid AS seq_id,t.delid AS del_id,t.subcnt AS sub_count,t.touchedat AS touched_at,t.createdat AS created_at,t.updatedat AS updated_at,t.public,t.trusted,t.access,t.tags,t.aux,
gs.name AS group_name,gs.avatar_file_id,gs.description,gs.group_status,gs.join_approval,gs.mute_all,gs.member_upload_files,gs.member_private_chat,gs.member_invite,gs.max_member_count,gs.max_admin_count,gs.member_count AS business_member_count,gs.version AS settings_version,gs.updated_at AS settings_updated_at`,
From: "FROM im_topics AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name",
DefaultSort: "ORDER BY t.id DESC",
DetailKey: "t.name",
Filters: []internalQueryFilter{{"topic", "t.name", "LIKE", "string"}, {"owner_id", "t.owner", "=", "uid"}, {"state", "t.state", "=", "int"}, {"group_status", "gs.group_status", "=", "string"}, {"created_at_start", "t.createdat", ">=", "time"}, {"created_at_end", "t.createdat", "<=", "time"}, {"touched_at_start", "t.touchedat", ">=", "time"}, {"touched_at_end", "t.touchedat", "<=", "time"}},
}
}
func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string) (string, string, []any, int, int, error) {
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200)
where := []string{"1=1"}
args := []any{}
if spec.ScopeColumn != "" && strings.TrimSpace(scope) != "" {
value, err := internalAdminFilterValue("scope", scope, scopeKindForColumn(spec.ScopeColumn))
if err != nil {
return "", "", nil, 0, 0, err
}
where = append(where, spec.ScopeColumn+"=?")
args = append(args, value)
}
for _, filter := range spec.Filters {
raw := strings.TrimSpace(req.URL.Query().Get(filter.Param))
if raw == "" {
continue
}
value, err := internalAdminFilterValue(filter.Param, raw, filter.Kind)
if err != nil {
return "", "", nil, 0, 0, err
}
op := filter.Op
if op == "" {
op = "="
}
if strings.EqualFold(op, "LIKE") {
value = "%" + fmt.Sprint(value) + "%"
}
where = append(where, filter.Column+" "+op+" ?")
args = append(args, value)
}
whereSQL := strings.Join(where, " AND ")
query := spec.Select + " " + spec.From + " WHERE " + whereSQL + " " + spec.DefaultSort
countQuery := "SELECT COUNT(*) " + spec.From + " WHERE " + whereSQL
return query, countQuery, args, page, perPage, nil
}
func internalAdminFilterValue(_ string, raw, kind string) (any, error) {
switch kind {
case "uid":
id, ok := parseInternalAdminDBID(raw)
if !ok {
return nil, types.ErrMalformed
}
return id, nil
case "int":
return strconv.ParseInt(raw, 10, 64)
default:
return raw, nil
}
}
func scopeKindForColumn(column string) string {
switch column {
case "userid", "fileid":
return "uid"
default:
return "string"
}
}
func parseIMPositiveInt(raw string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return fallback
}
return value
}
func normalizeInternalAdminRowsForAPI(rows []map[string]any) []map[string]any {
for i := range rows {
rows[i] = normalizeInternalAdminRowForAPI(rows[i])
}
return rows
}
func normalizeInternalAdminRowForAPI(row map[string]any) map[string]any {
return row
}
func parseInternalAdminDBID(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
return id, true
}
if uid := types.ParseUserId(raw); !uid.IsZero() {
return store.DecodeUid(uid), true
}
if uid := types.ParseUid(raw); !uid.IsZero() {
return store.DecodeUid(uid), true
}
return 0, false
}
func internalAdminUIDRaw(uid types.Uid) int64 {
if uid.IsZero() {
return 0
}
return store.DecodeUid(uid)
}
func internalAdminUIDStringRaw(raw string) any {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if id, ok := parseInternalAdminDBID(raw); ok {
return id
}
return raw
}
func numericID(value any) (int64, bool) {
switch v := value.(type) {
case int64:
return v, true
case int:
return int64(v), true
case int32:
return int64(v), true
case uint64:
return int64(v), true
case string:
id, err := strconv.ParseInt(v, 10, 64)
return id, err == nil
default:
return 0, false
}
}
+624
View File
@@ -0,0 +1,624 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
internalCapabilityAdminQuery = "admin_query"
internalHeaderService = "X-Service-Name"
internalHeaderTimestamp = "X-Timestamp"
internalHeaderNonce = "X-Nonce"
internalHeaderSignature = "X-Signature"
)
type internalAPIConfig struct {
Enabled bool `json:"enabled"`
TimeWindowSeconds int `json:"time_window_seconds"`
Services map[string]internalAPIServiceConfig `json:"services"`
}
type internalAPIServiceConfig struct {
Secret string `json:"secret"`
Allow []string `json:"allow"`
}
type internalAPIRuntime struct {
config internalAPIConfig
nonces sync.Map
}
var internalAPI internalAPIRuntime
func initInternalAPI(raw json.RawMessage) error {
internalAPI = internalAPIRuntime{}
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var cfg internalAPIConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
if cfg.TimeWindowSeconds <= 0 {
cfg.TimeWindowSeconds = 300
}
if cfg.Services == nil {
cfg.Services = map[string]internalAPIServiceConfig{}
}
internalAPI.config = cfg
if cfg.Enabled {
logs.Info.Printf("Internal API enabled for %d service(s)", len(cfg.Services))
}
return nil
}
func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix string) {
defer func() {
if recovered := recover(); recovered != nil {
logs.Err.Printf("internal admin query panic path:%s err:%v stack:%s", req.URL.RequestURI(), recovered, debug.Stack())
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}()
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if !authorizeInternalAPI(wrt, req, internalCapabilityAdminQuery) {
return
}
tail := strings.Trim(strings.TrimPrefix(req.URL.Path, prefix), "/")
parts := splitInternalPath(tail)
if len(parts) < 3 || parts[0] != "tenants" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
tenant, ok := resolveInternalTenant(wrt, req, parts[1])
if !ok {
return
}
switch {
case len(parts) == 3 && parts[2] == "users":
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users":
serveInternalUserQuery(wrt, req, tenant.ID, parts[3])
case len(parts) == 5 && parts[2] == "users" && parts[4] == "devices":
serveInternalAdminResourceList(wrt, req, tenant.ID, "user_devices", parts[3])
case len(parts) == 3 && parts[2] == "groups":
serveInternalAdminResourceList(wrt, req, tenant.ID, "groups", "")
case len(parts) == 4 && parts[2] == "groups":
serveInternalGroupQuery(wrt, req, tenant.ID, parts[3])
case len(parts) == 5 && parts[2] == "groups" && parts[4] == "members":
serveInternalGroupMembersQuery(wrt, req, tenant.ID, parts[3])
case len(parts) == 5 && parts[2] == "groups" && internalGroupResourceSpecs[parts[4]] != nil:
serveInternalAdminResourceList(wrt, req, tenant.ID, "group_"+parts[4], parts[3])
case len(parts) == 5 && parts[2] == "messages":
serveInternalMessageQuery(wrt, req, tenant.ID, parts[3], parts[4])
case len(parts) == 3 && parts[2] == "files":
serveInternalAdminResourceList(wrt, req, tenant.ID, "files", "")
case len(parts) == 4 && parts[2] == "files":
serveInternalFileQuery(wrt, req, tenant.ID, parts[3])
case len(parts) == 5 && parts[2] == "files" && parts[4] == "links":
serveInternalAdminResourceList(wrt, req, tenant.ID, "file_links", parts[3])
case len(parts) == 3 && internalTenantResourceSpecs[parts[2]] != nil:
serveInternalAdminResourceList(wrt, req, tenant.ID, parts[2], "")
case len(parts) == 4 && internalTenantResourceSpecs[parts[2]] != nil:
serveInternalAdminResourceDetail(wrt, req, tenant.ID, parts[2], parts[3])
case len(parts) == 4 && parts[2] == "reports" && parts[3] == "abuse":
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
case len(parts) == 6 && parts[2] == "reports" && parts[3] == "abuse" && parts[5] == "target":
serveInternalAbuseTargetQuery(wrt, req, tenant.ID, parts[4])
case len(parts) == 4 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil:
serveInternalAdminResourceList(wrt, req, tenant.ID, "outbox_"+parts[3], "")
case len(parts) == 5 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil:
serveInternalAdminResourceDetail(wrt, req, tenant.ID, "outbox_"+parts[3], parts[4])
case len(parts) == 4 && parts[2] == "org" && internalOrgResourceSpecs[parts[3]] != nil:
serveInternalAdminResourceList(wrt, req, tenant.ID, "org_"+parts[3], "")
case len(parts) == 4 && parts[2] == "workbench" && parts[3] == "apps":
serveInternalAdminResourceList(wrt, req, tenant.ID, "workbench/apps", "")
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "summary":
serveInternalDashboardSummary(wrt, req, tenant.ID)
case len(parts) == 4 && parts[2] == "kafka" && parts[3] == "dead-letters":
serveInternalKafkaDeadLettersQuery(wrt, req, tenant.ID)
case len(parts) == 5 && parts[2] == "kafka" && parts[3] == "dead-letters":
serveInternalKafkaDeadLetterQuery(wrt, req, tenant.ID, parts[4])
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
}
}
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
if !internalAPI.config.Enabled {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return false
}
serviceName := strings.TrimSpace(req.Header.Get(internalHeaderService))
timestampRaw := strings.TrimSpace(req.Header.Get(internalHeaderTimestamp))
nonce := strings.TrimSpace(req.Header.Get(internalHeaderNonce))
signature := strings.TrimSpace(req.Header.Get(internalHeaderSignature))
service, ok := internalAPI.config.Services[serviceName]
if !ok || service.Secret == "" || timestampRaw == "" || nonce == "" || signature == "" {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature required", nil)
return false
}
if !internalServiceAllows(service, capability) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return false
}
timestamp, err := strconv.ParseInt(timestampRaw, 10, 64)
if err != nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal timestamp", nil)
return false
}
window := time.Duration(internalAPI.config.TimeWindowSeconds) * time.Second
if delta := time.Since(time.Unix(timestamp, 0)); delta > window || delta < -window {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature expired", nil)
return false
}
bodyHash, err := hashInternalRequestBody(req)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return false
}
signingText := strings.Join([]string{
req.Method,
req.URL.RequestURI(),
bodyHash,
timestampRaw,
nonce,
}, "\n")
if !verifyInternalSignature(service.Secret, signingText, signature) {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil)
return false
}
if req.Method != http.MethodGet {
requestClaim := internalRequestClaimValue(signingText)
if !claimInternalNonce(serviceName, nonce, requestClaim, time.Now().Add(window)) {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal nonce replayed", nil)
return false
}
}
return true
}
func splitInternalPath(tail string) []string {
if tail == "" {
return nil
}
rawParts := strings.Split(tail, "/")
parts := make([]string, 0, len(rawParts))
for _, part := range rawParts {
if part == "" {
continue
}
decoded, err := url.PathUnescape(part)
if err != nil {
decoded = part
}
parts = append(parts, decoded)
}
return parts
}
func resolveInternalTenant(wrt http.ResponseWriter, req *http.Request, tenantCode string) (*types.Tenant, bool) {
if tenantCode == "" || store.Tenants == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
return tenant, true
}
func serveInternalUserQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, userID string) {
userDBID, ok := parseInternalAdminDBID(userID)
if !ok {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
if spec, ok := internalAdminQuerySpec("users"); ok {
query := spec.Select + " " + spec.From + " WHERE u.id=?"
if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{userDBID}); err == nil {
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
return
} else {
logs.Warn.Printf("internal user query sql failed tenant:%d user:%d err:%v", tenantID, userDBID, err)
}
}
uid := store.EncodeUid(userDBID)
user, err := store.Users.Get(tenantID, uid)
if err != nil {
logs.Warn.Printf("internal user query fallback failed tenant:%d user:%d err:%v", tenantID, userDBID, err)
writeInternalQueryError(wrt, req, err)
return
}
if user == nil {
writeInternalQueryError(wrt, req, types.ErrUserNotFound)
return
}
writeIMSuccess(wrt, req, map[string]any{
"user_id": userDBID,
"state": user.State,
"state_at": user.StateAt,
"username": user.Username,
"phone": user.Phone,
"public": user.Public,
"trusted": user.Trusted,
"last_seen": user.LastSeen,
"user_agent": user.UserAgent,
"tags": user.Tags,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
})
}
func serveInternalGroupQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) {
if topic == "" || store.GroupBusiness == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
if spec, ok := internalAdminQuerySpec("groups"); ok {
query := spec.Select + " " + spec.From + " WHERE t.name=?"
if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{topic}); err == nil {
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
return
}
}
settings, err := store.GroupBusiness.GetSettings(tenantID, topic)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, internalGroupSettingsPayload(settings))
}
func serveInternalGroupMembersQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) {
if topic == "" || store.GroupBusiness == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
spec := &internalQuerySpec{
Select: `SELECT gm.id,gm.topic,gm.user_id,u.state,u.public,p.nickname,p.public_account,s.modewant AS mode_want,s.modegiven AS mode_given,s.recvseqid AS recv_seq_id,s.readseqid AS read_seq_id,s.delid AS del_id,
gm.role,gm.member_status,gm.nickname AS group_nickname,gm.pinned,gm.mute_notifications,gm.join_source,gm.joined_by,gm.joined_at,gm.left_at,gm.removed_by,gm.version,gm.created_at,gm.updated_at`,
From: "FROM im_group_members AS gm LEFT JOIN im_users AS u ON u.id=gm.user_id LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id LEFT JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id",
DefaultSort: "ORDER BY gm.id DESC",
ScopeColumn: "gm.topic",
Filters: []internalQueryFilter{
{"user_id", "gm.user_id", "=", "uid"},
{"role", "gm.role", "=", "string"},
{"member_status", "gm.member_status", "=", "string"},
{"q", "gm.nickname", "LIKE", "string"},
{"eligible_role", "gm.role", "=", "string"},
},
}
query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, topic)
if err == nil {
if result, queryErr := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage); queryErr == nil {
writeIMSuccess(wrt, req, map[string]any{
"items": normalizeInternalAdminRowsForAPI(result.Items),
"total": result.Total,
"page": result.Page,
"per_page": result.PerPage,
"next_cursor": "",
"has_more": false,
})
return
}
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 200)
members, nextCursor, err := store.GroupBusiness.ListMembers(tenantID, topic, types.GroupMemberListOptions{
Role: strings.TrimSpace(req.URL.Query().Get("role")),
EligibleRole: strings.TrimSpace(req.URL.Query().Get("eligible_role")),
Query: strings.TrimSpace(req.URL.Query().Get("q")),
Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")),
Limit: limit,
})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
items := make([]any, 0, len(members))
for _, member := range members {
items = append(items, map[string]any{
"user_id": internalAdminUIDRaw(member.UserID),
"role": member.Role,
"member_status": member.MemberStatus,
"nickname": member.Nickname,
"pinned": member.Pinned,
"mute_notifications": member.MuteNotifications,
"join_source": member.JoinSource,
"joined_by": internalAdminUIDRaw(member.JoinedBy),
"joined_at": member.JoinedAt,
"left_at": member.LeftAt,
"removed_by": internalAdminUIDRaw(member.RemovedBy),
"version": member.Version,
"created_at": member.CreatedAt,
"updated_at": member.UpdatedAt,
})
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic, seqRaw string) {
seq, err := strconv.ParseInt(seqRaw, 10, 64)
if topic == "" || err != nil || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": msg.Topic,
"seq_id": msg.SeqId,
"from": internalAdminUIDStringRaw(msg.From),
"client_msg_id": msg.ClientMsgID,
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"deleted_at": msg.DeletedAt,
"recalled_at": msg.RecalledAt,
"recalled_by": internalAdminUIDStringRaw(msg.RecalledBy),
"created_at": msg.CreatedAt,
"updated_at": msg.UpdatedAt,
})
}
func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) {
if fileID == "" || store.Files == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
file, err := store.Files.Get(tenantID, fileID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"file_id": file.Id,
"status": file.Status,
"user_id": internalAdminUIDStringRaw(file.User),
"mime_type": file.MimeType,
"size": file.Size,
"location": file.Location,
"etag": file.ETag,
"created_at": file.CreatedAt,
"updated_at": file.UpdatedAt,
})
}
func serveInternalKafkaDeadLettersQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
if store.HTTPInfrastructure == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 200)
page, err := store.HTTPInfrastructure.ListKafkaDeadLetters(tenantID, types.KafkaDeadLetterListOptions{
Status: strings.TrimSpace(req.URL.Query().Get("status")),
BizType: strings.TrimSpace(req.URL.Query().Get("biz_type")),
BizID: strings.TrimSpace(req.URL.Query().Get("biz_id")),
EventType: strings.TrimSpace(req.URL.Query().Get("event_type")),
Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")),
Limit: limit,
})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
items := make([]any, 0, len(page.Items))
for _, dead := range page.Items {
items = append(items, internalKafkaDeadLetterPayload(&dead, false))
}
writeIMSuccess(wrt, req, map[string]any{
"items": items,
"next_cursor": page.NextCursor,
"has_more": page.NextCursor != "",
})
}
func serveInternalKafkaDeadLetterQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, deadID string) {
if store.HTTPInfrastructure == nil || strings.TrimSpace(deadID) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "dead_id"})
return
}
dead, err := store.HTTPInfrastructure.GetKafkaDeadLetter(tenantID, deadID)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, internalKafkaDeadLetterPayload(dead, true))
}
func internalGroupSettingsPayload(settings *types.GroupSettings) map[string]any {
if settings == nil {
return nil
}
return map[string]any{
"topic": settings.Topic,
"owner_user_id": internalAdminUIDRaw(settings.OwnerUserID),
"name": settings.Name,
"avatar_file_id": settings.AvatarFileID,
"description": settings.Description,
"group_status": settings.GroupStatus,
"join_approval": settings.JoinApproval,
"mute_all": settings.MuteAll,
"member_upload_files": settings.MemberUploadFiles,
"member_private_chat": settings.MemberPrivateChat,
"member_invite": settings.MemberInvite,
"max_member_count": settings.MaxMemberCount,
"max_admin_count": settings.MaxAdminCount,
"member_count": settings.MemberCount,
"version": settings.Version,
"created_by": internalAdminUIDRaw(settings.CreatedBy),
"dissolved_by": internalAdminUIDRaw(settings.DissolvedBy),
"dissolved_at": settings.DissolvedAt,
"created_at": settings.CreatedAt,
"updated_at": settings.UpdatedAt,
}
}
func internalKafkaDeadLetterPayload(dead *types.KafkaDeadLetter, includeEnvelope bool) map[string]any {
if dead == nil {
return nil
}
payload := map[string]any{
"id": dead.ID,
"dead_id": dead.DeadID,
"event_id": dead.EventID,
"parent_event_id": dead.ParentEventID,
"source_topic": dead.SourceTopic,
"consumer_group": dead.ConsumerGroup,
"event_type": dead.EventType,
"biz_type": dead.BizType,
"biz_id": dead.BizID,
"retry_count": dead.RetryCount,
"last_reason": dead.LastReason,
"last_error": dead.LastError,
"status": dead.Status,
"replay_count": dead.ReplayCount,
"created_at": dead.CreatedAt,
"updated_at": dead.UpdatedAt,
}
if includeEnvelope {
var envelope any
if len(dead.Envelope) > 0 && json.Unmarshal(dead.Envelope, &envelope) == nil {
payload["envelope"] = envelope
} else {
payload["envelope_raw"] = string(dead.Envelope)
}
}
return payload
}
func writeInternalQueryError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrUserNotFound), errors.Is(err, types.ErrTopicNotFound), errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}
func parseInternalUid(raw string) types.Uid {
if uid := types.ParseUserId(raw); !uid.IsZero() {
return uid
}
id, ok := parseInternalAdminDBID(raw)
if !ok {
return types.ZeroUid
}
return store.EncodeUid(id)
}
func internalServiceAllows(service internalAPIServiceConfig, capability string) bool {
for _, allowed := range service.Allow {
allowed = strings.TrimSpace(allowed)
if allowed == "*" || allowed == capability {
return true
}
}
return false
}
func hashInternalRequestBody(req *http.Request) (string, error) {
if req.Body == nil {
sum := sha256.Sum256(nil)
return hex.EncodeToString(sum[:]), nil
}
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
if err != nil {
return "", err
}
if err := req.Body.Close(); err != nil {
return "", err
}
req.Body = io.NopCloser(bytes.NewReader(body))
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:]), nil
}
func verifyInternalSignature(secret, signingText, signature string) bool {
signature = strings.TrimPrefix(signature, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingText))
expected := mac.Sum(nil)
if decoded, err := hex.DecodeString(signature); err == nil {
return hmac.Equal(decoded, expected)
}
if decoded, err := base64.StdEncoding.DecodeString(signature); err == nil {
return hmac.Equal(decoded, expected)
}
return false
}
func claimInternalNonce(serviceName, nonce, requestClaim string, expiresAt time.Time) bool {
ttl := time.Until(expiresAt)
nonceKey := fmt.Sprintf("%s:%s", serviceName, nonce)
if claimed, sameValue, enabled, err := store.ClaimShortStateValue("internal_api_nonce", nonceKey, requestClaim, ttl); enabled {
if err != nil {
logs.Warn.Println("internal api: failed to claim nonce in redis", err)
return false
}
return claimed || sameValue
}
now := time.Now()
internalAPI.nonces.Range(func(key, value any) bool {
if claim, ok := value.(internalNonceClaim); ok && claim.expiresAt.Before(now) {
internalAPI.nonces.Delete(key)
}
return true
})
if existing, loaded := internalAPI.nonces.LoadOrStore(nonceKey, internalNonceClaim{value: requestClaim, expiresAt: expiresAt}); loaded {
claim, ok := existing.(internalNonceClaim)
return ok && claim.value == requestClaim
}
return true
}
type internalNonceClaim struct {
value string
expiresAt time.Time
}
func internalRequestClaimValue(signingText string) string {
sum := sha256.Sum256([]byte(signingText))
return hex.EncodeToString(sum[:])
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
type internalAdminQueryNotFound struct{}
func (internalAdminQueryNotFound) Query(types.TenantID, string, string, []any, int, int) (*types.InternalAdminQueryResult, error) {
return nil, types.ErrNotFound
}
func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[string]any, error) {
return nil, types.ErrNotFound
}
func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldQueries := store.InternalAdminQueries
oldUsers := store.Users
defer func() {
store.InternalAdminQueries = oldQueries
store.Users = oldUsers
}()
store.InternalAdminQueries = internalAdminQueryNotFound{}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().Get(types.TenantID(1), gomock.Any()).Return(nil, nil)
store.Users = users
req := httptest.NewRequest(http.MethodGet, "/internal/admin-query/tenants/default/users/1566402774239809500", nil)
wrt := httptest.NewRecorder()
serveInternalUserQuery(wrt, req, types.TenantID(1), "1566402774239809500")
if wrt.Code != http.StatusNotFound {
t.Fatalf("unexpected status: got %d, want %d; body=%s", wrt.Code, http.StatusNotFound, wrt.Body.String())
}
var resp imHTTPResponse
if err := json.Unmarshal(wrt.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Code != 30004 {
t.Fatalf("unexpected response code: got %d, want 30004", resp.Code)
}
}
+644
View File
@@ -0,0 +1,644 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
adminCommandGroupProfileUpdate = "group.profile.update"
adminCommandGroupGovernUpdate = "group.governance.update"
adminCommandGroupAdminSet = "group.admin.set"
adminCommandGroupAdminRemove = "group.admin.remove"
adminCommandGroupMemberMute = "group.member.mute"
adminCommandGroupMemberUnmute = "group.member.unmute"
adminCommandGroupMemberRemove = "group.member.remove"
adminCommandGroupOwnerTransfer = "group.owner.transfer"
adminCommandUserBan = "user.ban"
adminCommandUserUnban = "user.unban"
adminCommandUserFreeze = "user.freeze"
adminCommandUserUnfreeze = "user.unfreeze"
adminCommandUserDeviceKick = "user.device.kick"
adminCommandUserLogoutAll = "user.logout_all"
adminCommandUserCancel = "user.account.cancel"
adminCommandUserDelete = "user.account.delete"
adminCommandUserPasswordReset = "user.password.reset"
adminCommandKafkaDeadReplay = "kafka.dead_letter.replay"
adminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore"
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
)
type adminCommandPayload struct {
CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"`
Topic string `json:"topic"`
UserID string `json:"user_id"`
UserIDs []string `json:"user_ids"`
TargetID string `json:"target_user_id"`
DeviceID string `json:"device_id"`
PasswordHash string `json:"password_hash"`
Version int `json:"version"`
Reason string `json:"reason"`
DeadID string `json:"dead_id"`
Note string `json:"note"`
Hard bool `json:"hard"`
ExpiresAt *time.Time `json:"expires_at"`
Profile groupProfilePatch `json:"profile"`
Governance groupGovernancePatch `json:"governance"`
}
type groupProfilePatch struct {
Name *string `json:"name"`
AvatarFileID *string `json:"avatar_file_id"`
Description *string `json:"description"`
}
type groupGovernancePatch struct {
JoinApproval *bool `json:"join_approval"`
MuteAll *bool `json:"mute_all"`
MemberUploadFiles *bool `json:"member_upload_files"`
MemberPrivateChat *bool `json:"member_private_chat"`
MemberInvite *bool `json:"member_invite"`
}
type adminCommandResultPayload struct {
CommandID string `json:"command_id"`
CommandEventID string `json:"command_event_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Result map[string]any `json:"result,omitempty"`
}
func startKafkaAdminCommandConsumer() {
startKafkaConsumer("admin_command", handleKafkaAdminCommand)
}
func handleKafkaAdminCommand(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
if strings.TrimSpace(envelope.EventID) == "" {
return publishKafkaAdminCommandResult(ctx, envelope, "", "failed", "MALFORMED_EVENT", "missing event_id", nil)
}
if strings.TrimSpace(envelope.EventType) == "" {
return publishKafkaAdminCommandResult(ctx, envelope, "", "failed", "MALFORMED_EVENT", "missing event_type", nil)
}
var payload adminCommandPayload
if len(envelope.Payload) > 0 {
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
return publishKafkaAdminCommandResult(ctx, envelope, "", "failed", "MALFORMED_PAYLOAD", err.Error(), nil)
}
}
if payload.CommandID == "" {
payload.CommandID = envelope.EventID
}
tenantID, tenantErr := kafkaTenantID(envelope.TenantCode)
if tenantErr != nil {
return publishKafkaAdminCommandResult(ctx, envelope, payload.CommandID, "failed", kafkaCommandErrorReason(tenantErr), tenantErr.Error(), nil)
}
consume, claimed, beginErr := beginKafkaConsumeForTopic(tenantID, envelope, "admin_command")
if beginErr != nil {
return beginErr
}
if !claimed {
logs.Info.Printf("kafka admin command skipped: event=%s group=%s status=%s", envelope.EventID, kafkaBus.config.ConsumerGroup, consume.Status)
return nil
}
result, execErr := executeKafkaAdminCommand(tenantID, envelope, payload)
if execErr != nil {
reason := kafkaCommandErrorReason(execErr)
failedRecord, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, reason, execErr.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if shouldDeadLetterKafkaCommand(reason, failedRecord.RetryCount) {
if deadErr := markKafkaDeadLetter(ctx, tenantID, envelope, failedRecord, reason, execErr.Error()); deadErr != nil {
return deadErr
}
if err := publishKafkaAdminCommandResult(ctx, envelope, payload.CommandID, "dead", reason, execErr.Error(), result); err != nil {
return err
}
logs.Warn.Printf("kafka admin command dead: event=%s type=%s reason=%s err=%v", envelope.EventID, envelope.EventType, reason, execErr)
return nil
}
if kafkaCommandReasonRetryable(reason) {
sleepKafkaRetryBackoff(failedRecord.RetryCount)
return execErr
}
if err := publishKafkaAdminCommandResult(ctx, envelope, payload.CommandID, "failed", reason, execErr.Error(), result); err != nil {
return err
}
logs.Warn.Printf("kafka admin command failed: event=%s type=%s reason=%s err=%v", envelope.EventID, envelope.EventType, reason, execErr)
return nil
}
if err := store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)); err != nil {
return err
}
if err := publishKafkaAdminCommandResult(ctx, envelope, payload.CommandID, "succeeded", "", "", result); err != nil {
return err
}
if err := enqueueKafkaAuditEvent(ctx, tenantID, "admin", parseKafkaCommandUser(payload.ActorUserID),
envelope.EventType, "admin_command", payload.CommandID, "succeeded", "", map[string]any{
"topic": payload.Topic,
"biz_id": envelope.BizID,
"event_id": envelope.EventID,
"result": result,
}); err != nil {
logs.Warn.Printf("kafka admin command audit enqueue failed: event=%s err=%v", envelope.EventID, err)
}
return nil
}
func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload) (map[string]any, error) {
eventType := strings.TrimSpace(envelope.EventType)
if eventType == adminCommandUserPasswordReset {
return executeKafkaUserPasswordResetCommand(tenantID, payload)
}
actor := parseKafkaCommandUser(payload.ActorUserID)
if actor.IsZero() {
return nil, types.ErrMalformed
}
switch eventType {
case adminCommandKafkaDeadReplay:
dead, err := replayKafkaDeadLetter(context.Background(), tenantID, payload.DeadID, actor, firstNonEmpty(payload.Note, payload.Reason))
if err != nil {
return nil, err
}
return kafkaDeadLetterResult(dead), nil
case adminCommandKafkaDeadIgnore:
if err := resolveKafkaDeadLetter(tenantID, payload.DeadID, types.KafkaDeadStatusIgnored, actor, firstNonEmpty(payload.Note, payload.Reason)); err != nil {
return nil, err
}
return map[string]any{"dead_id": payload.DeadID, "status": types.KafkaDeadStatusIgnored}, nil
case adminCommandKafkaDeadResolve:
if err := resolveKafkaDeadLetter(tenantID, payload.DeadID, types.KafkaDeadStatusResolved, actor, firstNonEmpty(payload.Note, payload.Reason)); err != nil {
return nil, err
}
return map[string]any{"dead_id": payload.DeadID, "status": types.KafkaDeadStatusResolved}, nil
case adminCommandUserBan, adminCommandUserUnban, adminCommandUserFreeze, adminCommandUserUnfreeze,
adminCommandUserDeviceKick, adminCommandUserLogoutAll, adminCommandUserCancel, adminCommandUserDelete:
return executeKafkaUserGovernanceCommand(tenantID, envelope, payload, actor)
}
if store.GroupBusiness == nil {
return nil, errors.New("group business store is not initialized")
}
topic := strings.TrimSpace(payload.Topic)
if topic == "" {
return nil, types.ErrMalformed
}
now := time.Now().UTC().Round(time.Millisecond)
switch strings.TrimSpace(envelope.EventType) {
case adminCommandGroupProfileUpdate:
settings, err := store.GroupBusiness.UpdateSettings(tenantID, topic, types.GroupSettingsPatch{
Name: payload.Profile.Name,
AvatarFileID: payload.Profile.AvatarFileID,
Description: payload.Profile.Description,
Version: payload.Version,
})
if err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "version": settings.Version, "updated_at": settings.UpdatedAt}, nil
case adminCommandGroupGovernUpdate:
settings, err := store.GroupBusiness.UpdateSettings(tenantID, topic, types.GroupSettingsPatch{
JoinApproval: payload.Governance.JoinApproval,
MuteAll: payload.Governance.MuteAll,
MemberUploadFiles: payload.Governance.MemberUploadFiles,
MemberPrivateChat: payload.Governance.MemberPrivateChat,
MemberInvite: payload.Governance.MemberInvite,
Version: payload.Version,
})
if err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "version": settings.Version, "updated_at": settings.UpdatedAt}, nil
case adminCommandGroupAdminSet:
users := payload.UserIDs
if len(users) == 0 && payload.UserID != "" {
users = []string{payload.UserID}
}
if len(users) == 0 {
return nil, types.ErrMalformed
}
succeeded := make([]map[string]any, 0, len(users))
for _, raw := range users {
target := parseKafkaCommandUser(raw)
if target.IsZero() {
return map[string]any{"succeeded": succeeded}, types.ErrMalformed
}
if err := store.GroupBusiness.UpdateRole(tenantID, topic, actor, target,
types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, now); err != nil {
return map[string]any{"succeeded": succeeded, "failed_user_id": raw}, err
}
succeeded = append(succeeded, map[string]any{"user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleAdmin})
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "succeeded": succeeded}, nil
case adminCommandGroupAdminRemove:
target := parseKafkaCommandUser(firstNonEmpty(payload.UserID, payload.TargetID))
if target.IsZero() {
return nil, types.ErrMalformed
}
if err := store.GroupBusiness.UpdateRole(tenantID, topic, actor, target,
types.GroupMemberRoleMember, types.ModeCPublic, now); err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "role": types.GroupMemberRoleMember}, nil
case adminCommandGroupMemberMute:
target := parseKafkaCommandUser(firstNonEmpty(payload.UserID, payload.TargetID))
if target.IsZero() {
return nil, types.ErrMalformed
}
mute, err := store.GroupBusiness.UpsertMute(tenantID, &types.GroupMute{
Topic: topic,
UserID: target,
MutedBy: actor,
MutedAt: now,
ExpiresAt: payload.ExpiresAt,
Reason: strings.TrimSpace(payload.Reason),
Status: types.GroupMuteStatusActive,
})
if err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "muted": true, "version": mute.Version}, nil
case adminCommandGroupMemberUnmute:
target := parseKafkaCommandUser(firstNonEmpty(payload.UserID, payload.TargetID))
if target.IsZero() {
return nil, types.ErrMalformed
}
if err := store.GroupBusiness.DeleteMute(tenantID, topic, target, actor, now); err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "muted": false}, nil
case adminCommandGroupMemberRemove:
target := parseKafkaCommandUser(firstNonEmpty(payload.UserID, payload.TargetID))
if target.IsZero() {
return nil, types.ErrMalformed
}
if err := store.GroupBusiness.Leave(tenantID, topic, target, now); err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "user_id": internalAdminUIDRaw(target), "status": "left"}, nil
case adminCommandGroupOwnerTransfer:
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
if target.IsZero() {
return nil, types.ErrMalformed
}
if err := store.GroupBusiness.TransferOwner(tenantID, topic, actor, target, payload.Version, now); err != nil {
return nil, err
}
dispatchIMGroupEvents(tenantID)
return map[string]any{"topic": topic, "owner": internalAdminUIDRaw(target), "previous_owner": internalAdminUIDRaw(actor)}, nil
}
return nil, errors.New("unsupported admin command: " + envelope.EventType)
}
func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload, actor types.Uid) (map[string]any, error) {
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
if target.IsZero() {
return nil, types.ErrMalformed
}
eventType := strings.TrimSpace(envelope.EventType)
now := time.Now().UTC().Round(time.Millisecond)
result := map[string]any{
"user_id": internalAdminUIDRaw(target),
"action": eventType,
}
switch eventType {
case adminCommandUserBan, adminCommandUserFreeze:
if err := store.Users.UpdateState(tenantID, target, types.StateSuspended); err != nil {
return nil, err
}
evictUserSessions(target)
notifyUserStateChanged(tenantID, target, types.StateSuspended)
result["state"] = types.StateSuspended.String()
result["evicted"] = true
return result, nil
case adminCommandUserUnban, adminCommandUserUnfreeze:
if err := store.Users.UpdateState(tenantID, target, types.StateOK); err != nil {
return nil, err
}
notifyUserStateChanged(tenantID, target, types.StateOK)
result["state"] = types.StateOK.String()
return result, nil
case adminCommandUserDeviceKick:
deviceID := strings.TrimSpace(payload.DeviceID)
if deviceID == "" {
return nil, types.ErrMalformed
}
evicted := 0
if globals.sessionStore != nil {
evicted = globals.sessionStore.EvictUserDevice(target, deviceID)
}
if store.Devices != nil {
if err := store.Devices.Delete(tenantID, target, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
return nil, err
}
}
result["device_id"] = deviceID
result["evicted_sessions"] = evicted
return result, nil
case adminCommandUserLogoutAll:
evictUserSessions(target)
result["evicted"] = true
return result, nil
case adminCommandUserCancel, adminCommandUserDelete:
hard := payload.Hard || eventType == adminCommandUserDelete
if err := deleteUserByAdminCommand(tenantID, target, hard, now); err != nil {
return nil, err
}
result["state"] = types.StateDeleted.String()
result["hard"] = hard
return result, nil
}
return nil, errors.New("unsupported user governance command: " + eventType)
}
func executeKafkaUserPasswordResetCommand(tenantID types.TenantID, payload adminCommandPayload) (map[string]any, error) {
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
if target.IsZero() || payload.PasswordHash == "" {
return nil, types.ErrMalformed
}
passwordHash, err := base64.StdEncoding.DecodeString(payload.PasswordHash)
if err != nil || len(passwordHash) == 0 {
return nil, types.ErrMalformed
}
_, authLevel, _, _, err := store.Users.GetAuthRecord(tenantID, target)
if err != nil {
return nil, err
}
if err = store.Users.UpdateAuthRecord(tenantID, target, authLevel, "", passwordHash, time.Time{}); err != nil {
return nil, err
}
now := time.Now().UTC().Round(time.Millisecond)
authVersion := 0
if store.Security != nil {
version, err := store.Security.BumpAuthVersion(tenantID, target, now)
if err != nil {
return nil, err
}
authVersion = version
}
evictUserSessions(target)
return map[string]any{
"user_id": internalAdminUIDRaw(target),
"action": adminCommandUserPasswordReset,
"auth_version": authVersion,
"evicted": true,
}, nil
}
func evictUserSessions(uid types.Uid) {
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(uid, "")
}
}
func notifyUserStateChanged(tenantID types.TenantID, uid types.Uid, state types.ObjState) {
usersRemoveUser(tenantID, uid)
if globals.hub != nil && globals.hub.userStatus != nil {
globals.hub.userStatus <- &userStatusReq{tenantID: tenantID, forUser: uid, state: state}
}
}
func deleteUserByAdminCommand(tenantID types.TenantID, uid types.Uid, hard bool, _ time.Time) error {
evictUserSessions(uid)
usersRemoveUser(tenantID, uid)
if globals.hub != nil && globals.hub.unreg != nil {
done := make(chan bool)
globals.hub.unreg <- &topicUnreg{tenantID: tenantID, forUser: uid, del: hard, done: done}
<-done
}
return store.Users.Delete(tenantID, uid, hard)
}
func publishKafkaAdminCommandResult(ctx context.Context, command kafkaEnvelope, commandID, status, reason, message string, result map[string]any) error {
if commandID == "" {
commandID = command.EventID
}
payload := adminCommandResultPayload{
CommandID: commandID,
CommandEventID: command.EventID,
Status: status,
Reason: reason,
Message: message,
Result: result,
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
eventID := stableIMEventID("admin-command-result", command.EventID, status, reason)
return publishKafkaEnvelope(ctx, "admin_command_result", commandID, kafkaEnvelope{
EventID: eventID,
TraceID: command.TraceID,
TenantCode: command.TenantCode,
SourceService: "im-server",
TargetService: command.SourceService,
EventType: "admin.command." + status,
BizType: command.BizType,
BizID: command.BizID,
IdempotencyKey: command.IdempotencyKey,
OccurredAt: time.Now().UTC(),
ParentEventID: command.EventID,
Payload: encoded,
})
}
func kafkaTenantID(tenantCode string) (types.TenantID, error) {
tenant, err := store.Tenants.GetByCode(strings.TrimSpace(tenantCode))
if err != nil {
return types.ZeroTenantID, err
}
if tenant == nil || tenant.ID.IsZero() {
return types.ZeroTenantID, types.ErrNotFound
}
return tenant.ID, nil
}
func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope kafkaEnvelope, consume *types.KafkaConsumeRecord, reason, message string) error {
encoded, err := json.Marshal(envelope)
if err != nil {
return err
}
retryCount := 0
sourceTopic := kafkaTopic("admin_command")
if consume != nil {
retryCount = consume.RetryCount
if strings.TrimSpace(consume.SourceTopic) != "" {
sourceTopic = consume.SourceTopic
}
}
deadID := stableIMEventID("dead", envelope.EventID, kafkaBus.config.ConsumerGroup, retryCount)
dead := &types.KafkaDeadLetter{
DeadID: deadID,
EventID: envelope.EventID,
ParentEventID: envelope.ParentEventID,
SourceTopic: sourceTopic,
ConsumerGroup: kafkaBus.config.ConsumerGroup,
EventType: envelope.EventType,
BizType: envelope.BizType,
BizID: envelope.BizID,
Envelope: encoded,
RetryCount: retryCount,
LastReason: reason,
LastError: message,
Status: types.KafkaDeadStatusDead,
}
now := time.Now().UTC().Round(time.Millisecond)
if err = store.HTTPInfrastructure.MarkKafkaConsumeDead(tenantID, dead, now); err != nil {
return err
}
if auditErr := enqueueKafkaAuditEvent(ctx, tenantID, "system", types.ZeroUid, "kafka.dead_letter.created", "kafka_event", envelope.EventID, "dead", reason, map[string]any{
"dead_id": deadID,
"event_type": envelope.EventType,
"biz_type": envelope.BizType,
"biz_id": envelope.BizID,
"consumer_group": kafkaBus.config.ConsumerGroup,
}); auditErr != nil {
logs.Warn.Printf("kafka dead letter audit enqueue failed: event=%s dead=%s err=%v", envelope.EventID, deadID, auditErr)
}
deadPayload, err := json.Marshal(dead)
if err != nil {
return err
}
return publishKafkaEnvelope(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
EventID: deadID,
TraceID: envelope.TraceID,
TenantCode: envelope.TenantCode,
SourceService: "im-server",
TargetService: "hotgo-admin",
EventType: "kafka.dead_letter.created",
BizType: envelope.BizType,
BizID: envelope.BizID,
OccurredAt: now,
ParentEventID: envelope.EventID,
Payload: deadPayload,
})
}
func shouldDeadLetterKafkaCommand(reason string, retryCount int) bool {
if !kafkaCommandReasonRetryable(reason) {
return true
}
if kafkaBus == nil {
return true
}
return retryCount >= kafkaBus.config.MaxRetryCount
}
func kafkaCommandReasonRetryable(reason string) bool {
switch reason {
case "COMMAND_FAILED":
return true
default:
return false
}
}
func sleepKafkaRetryBackoff(retryCount int) {
if kafkaBus == nil || retryCount <= 0 {
return
}
backoffs := kafkaBus.config.RetryBackoffMS
index := retryCount - 1
if index >= len(backoffs) {
index = len(backoffs) - 1
}
delay := time.Duration(backoffs[index]) * time.Millisecond
if delay > 0 {
time.Sleep(delay)
}
}
func parseKafkaCommandUser(raw string) types.Uid {
raw = strings.TrimSpace(raw)
if raw == "" {
return types.ZeroUid
}
if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
return store.EncodeUid(id)
}
uid := types.ParseUserId(raw)
if !uid.IsZero() {
return uid
}
return types.ParseUid(raw)
}
func kafkaDeadLetterResult(dead *types.KafkaDeadLetter) map[string]any {
if dead == nil {
return nil
}
return map[string]any{
"dead_id": dead.DeadID,
"event_id": dead.EventID,
"source_topic": dead.SourceTopic,
"event_type": dead.EventType,
"biz_type": dead.BizType,
"biz_id": dead.BizID,
"status": dead.Status,
"replay_count": dead.ReplayCount,
"updated_at": dead.UpdatedAt,
}
}
func kafkaCommandErrorReason(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, types.ErrMalformed):
return "MALFORMED_COMMAND"
case errors.Is(err, types.ErrPolicy):
return "POLICY_VIOLATION"
case errors.Is(err, types.ErrNotFound):
return "RESOURCE_NOT_FOUND"
case errors.Is(err, types.ErrPermissionDenied):
return "FORBIDDEN"
case errors.Is(err, types.ErrDuplicate):
return "COMMAND_DUPLICATED"
case errors.Is(err, types.ErrInvalidResponse):
return "GROUP_VERSION_CONFLICT"
default:
if strings.Contains(err.Error(), "unsupported admin command") {
return "UNSUPPORTED_COMMAND"
}
return "COMMAND_FAILED"
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
cacheInvalidateEventAll = "cache.invalidate"
cacheInvalidateEventTenant = "cache.tenant.invalidate"
cacheInvalidateEventRegistration = "cache.registration.invalidate"
cacheInvalidateEventThirdParty = "cache.third_party.invalidate"
cacheInvalidateEventDictionary = "cache.dictionary.invalidate"
cacheInvalidateEventRedisObject = "cache.redis_object.invalidate"
cacheInvalidateEventGroupHotMessages = "cache.group_hot_messages.invalidate"
cacheInvalidateNamespaceTenant = "tenant"
cacheInvalidateNamespaceRegistration = "registration"
cacheInvalidateNamespaceThirdParty = "third_party"
cacheInvalidateNamespaceDictionary = "dictionary"
cacheInvalidateNamespaceGroupHotMessage = "group_hot_messages"
)
type cacheInvalidatePayload struct {
TenantCode string `json:"tenant_code"`
TenantID types.TenantID `json:"tenant_id"`
Namespace string `json:"namespace"`
Group string `json:"group"`
Key string `json:"key"`
Keys []string `json:"keys"`
Topic string `json:"topic"`
Ranges []types.Range `json:"ranges"`
}
func startKafkaCacheInvalidateConsumer() {
startKafkaConsumer("cache_invalidate", handleKafkaCacheInvalidateEvent)
}
func handleKafkaCacheInvalidateEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
if strings.TrimSpace(envelope.EventID) == "" {
return nil
}
tenantID, _ := kafkaTenantIDFromEnvelopeOrPayload(envelope)
if tenantID.IsZero() {
tenantID = types.TenantID(1)
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "cache_invalidate")
if err != nil {
return err
}
if !claimed {
logs.Info.Printf("kafka cache invalidate skipped: event=%s group=%s status=%s", envelope.EventID, kafkaBus.config.ConsumerGroup, consume.Status)
return nil
}
if err = executeKafkaCacheInvalidateEvent(envelope); err != nil {
reason := kafkaCommandErrorReason(err)
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, reason, err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, reason, err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func executeKafkaCacheInvalidateEvent(envelope kafkaEnvelope) error {
var payload cacheInvalidatePayload
if len(envelope.Payload) > 0 {
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
return err
}
}
tenantCode := firstNonEmpty(payload.TenantCode, envelope.TenantCode)
tenantID := payload.TenantID
if tenantID.IsZero() && tenantCode != "" {
if tenant, err := store.Tenants.GetByCode(tenantCode); err == nil && tenant != nil {
tenantID = tenant.ID
}
}
eventType := strings.TrimSpace(envelope.EventType)
namespace := strings.TrimSpace(payload.Namespace)
switch {
case eventType == cacheInvalidateEventTenant || namespace == cacheInvalidateNamespaceTenant:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
case eventType == cacheInvalidateEventRegistration || namespace == cacheInvalidateNamespaceRegistration:
store.InvalidateRegistrationConfigCache()
case eventType == cacheInvalidateEventThirdParty || eventType == cacheInvalidateEventDictionary ||
namespace == cacheInvalidateNamespaceThirdParty || namespace == cacheInvalidateNamespaceDictionary:
store.InvalidateThirdPartyConfigCache(payload.Group)
case eventType == cacheInvalidateEventGroupHotMessages || namespace == cacheInvalidateNamespaceGroupHotMessage:
if tenantID.IsValid() && strings.TrimSpace(payload.Topic) != "" {
if err := store.InvalidateHotMessages(tenantID, strings.TrimSpace(payload.Topic), payload.Ranges); err != nil {
return err
}
}
case eventType == cacheInvalidateEventRedisObject || namespace != "":
if tenantID.IsValid() && namespace != "" {
keys := append([]string{}, payload.Keys...)
if strings.TrimSpace(payload.Key) != "" {
keys = append(keys, strings.TrimSpace(payload.Key))
}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if err := store.DeleteCachedObject(tenantID, namespace, key); err != nil {
return err
}
}
}
default:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
store.InvalidateThirdPartyConfigCache(payload.Group)
store.InvalidateRegistrationConfigCache()
}
logs.Info.Printf("kafka cache invalidate applied: event=%s type=%s tenant=%s namespace=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, namespace, payload.Group)
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
configEventTenantUpdated = "tenant.updated"
configEventTenantStatusChanged = "tenant.status.changed"
configEventTenantLimitsUpdated = "tenant.limits.updated"
configEventTenantFeaturesUpdated = "tenant.features.updated"
configEventDictionaryUpdated = "dictionary.updated"
configEventThirdPartyUpdated = "third_party.updated"
configEventRegistrationUpdated = "registration.config.updated"
)
type configEventPayload struct {
TenantCode string `json:"tenant_code"`
TenantID types.TenantID `json:"tenant_id"`
Group string `json:"group"`
Keys []string `json:"keys"`
}
func startKafkaConfigEventConsumer() {
startKafkaConsumer("config_event", handleKafkaConfigEvent)
}
func handleKafkaConfigEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
if strings.TrimSpace(envelope.EventID) == "" {
return nil
}
tenantID, _ := kafkaTenantIDFromEnvelopeOrPayload(envelope)
if tenantID.IsZero() {
tenantID = types.TenantID(1)
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "config_event")
if err != nil {
return err
}
if !claimed {
logs.Info.Printf("kafka config event skipped: event=%s group=%s status=%s", envelope.EventID, kafkaBus.config.ConsumerGroup, consume.Status)
return nil
}
if err = executeKafkaConfigEvent(envelope); err != nil {
reason := kafkaCommandErrorReason(err)
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, reason, err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, reason, err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func executeKafkaConfigEvent(envelope kafkaEnvelope) error {
var payload configEventPayload
if len(envelope.Payload) > 0 {
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
return err
}
}
tenantCode := firstNonEmpty(payload.TenantCode, envelope.TenantCode)
tenantID := payload.TenantID
if tenantID.IsZero() && tenantCode != "" {
if tenant, err := store.Tenants.GetByCode(tenantCode); err == nil && tenant != nil {
tenantID = tenant.ID
}
}
switch strings.TrimSpace(envelope.EventType) {
case configEventTenantUpdated, configEventTenantStatusChanged, configEventTenantLimitsUpdated, configEventTenantFeaturesUpdated:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
case configEventDictionaryUpdated, configEventThirdPartyUpdated:
store.InvalidateThirdPartyConfigCache(payload.Group)
case configEventRegistrationUpdated:
store.InvalidateRegistrationConfigCache()
default:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
store.InvalidateThirdPartyConfigCache(payload.Group)
store.InvalidateRegistrationConfigCache()
}
logs.Info.Printf("kafka config event applied: event=%s type=%s tenant=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, payload.Group)
return nil
}
func kafkaTenantIDFromEnvelopeOrPayload(envelope kafkaEnvelope) (types.TenantID, error) {
var payload configEventPayload
if len(envelope.Payload) > 0 {
_ = json.Unmarshal(envelope.Payload, &payload)
}
if payload.TenantID.IsValid() {
return payload.TenantID, nil
}
return kafkaTenantID(firstNonEmpty(envelope.TenantCode, payload.TenantCode))
}
func beginKafkaConsumeForTopic(tenantID types.TenantID, envelope kafkaEnvelope, topicName string) (*types.KafkaConsumeRecord, bool, error) {
if kafkaBus == nil {
return nil, false, types.ErrUnsupported
}
deadline := time.Now().UTC().Add(5 * time.Minute).Round(time.Millisecond)
return store.HTTPInfrastructure.BeginKafkaConsume(tenantID, &types.KafkaConsumeRecord{
EventID: envelope.EventID,
ConsumerGroup: kafkaBus.config.ConsumerGroup,
SourceTopic: kafkaTopic(topicName),
EventType: envelope.EventType,
BizType: envelope.BizType,
BizID: envelope.BizID,
}, deadline)
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func replayKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, deadID string, actor types.Uid, note string) (*types.KafkaDeadLetter, error) {
now := time.Now().UTC().Round(time.Millisecond)
dead, err := store.HTTPInfrastructure.MarkKafkaDeadLetterReplay(tenantID, deadID, actor.UserId(), note, now)
if err != nil {
return nil, err
}
var envelope kafkaEnvelope
if err = json.Unmarshal(dead.Envelope, &envelope); err != nil {
return nil, err
}
if envelope.EventID == "" {
envelope.EventID = dead.EventID
}
if envelope.ParentEventID == "" {
envelope.ParentEventID = dead.ParentEventID
}
if err = publishKafkaEnvelope(ctx, strings.TrimSpace(dead.SourceTopic), envelope.EventID, envelope); err != nil {
return nil, err
}
if err = store.HTTPInfrastructure.MarkKafkaDeadLetter(tenantID, deadID, types.KafkaDeadStatusReplayed, actor.UserId(), note, now); err != nil {
return nil, err
}
return store.HTTPInfrastructure.GetKafkaDeadLetter(tenantID, deadID)
}
func resolveKafkaDeadLetter(tenantID types.TenantID, deadID, status string, actor types.Uid, note string) error {
now := time.Now().UTC().Round(time.Millisecond)
return store.HTTPInfrastructure.MarkKafkaDeadLetter(tenantID, deadID, status, actor.UserId(), note, now)
}
+265
View File
@@ -0,0 +1,265 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
kafkaFileLifecycleUploadCompleted = "file.upload.completed"
kafkaFileLifecycleAttachmentsBind = "file.attachments.bind"
kafkaFileLifecycleGroupFileCreate = "group.file.created"
kafkaFileLifecycleGroupFileDelete = "group.file.deleted"
)
type kafkaFileLifecyclePayload struct {
Action string `json:"action"`
FileID string `json:"file_id,omitempty"`
FileIDs []string `json:"file_ids,omitempty"`
AttachmentURLs []string `json:"attachment_urls,omitempty"`
Topic string `json:"topic,omitempty"`
MessageID string `json:"message_id,omitempty"`
SeqID int64 `json:"seq_id,omitempty"`
UserID string `json:"user_id,omitempty"`
GroupFileID string `json:"group_file_id,omitempty"`
FileType string `json:"file_type,omitempty"`
FileName string `json:"file_name,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
Storage map[string]any `json:"storage,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
}
func startKafkaFileLifecycleConsumer() {
startKafkaConsumer("file_lifecycle", handleKafkaFileLifecycle)
}
func enqueueKafkaFileUploaded(ctx context.Context, tenantID types.TenantID, fd *types.FileDef) error {
if !kafkaEnabled() || fd == nil || fd.TenantID != tenantID || fd.Status != types.UploadCompleted {
return nil
}
payload := kafkaFileLifecyclePayload{
Action: kafkaFileLifecycleUploadCompleted,
FileID: fd.Uid().String(),
UserID: types.ParseUid(fd.User).UserId(),
MIMEType: fd.MimeType,
FileSize: fd.Size,
OccurredAt: fd.UpdatedAt,
Storage: map[string]any{
"location": fd.Location,
"etag": fd.ETag,
},
}
return enqueueKafkaFileLifecycle(ctx, tenantID, kafkaFileLifecycleUploadCompleted, fd.Uid().String(), payload)
}
func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string) error {
if !kafkaEnabled() || msg == nil || msg.TenantID != tenantID || len(attachmentURLs) == 0 {
return nil
}
payload := kafkaFileLifecyclePayload{
Action: kafkaFileLifecycleAttachmentsBind,
AttachmentURLs: append([]string(nil), attachmentURLs...),
Topic: msg.Topic,
MessageID: msg.Uid().String(),
SeqID: msg.SeqId,
UserID: types.ParseUid(msg.From).UserId(),
FileType: lifecycleFileTypeFromMessageHead(msg.Head),
OccurredAt: msg.CreatedAt,
}
return enqueueKafkaFileLifecycle(ctx, tenantID, kafkaFileLifecycleAttachmentsBind, fmt.Sprintf("%s:%d", msg.Topic, msg.SeqId), payload)
}
func enqueueKafkaGroupFileLifecycle(ctx context.Context, tenantID types.TenantID, eventType string, file *types.GroupFile, actor types.Uid) error {
if !kafkaEnabled() || file == nil || file.Topic == "" || file.FileID == "" {
return nil
}
occurredAt := file.UpdatedAt
if occurredAt.IsZero() {
occurredAt = time.Now().UTC().Round(time.Millisecond)
}
payload := kafkaFileLifecyclePayload{
Action: eventType,
FileID: file.FileID,
FileIDs: []string{file.FileID},
Topic: file.Topic,
GroupFileID: file.GroupFileID,
SeqID: file.SourceSeq,
UserID: actor.UserId(),
FileType: file.FileType,
FileName: file.FileName,
MIMEType: file.MIMEType,
FileSize: file.FileSize,
OccurredAt: occurredAt,
}
return enqueueKafkaFileLifecycle(ctx, tenantID, eventType, firstNonEmpty(file.GroupFileID, file.FileID), payload)
}
func enqueueKafkaFileLifecycle(_ context.Context, tenantID types.TenantID, eventType, bizID string, payload kafkaFileLifecyclePayload) error {
tenantCode := ""
if store.Tenants != nil {
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
tenantCode = tenant.Code
} else {
logs.Warn.Printf("kafka file lifecycle: failed to resolve tenant code for tenant %d: %v", tenantID, err)
}
}
if payload.OccurredAt.IsZero() {
payload.OccurredAt = time.Now().UTC().Round(time.Millisecond)
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
eventID := stableIMEventID("file-lifecycle", tenantID, eventType, bizID, payload.SeqID, strings.Join(payload.AttachmentURLs, ","))
err = enqueueKafkaOutbox(tenantID, "file_lifecycle", bizID, kafkaEnvelope{
EventID: eventID,
TenantCode: tenantCode,
SourceService: "im-server",
TargetService: "im-server",
EventType: eventType,
BizType: "file",
BizID: bizID,
OccurredAt: payload.OccurredAt,
Payload: encoded,
})
if err != nil {
return err
}
actor := types.ParseUserId(payload.UserID)
if auditErr := enqueueKafkaAuditEvent(context.Background(), tenantID, "user", actor, eventType, "file", bizID, "succeeded", "", map[string]any{
"file_id": payload.FileID,
"file_ids": payload.FileIDs,
"topic": payload.Topic,
"group_file_id": payload.GroupFileID,
"seq_id": payload.SeqID,
}); auditErr != nil {
logs.Warn.Printf("kafka file lifecycle: failed to enqueue audit event type=%s biz=%s: %v", eventType, bizID, auditErr)
}
return nil
}
func handleKafkaFileLifecycle(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "file_lifecycle")
if err != nil || !claimed {
return err
}
var payload kafkaFileLifecyclePayload
if err = json.Unmarshal(envelope.Payload, &payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if err = executeKafkaFileLifecycle(tenantID, payload); err != nil {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func executeKafkaFileLifecycle(tenantID types.TenantID, payload kafkaFileLifecyclePayload) error {
switch payload.Action {
case kafkaFileLifecycleAttachmentsBind:
return executeKafkaFileAttachmentsBind(tenantID, payload)
case kafkaFileLifecycleUploadCompleted, kafkaFileLifecycleGroupFileCreate, kafkaFileLifecycleGroupFileDelete:
return nil
default:
return types.ErrMalformed
}
}
func executeKafkaFileAttachmentsBind(tenantID types.TenantID, payload kafkaFileLifecyclePayload) error {
if payload.Topic == "" || payload.MessageID == "" || len(payload.AttachmentURLs) == 0 {
return types.ErrMalformed
}
msgID := types.ParseUid(payload.MessageID)
if msgID.IsZero() {
return types.ErrMalformed
}
if types.GetTopicCat(payload.Topic) != types.TopicCatGrp {
return store.Files.LinkAttachments(tenantID, "", msgID, payload.AttachmentURLs)
}
uploader := types.ParseUserId(payload.UserID)
if uploader.IsZero() {
return types.ErrMalformed
}
for _, url := range payload.AttachmentURLs {
fid := store.Store.GetMediaHandler().GetIdFromUrl(url)
if fid.IsZero() {
continue
}
_, err := store.GroupBusiness.CreateFile(tenantID, &types.GroupFile{
Topic: payload.Topic,
FileID: fid.String(),
SourceTopic: payload.Topic,
SourceSeq: payload.SeqID,
UploaderID: uploader,
FileType: lifecycleFileType(payload.FileType),
IdempotencyKey: fmt.Sprintf("%s:%d:%s", payload.Topic, payload.SeqID, fid.String()),
})
if err != nil && !errors.Is(err, types.ErrDuplicate) {
return err
}
}
return nil
}
func lifecycleFileTypeFromMessageHead(head types.KVMap) string {
msgType := lifecycleHeaderString(head, "x-im-type")
if msgType == "" {
msgType = lifecycleHeaderString(head, "type")
}
if msgType == "" {
return "file"
}
return lifecycleFileType(msgType)
}
func lifecycleFileType(raw string) string {
switch lifecycleCanonicalMessageType(raw) {
case "image", "audio", "voice", "video":
return lifecycleCanonicalMessageType(raw)
default:
return "file"
}
}
func lifecycleHeaderString(head types.KVMap, key string) string {
if head == nil {
return ""
}
value, ok := head[key]
if !ok {
return ""
}
str, _ := value.(string)
return strings.TrimSpace(str)
}
func lifecycleCanonicalMessageType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "audio":
return "voice"
default:
return strings.ToLower(strings.TrimSpace(raw))
}
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type kafkaMessagePersistPayload struct {
Message types.Message `json:"message"`
AttachmentURLs []string `json:"attachment_urls,omitempty"`
ReadBySender bool `json:"read_by_sender,omitempty"`
MessageKind string `json:"message_kind"`
}
func startKafkaMessagePersistConsumer() {
startKafkaConsumer("message_persist", handleKafkaMessagePersist)
}
func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
if !kafkaEnabled() {
return store.Messages.Save(tenantID, msg, attachmentURLs, readBySender)
}
saveResult, err := store.PrepareMessagePersist(tenantID, msg, attachmentURLs, readBySender)
if err != nil || saveResult == nil || saveResult.Duplicate {
return saveResult, err
}
if readBySender && !types.ParseUid(msg.From).IsZero() {
saveResult.MarkedReadBySender = true
}
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
return nil, err
}
return saveResult, nil
}
func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
tenantCode := ""
if store.Tenants != nil {
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
tenantCode = tenant.Code
} else {
logs.Warn.Printf("kafka message persist: failed to resolve tenant code for tenant %d: %v", tenantID, err)
}
}
kind := "p2p"
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
kind = "group"
}
payload, err := json.Marshal(kafkaMessagePersistPayload{
Message: *msg,
AttachmentURLs: attachmentURLs,
ReadBySender: readBySender,
MessageKind: kind,
})
if err != nil {
return err
}
eventID := fmt.Sprintf("msg.persist.%d.%s.%d", tenantID, msg.Topic, msg.SeqId)
envelope := kafkaEnvelope{
EventID: eventID,
TenantCode: tenantCode,
SourceService: "im-server",
TargetService: "im-server",
EventType: "message.persist." + kind,
BizType: "message",
BizID: fmt.Sprintf("%s:%d", msg.Topic, msg.SeqId),
IdempotencyKey: msg.ClientMsgID,
Version: msg.SeqId,
OccurredAt: msg.CreatedAt,
Actor: &kafkaActor{Type: "user", ID: types.ParseUid(msg.From).UserId()},
Payload: payload,
}
return enqueueKafkaOutbox(tenantID, "message_persist", msg.Topic, envelope)
}
func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist")
if err != nil || !claimed {
return err
}
var payload kafkaMessagePersistPayload
if err = json.Unmarshal(envelope.Payload, &payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if payload.Message.TenantID == types.ZeroTenantID {
payload.Message.TenantID = tenantID
}
if payload.Message.TenantID != tenantID {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "TENANT_MISMATCH", "message tenant does not match envelope tenant")
}
if err = validateKafkaMessageKind(payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if _, err = store.PersistPreparedMessage(tenantID, &payload.Message, nil, payload.ReadBySender); err != nil && !errors.Is(err, types.ErrDuplicate) {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
if len(payload.AttachmentURLs) > 0 {
if err = enqueueKafkaFileAttachmentsBind(ctx, tenantID, &payload.Message, payload.AttachmentURLs); err != nil {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
"KAFKA_FILE_LIFECYCLE_FAILED", err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, "KAFKA_FILE_LIFECYCLE_FAILED", err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func validateKafkaMessageKind(payload kafkaMessagePersistPayload) error {
if payload.Message.Topic == "" || payload.Message.SeqId <= 0 {
return types.ErrMalformed
}
isGroup := types.GetTopicCat(payload.Message.Topic) == types.TopicCatGrp
switch payload.MessageKind {
case "group":
if !isGroup {
return types.ErrMalformed
}
case "p2p", "":
if isGroup {
return types.ErrMalformed
}
default:
return types.ErrMalformed
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
kafkaOutboxPollInterval = 2 * time.Second
kafkaOutboxTenantLimit = 1000
kafkaOutboxBatchLimit = 100
)
func startKafkaOutboxPoller() {
if !kafkaEnabled() {
return
}
kafkaBus.wg.Add(1)
go func() {
defer kafkaBus.wg.Done()
ticker := time.NewTicker(kafkaOutboxPollInterval)
defer ticker.Stop()
for {
select {
case <-kafkaBus.ctx.Done():
return
default:
drainKafkaOutbox(kafkaBus.ctx)
}
select {
case <-kafkaBus.ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func enqueueKafkaOutbox(tenantID types.TenantID, topicName, key string, envelope kafkaEnvelope) error {
if envelope.OccurredAt.IsZero() {
envelope.OccurredAt = time.Now().UTC()
}
raw, err := json.Marshal(envelope)
if err != nil {
return err
}
return store.HTTPInfrastructure.CreateKafkaOutbox(tenantID, &types.KafkaOutboxRecord{
EventID: envelope.EventID,
Topic: topicName,
EventType: envelope.EventType,
BizType: envelope.BizType,
BizID: envelope.BizID,
Key: key,
Envelope: raw,
Status: types.KafkaOutboxPending,
NextRetryAt: nil,
})
}
func drainKafkaOutbox(ctx context.Context) {
if store.Tenants == nil || store.HTTPInfrastructure == nil {
return
}
tenants, err := store.Tenants.ListActive(kafkaOutboxTenantLimit)
if err != nil {
logs.Warn.Printf("kafka outbox: list active tenants: %v", err)
return
}
now := time.Now().UTC().Round(time.Millisecond)
for _, tenant := range tenants {
if tenant.ID.IsZero() {
continue
}
records, err := store.HTTPInfrastructure.ListPendingKafkaOutbox(tenant.ID, kafkaOutboxBatchLimit, now)
if err != nil {
logs.Warn.Printf("kafka outbox: list tenant=%d: %v", tenant.ID, err)
continue
}
for _, record := range records {
if err = publishKafkaOutboxRecord(ctx, record); err != nil {
nextRetryAt := now.Add(kafkaOutboxRetryDelay(record.Attempts + 1))
if markErr := store.HTTPInfrastructure.MarkKafkaOutboxFailed(tenant.ID, record.EventID,
"KAFKA_PUBLISH_FAILED", err.Error(), nextRetryAt, now); markErr != nil {
logs.Warn.Printf("kafka outbox: mark failed event=%s tenant=%d: %v", record.EventID, tenant.ID, markErr)
}
continue
}
if err = store.HTTPInfrastructure.MarkKafkaOutboxSent(tenant.ID, record.EventID, time.Now().UTC().Round(time.Millisecond)); err != nil {
logs.Warn.Printf("kafka outbox: mark sent event=%s tenant=%d: %v", record.EventID, tenant.ID, err)
}
}
}
}
func publishKafkaOutboxRecord(ctx context.Context, record types.KafkaOutboxRecord) error {
topic := kafkaTopic(record.Topic)
if topic == "" {
topic = record.Topic
}
return kafkaBus.writer.WriteMessages(ctx, kafka.Message{
Topic: topic,
Key: []byte(record.Key),
Value: record.Envelope,
Time: time.Now().UTC(),
})
}
func kafkaOutboxRetryDelay(attempt int) time.Duration {
if attempt <= 0 {
attempt = 1
}
if kafkaBus == nil || len(kafkaBus.config.RetryBackoffMS) == 0 {
return time.Duration(attempt) * 10 * time.Second
}
index := attempt - 1
if index >= len(kafkaBus.config.RetryBackoffMS) {
index = len(kafkaBus.config.RetryBackoffMS) - 1
}
return time.Duration(kafkaBus.config.RetryBackoffMS[index]) * time.Millisecond
}
+344
View File
@@ -0,0 +1,344 @@
package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"github.com/segmentio/kafka-go"
"github.com/segmentio/kafka-go/sasl"
"github.com/segmentio/kafka-go/sasl/scram"
"github.com/tinode/chat/server/logs"
)
const (
kafkaTopicAdminCommand = "im.admin.command"
kafkaTopicAdminCommandResult = "im.admin.command.result"
kafkaTopicConfigEvent = "im.config.event"
kafkaTopicBusinessEvent = "im.business.event"
kafkaTopicMessagePersist = "im.message.persist"
kafkaTopicMessagePush = "im.message.push"
kafkaTopicFileSecurity = "im.file.security"
kafkaTopicFileLifecycle = "im.file.lifecycle"
kafkaTopicWorkbenchEvent = "im.workbench.event"
kafkaTopicCacheInvalidate = "im.cache.invalidate"
kafkaTopicAuditEvent = "im.audit.event"
kafkaTopicDeadLetter = "im.dead.letter"
)
type kafkaConfig struct {
Enabled bool `json:"enabled"`
Brokers []string `json:"brokers"`
ClientID string `json:"client_id"`
ConsumerGroup string `json:"consumer_group"`
Topics map[string]string `json:"topics"`
DialTimeoutMS int `json:"dial_timeout_ms"`
WriteTimeoutMS int `json:"write_timeout_ms"`
ReadTimeoutMS int `json:"read_timeout_ms"`
RequiredAcks int `json:"required_acks"`
AllowAutoTopic bool `json:"allow_auto_topic_creation"`
TLSEnabled bool `json:"tls_enabled"`
SASLEnabled bool `json:"sasl_enabled"`
SASLMechanism string `json:"sasl_mechanism"`
SASLUsername string `json:"sasl_username"`
SASLPassword string `json:"sasl_password"`
MaxRetryCount int `json:"max_retry_count"`
RetryBackoffMS []int `json:"retry_backoff_ms"`
DeadRetentionSec int `json:"dead_letter_retention_seconds"`
}
type kafkaEnvelope struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id,omitempty"`
TenantCode string `json:"tenant_code,omitempty"`
SourceService string `json:"source_service,omitempty"`
TargetService string `json:"target_service,omitempty"`
EventType string `json:"event_type"`
BizType string `json:"biz_type,omitempty"`
BizID string `json:"biz_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Version int64 `json:"version,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
Actor *kafkaActor `json:"actor,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
type kafkaActor struct {
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
type kafkaRuntime struct {
config kafkaConfig
topics map[string]string
writer *kafka.Writer
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
var kafkaBus *kafkaRuntime
func initKafkaRuntime(raw json.RawMessage) error {
if len(raw) == 0 {
kafkaBus = nil
logs.Info.Println("Kafka disabled: no config")
return nil
}
var cfg kafkaConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
if !cfg.Enabled {
kafkaBus = nil
logs.Info.Println("Kafka disabled")
return nil
}
normalizeKafkaConfig(&cfg)
if len(cfg.Brokers) == 0 {
return errors.New("kafka: enabled but brokers are not configured")
}
saslMechanism, err := kafkaSASLMechanism(cfg)
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
rt := &kafkaRuntime{
config: cfg,
topics: kafkaTopics(cfg.Topics),
ctx: ctx,
cancel: cancel,
}
transport := &kafka.Transport{
ClientID: cfg.ClientID,
}
if cfg.TLSEnabled {
transport.TLS = &tls.Config{MinVersion: tls.VersionTLS12}
}
transport.SASL = saslMechanism
rt.writer = &kafka.Writer{
Addr: kafka.TCP(cfg.Brokers...),
Balancer: &kafka.Hash{},
Transport: transport,
RequiredAcks: kafka.RequiredAcks(cfg.RequiredAcks),
AllowAutoTopicCreation: cfg.AllowAutoTopic,
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
}
if err := rt.ping(context.Background()); err != nil {
cancel()
_ = rt.writer.Close()
return err
}
kafkaBus = rt
logs.Info.Printf("Kafka enabled: brokers=%s client_id=%s group=%s", strings.Join(cfg.Brokers, ","), cfg.ClientID, cfg.ConsumerGroup)
return nil
}
func normalizeKafkaConfig(cfg *kafkaConfig) {
if cfg.ClientID == "" {
cfg.ClientID = "im-server"
}
if cfg.ConsumerGroup == "" {
cfg.ConsumerGroup = "im-server"
}
if cfg.DialTimeoutMS <= 0 {
cfg.DialTimeoutMS = 3000
}
if cfg.WriteTimeoutMS <= 0 {
cfg.WriteTimeoutMS = 3000
}
if cfg.ReadTimeoutMS <= 0 {
cfg.ReadTimeoutMS = 3000
}
if cfg.RequiredAcks == 0 {
cfg.RequiredAcks = int(kafka.RequireOne)
}
if cfg.MaxRetryCount <= 0 {
cfg.MaxRetryCount = 5
}
if len(cfg.RetryBackoffMS) == 0 {
cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000}
}
if cfg.DeadRetentionSec <= 0 {
cfg.DeadRetentionSec = 90 * 24 * 3600
}
if cfg.SASLMechanism == "" {
cfg.SASLMechanism = "SCRAM-SHA-512"
}
}
func kafkaSASLMechanism(cfg kafkaConfig) (sasl.Mechanism, error) {
if !cfg.SASLEnabled && cfg.SASLUsername == "" {
return nil, nil
}
if cfg.SASLUsername == "" || cfg.SASLPassword == "" {
return nil, errors.New("kafka: sasl enabled but username or password is empty")
}
switch strings.ToUpper(strings.ReplaceAll(cfg.SASLMechanism, "_", "-")) {
case "", "SCRAM-SHA-512":
return scram.Mechanism(scram.SHA512, cfg.SASLUsername, cfg.SASLPassword)
case "SCRAM-SHA-256":
return scram.Mechanism(scram.SHA256, cfg.SASLUsername, cfg.SASLPassword)
default:
return nil, errors.New("kafka: unsupported sasl mechanism " + cfg.SASLMechanism)
}
}
func kafkaTopics(overrides map[string]string) map[string]string {
topics := map[string]string{
"admin_command": kafkaTopicAdminCommand,
"admin_command_result": kafkaTopicAdminCommandResult,
"config_event": kafkaTopicConfigEvent,
"business_event": kafkaTopicBusinessEvent,
"message_persist": kafkaTopicMessagePersist,
"message_push": kafkaTopicMessagePush,
"file_security": kafkaTopicFileSecurity,
"file_lifecycle": kafkaTopicFileLifecycle,
"workbench_event": kafkaTopicWorkbenchEvent,
"cache_invalidate": kafkaTopicCacheInvalidate,
"audit_event": kafkaTopicAuditEvent,
"dead_letter": kafkaTopicDeadLetter,
}
for key, value := range overrides {
if value != "" {
topics[key] = value
}
}
return topics
}
func (rt *kafkaRuntime) ping(ctx context.Context) error {
timeout := time.Duration(rt.config.DialTimeoutMS) * time.Millisecond
dialer := rt.dialer()
pingCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := dialer.DialContext(pingCtx, "tcp", rt.config.Brokers[0])
if err != nil {
return err
}
return conn.Close()
}
func kafkaEnabled() bool {
return kafkaBus != nil
}
func kafkaTopic(name string) string {
if kafkaBus == nil {
return ""
}
return kafkaBus.topics[name]
}
func publishKafkaEnvelope(ctx context.Context, topicName string, key string, envelope kafkaEnvelope) error {
if kafkaBus == nil {
return nil
}
if envelope.OccurredAt.IsZero() {
envelope.OccurredAt = time.Now().UTC()
}
topic := kafkaBus.topics[topicName]
if topic == "" {
topic = topicName
}
value, err := json.Marshal(envelope)
if err != nil {
return err
}
return kafkaBus.writer.WriteMessages(ctx, kafka.Message{
Topic: topic,
Key: []byte(key),
Value: value,
Time: envelope.OccurredAt,
})
}
type kafkaMessageHandler func(context.Context, kafkaEnvelope, kafka.Message) error
func startKafkaConsumer(topicName string, handler kafkaMessageHandler) {
if kafkaBus == nil || handler == nil {
return
}
topic := kafkaBus.topics[topicName]
if topic == "" {
topic = topicName
}
reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: kafkaBus.config.Brokers,
GroupID: kafkaBus.config.ConsumerGroup,
Topic: topic,
StartOffset: kafka.FirstOffset,
MinBytes: 1,
MaxBytes: 10e6,
MaxWait: time.Second,
Dialer: kafkaBus.dialer(),
})
kafkaBus.wg.Add(1)
go func() {
defer kafkaBus.wg.Done()
defer reader.Close()
for {
msg, err := reader.FetchMessage(kafkaBus.ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(kafkaBus.ctx.Err(), context.Canceled) {
return
}
logs.Err.Println("Kafka fetch failed:", err)
continue
}
var envelope kafkaEnvelope
if err = json.Unmarshal(msg.Value, &envelope); err != nil {
logs.Err.Println("Kafka envelope decode failed:", err)
_ = reader.CommitMessages(context.Background(), msg)
continue
}
if err = handler(kafkaBus.ctx, envelope, msg); err != nil {
logs.Err.Println("Kafka handler failed:", envelope.EventID, envelope.EventType, err)
continue
}
if err = reader.CommitMessages(context.Background(), msg); err != nil {
logs.Err.Println("Kafka commit failed:", err)
}
}
}()
logs.Info.Printf("Kafka consumer started: topic=%s group=%s", topic, kafkaBus.config.ConsumerGroup)
}
func (rt *kafkaRuntime) dialer() *kafka.Dialer {
dialer := &kafka.Dialer{
ClientID: rt.config.ClientID,
Timeout: time.Duration(rt.config.DialTimeoutMS) * time.Millisecond,
}
if rt.config.TLSEnabled {
dialer.TLS = &tls.Config{MinVersion: tls.VersionTLS12}
}
if mechanism, err := kafkaSASLMechanism(rt.config); err != nil {
logs.Err.Println("Kafka SASL config failed:", err)
} else {
dialer.SASLMechanism = mechanism
}
return dialer
}
func stopKafkaRuntime() {
if kafkaBus == nil {
return
}
kafkaBus.cancel()
kafkaBus.wg.Wait()
if kafkaBus.writer != nil {
_ = kafkaBus.writer.Close()
}
logs.Info.Println("Stopped Kafka runtime")
kafkaBus = nil
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"context"
"encoding/json"
"time"
"github.com/segmentio/kafka-go"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type kafkaWorkbenchPayload struct {
Module string `json:"module"`
Action string `json:"action"`
ResourceID string `json:"resource_id"`
ActorUserID string `json:"actor_user_id"`
TargetUserIDs []string `json:"target_user_ids,omitempty"`
Title string `json:"title,omitempty"`
Summary string `json:"summary,omitempty"`
Data map[string]any `json:"data,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
}
type kafkaAuditPayload struct {
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Result string `json:"result"`
Reason string `json:"reason,omitempty"`
Data map[string]any `json:"data,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
}
func startKafkaWorkbenchEventConsumer() {
startKafkaConsumer("workbench_event", handleKafkaWorkbenchEvent)
}
func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string,
targets []types.Uid, title, summary string, data map[string]any) error {
if !kafkaEnabled() {
return nil
}
targetUserIDs := make([]string, 0, len(targets))
for _, target := range targets {
if !target.IsZero() {
targetUserIDs = append(targetUserIDs, target.UserId())
}
}
payload := kafkaWorkbenchPayload{
Module: module,
Action: action,
ResourceID: resourceID,
ActorUserID: actor.UserId(),
TargetUserIDs: targetUserIDs,
Title: title,
Summary: summary,
Data: data,
OccurredAt: time.Now().UTC().Round(time.Millisecond),
}
return enqueueKafkaTypedEvent(ctx, tenantID, "workbench_event", "workbench."+action, "workbench", resourceID, actor, payload)
}
func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorType string, actor types.Uid,
action, resource, resourceID, result, reason string, data map[string]any) error {
if !kafkaEnabled() {
return nil
}
actorID := ""
if !actor.IsZero() {
actorID = actor.UserId()
}
payload := kafkaAuditPayload{
ActorType: actorType,
ActorID: actorID,
Action: action,
Resource: resource,
ResourceID: resourceID,
Result: result,
Reason: reason,
Data: data,
OccurredAt: time.Now().UTC().Round(time.Millisecond),
}
return enqueueKafkaTypedEvent(ctx, tenantID, "audit_event", "audit."+action, "audit", resource+":"+resourceID, actor, payload)
}
func enqueueKafkaTypedEvent(_ context.Context, tenantID types.TenantID, topicName, eventType, bizType, bizID string, actor types.Uid, payload any) error {
tenantCode := ""
if store.Tenants != nil {
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
tenantCode = tenant.Code
} else {
logs.Warn.Printf("kafka event: failed to resolve tenant code for tenant %d: %v", tenantID, err)
}
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded)
envelope := kafkaEnvelope{
EventID: eventID,
TenantCode: tenantCode,
SourceService: "im-server",
TargetService: "im-server",
EventType: eventType,
BizType: bizType,
BizID: bizID,
OccurredAt: time.Now().UTC(),
Payload: encoded,
}
if !actor.IsZero() {
envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()}
}
return enqueueKafkaOutbox(tenantID, topicName, bizID, envelope)
}
func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
tenantID, err := kafkaTenantID(envelope.TenantCode)
if err != nil {
return err
}
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "workbench_event")
if err != nil || !claimed {
return err
}
var payload kafkaWorkbenchPayload
if err = json.Unmarshal(envelope.Payload, &payload); err != nil {
return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error())
}
if err = executeKafkaWorkbenchEvent(tenantID, envelope, payload); err != nil {
failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup,
kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond))
if markErr != nil {
return markErr
}
if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount {
return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error())
}
sleepKafkaRetryBackoff(failed.RetryCount)
return err
}
return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond))
}
func executeKafkaWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaWorkbenchPayload) error {
actor := types.ParseUserId(payload.ActorUserID)
if actor.IsZero() {
actor = types.ParseUid(payload.ActorUserID)
}
for _, raw := range payload.TargetUserIDs {
target := types.ParseUserId(raw)
if target.IsZero() {
continue
}
eventID := stableIMEventID("workbench", envelope.EventID, target.UserId())
if target != actor && payload.Title != "" {
if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), payload.Action,
payload.Title, payload.Summary, types.KVMap{"module": payload.Module, "resource_id": payload.ResourceID}); err != nil {
return err
}
}
if err := enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, payload.Action, payload.Data); err != nil {
return err
}
}
return nil
}
+33 -1
View File
@@ -11,6 +11,7 @@ package main
//go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto //go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto
import ( import (
"context"
"encoding/json" "encoding/json"
"flag" "flag"
"net/http" "net/http"
@@ -323,6 +324,9 @@ type configType struct {
Cluster json.RawMessage `json:"cluster_config"` Cluster json.RawMessage `json:"cluster_config"`
Plugin json.RawMessage `json:"plugins"` Plugin json.RawMessage `json:"plugins"`
Store json.RawMessage `json:"store_config"` Store json.RawMessage `json:"store_config"`
Kafka json.RawMessage `json:"kafka"`
Internal json.RawMessage `json:"internal_api"`
AdminAPI json.RawMessage `json:"admin_internal_api"`
Push json.RawMessage `json:"push"` Push json.RawMessage `json:"push"`
TLS json.RawMessage `json:"tls"` TLS json.RawMessage `json:"tls"`
Auth map[string]json.RawMessage `json:"auth_config"` Auth map[string]json.RawMessage `json:"auth_config"`
@@ -337,7 +341,7 @@ func main() {
logFlags := flag.String("log_flags", "stdFlags", logFlags := flag.String("log_flags", "stdFlags",
"Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix)") "Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix)")
configfile := flag.String("config", "tinode.conf", "Path to config file.") configfile := flag.String("config", "meetingim.conf", "Path to config file.")
// Path to static content. // Path to static content.
staticPath := flag.String("static_data", defaultStaticPath, "File path to directory with static files to be served.") staticPath := flag.String("static_data", defaultStaticPath, "File path to directory with static files to be served.")
listenOn := flag.String("listen", "", "Override address and port to listen on for HTTP(S) clients.") listenOn := flag.String("listen", "", "Override address and port to listen on for HTTP(S) clients.")
@@ -449,6 +453,17 @@ func main() {
}() }()
statsRegisterDbStats() statsRegisterDbStats()
if err = initKafkaRuntime(config.Kafka); err != nil {
logs.Err.Fatal("Failed to initialize Kafka:", err)
}
defer stopKafkaRuntime()
if err = initInternalAPI(config.Internal); err != nil {
logs.Err.Fatal("Failed to initialize internal API:", err)
}
if err = initAdminInternalAPI(config.AdminAPI); err != nil {
logs.Err.Fatal("Failed to initialize Admin internal API client:", err)
}
// API key signing secret // API key signing secret
globals.apiKeySalt = config.APIKeySalt globals.apiKeySalt = config.APIKeySalt
err = store.InitAuthLogicalNames(config.Auth["logical_names"]) err = store.InitAuthLogicalNames(config.Auth["logical_names"])
@@ -676,6 +691,14 @@ func main() {
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second) globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
// The hub (the main message router) // The hub (the main message router)
globals.hub = newHub() globals.hub = newHub()
startRedisGroupFanout(context.Background())
startKafkaAdminCommandConsumer()
startKafkaConfigEventConsumer()
startKafkaCacheInvalidateConsumer()
startKafkaMessagePersistConsumer()
startKafkaFileLifecycleConsumer()
startKafkaWorkbenchEventConsumer()
startKafkaOutboxPoller()
stopExternalBusinessEvents := startIMExternalBusinessEventPoller() stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents() defer stopExternalBusinessEvents()
@@ -802,6 +825,10 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource) mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource)
mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers) mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers)
mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch) mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/config", serveIMAttendanceConfig)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/records", serveIMAttendanceRecords)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals)
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings) mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons) mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
@@ -830,6 +857,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch) mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/p2p", serveIMP2PConversation) mux.HandleFunc(config.ApiPath+"v1/im/conversations/p2p", serveIMP2PConversation)
mux.HandleFunc(config.ApiPath+"v1/im/forward-targets", serveIMForwardTargets) mux.HandleFunc(config.ApiPath+"v1/im/forward-targets", serveIMForwardTargets)
mux.HandleFunc(config.ApiPath+"v1/im/messages/forward", serveIMMessageForward)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites) mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
@@ -850,6 +878,10 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser) mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser)
mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends) mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends)
mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource) mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource)
adminQueryPrefix := config.ApiPath + "internal/admin-query/"
mux.HandleFunc(adminQueryPrefix, func(wrt http.ResponseWriter, req *http.Request) {
serveInternalAdminQuery(wrt, req, adminQueryPrefix)
})
// Handle long polling clients. Enable compression. // Handle long polling clients. Enable compression.
mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
if config.Media != nil { if config.Media != nil {
+223
View File
@@ -0,0 +1,223 @@
{
"listen": ":6060",
"api_path": "/",
"cache_control": 39600,
"ws_compression_disabled": false,
"static_mount": "/",
"static_data": "static",
"grpc_listen": ":16060",
"grpc_keepalive_enabled": true,
"api_key_salt": "T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=",
"max_message_size": 131072,
"max_subscriber_count": 128,
"max_tag_count": 16,
"permanent_accounts": false,
"expvar": "/debug/vars",
"server_status": "-",
"use_x_forwarded_for": true,
"x_frame_options": "SAMEORIGIN",
"default_country_code": "CN",
"p2p_delete_enabled": true,
"msg_delete_age": 600,
"alias_tag": "alias",
"media": {
"use_handler": "fs",
"max_size": 104857600,
"gc_period": 3600,
"gc_block_size": 100,
"handlers": {
"fs": {
"upload_dir": "uploads",
"cache_control": "max-age=86400",
"cors_origins": ["*"]
}
}
},
"webrtc": {
"enabled": true,
"call_establishment_timeout": 30,
"ice_servers": [
{
"urls": ["stun:stun.l.google.com:19302"]
}
]
},
"tls": {
"enabled": false,
"http_redirect": "",
"strict_max_age": 0
},
"auth_config": {
"logical_names": [],
"basic": {
"add_to_tags": false,
"min_login_length": 1,
"min_password_length": 6
},
"token": {
"expire_in": 1209600,
"serial_num": 1,
"key": "wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc="
},
"code": {
"expire_in": 900,
"max_retries": 3,
"code_length": 6
}
},
"store_config": {
"uid_key": "la6YsO+bNX/+XIkOqc5Svw==",
"max_results": 1024,
"use_adapter": "mysql",
"adapters": {
"mysql": {
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(64.118.137.182:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo",
"tenant_db_routing": true,
"max_open_conns": 64,
"max_idle_conns": 64,
"conn_max_lifetime": 60,
"sql_timeout": 10
}
},
"redis": {
"addr": "64.118.137.182:56379",
"username": "",
"password": "HMTJzkm.h>Y+E9ep",
"db": 0,
"key_prefix": "im",
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
"presence_ttl_seconds": 120,
"cache_ttl_seconds": 300,
"short_state_ttl_seconds": 600,
"group_fanout_enabled": true
}
},
"kafka": {
"enabled": true,
"brokers": ["64.118.137.182:9094"],
"client_id": "im-server",
"consumer_group": "im-server",
"dial_timeout_ms": 3000,
"write_timeout_ms": 3000,
"read_timeout_ms": 3000,
"required_acks": 1,
"allow_auto_topic_creation": false,
"tls_enabled": false,
"sasl_enabled": true,
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "kafka-external",
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
"max_retry_count": 5,
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
"dead_letter_retention_seconds": 7776000,
"topics": {
"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",
"message_push": "im.message.push",
"file_security": "im.file.security",
"file_lifecycle": "im.file.lifecycle",
"workbench_event": "im.workbench.event",
"cache_invalidate": "im.cache.invalidate",
"audit_event": "im.audit.event",
"dead_letter": "im.dead.letter"
}
},
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"services": {
"hotgo-admin": {
"secret": "8569f5626a54950539eece9f731c9e3bf073daa74af8d23735d19afacac40f4b",
"allow": ["admin_query"]
}
}
},
"admin_internal_api": {
"enabled": true,
"base_url": "http://im-admin:8000",
"service_name": "im-server",
"secret": "b96832c04dbfb6dd768930ec9f082ecd90d87a1ffdaa493aa070617c48de9097",
"timeout_ms": 3000
},
"acc_validation": {
"email": {
"add_to_tags": false,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "MeetingIM <noreply@118im.com>",
"validation_templ": "server/templ/email-validation-en.templ",
"reset_secret_templ": "server/templ/email-password-reset-en.templ",
"debug_response": "123456",
"max_retries": 3
}
},
"tel": {
"add_to_tags": false,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "MeetingIM",
"universal_templ": "server/templ/sms-universal-en.templ",
"debug_response": "123456",
"max_retries": 3
}
}
},
"acc_gc_config": {
"enabled": true,
"gc_period": 3600,
"gc_block_size": 10,
"gc_min_account_age": 30
},
"push": [
{
"name": "stdout",
"config": {
"enabled": false
}
},
{
"name": "fcm",
"config": {
"enabled": false
}
},
{
"name": "tnpg",
"config": {
"enabled": false
}
}
],
"cluster_config": {
"self": "",
"nodes": [],
"failover": {
"enabled": false
}
},
"plugins": []
}
+31 -31
View File
@@ -41,7 +41,7 @@ func pbServDataSerialize(data *MsgServerData) *pbx.ServerMsg_Data {
FromUserId: data.From, FromUserId: data.From,
Timestamp: timeToInt64(&data.Timestamp), Timestamp: timeToInt64(&data.Timestamp),
DeletedAt: timeToInt64(data.DeletedAt), DeletedAt: timeToInt64(data.DeletedAt),
SeqId: int32(data.SeqId), SeqId: data.SeqId,
Head: interfaceMapToByteMap(data.Head), Head: interfaceMapToByteMap(data.Head),
Content: interfaceToBytes(data.Content), Content: interfaceToBytes(data.Content),
}, },
@@ -86,7 +86,7 @@ func pbServPresSerialize(pres *MsgServerPres) *pbx.ServerMsg_Pres {
Src: pres.Src, Src: pres.Src,
What: what, What: what,
UserAgent: pres.UserAgent, UserAgent: pres.UserAgent,
SeqId: int32(pres.SeqId), SeqId: pres.SeqId,
DelId: int32(pres.DelId), DelId: int32(pres.DelId),
DelSeq: pbDelQuerySerialize(pres.DelSeq), DelSeq: pbDelQuerySerialize(pres.DelSeq),
TargetUserId: pres.AcsTarget, TargetUserId: pres.AcsTarget,
@@ -103,7 +103,7 @@ func pbServInfoSerialize(info *MsgServerInfo) *pbx.ServerMsg_Info {
FromUserId: info.From, FromUserId: info.From,
Src: info.Src, Src: info.Src,
What: pbInfoNoteWhatSerialize(info.What), What: pbInfoNoteWhatSerialize(info.What),
SeqId: int32(info.SeqId), SeqId: info.SeqId,
Event: pbCallEventSerialize(info.Event), Event: pbCallEventSerialize(info.Event),
Payload: info.Payload, Payload: info.Payload,
}, },
@@ -165,7 +165,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
From: data.GetFromUserId(), From: data.GetFromUserId(),
Timestamp: *tsptr, Timestamp: *tsptr,
DeletedAt: int64ToTime(data.GetDeletedAt()), DeletedAt: int64ToTime(data.GetDeletedAt()),
SeqId: int(data.GetSeqId()), SeqId: data.GetSeqId(),
Head: byteMapToInterfaceMap(data.GetHead()), Head: byteMapToInterfaceMap(data.GetHead()),
Content: data.GetContent(), Content: data.GetContent(),
} }
@@ -204,7 +204,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
Src: pres.GetSrc(), Src: pres.GetSrc(),
What: what, What: what,
UserAgent: pres.GetUserAgent(), UserAgent: pres.GetUserAgent(),
SeqId: int(pres.GetSeqId()), SeqId: pres.GetSeqId(),
DelId: int(pres.GetDelId()), DelId: int(pres.GetDelId()),
DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()), DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()),
AcsTarget: pres.GetTargetUserId(), AcsTarget: pres.GetTargetUserId(),
@@ -217,7 +217,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
Src: info.GetSrc(), Src: info.GetSrc(),
From: info.GetFromUserId(), From: info.GetFromUserId(),
What: pbInfoNoteWhatDeserialize(info.GetWhat()), What: pbInfoNoteWhatDeserialize(info.GetWhat()),
SeqId: int(info.GetSeqId()), SeqId: info.GetSeqId(),
Event: pbCallEventDeserialize(info.GetEvent()), Event: pbCallEventDeserialize(info.GetEvent()),
Payload: info.GetPayload(), Payload: info.GetPayload(),
} }
@@ -365,7 +365,7 @@ func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg {
Note: &pbx.ClientNote{ Note: &pbx.ClientNote{
Topic: msg.Note.Topic, Topic: msg.Note.Topic,
What: pbInfoNoteWhatSerialize(msg.Note.What), What: pbInfoNoteWhatSerialize(msg.Note.What),
SeqId: int32(msg.Note.SeqId), SeqId: msg.Note.SeqId,
Unread: int32(msg.Note.Unread), Unread: int32(msg.Note.Unread),
Event: pbCallEventSerialize(msg.Note.Event), Event: pbCallEventSerialize(msg.Note.Event),
Payload: msg.Note.Payload, Payload: msg.Note.Payload,
@@ -485,7 +485,7 @@ func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage {
} else if note := pkt.GetNote(); note != nil { } else if note := pkt.GetNote(); note != nil {
msg.Note = &MsgClientNote{ msg.Note = &MsgClientNote{
Topic: note.GetTopic(), Topic: note.GetTopic(),
SeqId: int(note.GetSeqId()), SeqId: note.GetSeqId(),
What: pbInfoNoteWhatDeserialize(note.GetWhat()), What: pbInfoNoteWhatDeserialize(note.GetWhat()),
Unread: int(note.GetUnread()), Unread: int(note.GetUnread()),
Event: pbCallEventDeserialize(note.GetEvent()), Event: pbCallEventDeserialize(note.GetEvent()),
@@ -585,15 +585,15 @@ func pbGetQuerySerialize(in *MsgGetQuery) *pbx.GetQuery {
} }
if in.Data != nil { if in.Data != nil {
out.Data = &pbx.GetOpts{ out.Data = &pbx.GetOpts{
BeforeId: int32(in.Data.BeforeId), BeforeId: in.Data.BeforeId,
SinceId: int32(in.Data.SinceId), SinceId: in.Data.SinceId,
Limit: int32(in.Data.Limit), Limit: int32(in.Data.Limit),
} }
if len(in.Data.IdRanges) > 0 { if len(in.Data.IdRanges) > 0 {
out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges)) out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges))
for i, dq := range in.Data.IdRanges { for i, dq := range in.Data.IdRanges {
out.Data.Ranges[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)} out.Data.Ranges[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId}
} }
} }
} }
@@ -623,16 +623,16 @@ func pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery {
} }
if data := in.GetData(); data != nil { if data := in.GetData(); data != nil {
msg.Data = &MsgGetOpts{ msg.Data = &MsgGetOpts{
BeforeId: int(data.GetBeforeId()), BeforeId: data.GetBeforeId(),
SinceId: int(data.GetSinceId()), SinceId: data.GetSinceId(),
Limit: int(data.GetLimit()), Limit: int(data.GetLimit()),
} }
if ranges := data.GetRanges(); len(ranges) > 0 { if ranges := data.GetRanges(); len(ranges) > 0 {
msg.Data.IdRanges = make([]MsgRange, len(ranges)) msg.Data.IdRanges = make([]MsgRange, len(ranges))
for i, sr := range ranges { for i, sr := range ranges {
msg.Data.IdRanges[i].LowId = int(sr.GetLow()) msg.Data.IdRanges[i].LowId = sr.GetLow()
msg.Data.IdRanges[i].HiId = int(sr.GetHi()) msg.Data.IdRanges[i].HiId = sr.GetHi()
} }
} }
} }
@@ -890,9 +890,9 @@ func pbTopicDescSerialize(desc *MsgTopicDesc) *pbx.TopicDesc {
IsChan: desc.IsChan, IsChan: desc.IsChan,
Defacs: pbDefaultAcsSerialize(desc.DefaultAcs), Defacs: pbDefaultAcsSerialize(desc.DefaultAcs),
Acs: pbAccessModeSerialize(desc.Acs), Acs: pbAccessModeSerialize(desc.Acs),
SeqId: int32(desc.SeqId), SeqId: desc.SeqId,
ReadId: int32(desc.ReadSeqId), ReadId: desc.ReadSeqId,
RecvId: int32(desc.RecvSeqId), RecvId: desc.RecvSeqId,
DelId: int32(desc.DelId), DelId: int32(desc.DelId),
Public: interfaceToBytes(desc.Public), Public: interfaceToBytes(desc.Public),
Trusted: interfaceToBytes(desc.Trusted), Trusted: interfaceToBytes(desc.Trusted),
@@ -918,9 +918,9 @@ func pbTopicDescDeserialize(desc *pbx.TopicDesc) *MsgTopicDesc {
IsChan: desc.GetIsChan(), IsChan: desc.GetIsChan(),
DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()), DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()),
Acs: pbAccessModeDeserialize(desc.GetAcs()), Acs: pbAccessModeDeserialize(desc.GetAcs()),
SeqId: int(desc.SeqId), SeqId: desc.SeqId,
ReadSeqId: int(desc.ReadId), ReadSeqId: desc.ReadId,
RecvSeqId: int(desc.RecvId), RecvSeqId: desc.RecvId,
DelId: int(desc.DelId), DelId: int(desc.DelId),
Public: bytesToInterface(desc.Public), Public: bytesToInterface(desc.Public),
Trusted: bytesToInterface(desc.Trusted), Trusted: bytesToInterface(desc.Trusted),
@@ -947,7 +947,7 @@ func pbTopicSerializeToDesc(topic *Topic) *pbx.TopicDesc {
Auth: topic.accessAuth.String(), Auth: topic.accessAuth.String(),
Anon: topic.accessAnon.String(), Anon: topic.accessAnon.String(),
}, },
SeqId: int32(topic.lastID), SeqId: topic.lastID,
DelId: int32(topic.delID), DelId: int32(topic.delID),
Public: interfaceToBytes(topic.public), Public: interfaceToBytes(topic.public),
Trusted: interfaceToBytes(topic.trusted), Trusted: interfaceToBytes(topic.trusted),
@@ -972,15 +972,15 @@ func pbTopicSubSerialize(sub *MsgTopicSub) *pbx.TopicSub {
DeletedAt: timeToInt64(sub.DeletedAt), DeletedAt: timeToInt64(sub.DeletedAt),
Online: sub.Online, Online: sub.Online,
Acs: pbAccessModeSerialize(&sub.Acs), Acs: pbAccessModeSerialize(&sub.Acs),
ReadId: int32(sub.ReadSeqId), ReadId: sub.ReadSeqId,
RecvId: int32(sub.RecvSeqId), RecvId: sub.RecvSeqId,
Public: interfaceToBytes(sub.Public), Public: interfaceToBytes(sub.Public),
Trusted: interfaceToBytes(sub.Trusted), Trusted: interfaceToBytes(sub.Trusted),
Private: interfaceToBytes(sub.Private), Private: interfaceToBytes(sub.Private),
UserId: sub.User, UserId: sub.User,
Topic: sub.Topic, Topic: sub.Topic,
TouchedAt: timeToInt64(sub.TouchedAt), TouchedAt: timeToInt64(sub.TouchedAt),
SeqId: int32(sub.SeqId), SeqId: sub.SeqId,
DelId: int32(sub.DelId), DelId: int32(sub.DelId),
} }
if sub.LastSeen != nil { if sub.LastSeen != nil {
@@ -1001,15 +1001,15 @@ func pbTopicSubSliceDeserialize(subs []*pbx.TopicSub) []MsgTopicSub {
UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()), UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()),
DeletedAt: int64ToTime(subs[i].GetDeletedAt()), DeletedAt: int64ToTime(subs[i].GetDeletedAt()),
Online: subs[i].GetOnline(), Online: subs[i].GetOnline(),
ReadSeqId: int(subs[i].GetReadId()), ReadSeqId: subs[i].GetReadId(),
RecvSeqId: int(subs[i].GetRecvId()), RecvSeqId: subs[i].GetRecvId(),
Public: bytesToInterface(subs[i].GetPublic()), Public: bytesToInterface(subs[i].GetPublic()),
Trusted: bytesToInterface(subs[i].GetTrusted()), Trusted: bytesToInterface(subs[i].GetTrusted()),
Private: bytesToInterface(subs[i].GetPrivate()), Private: bytesToInterface(subs[i].GetPrivate()),
User: subs[i].GetUserId(), User: subs[i].GetUserId(),
Topic: subs[i].GetTopic(), Topic: subs[i].GetTopic(),
TouchedAt: int64ToTime(subs[i].GetTouchedAt()), TouchedAt: int64ToTime(subs[i].GetTouchedAt()),
SeqId: int(subs[i].GetSeqId()), SeqId: subs[i].GetSeqId(),
DelId: int(subs[i].GetDelId()), DelId: int(subs[i].GetDelId()),
} }
if acs := subs[i].GetAcs(); acs != nil { if acs := subs[i].GetAcs(); acs != nil {
@@ -1063,7 +1063,7 @@ func pbDelQuerySerialize(in []MsgRange) []*pbx.SeqRange {
out := make([]*pbx.SeqRange, len(in)) out := make([]*pbx.SeqRange, len(in))
for i, dq := range in { for i, dq := range in {
out[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)} out[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId}
} }
return out return out
@@ -1076,8 +1076,8 @@ func pbDelQueryDeserialize(in []*pbx.SeqRange) []MsgRange {
out := make([]MsgRange, len(in)) out := make([]MsgRange, len(in))
for i, sr := range in { for i, sr := range in {
out[i].LowId = int(sr.GetLow()) out[i].LowId = sr.GetLow()
out[i].HiId = int(sr.GetHi()) out[i].HiId = sr.GetHi()
} }
return out return out
+2 -2
View File
@@ -577,8 +577,8 @@ func pluginSubscription(sub *types.Subscription, action int) {
UserId: sub.User, UserId: sub.User,
DelId: int32(sub.DelId), DelId: int32(sub.DelId),
ReadId: int32(sub.ReadSeqId), ReadId: sub.ReadSeqId,
RecvId: int32(sub.RecvSeqId), RecvId: sub.RecvSeqId,
Mode: &pbx.AccessMode{ Mode: &pbx.AccessMode{
Want: sub.ModeWant.String(), Want: sub.ModeWant.String(),
+5 -5
View File
@@ -12,7 +12,7 @@ import (
// presParams defines parameters for creating a presence notification. // presParams defines parameters for creating a presence notification.
type presParams struct { type presParams struct {
userAgent string userAgent string
seqID int seqID int64
delID int delID int
delSeq []MsgRange delSeq []MsgRange
@@ -483,7 +483,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams,
// Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic, // Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic,
// on subscriber's 'me'. Group and P2P. // on subscriber's 'me'. Group and P2P.
func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid string) { func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int64, skipSid string) {
user := from.UserId() user := from.UserId()
for uid, pud := range t.perUser { for uid, pud := range t.perUser {
@@ -509,7 +509,7 @@ func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid st
} }
// Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'. // Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'.
func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int, func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int64,
sdp json.RawMessage, skipSid string, offlineOnly bool) { sdp json.RawMessage, skipSid string, offlineOnly bool) {
if target.IsZero() { if target.IsZero() {
logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from) logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from)
@@ -672,9 +672,9 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin
// Let other sessions of a given user know what messages are now received/read. // Let other sessions of a given user know what messages are now received/read.
// If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'. // If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'.
// Cases U // Cases U
func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int, skip string) { func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int64, skip string) {
var what string var what string
var seq int var seq int64
if read > 0 { if read > 0 {
what = "read" what = "read"
seq = read seq = read
+1 -1
View File
@@ -164,7 +164,7 @@ func pushForChanDelete(tenantID types.TenantID, topicName string, now time.Time)
} }
// Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification. // Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification.
func (t *Topic) pushForReadRcpt(uid types.Uid, seq int, now time.Time) *push.Receipt { func (t *Topic) pushForReadRcpt(uid types.Uid, seq int64, now time.Time) *push.Receipt {
// The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics, // The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,
// not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender. // not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.
topic := t.xoriginal topic := t.xoriginal
+2 -2
View File
@@ -39,7 +39,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
// Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere. // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
data["xfrom"] = pl.From data["xfrom"] = pl.From
if pl.What == push.ActMsg { if pl.What == push.ActMsg {
data["seq"] = strconv.Itoa(pl.SeqId) data["seq"] = strconv.FormatInt(pl.SeqId, 10)
if pl.ContentType != "" { if pl.ContentType != "" {
data["mime"] = pl.ContentType data["mime"] = pl.ContentType
} }
@@ -81,7 +81,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
data["modeWant"] = pl.ModeWant.String() data["modeWant"] = pl.ModeWant.String()
data["modeGiven"] = pl.ModeGiven.String() data["modeGiven"] = pl.ModeGiven.String()
} else if pl.What == push.ActRead { } else if pl.What == push.ActRead {
data["seq"] = strconv.Itoa(pl.SeqId) data["seq"] = strconv.FormatInt(pl.SeqId, 10)
data["silent"] = "true" data["silent"] = "true"
} else { } else {
return nil, errors.New("unknown push type") return nil, errors.New("unknown push type")
+1 -1
View File
@@ -76,7 +76,7 @@ type Payload struct {
// Message sender 'usrXXX' // Message sender 'usrXXX'
From string `json:"from"` From string `json:"from"`
// Sequential ID of the message. // Sequential ID of the message.
SeqId int `json:"seq"` SeqId int64 `json:"seq"`
// MIME-Type of the message content, text/x-drafty or text/plain // MIME-Type of the message content, text/x-drafty or text/plain
ContentType string `json:"mime"` ContentType string `json:"mime"`
// Actual Data.Content of the message, if requested // Actual Data.Content of the message, if requested
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"context"
"encoding/json"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func startRedisGroupFanout(ctx context.Context) {
if err := store.StartGroupFanoutSubscriber(ctx, currentNodeID(), func(raw []byte) {
var msg ServerComMessage
if err := json.Unmarshal(raw, &msg); err != nil {
logs.Warn.Println("redis group fanout: malformed message", err)
return
}
if !msg.TenantID.IsValid() || msg.RcptTo == "" || types.GetTopicCat(msg.RcptTo) != types.TopicCatGrp {
return
}
msg.sess = nil
deliverRedisGroupFanout(&msg)
}); err != nil {
logs.Warn.Println("redis group fanout: subscriber disabled", err)
}
}
func deliverRedisGroupFanout(msg *ServerComMessage) {
key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
if globals.hub.topicGet(key) != nil {
select {
case globals.hub.routeSrv <- msg:
default:
logs.Err.Println("redis group fanout: hub routeSrv queue full", msg.RcptTo)
}
return
}
delivered := 0
localMembers := redisGroupFanoutMembers(msg.TenantID, msg.RcptTo)
globals.sessionStore.Range(func(_ string, sess *Session) bool {
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != msg.TenantID || sess.sid == msg.SkipSid {
return true
}
_, listedOnline := localMembers[sess.uid.UserId()]
if !listedOnline && !sess.hasSub(msg.RcptTo) {
return true
}
msgCopy := msg.copy()
prepareRedisGroupFanoutMessage(msgCopy, msg.RcptTo)
if sess.queueOut(msgCopy) {
delivered++
} else {
logs.Warn.Printf("redis group fanout: session queue full, sid=%s topic=%s", sess.sid, msg.RcptTo)
}
return true
})
if delivered == 0 {
logs.Info.Println("redis group fanout: no local subscribers for unloaded topic", msg.RcptTo)
}
}
func redisGroupFanoutMembers(tenantID types.TenantID, topic string) map[string]struct{} {
uids, err := store.GetTopicNodeOnlineMembers(tenantID, topic, currentNodeID())
if err != nil {
logs.Warn.Println("redis group fanout: failed to read local online members", topic, err)
return nil
}
members := make(map[string]struct{}, len(uids))
for _, uid := range uids {
members[uid.UserId()] = struct{}{}
}
return members
}
func prepareRedisGroupFanoutMessage(msg *ServerComMessage, topic string) {
switch {
case msg.Data != nil && msg.Data.Topic == "":
msg.Data.Topic = topic
case msg.Pres != nil && msg.Pres.Topic == "":
msg.Pres.Topic = topic
case msg.Info != nil && msg.Info.Topic == "":
msg.Info.Topic = topic
}
}
+7 -7
View File
@@ -9,7 +9,7 @@ HTTP_BASE_PORT=6060
# Port where the first node will listen for gRPC intra-cluster connections. # Port where the first node will listen for gRPC intra-cluster connections.
GRPC_BASE_PORT=16060 GRPC_BASE_PORT=16060
USAGE="Usage: $0 [ --config <path_to_tinode.conf> ] {start|stop}" USAGE="Usage: $0 [ --config <path_to_meetingim.conf> ] {start|stop}"
# Your server binary may have a different name and location. # Your server binary may have a different name and location.
SERVER='./server' SERVER='./server'
@@ -34,9 +34,9 @@ while [[ $# -gt 0 ]]; do
;; ;;
start) start)
if [ ! -z "$config" ] ; then if [ ! -z "$config" ] ; then
TINODE_CONF=$config MEETINGIM_CONF=$config
else else
TINODE_CONF="tinode.conf" MEETINGIM_CONF="meetingim.conf"
fi fi
if [ ! -z "${static_data+x}" ] ; then if [ ! -z "${static_data+x}" ] ; then
STATIC_DATA_DIR=$static_data STATIC_DATA_DIR=$static_data
@@ -51,10 +51,10 @@ while [[ $# -gt 0 ]]; do
for NODE_NAME in "${ALL_NODE_NAMES[@]}" for NODE_NAME in "${ALL_NODE_NAMES[@]}"
do do
# Start the node # Start the node
$SERVER -config=${TINODE_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} -static_data=${STATIC_DATA_DIR} -log_flags=stdFlags,shortfile & $SERVER -config=${MEETINGIM_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} -static_data=${STATIC_DATA_DIR} -log_flags=stdFlags,shortfile &
# Save PID of the node to a temp file. # Save PID of the node to a temp file.
# /var/tmp/ does not requre root access. # /var/tmp/ does not requre root access.
echo $!> "/var/tmp/tinode-${NODE_NAME}.pid" echo $!> "/var/tmp/meetingim-${NODE_NAME}.pid"
# Increment ports for the next node. # Increment ports for the next node.
HTTP_PORT=$((HTTP_PORT+1)) HTTP_PORT=$((HTTP_PORT+1))
GRPC_PORT=$((GRPC_PORT+1)) GRPC_PORT=$((GRPC_PORT+1))
@@ -67,9 +67,9 @@ while [[ $# -gt 0 ]]; do
for NODE_NAME in "${ALL_NODE_NAMES[@]}" for NODE_NAME in "${ALL_NODE_NAMES[@]}"
do do
# Read PIDs of running nodes from temp files and kill them. # Read PIDs of running nodes from temp files and kill them.
kill `cat /var/tmp/tinode-${NODE_NAME}.pid` kill `cat /var/tmp/meetingim-${NODE_NAME}.pid`
# Clean up: delete temp files. # Clean up: delete temp files.
rm "/var/tmp/tinode-${NODE_NAME}.pid" rm "/var/tmp/meetingim-${NODE_NAME}.pid"
done done
exit 0 exit 0
;; ;;
+12 -12
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
BINARY_PATH=$GOPATH/bin BINARY_PATH=$GOPATH/bin
TINODE_BINARY=$BINARY_PATH/server MEETINGIM_BINARY=$BINARY_PATH/server
# Kills and removes any running containers. # Kills and removes any running containers.
cleanup() { cleanup() {
@@ -45,23 +45,23 @@ setup() {
echo -n "." echo -n "."
sleep 1 sleep 1
done done
# Make sure there's no Tinode server binary in the current directory. # Make sure there's no MeetingIM server binary in the current directory.
if [ -f "./server" ]; then if [ -f "./server" ]; then
rm ./server rm ./server
fi fi
} }
# Compiles Tinode binaries. # Compiles MeetingIM binaries.
build() { build() {
go install -tags mysql -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" \ go install -tags mysql -ldflags "-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`" \
github.com/tinode/chat/tinode-db \ github.com/tinode/chat/tinode-db \
github.com/tinode/chat/server && \ github.com/tinode/chat/server && \
ln -s $TINODE_BINARY ln -s $MEETINGIM_BINARY
} }
# Initializes Tinode database. # Initializes MeetingIM database.
init-db() { init-db() {
$GOPATH/bin/tinode-db -config=./tinode.conf -data=../tinode-db/data.json $GOPATH/bin/tinode-db -config=./meetingim.conf -data=../tinode-db/data.json
} }
wait-for() { wait-for() {
@@ -71,7 +71,7 @@ wait-for() {
done done
} }
# Brings up a three-node Tinode cluster. # Brings up a three-node MeetingIM cluster.
run-server() { run-server() {
./run-cluster.sh -s "" start && wait-for 16060 ./run-cluster.sh -s "" start && wait-for 16060
} }
@@ -80,7 +80,7 @@ send-requests() {
local expect=12 local expect=12
local port=$1 local port=$1
local id=$2 local id=$2
local outfile=$(mktemp /tmp/tinode-${id}.txt) local outfile=$(mktemp /tmp/meetingim-${id}.txt)
pushd . pushd .
cd ../tn-cli cd ../tn-cli
python3 tn-cli.py --host=localhost:${port} --no-login < sample-script.txt > $outfile || fail "Test script failed (instance port ${port})" python3 tn-cli.py --host=localhost:${port} --no-login < sample-script.txt > $outfile || fail "Test script failed (instance port ${port})"
@@ -103,14 +103,14 @@ trap 'cleanup ; fail "For Unexpected Reasons"'\
run_id=`date +%s` run_id=`date +%s`
echo "+----------------------------------------------------+" echo "+----------------------------------------------------+"
echo "| Tinode sanity test. |" echo "| MeetingIM sanity test. |"
echo "+----------------------------------------------------+" echo "+----------------------------------------------------+"
echo "Timestamp = ${run_id}" echo "Timestamp = ${run_id}"
setup || fail "Test setup failed." setup || fail "Test setup failed."
build || fail "Could not build Tinode binaries" build || fail "Could not build MeetingIM binaries"
init-db || fail "Could not initialize Tinode database" init-db || fail "Could not initialize MeetingIM database"
run-server || fail "Could not start tinode" run-server || fail "Could not start meetingim"
# Test requests. # Test requests.
send-requests 16060 $run_id send-requests 16060 $run_id
+63
View File
@@ -205,6 +205,32 @@ func (s *Session) getSub(topic string) *Subscription {
return s.subs[topic] return s.subs[topic]
} }
func (s *Session) hasSub(topic string) bool {
if s.multi != nil {
return s.multi.hasSub(topic)
}
s.subsLock.RLock()
defer s.subsLock.RUnlock()
_, ok := s.subs[topic]
return ok
}
func (s *Session) groupSubs() []string {
if s.multi != nil {
return s.multi.groupSubs()
}
s.subsLock.RLock()
defer s.subsLock.RUnlock()
topics := make([]string, 0, len(s.subs))
for topic := range s.subs {
if len(topic) >= 3 && types.GetTopicCat(topic) == types.TopicCatGrp {
topics = append(topics, topic)
}
}
return topics
}
func (s *Session) delSub(topic string) { func (s *Session) delSub(topic string) {
if s.multi != nil { if s.multi != nil {
s.multi.delSub(topic) s.multi.delSub(topic)
@@ -434,6 +460,11 @@ func (s *Session) purgeChannels() {
// cleanUp is called when the session is terminated to perform resource cleanup. // cleanUp is called when the session is terminated to perform resource cleanup.
func (s *Session) cleanUp(expired bool) { func (s *Session) cleanUp(expired bool) {
atomic.StoreInt32(&s.terminating, 1) atomic.StoreInt32(&s.terminating, 1)
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.MarkUserOffline(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.cleanUp: failed to mark user offline", s.uid, err, s.sid)
}
}
s.purgeChannels() s.purgeChannels()
s.inflightReqs.Wait() s.inflightReqs.Wait()
s.inflightReqs = nil s.inflightReqs = nil
@@ -488,6 +519,16 @@ func (s *Session) dispatchRaw(raw []byte) {
func (s *Session) dispatch(msg *ClientComMessage) { func (s *Session) dispatch(msg *ClientComMessage) {
now := types.TimeNow() now := types.TimeNow()
atomic.StoreInt64(&s.lastAction, now.UnixNano()) atomic.StoreInt64(&s.lastAction, now.UnixNano())
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.RefreshUserPresence(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh user presence", s.uid, err, s.sid)
}
for _, topic := range s.groupSubs() {
if err := store.MarkTopicMemberOnline(s.tenantID, topic, s.uid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh group member presence", topic, s.uid, err, s.sid)
}
}
}
// This should be the first block here, before any other checks. // This should be the first block here, before any other checks.
var resp *ServerComMessage var resp *ServerComMessage
@@ -1200,6 +1241,9 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Authenticate the session. // Authenticate the session.
s.uid = rec.Uid s.uid = rec.Uid
s.authLvl = rec.AuthLevel s.authLvl = rec.AuthLevel
if err := store.MarkUserOnline(s.tenantID, s.uid, s.sid, currentNodeID(), s.platf, s.userAgent); err != nil {
logs.Warn.Println("s.onLogin: failed to mark user online", s.uid, err, s.sid)
}
// Reset expiration time. // Reset expiration time.
rec.Lifetime = 0 rec.Lifetime = 0
} }
@@ -1228,6 +1272,25 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
return reply return reply
} }
func currentNodeID() string {
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
return globals.cluster.thisNodeName
}
return "local"
}
func redisUserOnline(tenantID types.TenantID, uid types.Uid, fallback bool) bool {
if !store.UserPresenceEnabled() || !tenantID.IsValid() || uid.IsZero() {
return fallback
}
presence, err := store.GetUserPresence(tenantID, uid)
if err != nil {
logs.Warn.Printf("presence[%s]: failed to read redis presence: %v", uid.UserId(), err)
return fallback
}
return presence.Online
}
func (s *Session) get(msg *ClientComMessage) { func (s *Session) get(msg *ClientComMessage) {
// Expand topic name. // Expand topic name.
var resp *ServerComMessage var resp *ServerComMessage
+7
View File
@@ -27,6 +27,13 @@ func (s testTenantStore) GetByID(types.TenantID) (*types.Tenant, error) {
return s.tenant, s.err return s.tenant, s.err
} }
func (s testTenantStore) ListActive(int) ([]types.Tenant, error) {
if s.err != nil || s.tenant == nil {
return nil, s.err
}
return []types.Tenant{*s.tenant}, nil
}
func test_makeSession(uid types.Uid) *Session { func test_makeSession(uid types.Uid) *Session {
return &Session{ return &Session{
send: make(chan any, 10), send: make(chan any, 10),
+27
View File
@@ -241,6 +241,33 @@ func (ss *SessionStore) EvictUser(uid types.Uid, skipSid string) {
statsSet("LiveSessions", int64(len(ss.sessCache))) statsSet("LiveSessions", int64(len(ss.sessCache)))
} }
// EvictUserDevice terminates all sessions of a given user connected with a device.
func (ss *SessionStore) EvictUserDevice(uid types.Uid, deviceID string) int {
ss.lock.Lock()
defer ss.lock.Unlock()
if uid.IsZero() || deviceID == "" {
return 0
}
evicted := NoErrEvicted("", "", types.TimeNow())
evicted.AsUser = uid.UserId()
count := 0
for _, s := range ss.sessCache {
if s.uid == uid && s.deviceID == deviceID && !s.isMultiplex() {
_, data := s.serialize(evicted)
s.stopSession(data)
delete(ss.sessCache, s.sid)
if s.proto == LPOLL {
ss.lru.Remove(s.lpTracker)
}
count++
}
}
statsSet("LiveSessions", int64(len(ss.sessCache)))
return count
}
// NodeRestarted removes stale sessions from a restarted cluster node. // NodeRestarted removes stale sessions from a restarted cluster node.
// - nodeName is the name of affected node // - nodeName is the name of affected node
// - fingerprint is the new fingerprint of the node. // - fingerprint is the new fingerprint of the node.
+138
View File
@@ -0,0 +1,138 @@
package store
import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/store/types"
)
const configCacheTTL = time.Minute
type configCacheEntry[T any] struct {
value T
expiresAt time.Time
}
type configCache[T any] struct {
mu sync.RWMutex
items map[string]configCacheEntry[T]
}
func newConfigCache[T any]() *configCache[T] {
return &configCache[T]{items: make(map[string]configCacheEntry[T])}
}
func (c *configCache[T]) get(key string) (T, bool) {
var zero T
c.mu.RLock()
entry, ok := c.items[key]
c.mu.RUnlock()
if !ok || time.Now().After(entry.expiresAt) {
return zero, false
}
return entry.value, true
}
func (c *configCache[T]) set(key string, value T) {
c.mu.Lock()
c.items[key] = configCacheEntry[T]{value: value, expiresAt: time.Now().Add(configCacheTTL)}
c.mu.Unlock()
}
func (c *configCache[T]) delete(key string) {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
}
func (c *configCache[T]) clear() {
c.mu.Lock()
c.items = make(map[string]configCacheEntry[T])
c.mu.Unlock()
}
var (
tenantByCodeCache = newConfigCache[*types.Tenant]()
tenantByIDCache = newConfigCache[*types.Tenant]()
registrationSMSCache = newConfigCache[bool]()
thirdPartyConfigCache = newConfigCache[map[string]string]()
thirdPartyConfigAllKeys = "*"
)
func cloneTenant(t *types.Tenant) *types.Tenant {
if t == nil {
return nil
}
cp := *t
return &cp
}
func cloneStringMap(src map[string]string) map[string]string {
if src == nil {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func thirdPartyCacheKey(group string, keys ...string) string {
group = strings.TrimSpace(group)
if len(keys) == 0 {
return group + ":" + thirdPartyConfigAllKeys
}
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if key = strings.TrimSpace(key); key != "" {
normalized = append(normalized, key)
}
}
sort.Strings(normalized)
return group + ":" + strings.Join(normalized, ",")
}
// InvalidateTenantConfigCache clears cached tenant registry/config records. An
// empty selector clears all tenant records.
func InvalidateTenantConfigCache(code string, id types.TenantID) {
if strings.TrimSpace(code) != "" {
tenantByCodeCache.delete(strings.TrimSpace(code))
} else {
tenantByCodeCache.clear()
}
if id.IsValid() {
tenantByIDCache.delete(formatTenantIDForCache(id))
} else if strings.TrimSpace(code) == "" {
tenantByIDCache.clear()
}
}
// InvalidateRegistrationConfigCache clears cached global registration config.
func InvalidateRegistrationConfigCache() {
registrationSMSCache.clear()
}
// InvalidateThirdPartyConfigCache clears cached third-party config.
func InvalidateThirdPartyConfigCache(group string) {
if strings.TrimSpace(group) == "" {
thirdPartyConfigCache.clear()
return
}
prefix := strings.TrimSpace(group) + ":"
thirdPartyConfigCache.mu.Lock()
for key := range thirdPartyConfigCache.items {
if strings.HasPrefix(key, prefix) {
delete(thirdPartyConfigCache.items, key)
}
}
thirdPartyConfigCache.mu.Unlock()
}
func formatTenantIDForCache(id types.TenantID) string {
return strconv.FormatInt(int64(id), 10)
}
+919
View File
@@ -0,0 +1,919 @@
package store
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/tinode/chat/server/store/types"
)
type redisConfig struct {
Addr string `json:"addr"`
Username string `json:"username"`
Password string `json:"password"`
DB int `json:"db"`
KeyPrefix string `json:"key_prefix"`
DialMS int `json:"dial_timeout_ms"`
ReadMS int `json:"read_timeout_ms"`
WriteMS int `json:"write_timeout_ms"`
PendingTTL int `json:"pending_ttl_seconds"`
DoneTTL int `json:"done_ttl_seconds"`
FailedTTL int `json:"failed_ttl_seconds"`
HotMsgTTL int `json:"hot_message_ttl_seconds"`
PresenceTTL int `json:"presence_ttl_seconds"`
CacheTTL int `json:"cache_ttl_seconds"`
ShortTTL int `json:"short_state_ttl_seconds"`
GroupFanout bool `json:"group_fanout_enabled"`
}
type groupMessageUniqueRecord struct {
Status string `json:"status"`
Seq int64 `json:"seq,omitempty"`
MessageID string `json:"message_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type groupFanoutEnvelope struct {
OriginNodeID string `json:"origin_node_id"`
Message json.RawMessage `json:"message"`
}
type groupMessageRuntime interface {
Close() error
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error)
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error)
MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error)
PublishGroupFanout(originNodeID string, message any) error
StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
ClaimShortState(namespace, id string, ttl time.Duration) (bool, error)
ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool, bool, error)
}
var groupMsgRuntime groupMessageRuntime
type UserPresence struct {
Online bool `json:"online"`
NodeID string `json:"node_id"`
SessionCount int `json:"session_count"`
LastActiveAt time.Time `json:"last_active_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
func MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.MarkUserOnline(tenantID, uid, sid, nodeID, device, userAgent)
}
func ClaimShortState(namespace, id string, ttl time.Duration) (bool, bool, error) {
if groupMsgRuntime == nil || namespace == "" || id == "" {
return false, false, nil
}
ok, err := groupMsgRuntime.ClaimShortState(namespace, id, ttl)
return ok, true, err
}
func ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool, bool, bool, error) {
if groupMsgRuntime == nil || namespace == "" || id == "" || value == "" {
return false, false, false, nil
}
claimed, sameValue, err := groupMsgRuntime.ClaimShortStateValue(namespace, id, value, ttl)
return claimed, sameValue, true, err
}
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.RefreshUserPresence(tenantID, uid, sid, nodeID)
}
func MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.MarkUserOffline(tenantID, uid, sid, nodeID)
}
func GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() {
return &UserPresence{}, nil
}
return groupMsgRuntime.GetUserPresence(tenantID, uid)
}
func UserPresenceEnabled() bool {
return groupMsgRuntime != nil
}
func MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
return nil
}
return groupMsgRuntime.MarkTopicMemberOnline(tenantID, topic, uid, nodeID)
}
func MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
return nil
}
return groupMsgRuntime.MarkTopicMemberOffline(tenantID, topic, uid, nodeID)
}
func GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil, nil
}
return groupMsgRuntime.GetTopicNodeOnlineMembers(tenantID, topic, nodeID)
}
func PublishGroupFanout(originNodeID string, message any) error {
if groupMsgRuntime == nil || originNodeID == "" || message == nil {
return nil
}
return groupMsgRuntime.PublishGroupFanout(originNodeID, message)
}
func StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
if groupMsgRuntime == nil || nodeID == "" || handler == nil {
return nil
}
return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler)
}
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return nil
}
return groupMsgRuntime.CacheObject(tenantID, namespace, id, value)
}
func GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return false, nil
}
return groupMsgRuntime.GetCachedObject(tenantID, namespace, id, value)
}
func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" {
return nil
}
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
}
func InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil
}
return groupMsgRuntime.InvalidateHotMessages(tenantID, topic, ranges)
}
func AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
}
return groupMsgRuntime.AddCachedSetMember(tenantID, namespace, id, member)
}
func RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
}
return groupMsgRuntime.RemoveCachedSetMember(tenantID, namespace, id, member)
}
func CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return false, nil
}
return groupMsgRuntime.CachedSetContains(tenantID, namespace, id, member)
}
func AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || scope == "" || limit <= 0 || window <= 0 {
return true, 0, 0, nil
}
return groupMsgRuntime.AllowRate(tenantID, scope, limit, window)
}
func initGroupMessageRuntime(raw json.RawMessage) error {
closeGroupMessageRuntime()
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var cfg redisConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
cfg.Addr = strings.TrimSpace(cfg.Addr)
if cfg.Addr == "" {
return nil
}
if cfg.KeyPrefix == "" {
cfg.KeyPrefix = "im"
}
if cfg.PendingTTL <= 0 {
cfg.PendingTTL = 120
}
if cfg.DoneTTL <= 0 {
cfg.DoneTTL = 86400
}
if cfg.FailedTTL <= 0 {
cfg.FailedTTL = 600
}
if cfg.HotMsgTTL <= 0 {
cfg.HotMsgTTL = 86400
}
if cfg.PresenceTTL <= 0 {
cfg.PresenceTTL = 120
}
if cfg.CacheTTL <= 0 {
cfg.CacheTTL = 300
}
if cfg.ShortTTL <= 0 {
cfg.ShortTTL = 600
}
dialTimeout := timeoutOrDefault(cfg.DialMS, 2*time.Second)
readTimeout := timeoutOrDefault(cfg.ReadMS, 2*time.Second)
writeTimeout := timeoutOrDefault(cfg.WriteMS, 2*time.Second)
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
})
ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return err
}
groupMsgRuntime = &redisGroupMessageRuntime{client: client, prefix: cfg.KeyPrefix, cfg: cfg}
return nil
}
func closeGroupMessageRuntime() {
if groupMsgRuntime != nil {
_ = groupMsgRuntime.Close()
groupMsgRuntime = nil
}
}
func timeoutOrDefault(ms int, fallback time.Duration) time.Duration {
if ms <= 0 {
return fallback
}
return time.Duration(ms) * time.Millisecond
}
type redisGroupMessageRuntime struct {
client *redis.Client
prefix string
cfg redisConfig
}
func (r *redisGroupMessageRuntime) Close() error {
return r.client.Close()
}
func (r *redisGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) {
if clientMsgID == "" {
return nil, true, nil
}
key := r.uniqueKey(tenantID, topic, from, clientMsgID)
rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
ctx := context.Background()
ok, err := r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
if err != nil {
return nil, false, err
}
if ok {
return rec, true, nil
}
existing, err := r.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var current groupMessageUniqueRecord
if err = json.Unmarshal(existing, &current); err != nil {
return nil, false, err
}
if current.Status == "failed" {
_ = r.client.Del(ctx, key).Err()
ok, err = r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
if err != nil {
return nil, false, err
}
if ok {
return rec, true, nil
}
}
return &current, false, nil
}
func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) {
if baseline < 0 {
baseline = 0
}
seqKey := r.seqKey(tenantID, topic)
seq, err := r.client.Eval(context.Background(), `
if redis.call("EXISTS", KEYS[1]) == 0 then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCR", KEYS[1])
`, []string{seqKey}, baseline).Int64()
if err != nil {
return 0, err
}
return seq, nil
}
func (r *redisGroupMessageRuntime) MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
if clientMsgID == "" {
return nil
}
rec := &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.DoneTTL)*time.Second).Err()
}
func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error {
if clientMsgID == "" {
return nil
}
rec := &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.FailedTTL)*time.Second).Err()
}
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return types.ErrMalformed
}
payload, err := json.Marshal(msg)
if err != nil {
return err
}
ctx := context.Background()
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
seq := strconv.FormatInt(msg.SeqId, 10)
pipe := r.client.Pipeline()
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
pipe.ZAdd(ctx, r.hotSeqKey(tenantID, msg.Topic), redis.Z{Score: float64(msg.SeqId), Member: seq})
pipe.Expire(ctx, r.hotSeqKey(tenantID, msg.Topic), ttl+time.Hour)
_, err = pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) {
if topic == "" {
return nil, false, types.ErrMalformed
}
limit := maxLimit
if limit <= 0 {
limit = 100
}
if opts != nil && opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
seqs, completeRange, err := r.hotSeqIDs(tenantID, topic, opts, limit)
if err != nil || len(seqs) == 0 || !completeRange {
return nil, false, err
}
keys := make([]string, 0, len(seqs))
for _, seq := range seqs {
keys = append(keys, r.hotMsgKey(tenantID, topic, seq))
}
values, err := r.client.MGet(context.Background(), keys...).Result()
if err != nil {
return nil, false, err
}
if len(values) != len(seqs) {
return nil, false, nil
}
msgs := make([]types.Message, 0, len(values))
for _, value := range values {
if value == nil {
return nil, false, nil
}
raw, ok := value.(string)
if !ok {
return nil, false, nil
}
var msg types.Message
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
return nil, false, err
}
msg.TenantID = tenantID
msgs = append(msgs, msg)
}
return msgs, true, nil
}
func (r *redisGroupMessageRuntime) InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if topic == "" {
return types.ErrMalformed
}
seqs := expandRanges(ranges, int(^uint(0)>>1))
ctx := context.Background()
if len(seqs) == 0 {
pipe := r.client.Pipeline()
pipe.Del(ctx, r.hotSeqKey(tenantID, topic))
keys, err := r.client.Keys(ctx, r.hotMsgKey(tenantID, topic, "*")).Result()
if err != nil {
return err
}
if len(keys) > 0 {
pipe.Del(ctx, keys...)
}
_, err = pipe.Exec(ctx)
return err
}
pipe := r.client.Pipeline()
members := make([]any, 0, len(seqs))
for _, seq := range seqs {
pipe.Del(ctx, r.hotMsgKey(tenantID, topic, seq))
members = append(members, strconv.FormatInt(int64(seq), 10))
}
pipe.ZRem(ctx, r.hotSeqKey(tenantID, topic), members...)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
member := nodeID + ":" + sid
pipe := r.client.Pipeline()
pipe.HSet(ctx, sessionKey, map[string]any{
"node_id": nodeID,
"session_id": sid,
"device": device,
"user_agent": userAgent,
"last_active_at": now.Format(time.RFC3339Nano),
})
pipe.Expire(ctx, sessionKey, ttl)
pipe.SAdd(ctx, sessionsKey, member)
pipe.Expire(ctx, sessionsKey, ttl)
pipe.SAdd(ctx, nodeKey, uid.UserId())
pipe.Expire(ctx, nodeKey, ttl)
pipe.HSet(ctx, userKey, map[string]any{
"online": "1",
"node_id": nodeID,
"last_active_at": now.Format(time.RFC3339Nano),
"last_seen_at": now.Format(time.RFC3339Nano),
})
pipe.Expire(ctx, userKey, ttl)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
count, err := r.client.SCard(ctx, sessionsKey).Result()
if err != nil {
return err
}
return r.client.HSet(ctx, userKey, "session_count", count).Err()
}
func (r *redisGroupMessageRuntime) RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
pipe := r.client.Pipeline()
pipe.HSet(ctx, sessionKey, "last_active_at", now.Format(time.RFC3339Nano), "node_id", nodeID)
pipe.Expire(ctx, sessionKey, ttl)
pipe.Expire(ctx, sessionsKey, ttl)
pipe.SAdd(ctx, nodeKey, uid.UserId())
pipe.Expire(ctx, nodeKey, ttl)
pipe.HSet(ctx, userKey, "online", "1", "node_id", nodeID, "last_active_at", now.Format(time.RFC3339Nano), "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
member := nodeID + ":" + sid
pipe := r.client.Pipeline()
pipe.Del(ctx, sessionKey)
pipe.SRem(ctx, sessionsKey, member)
pipe.SRem(ctx, nodeKey, uid.UserId())
if _, err := pipe.Exec(ctx); err != nil {
return err
}
count, err := r.client.SCard(ctx, sessionsKey).Result()
if err != nil {
return err
}
if count > 0 {
pipe = r.client.Pipeline()
pipe.HSet(ctx, userKey, "online", "1", "session_count", count, "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
pipe.Expire(ctx, sessionsKey, ttl)
_, err = pipe.Exec(ctx)
return err
}
pipe = r.client.Pipeline()
pipe.HSet(ctx, userKey, "online", "0", "session_count", 0, "node_id", nodeID, "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
pipe.Del(ctx, sessionsKey)
_, err = pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
raw, err := r.client.HGetAll(context.Background(), r.presenceUserKey(tenantID, uid)).Result()
if err != nil {
return nil, err
}
presence := &UserPresence{}
if len(raw) == 0 {
return presence, nil
}
presence.Online = raw["online"] == "1"
presence.NodeID = raw["node_id"]
presence.SessionCount, _ = strconv.Atoi(raw["session_count"])
presence.LastActiveAt, _ = time.Parse(time.RFC3339Nano, raw["last_active_at"])
presence.LastSeenAt, _ = time.Parse(time.RFC3339Nano, raw["last_seen_at"])
return presence, nil
}
func (r *redisGroupMessageRuntime) MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SAdd(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
pipe.Expire(ctx, r.topicOnlineMembersKey(tenantID, topic), ttl)
pipe.SAdd(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
pipe.Expire(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), ttl)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SRem(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
pipe.SRem(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
if nodeID == "" {
nodeID = "local"
}
members, err := r.client.SMembers(context.Background(), r.topicNodeOnlineMembersKey(tenantID, topic, nodeID)).Result()
if err != nil {
return nil, err
}
uids := make([]types.Uid, 0, len(members))
for _, member := range members {
uid := types.ParseUserId(member)
if !uid.IsZero() {
uids = append(uids, uid)
}
}
return uids, nil
}
func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error {
if !r.cfg.GroupFanout {
return nil
}
payload, err := json.Marshal(message)
if err != nil {
return err
}
envelope, err := json.Marshal(groupFanoutEnvelope{
OriginNodeID: originNodeID,
Message: payload,
})
if err != nil {
return err
}
return r.client.Publish(context.Background(), r.groupFanoutChannel(), envelope).Err()
}
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
if !r.cfg.GroupFanout {
return nil
}
pubsub := r.client.Subscribe(ctx, r.groupFanoutChannel())
if _, err := pubsub.Receive(ctx); err != nil {
_ = pubsub.Close()
return err
}
go func() {
defer pubsub.Close()
ch := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
var envelope groupFanoutEnvelope
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 {
continue
}
handler(envelope.Message)
}
}
}()
return nil
}
func (r *redisGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
return r.client.Set(context.Background(), r.objectCacheKey(tenantID, namespace, id), payload, r.cacheTTL(namespace)).Err()
}
func (r *redisGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
payload, err := r.client.Get(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Bytes()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
if err = json.Unmarshal(payload, value); err != nil {
_ = r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
return false, err
}
return true, nil
}
func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
}
func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SAdd(ctx, key, member)
pipe.Expire(ctx, key, r.cacheTTL(namespace))
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SRem(ctx, key, member)
pipe.Expire(ctx, key, r.cacheTTL(namespace))
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
return r.client.SIsMember(context.Background(), r.setCacheKey(tenantID, namespace, id), member).Result()
}
func (r *redisGroupMessageRuntime) AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
key := r.rateKey(tenantID, scope)
ctx := context.Background()
count, err := r.client.Incr(ctx, key).Result()
if err != nil {
return true, 0, 0, err
}
if count == 1 {
if err = r.client.Expire(ctx, key, window).Err(); err != nil {
return true, int(count), 0, err
}
}
ttl, _ := r.client.TTL(ctx, key).Result()
return count <= int64(limit), int(count), ttl, nil
}
func (r *redisGroupMessageRuntime) ClaimShortState(namespace, id string, ttl time.Duration) (bool, error) {
if ttl <= 0 {
ttl = time.Duration(r.cfg.ShortTTL) * time.Second
}
return r.client.SetNX(context.Background(), r.shortStateKey(namespace, id), "1", ttl).Result()
}
func (r *redisGroupMessageRuntime) ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool, bool, error) {
if ttl <= 0 {
ttl = time.Duration(r.cfg.ShortTTL) * time.Second
}
key := r.shortStateKey(namespace, id)
ctx := context.Background()
claimed, err := r.client.SetNX(ctx, key, value, ttl).Result()
if err != nil || claimed {
return claimed, false, err
}
existing, err := r.client.Get(ctx, key).Result()
if err != nil {
return false, false, err
}
return false, existing == value, nil
}
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
if opts != nil && len(opts.IdRanges) > 0 {
seqs := expandRanges(opts.IdRanges, limit)
sort.Slice(seqs, func(i, j int) bool { return seqs[i] > seqs[j] })
return seqs, len(seqs) > 0 && len(seqs) <= limit, nil
}
min := "-inf"
max := "+inf"
if opts != nil {
if opts.Since > 0 {
min = strconv.FormatInt(opts.Since, 10)
}
if opts.Before > 1 {
max = strconv.FormatInt(opts.Before-1, 10)
}
}
raw, err := r.client.ZRevRangeByScore(context.Background(), r.hotSeqKey(tenantID, topic), &redis.ZRangeBy{
Min: min,
Max: max,
Offset: 0,
Count: int64(limit),
}).Result()
if err != nil {
return nil, false, err
}
if len(raw) < limit {
return nil, false, nil
}
seqs := make([]int64, 0, len(raw))
for _, value := range raw {
seq, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, false, err
}
seqs = append(seqs, seq)
}
return seqs, true, nil
}
func expandRanges(ranges []types.Range, limit int) []int64 {
if limit <= 0 {
return nil
}
seqs := make([]int64, 0)
for _, rng := range ranges {
if rng.Low <= 0 {
continue
}
if rng.Hi == 0 {
seqs = append(seqs, rng.Low)
} else {
for seq := rng.Low; seq < rng.Hi; seq++ {
seqs = append(seqs, seq)
if len(seqs) >= limit {
return seqs
}
}
}
if len(seqs) >= limit {
return seqs
}
}
return seqs
}
func (r *redisGroupMessageRuntime) seqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "last_seq")
}
func (r *redisGroupMessageRuntime) uniqueKey(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) string {
return r.key(tenantID, "topic", topic, "uniq", "msg", from.UserId(), clientMsgID)
}
func (r *redisGroupMessageRuntime) hotSeqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "hot_msg_seqs")
}
func (r *redisGroupMessageRuntime) hotMsgKey(tenantID types.TenantID, topic string, seq any) string {
return r.key(tenantID, "topic", topic, "hot_msg", fmt.Sprint(seq))
}
func (r *redisGroupMessageRuntime) presenceUserKey(tenantID types.TenantID, uid types.Uid) string {
return r.key(tenantID, "presence", "user", uid.UserId())
}
func (r *redisGroupMessageRuntime) presenceSessionsKey(tenantID types.TenantID, uid types.Uid) string {
return r.key(tenantID, "presence", "user", uid.UserId(), "sessions")
}
func (r *redisGroupMessageRuntime) presenceSessionKey(tenantID types.TenantID, uid types.Uid, sid string) string {
return r.key(tenantID, "presence", "user", uid.UserId(), "session", sid)
}
func (r *redisGroupMessageRuntime) presenceNodeUsersKey(tenantID types.TenantID, nodeID string) string {
return r.key(tenantID, "presence", "node", nodeID, "users")
}
func (r *redisGroupMessageRuntime) topicOnlineMembersKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "online_members")
}
func (r *redisGroupMessageRuntime) topicNodeOnlineMembersKey(tenantID types.TenantID, topic, nodeID string) string {
return r.key(tenantID, "topic", topic, "node", nodeID, "online_members")
}
func (r *redisGroupMessageRuntime) groupFanoutChannel() string {
return r.key(types.ZeroTenantID, "group_fanout")
}
func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string {
return r.key(tenantID, "cache", namespace, id)
}
func (r *redisGroupMessageRuntime) setCacheKey(tenantID types.TenantID, namespace, id string) string {
return r.key(tenantID, "set", namespace, id)
}
func (r *redisGroupMessageRuntime) rateKey(tenantID types.TenantID, scope string) string {
return r.key(tenantID, "rate", scope)
}
func (r *redisGroupMessageRuntime) shortStateKey(namespace, id string) string {
return r.key(types.ZeroTenantID, "short", namespace, id)
}
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
ttl := r.cfg.CacheTTL
if namespace == "verification" || namespace == "action_token" {
ttl = r.cfg.ShortTTL
}
return time.Duration(ttl) * time.Second
}
func (r *redisGroupMessageRuntime) key(tenantID types.TenantID, parts ...string) string {
all := []string{strings.TrimSuffix(r.prefix, ":"), strconv.FormatInt(int64(tenantID), 10)}
all = append(all, parts...)
return strings.Join(all, ":")
}
+108
View File
@@ -14,6 +14,18 @@ type HTTPInfrastructurePersistenceInterface interface {
CreateEvent(types.TenantID, *types.BusinessEvent) error CreateEvent(types.TenantID, *types.BusinessEvent) error
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error) ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error MarkEventDelivered(types.TenantID, string, time.Time) error
BeginKafkaConsume(types.TenantID, *types.KafkaConsumeRecord, time.Time) (*types.KafkaConsumeRecord, bool, error)
MarkKafkaConsumeSucceeded(types.TenantID, string, string, time.Time) error
MarkKafkaConsumeFailed(types.TenantID, string, string, string, string, time.Time) (*types.KafkaConsumeRecord, error)
MarkKafkaConsumeDead(types.TenantID, *types.KafkaDeadLetter, time.Time) error
ListKafkaDeadLetters(types.TenantID, types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error)
GetKafkaDeadLetter(types.TenantID, string) (*types.KafkaDeadLetter, error)
MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error
MarkKafkaDeadLetterReplay(types.TenantID, string, string, string, time.Time) (*types.KafkaDeadLetter, error)
CreateKafkaOutbox(types.TenantID, *types.KafkaOutboxRecord) error
ListPendingKafkaOutbox(types.TenantID, int, time.Time) ([]types.KafkaOutboxRecord, error)
MarkKafkaOutboxSent(types.TenantID, string, time.Time) error
MarkKafkaOutboxFailed(types.TenantID, string, string, string, time.Time, time.Time) error
} }
type httpInfrastructureMapper struct{} type httpInfrastructureMapper struct{}
@@ -79,3 +91,99 @@ func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, even
} }
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now) return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
} }
func (httpInfrastructureMapper) BeginKafkaConsume(tenantID types.TenantID, record *types.KafkaConsumeRecord, deadline time.Time) (*types.KafkaConsumeRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantKafkaConsumeBegin(tenantID, record, deadline)
}
func (httpInfrastructureMapper) MarkKafkaConsumeSucceeded(tenantID types.TenantID, eventID, consumerGroup string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaConsumeSucceed(tenantID, eventID, consumerGroup, now)
}
func (httpInfrastructureMapper) MarkKafkaConsumeFailed(tenantID types.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*types.KafkaConsumeRecord, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaConsumeFail(tenantID, eventID, consumerGroup, reason, message, now)
}
func (httpInfrastructureMapper) MarkKafkaConsumeDead(tenantID types.TenantID, dead *types.KafkaDeadLetter, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaConsumeDead(tenantID, dead, now)
}
func (httpInfrastructureMapper) ListKafkaDeadLetters(tenantID types.TenantID, opts types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterList(tenantID, opts)
}
func (httpInfrastructureMapper) GetKafkaDeadLetter(tenantID types.TenantID, deadID string) (*types.KafkaDeadLetter, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterGet(tenantID, deadID)
}
func (httpInfrastructureMapper) MarkKafkaDeadLetter(tenantID types.TenantID, deadID, status, resolvedBy, note string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaDeadLetterMark(tenantID, deadID, status, resolvedBy, note, now)
}
func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantID, deadID, resolvedBy, note string, now time.Time) (*types.KafkaDeadLetter, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
}
func (httpInfrastructureMapper) CreateKafkaOutbox(tenantID types.TenantID, record *types.KafkaOutboxRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxCreate(tenantID, record)
}
func (httpInfrastructureMapper) ListPendingKafkaOutbox(tenantID types.TenantID, limit int, now time.Time) ([]types.KafkaOutboxRecord, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaOutboxListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkKafkaOutboxSent(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxMarkSent(tenantID, eventID, now)
}
func (httpInfrastructureMapper) MarkKafkaOutboxFailed(tenantID types.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxMarkFailed(tenantID, eventID, reason, message, nextRetryAt, now)
}
+43
View File
@@ -0,0 +1,43 @@
package store
import (
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type InternalAdminQueryPersistenceInterface interface {
Query(types.TenantID, string, string, []any, int, int) (*types.InternalAdminQueryResult, error)
QueryOne(types.TenantID, string, []any) (map[string]any, error)
}
type internalAdminQueryMapper struct{}
var InternalAdminQueries InternalAdminQueryPersistenceInterface = internalAdminQueryMapper{}
func internalAdminQueryAdapter(tenantID types.TenantID) (adapter.TenantInternalAdminQueryAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
queryAdp, ok := business.(adapter.TenantInternalAdminQueryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return queryAdp, nil
}
func (internalAdminQueryMapper) Query(tenantID types.TenantID, query string, countQuery string, args []any, page, perPage int) (*types.InternalAdminQueryResult, error) {
queryAdp, err := internalAdminQueryAdapter(tenantID)
if err != nil {
return nil, err
}
return queryAdp.TenantInternalAdminQuery(tenantID, query, countQuery, args, page, perPage)
}
func (internalAdminQueryMapper) QueryOne(tenantID types.TenantID, query string, args []any) (map[string]any, error) {
queryAdp, err := internalAdminQueryAdapter(tenantID)
if err != nil {
return nil, err
}
return queryAdp.TenantInternalAdminQueryOne(tenantID, query, args)
}
+63 -3
View File
@@ -330,6 +330,36 @@ func (mr *MockTenantsPersistenceInterfaceMockRecorder) GetByCode(code interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByCode", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).GetByCode), code) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByCode", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).GetByCode), code)
} }
// GetByID mocks base method.
func (m *MockTenantsPersistenceInterface) GetByID(id types.TenantID) (*types.Tenant, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByID", id)
ret0, _ := ret[0].(*types.Tenant)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByID indicates an expected call of GetByID.
func (mr *MockTenantsPersistenceInterfaceMockRecorder) GetByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).GetByID), id)
}
// ListActive mocks base method.
func (m *MockTenantsPersistenceInterface) ListActive(limit int) ([]types.Tenant, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListActive", limit)
ret0, _ := ret[0].([]types.Tenant)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListActive indicates an expected call of ListActive.
func (mr *MockTenantsPersistenceInterfaceMockRecorder) ListActive(limit interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListActive", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).ListActive), limit)
}
// MockUsersPersistenceInterface is a mock of UsersPersistenceInterface interface. // MockUsersPersistenceInterface is a mock of UsersPersistenceInterface interface.
type MockUsersPersistenceInterface struct { type MockUsersPersistenceInterface struct {
ctrl *gomock.Controller ctrl *gomock.Controller
@@ -1114,7 +1144,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top
} }
// Edit mocks base method. // Edit mocks base method.
func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
ret0, _ := ret[0].(*types.Message) ret0, _ := ret[0].(*types.Message)
@@ -1176,7 +1206,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic,
} }
// GetOne mocks base method. // GetOne mocks base method.
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq) ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq)
ret0, _ := ret[0].(*types.Message) ret0, _ := ret[0].(*types.Message)
@@ -1191,7 +1221,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic,
} }
// Recall mocks base method. // Recall mocks base method.
func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt) ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt)
ret0, _ := ret[0].(*types.Message) ret0, _ := ret[0].(*types.Message)
@@ -1220,6 +1250,36 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Save(tenantID, msg, atta
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Save), tenantID, msg, attachmentURLs, readBySender) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Save), tenantID, msg, attachmentURLs, readBySender)
} }
// PreparePersist mocks base method.
func (m *MockMessagesPersistenceInterface) PreparePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PreparePersist", tenantID, msg, attachmentURLs, readBySender)
ret0, _ := ret[0].(*types.MessageSaveResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PreparePersist indicates an expected call of PreparePersist.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) PreparePersist(tenantID, msg, attachmentURLs, readBySender interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreparePersist", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).PreparePersist), tenantID, msg, attachmentURLs, readBySender)
}
// PersistPrepared mocks base method.
func (m *MockMessagesPersistenceInterface) PersistPrepared(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PersistPrepared", tenantID, msg, attachmentURLs, readBySender)
ret0, _ := ret[0].(*types.MessageSaveResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PersistPrepared indicates an expected call of PersistPrepared.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) PersistPrepared(tenantID, msg, attachmentURLs, readBySender interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PersistPrepared", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).PersistPrepared), tenantID, msg, attachmentURLs, readBySender)
}
// MockDevicePersistenceInterface is a mock of DevicePersistenceInterface interface. // MockDevicePersistenceInterface is a mock of DevicePersistenceInterface interface.
type MockDevicePersistenceInterface struct { type MockDevicePersistenceInterface struct {
ctrl *gomock.Controller ctrl *gomock.Controller
+887 -49
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
package types
import "time"
const (
AttendanceActionCheckIn = "check_in"
AttendanceActionCheckOut = "check_out"
AttendanceStatusNotChecked = "not_checked"
AttendanceStatusCheckedIn = "checked_in"
AttendanceStatusNormal = "normal"
AttendanceStatusLate = "late"
AttendanceStatusEarlyLeave = "early_leave"
AttendanceStatusAbnormal = "abnormal"
AttendanceCheckinNormal = "normal"
AttendanceCheckinLate = "late"
AttendanceCheckinEarlyLeave = "early_leave"
AttendanceCheckinOutsideRange = "outside_range"
AttendanceAppealPending = "pending"
AttendanceAppealApproved = "approved"
AttendanceAppealRejected = "rejected"
AttendanceAppealCanceled = "canceled"
)
type AttendanceRule struct {
ID int64
RuleID string
Name string
Workday bool
LocationRequired bool
WifiRequired bool
AllowOutsideCheckin bool
AppealAllowed bool
AppealDeadline *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type AttendanceShift struct {
ID int64
ShiftID string
RuleID string
Name string
WorkStart string
WorkEnd string
CheckinStart string
CheckoutEnd string
SortOrder int
CreatedAt time.Time
UpdatedAt time.Time
}
type AttendanceCheckin struct {
ID int64
CheckinID string
RecordID string
UserID Uid
Date time.Time
Action string
RuleID string
ShiftID string
ClientTime time.Time
ServerTime time.Time
Result string
Location KVMap
Wifi KVMap
Device KVMap
IdempotencyKey string
CreatedAt time.Time
}
type AttendanceRecord struct {
ID int64
RecordID string
UserID Uid
Date time.Time
RuleID string
ShiftID string
Status string
Checkins []AttendanceCheckin
Exceptions []string
AppealStatus string
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
type AttendanceConfig struct {
Date time.Time
Timezone string
Rule AttendanceRule
Shifts []AttendanceShift
AllowedActions []string
TodayRecord *AttendanceRecord
CanViewManaged bool
}
type AttendanceRecordListOptions struct {
Scope string
From string
To string
Date string
Status string
DepartmentID string
UserID string
Cursor string
Limit int
}
type AttendanceRecordStats struct {
ExpectedCount int
CheckedCount int
ExceptionCount int
NotCheckedCount int
}
type AttendanceCheckinRequest struct {
Date time.Time
Action string
RuleID string
ShiftID string
ClientTime time.Time
Location KVMap
Wifi KVMap
Device KVMap
IdempotencyKey string
}
type AttendanceAppeal struct {
ID int64
AppealID string
RecordID string
UserID Uid
AppealType string
Reason string
TargetAction string
TargetTime *time.Time
AttachmentFileIDs []string
Status string
Version int
SubmittedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type AttendanceAppealListOptions struct {
Scope string
Status string
Cursor string
Limit int
}
+1 -1
View File
@@ -16,7 +16,7 @@ type CallRecord struct {
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
CallID string `db:"call_id"` CallID string `db:"call_id"`
Topic string Topic string
SeqID int `db:"seq_id"` SeqID int64 `db:"seq_id"`
CallerID Uid `db:"caller_id"` CallerID Uid `db:"caller_id"`
CalleeID Uid `db:"callee_id"` CalleeID Uid `db:"callee_id"`
CallType string `db:"call_type"` CallType string `db:"call_type"`

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