Compare commits

...
25 Commits
Author SHA1 Message Date
richjohnrambo 7c1fab5065 租户库共库分表实现 2026-09-23 09:36:17 +08:00
richjohnrambo 137d6fb6da fix verification cache for redis-backed challenges 2026-09-23 09:02:56 +08:00
richjohnrambo e37b52c3c0 feat: complete shared database tenant sharding 2026-09-23 02:19:07 +08:00
richjohnrambo 1ec2feee47 fix: keep only tenant realname review policy 2026-09-23 02:08:13 +08:00
richjohnrambo 62856194e5 fix HTTP idempotency insert placeholders 2026-09-23 00:28:26 +08:00
richjohnrambo 1909074898 fix tenant friend request row scans 2026-09-22 20:26:43 +08:00
richjohnrambo 9ddf6ec5ee fix shared-db tenant runtime gaps 2026-09-22 19:28:33 +08:00
Austin 49e43aa3cf fix(mysql): read realname policy fallback from im_tenant
Tenant config was merged into im_tenant on the manager side; switch the
DB fallback query accordingly. Keep ErrNoRows/unknown-column fallbacks
so environments before the phase-1 migration degrade to defaults.
2026-09-22 19:08:42 +08:00
richjohnrambo 8e04f00e96 fix shared database account sequence initialization 2026-09-22 18:00:18 +08:00
richjohnrambo e4416da04a fix im server shared database config 2026-09-22 16:45:24 +08:00
richjohnrambo b8c93e8d6a fix(mysql): route tenant data to physical shard tables 2026-09-22 16:11:10 +08:00
richjohnrambo 76b462e334 docs(sql): add shared database sharding migrations 2026-09-22 15:19:56 +08:00
richjohnrambo 2e0612acbb fix(mysql): adapt tenant shared database fields 2026-09-22 14:40:15 +08:00
richjohnrambo 865b436264 fix: skip challenge requirement for fixed test code 2026-09-22 00:46:35 +08:00
richjohnrambo ac10b31aa1 fix: accept fixed legacy registration code 2026-09-22 00:39:14 +08:00
richjohnrambo 3276c6d1cd fix: allow legacy enterprise registration payloads 2026-09-22 00:23:53 +08:00
richjohnrambo dc36ce292e refactor: unify internal api authentication 2026-09-21 23:08:15 +08:00
richjohnrambo 186cfa21c8 feat: validate personal and enterprise auth tenants 2026-09-21 21:24:25 +08:00
richjohnrambo fc393cd685 docs: add aliyun realname integration notes 2026-09-21 21:24:25 +08:00
Your NameandClaude Sonnet 5 b30cce950a fix(docker): mirror tinode's own log output to stdout
Confirmed live: /var/log/tinode.log-only redirection meant Loki never
received a single real tinode runtime log line (election, rehash,
connection, error output) since the container started -- Promtail
collects standard container stdout/stderr, not files written inside
the container. Every Pod's collected log was just this script's own
"NO_DB_INIT=true: skipping..." init line, nothing from tinode itself.

Now more consequential with native Cluster enabled: failover/rehash
state transitions were completely invisible to the normal Grafana/Loki
monitoring path, visible only via manual kubectl exec.

tee mirrors to both stdout (collected) and the file (still readable via
kubectl exec). PIPESTATUS[0] is required since a bare $? after a pipe
would report tee's exit status, not tinode's -- which also let this
drop the now-redundant "tail last 200 lines to stderr on crash" block,
since the full live stream already went to stdout throughout the run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:23:25 +08:00
Your NameandClaude Sonnet 5 4abd6604d7 fix(deploy): raise rollout timeout for StatefulSet's sequential update
StatefulSet's RollingUpdate replaces Pods one at a time in strict
sequence (unlike a Deployment, and unaffected by podManagementPolicy:
Parallel, which only governs initial scale-up/down) -- confirmed live:
run #152 restarted correctly (2/3 pods updated, then the 3rd) but hit
the old 120s ceiling before the last pod turned Ready, so CI reported
"failure" for a rollout that actually completed successfully seconds
later. 300s gives enough headroom for three sequential pod cycles
(image pull + start + native-Cluster rejoin) instead of tuning it to
the exact observed timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 19:49:44 +08:00
Your NameandClaude Sonnet 5 eca2ee56c0 fix(deploy): update rollout commands for StatefulSet migration
im-server moved from Deployment to StatefulSet in im-deploy-config
(native Cluster enablement) -- this workflow's kubectl commands still
targeted the old resource type, so every deploy since the migration
failed at the final step with "deployments.apps im-server not found"
(image built/pushed fine, just never got rolled out).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 19:40:26 +08:00
Austin 9820e89e1c refactor(server/auth): 移除账号掩码函数并简化审计日志存储
1.  移除不再使用的maskLoginAccount工具函数
2.  登录审计日志直接存储原始账号而非掩码后的版本
3.  重命名测试函数以匹配新的业务逻辑
2026-09-21 17:50:55 +08:00
richjohnrambo 55c59878cf fix: resync user account sequence 2026-09-21 16:55:24 +08:00
Austin a147dc718a refactor(server): 优化登录锁定提示文案和时长计算逻辑
更新了loginLockedMessage函数,细化了锁定时长的提示分级,增加了过期回退文案,让提示更准确友好
2026-09-21 13:55:02 +08:00
71 changed files with 4539 additions and 2117 deletions
+2 -2
View File
@@ -34,5 +34,5 @@ jobs:
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-server -n im-server-system
kubectl rollout status deployment/im-server -n im-server-system --timeout=120s
kubectl rollout restart statefulset/im-server -n im-server-system
kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s
+11 -1
View File
@@ -143,7 +143,7 @@ tinode 移除
全局禁止加好友 比如下班时间到了 不能加 从群成员去看 不能显示个人信息 个人名片也不能看 二维码也不能加
阿里云的oss放一个域名清单 要加密
阿里云的oss放一个域名清单 要加密 apikey动态更换
设备类型 ip 全部采集入库
离线时间 和上线时间的采集
检查一下多处存同一个信息的。尤其是用户的
@@ -201,4 +201,14 @@ Bundle ID:com.ysl.IM
KEY ID:J4ZUVHW59G
Team ID:2DN6KF37CH
单号登录 多设备顶号 已经实现
多标签登录的消息到达率
建一个千人大群
telegram报警功能
加上ai翻译的功能 ai笔记
后端要做一下 redis 的 sentinel适配, 不然重新选举之后可能会写错节点报错
https://api.118im.com 简单弄一个官网,https://api.118im.com/legal/privacy-policy需要把名字改对,TestFlight的beta版需要审核,这两个可能会是卡点  
+4 -3
View File
@@ -196,7 +196,7 @@ func (a *audit) run() {
a.testHTTP("5", "5.qr-resolve", "二维码解析", "POST", "/v1/im/qrcodes/resolve", map[string]any{"token": a.meQR, "purpose": a.meQRPurpose}, a.tokenB, true, []int{200}, nil)
}
a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "联调群" + suffix, "description": "自动化测试群", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string {
a.testHTTP("6", "6.create", "创建群", "POST", "/v1/im/groups", map[string]any{"name": "auditgroup" + suffix, "description": "audit group", "member_user_ids": []string{a.userB}, "avatar_file_id": ""}, a.tokenA, true, []int{200}, func(r imResp) string {
a.groupTopic = asString(r.Data["topic"])
a.groupVer = asInt(r.Data["version"])
return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer)
@@ -379,7 +379,7 @@ func (a *audit) run() {
func (a *audit) register(chapter, id, username, phone, password string, primary bool) {
var challenge, verification string
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) string {
a.testHTTP(chapter, id+"-code", "注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "", true, []int{200}, func(r imResp) string {
challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge)
})
@@ -404,7 +404,7 @@ func (a *audit) register(chapter, id, username, phone, password string, primary
func (a *audit) registerThird(chapter, id, username, phone, password string) {
var challenge, verification string
a.testHTTP(chapter, id+"-code", "入群用户注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "", true, []int{200}, func(r imResp) string {
a.testHTTP(chapter, id+"-code", "入群用户注册发码 "+username, "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "", true, []int{200}, func(r imResp) string {
challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge)
})
@@ -451,6 +451,7 @@ func (a *audit) wsRegister(username, phone, password, challenge, verification st
"scheme": "basic",
"username": username,
"phone": phone,
"registration_type": 1,
"secret": secret,
"login": true,
"cred": []map[string]any{{
+5 -3
View File
@@ -642,6 +642,7 @@ func (a *audit) register(username, phone, password string) (string, string, erro
"scheme": "basic",
"username": username,
"phone": phone,
"registration_type": 1,
"secret": secret,
"login": true,
"cred": []map[string]any{{
@@ -667,7 +668,7 @@ func (a *audit) register(username, phone, password string) (string, string, erro
}
func (a *audit) verificationCode(phone string) (string, error) {
status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "phone": phone}, "")
status, r, err := a.call("POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "register", "registration_type": 1, "phone": phone}, "")
if err != nil {
return "", err
}
@@ -724,8 +725,8 @@ func (a *audit) createFriendship() (string, error) {
func (a *audit) createGroup(suffix string) (string, error) {
status, r, err := a.call("POST", "/v1/im/groups", map[string]any{
"name": "WS协议群" + suffix,
"description": "WS audit group",
"name": "auditgroup" + suffix,
"description": "audit group",
"member_user_ids": []string{a.userB},
"avatar_file_id": "",
}, a.tokenA)
@@ -829,6 +830,7 @@ func (a *audit) openBasicLoggedIn(name, username, password string) (*wsClient, e
if err = c.write(map[string]any{"login": map[string]any{
"id": loginID,
"scheme": "basic",
"login_type": 1,
"secret": secret,
}}); err != nil {
_ = c.conn.Close()
+6 -2
View File
@@ -51,8 +51,10 @@ ENV SAMPLE_DATA=$SAMPLE_DATA
ENV DEFAULT_COUNTRY_CODE=US
# The MySQL DSN connection.
ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci'
ENV MYSQL_DSN='root@tcp(mysql)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci'
ENV REDIS_ADDR=
ENV REDIS_SENTINEL_MASTER_NAME=
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
ENV REDIS_USERNAME=
ENV REDIS_PASSWORD=
ENV REDIS_DB=0
@@ -97,10 +99,12 @@ ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=false
ENV INTERNAL_API_ENABLED=true
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET=
ENV INTERNAL_API_CULTIVATE_SERVICE_NAME=cultivate-server
ENV INTERNAL_API_CULTIVATE_SECRET=
ENV ADMIN_INTERNAL_API_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
+6 -2
View File
@@ -35,6 +35,8 @@ ENV DEFAULT_COUNTRY_CODE=US
ENV STORE_USE_ADAPTER=mysql
ENV REDIS_ADDR=
ENV REDIS_SENTINEL_MASTER_NAME=
ENV REDIS_SENTINEL_ADDRS_JSON='[]'
ENV REDIS_USERNAME=
ENV REDIS_DB=0
ENV REDIS_DIAL_TIMEOUT_MS=2000
@@ -77,10 +79,12 @@ ENV KAFKA_TOPIC_FILE_SECURITY=im.file.security
ENV KAFKA_TOPIC_CACHE_INVALIDATE=im.cache.invalidate
ENV KAFKA_TOPIC_AUDIT_EVENT=im.audit.event
ENV KAFKA_TOPIC_DEAD_LETTER=im.dead.letter
ENV INTERNAL_API_ENABLED=false
ENV INTERNAL_API_ENABLED=true
ENV INTERNAL_API_TIME_WINDOW_SECONDS=300
ENV INTERNAL_API_HOTGO_SERVICE_NAME=hotgo-admin
ENV INTERNAL_API_HOTGO_SECRET=
ENV INTERNAL_API_CULTIVATE_SERVICE_NAME=cultivate-server
ENV INTERNAL_API_CULTIVATE_SECRET=
ENV ADMIN_INTERNAL_API_ENABLED=false
ENV ADMIN_INTERNAL_API_BASE_URL=
ENV ADMIN_INTERNAL_API_SERVICE_NAME=im-server
@@ -88,7 +92,7 @@ ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# Tenant registry lives in the hotgo database (shared with im_manager); tenant
# DB routing resolves to the same tinode DB for the seeded "default" tenant.
# business data lives in the shared meeting_im DB and is scoped by tenant_id.
ENV TENANT_REGISTRY_DB=hotgo
ENV MEDIA_HANDLER=sys_config
+27 -21
View File
@@ -77,7 +77,7 @@
"use_adapter": "$STORE_USE_ADAPTER",
"adapters": {
"mysql": {
"database": "tinode",
"database": "meeting_im",
"dsn": "$MYSQL_DSN",
"max_open_conns": 64,
"max_idle_conns": 64,
@@ -87,7 +87,7 @@
"tenant_db_routing": true
},
"postgres": {
"database": "tinode",
"database": "meeting_im",
"dsn": "$POSTGRES_DSN"
},
"rethinkdb": {
@@ -101,22 +101,24 @@
}
},
"redis": {
"addr": "redis.118im.com:30637",
"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": 300,
"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
"addr": "$REDIS_ADDR",
"sentinel_master_name": "$REDIS_SENTINEL_MASTER_NAME",
"sentinel_addrs": $REDIS_SENTINEL_ADDRS_JSON,
"username": "$REDIS_USERNAME",
"password": "$REDIS_PASSWORD",
"db": $REDIS_DB,
"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
}
},
@@ -154,12 +156,16 @@
}
},
"internal_api": {
"enabled": true,
"time_window_seconds": 300,
"enabled": $INTERNAL_API_ENABLED,
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
"services": {
"hotgo-admin": {
"$INTERNAL_API_HOTGO_SERVICE_NAME": {
"secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"]
},
"$INTERNAL_API_CULTIVATE_SERVICE_NAME": {
"secret": "$INTERNAL_API_CULTIVATE_SECRET",
"allow": ["cultivate"]
}
}
},
+9 -8
View File
@@ -188,11 +188,12 @@ fi
args=("--config=${CONFIG}" "--static_data=$STATIC_DIR" "--cluster_self=$CLUSTER_SELF" "--pprof_url=$PPROF_URL")
# Run the tinode server. Mirror the captured stderr to container logs on exit so
# CrashLoopBackOff pods expose the actual startup failure via kubectl logs.
./tinode "${args[@]}" 2>> /var/log/tinode.log
status=$?
if [ "$status" -ne 0 ] && [ -s /var/log/tinode.log ]; then
tail -200 /var/log/tinode.log >&2
fi
exit "$status"
# Run the tinode server, mirroring output to both stdout (so kubectl logs/Promtail's
# standard container-log collection actually sees it -- confirmed live that the old
# file-only redirect meant Loki never received a single real tinode log line, only
# this script's own earlier init-db output) and the log file (kept for exec-based
# inspection, e.g. `kubectl exec ... -- cat /var/log/tinode.log`). PIPESTATUS[0] is
# required here: with output piped through tee, a bare $? would report tee's exit
# status (almost always 0) instead of tinode's.
./tinode "${args[@]}" 2>&1 | tee -a /var/log/tinode.log
exit "${PIPESTATUS[0]}"
@@ -0,0 +1,343 @@
# IM 个人/企业注册登录前后端对接说明
## 1. 目标
当前客户端没有独立的企业码进入页面,登录页和注册页由用户选择“个人用户”或“企业用户”。前端提交登录和注册时必须显式带类型,后端不能再通过企业码是否为空来推断个人/企业。
核心规则:
1. 前端新增字段 `registration_type`,类型为 int,取值只能是 `0` 或 `1`。
2. `registration_type=1` 时,后端按个人注册处理,内部租户使用 `default`,数据写入 `default` 租户业务库。
3. `registration_type=0` 时,前端必须提交企业码,后端必须校验企业存在、启用且允许注册。
4. 登录新增字段 `login_type`,类型为 int,取值同样是 `0` 企业、`1` 个人。
5. 企业登录或企业注册都不允许使用 `default`。
6. `default` 是系统保留的个人租户码,不允许作为企业码输入。
一句话规则:**注册类型由 `registration_type` 决定,登录类型由 `login_type` 决定;个人落 `default`,企业必须校验企业码。**
## 2. 前端注册字段
注册页提交发码和最终注册时都带同一组注册上下文字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `registration_type` | int | 是 | `0` 表示企业注册,`1` 表示个人注册 |
| `tenant_code` | string | 企业注册必填 | 企业码;个人注册不填或传空字符串 |
前端规则:
| `registration_type` | `tenant_code` | 后端处理 |
| --- | --- | --- |
| `1` | 空 | 允许提交,后端内部使用 `default` |
| `1` | 非空 | 后端拒绝,避免误入库 |
| `0` | 空 | 后端拒绝,提示请输入企业码 |
| `0` | `default`、`Default`、`DEFAULT` | 后端拒绝 |
| `0` | 其他值 | 后端在注册链路中校验 |
用户在企业注册模式输入 `default` 时,建议提示:
```text
default 是系统保留企业码。个人注册请选择个人用户,企业注册请输入企业提供的企业码。
```
## 3. 企业码校验
不新增单独的企业码校验接口,也不要求前端在注册前调用 `/v1/im/tenants/resolve`。
后端在注册发码和最终注册链路中校验:
1. `registration_type=1`
- 不要求企业码。
- 即使请求 Header 或内部路由使用 `default`,也属于服务端内部租户,不是用户输入企业码。
- 创建用户时写入 `default` 租户业务库。
2. `registration_type=0`
- `tenant_code` 必填。
- `tenant_code` 大小写不敏感等于 `default` 时拒绝。
- 查询企业是否存在、启用、允许注册。
- 通过后在对应企业租户业务库创建用户。
企业不存在、停用或不允许注册时,客户端统一提示:
```text
企业不存在或不可用
```
客户端不要区分不存在、停用、禁用注册等内部原因。
`/v1/im/tenants/resolve` 只保留给服务发现、品牌配置、历史企业入口等场景使用;当前注册页不依赖它完成主流程。
## 4. 前端登录字段
登录页提交 WebSocket `{login}` 时带登录上下文字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `login_type` | int | basic 登录必填 | `0` 表示企业登录,`1` 表示个人登录 |
| `tenant_code` | string | 企业登录必填 | 企业码;个人登录不填、传空字符串或内部传 `default` |
登录规则:
| `login_type` | `tenant_code` / `hi.tenant` | 后端处理 |
| --- | --- | --- |
| `1` | `default` | 个人登录,允许 |
| `1` | 非 `default` | 拒绝,避免个人登录误进企业租户 |
| `0` | 空 | 拒绝,提示请输入企业码 |
| `0` | `default`、`Default`、`DEFAULT` | 拒绝,统一提示“企业不存在或不可用” |
| `0` | 其他值 | 企业登录,后端按该租户认证 |
企业用户如果在企业入口输入 `default`,后端返回策略错误,错误参数为 `what=tenant_code`、`reason=TENANT_CODE_FORBIDDEN`;客户端统一展示:
```text
企业不存在或不可用
```
个人登录 `{login}` 示例:
```json
{
"login": {
"id": "login-1",
"scheme": "basic",
"login_type": 1,
"tenant_code": "",
"secret": "base64(account:password)"
}
}
```
企业登录 `{login}` 示例:
```json
{
"login": {
"id": "login-1",
"scheme": "basic",
"login_type": 0,
"tenant_code": "acme",
"secret": "base64(account:password)"
}
}
```
## 5. 个人注册流程
个人注册必须显式提交 `registration_type=1`。
1. 用户选择个人注册。
2. 前端提交 `registration_type=1`,`tenant_code` 为空。
3. 注册发码、验证码校验、WebSocket `hi`、`acc` 注册链路使用服务端内部租户 `default`。
4. 服务端将用户创建到 `default` 租户对应的业务库。
发码示例:
```http
POST /v1/im/auth/verification-codes
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Tenant-Code: default
X-Request-ID: <uuid>
```
```json
{
"scene": "register",
"registration_type": 1,
"tenant_code": "",
"phone": "19122421333"
}
```
WebSocket 握手示例:
```json
{
"hi": {
"id": "hi-1",
"ver": "0.25",
"ua": "ios/1.0.0",
"platf": "ios",
"lang": "zh-CN",
"tenant": "default"
}
}
```
最终注册 `{acc}` 示例:
```json
{
"acc": {
"id": "acc-1",
"user": "new",
"scheme": "basic",
"registration_type": 1,
"tenant_code": "",
"username": "henry",
"phone": "19122421333",
"secret": "base64(henry:password)",
"login": true,
"cred": [
{
"meth": "tel",
"val": "19122421333",
"resp": "verification_token",
"params": {"challenge_id": "challenge-id"}
}
]
}
}
```
## 6. 企业注册流程
企业注册必须显式提交 `registration_type=0`。
1. 用户选择企业注册。
2. 用户填写企业码,例如 `acme`。
3. 前端直接提交用户输入的企业码,不做企业码存在性或保留字拦截。
4. 获取验证码、提交注册时都带 `registration_type=0` 和 `tenant_code=acme`。
5. 后端在注册链路中校验企业是否存在且可用。
6. 校验通过后,服务端在该企业租户业务库中创建用户;校验不通过时返回“企业不存在或不可用”。
注册发码请求:
```http
POST /v1/im/auth/verification-codes
X-MeetingIM-APIKey: <api_key>
X-IM-Schema: 1
X-Tenant-Code: acme
X-Request-ID: <uuid>
```
```json
{
"scene": "register",
"registration_type": 0,
"tenant_code": "acme",
"phone": "19122421333"
}
```
WebSocket `hi`:
```json
{
"hi": {
"id": "hi-1",
"ver": "0.25",
"ua": "android/1.0.0",
"platf": "android",
"lang": "zh-CN",
"tenant": "acme"
}
}
```
最终注册 `{acc}` 示例:
```json
{
"acc": {
"id": "acc-1",
"user": "new",
"scheme": "basic",
"registration_type": 0,
"tenant_code": "acme",
"username": "henry",
"phone": "19122421333",
"secret": "base64(henry:password)",
"login": true,
"cred": [
{
"meth": "tel",
"val": "19122421333",
"resp": "verification_token",
"params": {"challenge_id": "challenge-id"}
}
]
}
}
```
## 7. 后端校验规则
后端负责注册链路的最终校验,前端不做企业码拦截。
1. `registration_type` 缺失或不是 `0|1`
- 拒绝注册。
- 返回 `MALFORMED_REQUEST`,提示客户端升级或检查参数。
2. `registration_type=1`
- 不从空企业码推断,而是由该字段明确进入个人注册。
- 内部租户码设置为 `default`。
- 拒绝用户提交的非空 `tenant_code`,避免误入库。
- 注册时写入 `default` 租户业务库。
3. `registration_type=0`
- `tenant_code` 必填。
- 先 trim 首尾空格。
- 如果大小写不敏感等于 `default`,拒绝注册。
- 否则查询租户是否存在、是否启用、是否允许注册。
- 校验通过后绑定对应租户。
建议错误:
```json
{
"code": 30000,
"reason": "MALFORMED_REQUEST",
"message": "invalid registration_type or tenant_code"
}
```
企业不可用时,客户端展示:
```text
企业不存在或不可用
```
## 8. 缓存和本地数据隔离
客户端本地缓存、Token、会话、草稿、附件缓存必须继续按 `tenant_code + user_id` 隔离。
个人注册用户登录后,缓存键中的 `tenant_code` 使用 `default`。企业用户登录后,使用对应企业码。切换个人/企业身份时必须清理当前连接并重新 `hi`,不能在同一个 WebSocket 中切换租户。
## 9. 验收用例
| 场景 | 预期 |
| --- | --- |
| `login_type=1`,`hi.tenant=default` | 个人登录成功进入 `default` 租户认证 |
| `login_type=1`,`hi.tenant=acme` | 拒绝登录 |
| `login_type=0`,企业码空 | 拒绝登录,提示请输入企业码 |
| `login_type=0`,企业码为 `default` | 拒绝登录,提示企业不存在或不可用 |
| `login_type=0`,企业码有效 | 在该企业租户认证 |
| `registration_type=1`,企业码空,手机号注册 | 成功进入 `default` 租户注册链路 |
| `registration_type` 缺失 | 拒绝注册,返回参数错误 |
| `registration_type=1`,企业码非空 | 拒绝或忽略,建议拒绝,避免误入库 |
| `registration_type=0`,企业码空 | 拒绝注册,提示请输入企业码 |
| `registration_type=0`,企业码为 `default` | 拒绝注册 |
| `registration_type=0`,企业码不存在 | 发码或注册失败,提示企业不存在或不可用 |
| `registration_type=0`,企业码已停用 | 统一提示企业不存在或不可用 |
| `registration_type=0`,企业码有效 | 注册写入该企业租户库 |
| 个人用户注册成功后登录 | `tenant_code=default`,可正常登录、收发消息 |
## 10. 对客户端的明确要求
1. 登录页和注册页都必须明确区分个人和企业。
2. basic 登录必须提交 `login_type`。
3. 发码和最终注册都必须提交 `registration_type`。
4. 个人登录/注册提交类型 `1`,不要让用户输入或看到 `default`。
5. 企业登录/注册提交类型 `0` 和用户输入的企业码。
6. 企业用户手动输入 `default` 也直接提交,由后端返回错误,客户端按后端错误展示提示。
7. 不需要为了注册主流程调用 `/v1/im/tenants/resolve`。
## 11. 对后端的明确要求
1. basic 登录链路必须以 `login_type` 作为个人/企业登录的判定来源。
2. 注册链路必须以 `registration_type` 作为个人/企业注册的判定来源。
3. 不允许仅根据企业码是否为空推断个人注册或个人登录。
4. 登录、注册、发码、验证码校验、WebSocket `hi/login/acc` 链路保持同一租户上下文。
5. 显式企业码 `default` 必须拒绝,防止用户绕过企业码校验。
6. 企业登录/注册的企业码必须校验存在、启用、允许登录/注册。
7. 日志中要能看出来源是 `personal_default` 还是 `enterprise_code`,方便排查误入库问题。
+21 -19
View File
@@ -160,8 +160,8 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
```text
读取客户端预置的 host 和普通 API Key
-> 用户输入/客户端读取 tenant_code
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
-> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
-> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
-> 带 API Key 完成 WebSocket Upgrade
-> hi(tenant_code)
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
@@ -173,7 +173,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
-> 进入可发送状态
```
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
当前服务端已提供 `/v1/im/tenants/resolve`,用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不把它作为必选前置校验。企业是否存在、是否可用必须在 `{hi}`、登录和注册链路中最终校验。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
`hi` 请求:
@@ -322,8 +322,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` |
| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
@@ -617,7 +617,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| --- | --- | --- |
| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` |
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 |
@@ -663,22 +663,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/企业码登录.png" alt="企业码登录" width="360">
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
**状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
**当前系统的执行方式:**
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。
- 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,直接使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket;当前登录/注册主流程不要求先调用 `POST /v1/im/tenants/resolve`。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"登录/注册页确定的 tenant_code"}}`。
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。
- 当前失败表现:企业码超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。登录/注册页统一展示“企业不存在或不可用”。
**企业解析接口:**
- `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`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
- 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](IM-个人无企业码注册前后端对接说明.md)。
成功响应示例:
@@ -810,13 +811,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
- 前端动作:企业码、账号、密码和协议勾选在同一个登录页完成;个人用户可不填企业码,企业用户填写企业码。账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:点击登录后先按页面企业码确定 `tenant_code` 并发送 WebSocket `{hi}`;`hi` 成功后发送 `{login scheme:"basic"}`,`secret=base64(username:password)`。Base64 不是加密,必须通过 WSS 传输。同一次登录不得先登录再切换企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- 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`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
- 错误:企业不存在、停用或不允许登录统一显示“企业不存在或不可用”;`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并留在登录页重新选择个人/企业身份;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
登录成功示例:
@@ -856,6 +857,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。
- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。
@@ -874,19 +876,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
用户名方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}}
```
手机号验证方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
仅手机号注册方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","registration_type":1,"secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
@@ -986,7 +988,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
+36 -33
View File
@@ -110,22 +110,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications (
INDEX idx_im_realname_id_hash (id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_action_tokens (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_action_tokens_token (token_id),
INDEX idx_im_action_tokens_user_action (user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DROP TABLE IF EXISTS tinode.im_action_tokens;
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
id BIGINT NOT NULL,
@@ -164,23 +149,9 @@ CREATE TABLE IF NOT EXISTS tinode.im_system_notifications (
INDEX idx_im_system_notifications_inbox (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing',
response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB,
expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_http_idempotency_scope (user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- HTTP write idempotency is Redis-backed in the latest code.
DROP TABLE IF EXISTS meeting_im.im_http_idempotency;
DROP TABLE IF EXISTS tinode.im_http_idempotency;
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1031,5 +1002,37 @@ INSERT INTO tinode.im_feedback_config(id, categories, max_length, attachment_pol
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at = updated_at;
-- ============================================================
-- Shared database runtime fixes for meeting_im.
-- These are required after moving default/system tenants into the shared DB.
-- ============================================================
CREATE TABLE IF NOT EXISTS meeting_im.im_user_account_sequences (
tenant_id BIGINT NOT NULL,
id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
ALTER TABLE meeting_im.im_user_account_sequences
DROP PRIMARY KEY,
ADD PRIMARY KEY (tenant_id, id);
INSERT INTO meeting_im.im_user_account_sequences(tenant_id, id, next_value)
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM meeting_im.im_users
WHERE tenant_id > 0
GROUP BY tenant_id
ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value));
INSERT IGNORE INTO meeting_im.im_user_account_sequences(tenant_id, id, next_value)
SELECT id, 1, 999
FROM hotgo.im_tenant
WHERE state = 1;
DROP TABLE IF EXISTS meeting_im.im_login_lockouts;
-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go.
UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version';
+2 -17
View File
@@ -244,20 +244,5 @@ CREATE TABLE IF NOT EXISTS im_group_admin_logs (
INDEX idx_im_group_admin_logs_object (object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 11. Group operation idempotency.
-- This can later be replaced by a global HTTP idempotency table.
CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0,
result_body JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_idempotency_request (user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 11. Group operation idempotency is handled by Redis-backed HTTP idempotency.
DROP TABLE IF EXISTS im_group_idempotency;
+3 -25
View File
@@ -2,32 +2,10 @@
-- Date: 2026-08-26
-- Scope: run once in every tenant business database, such as tinode.
-- Notes:
-- * Do not run in hotgo. Admin/HotGo side needs its own admin_command/outbox tables.
-- * Tables do not contain tenant_id because each tenant has an independent database.
-- * Do not run in hotgo. Admin/HotGo side needs its own admin_command tables.
-- * Kafka consume idempotency/retry state is Redis-backed; MySQL only keeps manual dead letters.
CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(96) NOT NULL,
consumer_group VARCHAR(128) NOT NULL,
source_topic VARCHAR(128) NOT NULL,
event_type VARCHAR(96) NOT NULL DEFAULT '',
biz_type VARCHAR(64) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'processing',
retry_count INT NOT NULL DEFAULT 0,
last_reason VARCHAR(64) NOT NULL DEFAULT '',
last_error VARCHAR(1000) NOT NULL DEFAULT '',
processing_deadline DATETIME(3),
succeeded_at DATETIME(3),
dead_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_kafka_consume_log_event_group(event_id, consumer_group),
INDEX idx_im_kafka_consume_log_status(status, processing_deadline),
INDEX idx_im_kafka_consume_log_biz(biz_type, biz_id),
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DROP TABLE IF EXISTS im_kafka_consume_log;
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -0,0 +1,92 @@
-- 仅用于本地环境:为 IM 业务表补充 tenant_id 字段。
--
-- 本脚本只修改表结构并回填现有数据,不修改索引、唯一约束或应用代码,
-- 也不会启用多商户共库能力。
USE `tinode`;
DELIMITER $$
DROP PROCEDURE IF EXISTS add_im_tenant_columns$$
CREATE PROCEDURE add_im_tenant_columns(IN target_schema VARCHAR(64), IN default_tenant_id BIGINT)
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE target_table VARCHAR(128);
DECLARE has_id INT DEFAULT 0;
DECLARE table_cursor CURSOR FOR
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema = target_schema
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1
FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.column_name = 'tenant_id'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN table_cursor;
table_loop: LOOP
FETCH table_cursor INTO target_table;
IF done = 1 THEN
LEAVE table_loop;
END IF;
SELECT COUNT(*) INTO has_id
FROM information_schema.columns
WHERE table_schema = target_schema
AND table_name = target_table
AND column_name = 'id';
SET @position_sql = IF(has_id > 0, ' AFTER `id`', ' FIRST');
SET @alter_sql = CONCAT(
'ALTER TABLE `', target_schema, '`.`', target_table,
'` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT ',
default_tenant_id,
' COMMENT ''租户ID''',
@position_sql
);
PREPARE alter_stmt FROM @alter_sql;
EXECUTE alter_stmt;
DEALLOCATE PREPARE alter_stmt;
END LOOP;
CLOSE table_cursor;
END$$
DELIMITER ;
-- 本地默认业务库,现有数据归属 tenant_id=1。
CALL add_im_tenant_columns('tinode', 1);
-- 本地独立业务库,现有数据归属 tenant_id=2。
CALL add_im_tenant_columns('meeting_im', 2);
DROP PROCEDURE IF EXISTS add_im_tenant_columns;
-- 验证:以下结果中 missing_count 应全部为 0。
SELECT 'tinode' AS db_name, COUNT(*) AS missing_count
FROM information_schema.tables t
WHERE t.table_schema = 'tinode'
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.column_name = 'tenant_id'
)
UNION ALL
SELECT 'meeting_im', COUNT(*)
FROM information_schema.tables t
WHERE t.table_schema = 'meeting_im'
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name
AND c.column_name = 'tenant_id'
);
@@ -0,0 +1,153 @@
-- 仅用于本地环境:将高数据量表迁移为“共库、按商户分表”。
-- 目标命名:
-- im_001_friendships
-- im_001_messages
-- im_001_group_messages_YYYYMM
-- 测试环境不得执行本文件。
-- 1. 商户编号改为事务序列分配,下一编号按当前最大 ID 顺序递增。
USE `hotgo`;
CREATE TABLE IF NOT EXISTS `im_tenant_id_sequence` (
`sequence_name` VARCHAR(32) NOT NULL,
`next_id` BIGINT NOT NULL,
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`sequence_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `im_tenant_id_sequence` (`sequence_name`, `next_id`)
SELECT 'tenant', COALESCE(MAX(`id`), 0) + 1 FROM `im_tenant`
ON DUPLICATE KEY UPDATE `next_id` = GREATEST(`next_id`, VALUES(`next_id`));
-- 禁止绕过序列表使用 AUTO_INCREMENT 生成跳号或历史大整数编号。
ALTER TABLE `hg_admin_member` DROP FOREIGN KEY `fk_hg_admin_member_im_tenant`;
ALTER TABLE `im_tenant_config` DROP FOREIGN KEY `fk_im_tenant_config_tenant`;
ALTER TABLE `im_tenant` MODIFY `id` BIGINT NOT NULL;
ALTER TABLE `hg_admin_member` ADD CONSTRAINT `fk_hg_admin_member_im_tenant`
FOREIGN KEY (`tenant_id`) REFERENCES `im_tenant` (`id`);
ALTER TABLE `im_tenant_config` ADD CONSTRAINT `fk_im_tenant_config_tenant`
FOREIGN KEY (`tenant_id`) REFERENCES `im_tenant` (`id`);
UPDATE `im_tenant` AS target
JOIN `im_tenant` AS shared ON shared.`code`='default'
SET target.`db_name`=shared.`db_name`, target.`db_dsn`=shared.`db_dsn`
WHERE target.`id`<>shared.`id`;
-- 2. 月表路由元数据改为以 tenant_id 为第一维度。
USE `tinode`;
ALTER TABLE `im_group_message_shards`
DROP PRIMARY KEY,
DROP INDEX `uk_im_group_message_shards_table`,
DROP INDEX `idx_im_group_message_shards_status`,
ADD PRIMARY KEY (`tenant_id`, `shard_month`),
ADD UNIQUE INDEX `uk_im_group_message_shards_table` (`tenant_id`, `table_name`),
ADD INDEX `idx_im_group_message_shards_status` (`tenant_id`, `status`, `shard_month`);
DROP TABLE IF EXISTS `im_group_message_topic_shards`;
DROP TABLE IF EXISTS `im_group_message_dedup`;
-- 3. 创建好友与单聊消息分表并迁移现有数据。
CREATE TABLE IF NOT EXISTS `im_001_friendships` LIKE `im_friendships`;
ALTER TABLE `im_001_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT IGNORE INTO `im_001_friendships` SELECT * FROM `im_friendships` WHERE `tenant_id` = 1;
CREATE TABLE IF NOT EXISTS `im_001_messages` LIKE `im_messages`;
ALTER TABLE `im_001_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT IGNORE INTO `im_001_messages` SELECT * FROM `im_messages` WHERE `tenant_id` = 1;
CREATE TABLE IF NOT EXISTS `im_002_friendships` LIKE `im_friendships`;
ALTER TABLE `im_002_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT IGNORE INTO `im_002_friendships` SELECT * FROM `meeting_im`.`im_friendships`;
CREATE TABLE IF NOT EXISTS `im_002_messages` LIKE `im_messages`;
ALTER TABLE `im_002_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT IGNORE INTO `im_002_messages` SELECT * FROM `meeting_im`.`im_messages`;
-- 4. 迁移所有已存在的标准月表。过程只识别 im_group_messages_YYYYMM。
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_group_month_tables$$
CREATE PROCEDURE migrate_group_month_tables(IN source_schema VARCHAR(64), IN source_tenant BIGINT, IN tenant_code CHAR(3))
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE source_table VARCHAR(64);
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE month_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema = source_schema
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN month_cursor;
month_loop: LOOP
FETCH month_cursor INTO source_table;
IF done = 1 THEN
LEAVE month_loop;
END IF;
SET month_code = RIGHT(source_table, 6);
SET target_table = CONCAT('im_', tenant_code, '_group_messages_', month_code);
SET @create_sql = CONCAT('CREATE TABLE IF NOT EXISTS `tinode`.`', target_table,
'` LIKE `', source_schema, '`.`', source_table, '`');
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @alter_sql = CONCAT('ALTER TABLE `tinode`.`', target_table,
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT ', source_tenant, ' COMMENT ''商户ID''');
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @copy_sql = CONCAT('INSERT IGNORE INTO `tinode`.`', target_table,
'` SELECT * FROM `', source_schema, '`.`', source_table,
'` WHERE `tenant_id`=', source_tenant);
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE month_cursor;
END$$
DELIMITER ;
CALL migrate_group_month_tables('tinode', 1, '001');
CALL migrate_group_month_tables('meeting_im', 2, '002');
DROP PROCEDURE IF EXISTS migrate_group_month_tables;
-- 旧的非月表数据按创建月份归入 1 号商户月表(本地现有数据为 202608)。
INSERT IGNORE INTO `im_001_group_messages_202608`
(`tenant_id`,`message_id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,`status`,
`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,
`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,`deleted_at`,
`expires_at`,`created_at`,`updated_at`)
SELECT 1,`id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,`status`,
`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,
`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,`deleted_at`,
`expires_at`,`created_at`,`updated_at`
FROM `im_group_messages`
WHERE `tenant_id` = 1 AND DATE_FORMAT(`created_at`, '%Y%m') = '202608';
-- 5. 修正 1 号商户路由,并合入 2 号商户路由元数据。
UPDATE `im_group_message_shards`
SET `table_name` = CONCAT('im_001_group_messages_', `shard_month`)
WHERE `tenant_id` = 1;
INSERT INTO `im_group_message_shards`
(`tenant_id`,`shard_month`,`table_name`,`shard_start`,`shard_end`,`status`,`row_count`,
`min_created_at`,`max_created_at`,`created_at`,`updated_at`)
SELECT 2,`shard_month`,CONCAT('im_002_group_messages_', `shard_month`),`shard_start`,`shard_end`,`status`,
`row_count`,`min_created_at`,`max_created_at`,`created_at`,`updated_at`
FROM `meeting_im`.`im_group_message_shards`
ON DUPLICATE KEY UPDATE `table_name`=VALUES(`table_name`),`status`=VALUES(`status`);
-- Topic-to-month routes are Redis-backed and are rebuilt by runtime reads.
UPDATE `im_group_message_shards` AS s
SET s.`row_count`=(SELECT COUNT(*) FROM `im_001_group_messages_202608`),
s.`min_created_at`=(SELECT MIN(`created_at`) FROM `im_001_group_messages_202608`),
s.`max_created_at`=(SELECT MAX(`created_at`) FROM `im_001_group_messages_202608`)
WHERE s.`tenant_id`=1 AND s.`shard_month`='202608';
-- 验证结果。
SELECT `sequence_name`,`next_id` FROM `hotgo`.`im_tenant_id_sequence`;
SELECT `table_name`,`table_rows`
FROM information_schema.tables
WHERE table_schema = 'tinode'
AND table_name REGEXP '^im_[0-9]{3}_(friendships|messages|group_messages_[0-9]{6})$'
ORDER BY table_name;
@@ -0,0 +1,272 @@
-- 仅用于测试环境复制库 meeting_im_sharding_preview。
-- 源库 meeting_im 和注册库 hotgo 不在本脚本修改范围内。
USE `meeting_im_sharding_preview`;
-- 1. 共享业务表补充商户维度,复制库全部现有数据归属商户 2。
DELIMITER $$
DROP PROCEDURE IF EXISTS add_preview_tenant_columns$$
CREATE PROCEDURE add_preview_tenant_columns()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE target_table VARCHAR(128);
DECLARE has_id INT DEFAULT 0;
DECLARE table_cursor CURSOR FOR
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema = 'meeting_im_sharding_preview'
AND t.table_type = 'BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema=t.table_schema
AND c.table_name=t.table_name
AND c.column_name='tenant_id'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN table_cursor;
table_loop: LOOP
FETCH table_cursor INTO target_table;
IF done = 1 THEN
LEAVE table_loop;
END IF;
SELECT COUNT(*) INTO has_id
FROM information_schema.columns
WHERE table_schema='meeting_im_sharding_preview'
AND table_name=target_table AND column_name='id';
SET @position_sql = IF(has_id > 0, ' AFTER `id`', ' FIRST');
SET @alter_sql = CONCAT('ALTER TABLE `meeting_im_sharding_preview`.`', target_table,
'` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT ''商户ID''',
@position_sql);
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE table_cursor;
END$$
DELIMITER ;
CALL add_preview_tenant_columns();
DROP PROCEDURE IF EXISTS add_preview_tenant_columns;
-- 2. 群消息路由元数据的唯一性增加商户维度。
ALTER TABLE `im_group_message_shards`
DROP PRIMARY KEY,
DROP INDEX `uk_im_group_message_shards_table`,
DROP INDEX `idx_im_group_message_shards_status`,
ADD PRIMARY KEY (`tenant_id`,`shard_month`),
ADD UNIQUE INDEX `uk_im_group_message_shards_table` (`tenant_id`,`table_name`),
ADD INDEX `idx_im_group_message_shards_status` (`tenant_id`,`status`,`shard_month`);
DROP TABLE IF EXISTS `im_group_message_topic_shards`;
DROP TABLE IF EXISTS `im_group_message_dedup`;
-- 3. 创建商户 2 的好友与单聊消息分表并复制快照数据。
CREATE TABLE `im_002_friendships` LIKE `im_friendships`;
ALTER TABLE `im_002_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT INTO `im_002_friendships` SELECT * FROM `im_friendships` WHERE `tenant_id`=2;
CREATE TABLE `im_002_messages` LIKE `im_messages`;
ALTER TABLE `im_002_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT '商户ID';
INSERT INTO `im_002_messages` SELECT * FROM `im_messages` WHERE `tenant_id`=2;
-- 4. 将所有现存标准月表复制为商户 2 的月表。
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_preview_group_month_tables$$
CREATE PROCEDURE migrate_preview_group_month_tables()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE source_table VARCHAR(64);
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE month_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema='meeting_im_sharding_preview'
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN month_cursor;
month_loop: LOOP
FETCH month_cursor INTO source_table;
IF done = 1 THEN
LEAVE month_loop;
END IF;
SET month_code = RIGHT(source_table, 6);
SET target_table = CONCAT('im_002_group_messages_', month_code);
SET @create_sql = CONCAT('CREATE TABLE `', target_table, '` LIKE `', source_table, '`');
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @alter_sql = CONCAT('ALTER TABLE `', target_table,
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 2 COMMENT ''商户ID''');
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @copy_sql = CONCAT('INSERT INTO `', target_table, '` SELECT * FROM `', source_table,
'` WHERE `tenant_id`=2');
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE month_cursor;
END$$
DELIMITER ;
CALL migrate_preview_group_month_tables();
DROP PROCEDURE IF EXISTS migrate_preview_group_month_tables;
UPDATE `im_group_message_shards`
SET `table_name`=CONCAT('im_002_group_messages_',`shard_month`)
WHERE `tenant_id`=2;
-- 5. 补齐 default 商户(ID=1,分表编号 001)的核心分表。
-- tinode 是 default 商户的原库,只从该库读取,不修改原表。
CREATE TABLE `im_001_friendships` LIKE `im_friendships`;
ALTER TABLE `im_001_friendships` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT INTO `im_001_friendships` (
`id`,`tenant_id`,`user_id`,`peer_id`,`status`,`remark`,`topic`,`created_at`,`updated_at`
)
SELECT `id`,1,`user_id`,`peer_id`,`status`,`remark`,`topic`,`created_at`,`updated_at`
FROM `tinode`.`im_friendships`;
CREATE TABLE `im_001_messages` LIKE `im_messages`;
ALTER TABLE `im_001_messages` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT '商户ID';
INSERT INTO `im_001_messages` (
`id`,`tenant_id`,`createdat`,`updatedat`,`deletedat`,`delid`,`seqid`,`topic`,`from`,
`client_msg_id`,`version`,`recalled_at`,`recalled_by`,`head`,`content`,`content_nonce`,`key_version`
)
SELECT `id`,1,`createdat`,`updatedat`,`deletedat`,`delid`,`seqid`,`topic`,`from`,
`client_msg_id`,`version`,`recalled_at`,`recalled_by`,`head`,`content`,`content_nonce`,`key_version`
FROM `tinode`.`im_messages`;
DELIMITER $$
DROP PROCEDURE IF EXISTS migrate_default_group_month_tables$$
CREATE PROCEDURE migrate_default_group_month_tables()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE source_table VARCHAR(64);
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE month_cursor CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema='tinode'
AND table_name REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN month_cursor;
month_loop: LOOP
FETCH month_cursor INTO source_table;
IF done = 1 THEN
LEAVE month_loop;
END IF;
SET month_code = RIGHT(source_table, 6);
SET target_table = CONCAT('im_001_group_messages_', month_code);
SET @create_sql = CONCAT('CREATE TABLE `', target_table,
'` LIKE `im_group_messages_', month_code, '`');
PREPARE stmt FROM @create_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @alter_sql = CONCAT('ALTER TABLE `', target_table,
'` MODIFY `tenant_id` BIGINT NOT NULL DEFAULT 1 COMMENT ''商户ID''');
PREPARE stmt FROM @alter_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @copy_sql = CONCAT(
'INSERT INTO `', target_table, '` (`tenant_id`,`message_id`,`topic`,`group_id`,`seqid`,',
'`from_user`,`client_msg_id`,`msg_type`,`status`,`content`,`content_nonce`,`key_version`,',
'`head`,`mentioned_user_ids`,`mention_all`,`reply_seqid`,`root_seqid`,`file_count`,',
'`content_size`,`has_risk`,`audit_status`,`version`,`recalled_at`,`recalled_by`,',
'`deleted_at`,`expires_at`,`created_at`,`updated_at`) ',
'SELECT 1,`message_id`,`topic`,`group_id`,`seqid`,`from_user`,`client_msg_id`,`msg_type`,',
'`status`,`content`,`content_nonce`,`key_version`,`head`,`mentioned_user_ids`,`mention_all`,',
'`reply_seqid`,`root_seqid`,`file_count`,`content_size`,`has_risk`,`audit_status`,`version`,',
'`recalled_at`,`recalled_by`,`deleted_at`,`expires_at`,`created_at`,`updated_at` ',
'FROM `tinode`.`', source_table, '`'
);
PREPARE stmt FROM @copy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE month_cursor;
END$$
DELIMITER ;
CALL migrate_default_group_month_tables();
DROP PROCEDURE IF EXISTS migrate_default_group_month_tables;
INSERT INTO `im_group_message_shards` (
`tenant_id`,`shard_month`,`table_name`,`shard_start`,`shard_end`,`status`,`row_count`,
`min_created_at`,`max_created_at`,`created_at`,`updated_at`
)
SELECT 1,`shard_month`,CONCAT('im_001_group_messages_',`shard_month`),`shard_start`,`shard_end`,
`status`,`row_count`,`min_created_at`,`max_created_at`,`created_at`,`updated_at`
FROM `tinode`.`im_group_message_shards`;
-- Topic-to-month routes are Redis-backed and are rebuilt by runtime reads.
-- 以实际复制后的物理表为准刷新统计,避免源库路由统计滞后。
DELIMITER $$
DROP PROCEDURE IF EXISTS refresh_default_group_shard_stats$$
CREATE PROCEDURE refresh_default_group_shard_stats()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE month_code CHAR(6);
DECLARE target_table VARCHAR(64);
DECLARE shard_cursor CURSOR FOR
SELECT `shard_month`
FROM `im_group_message_shards`
WHERE `tenant_id`=1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN shard_cursor;
shard_loop: LOOP
FETCH shard_cursor INTO month_code;
IF done = 1 THEN
LEAVE shard_loop;
END IF;
SET target_table = CONCAT('im_001_group_messages_', month_code);
SET @stats_sql = CONCAT(
'UPDATE `im_group_message_shards` SET ',
'`row_count`=(SELECT COUNT(*) FROM `', target_table, '`),',
'`min_created_at`=(SELECT MIN(`created_at`) FROM `', target_table, '`),',
'`max_created_at`=(SELECT MAX(`created_at`) FROM `', target_table, '`) ',
'WHERE `tenant_id`=1 AND `shard_month`=''', month_code, ''''
);
PREPARE stmt FROM @stats_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE shard_cursor;
END$$
DELIMITER ;
CALL refresh_default_group_shard_stats();
DROP PROCEDURE IF EXISTS refresh_default_group_shard_stats;
-- 6. 验证:missing_tenant_id 必须为 0,两个商户的分表与路由均存在。
SELECT COUNT(*) AS missing_tenant_id
FROM information_schema.tables t
WHERE t.table_schema='meeting_im_sharding_preview'
AND t.table_type='BASE TABLE'
AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema=t.table_schema
AND c.table_name=t.table_name
AND c.column_name='tenant_id'
);
SELECT 'friendships' AS data_type,
(SELECT COUNT(*) FROM `im_friendships` WHERE `tenant_id`=2) AS source_count,
(SELECT COUNT(*) FROM `im_002_friendships`) AS target_count
UNION ALL
SELECT 'messages',
(SELECT COUNT(*) FROM `im_messages` WHERE `tenant_id`=2),
(SELECT COUNT(*) FROM `im_002_messages`);
SELECT s.`shard_month`,s.`table_name`,s.`status`,s.`row_count`
FROM `im_group_message_shards` s
WHERE s.`tenant_id` IN (1,2)
ORDER BY s.`tenant_id`,s.`shard_month`;
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema='meeting_im_sharding_preview'
AND t.table_name REGEXP '^im_(001|002)_(friendships|messages|group_messages_[0-9]{6})$'
ORDER BY t.table_name;
@@ -0,0 +1,73 @@
-- MySQL 8.0. Runtime fixes for the shared meeting_im database.
-- Execute with a privileged account that can read hotgo.im_tenant.
CREATE TABLE IF NOT EXISTS im_user_account_sequences (
tenant_id BIGINT NOT NULL,
id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- If this table was created by the older independent-database script, its
-- primary key may still be only (id). Shared DB must key by tenant_id too.
ALTER TABLE im_user_account_sequences
DROP PRIMARY KEY,
ADD PRIMARY KEY (tenant_id, id);
INSERT INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM im_users
WHERE tenant_id > 0
GROUP BY tenant_id
ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value));
INSERT IGNORE INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT id, 1, 999
FROM hotgo.im_tenant
WHERE state = 1;
DROP TABLE IF EXISTS im_login_lockouts;
-- HTTP write idempotency is Redis-backed. Keep it out of MySQL.
DROP TABLE IF EXISTS im_http_idempotency;
DROP TABLE IF EXISTS im_group_idempotency;
DROP TABLE IF EXISTS im_group_message_dedup;
DROP TABLE IF EXISTS im_group_message_topic_shards;
DROP TABLE IF EXISTS im_kafka_outbox;
DROP TABLE IF EXISTS im_kafka_consume_log;
DROP TABLE IF EXISTS im_business_events_outbox;
DROP TABLE IF EXISTS im_group_events_outbox;
DROP TABLE IF EXISTS im_login_failure_logs;
DROP TABLE IF EXISTS im_action_tokens;
-- User identity indexes must be unique inside one tenant, not globally.
ALTER TABLE im_users
DROP INDEX uk_im_users_account_no,
DROP INDEX users_phone,
DROP INDEX users_username,
ADD UNIQUE INDEX uk_im_users_tenant_account_no (tenant_id, account_no),
ADD UNIQUE INDEX uk_im_users_tenant_phone (tenant_id, phone),
ADD UNIQUE INDEX uk_im_users_tenant_username (tenant_id, username);
ALTER TABLE im_auth
DROP INDEX auth_userid,
ADD UNIQUE INDEX auth_tenant_userid (tenant_id, userid);
-- Default app release rows used by /v1/im/app/releases/latest.
INSERT INTO hotgo.im_app_releases (
platform, channel, latest_version, min_supported_version, update_type,
release_notes, remind_after_seconds
) VALUES
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
('web', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0)
ON DUPLICATE KEY UPDATE
latest_version = VALUES(latest_version),
min_supported_version = VALUES(min_supported_version),
update_type = VALUES(update_type),
release_notes = VALUES(release_notes),
remind_after_seconds = VALUES(remind_after_seconds),
updated_at = CURRENT_TIMESTAMP(3);
+3 -28
View File
@@ -30,34 +30,9 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs (
INDEX idx_im_user_login_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_login_failure_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NULL,
account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '',
failure_type VARCHAR(32) NOT NULL,
reason VARCHAR(96) NOT NULL DEFAULT '',
detail VARCHAR(500) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium',
risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
request_id VARCHAR(64) NOT NULL DEFAULT '',
attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_login_failure_account_time (account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time (user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time (ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time (failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DROP TABLE IF EXISTS im_login_failure_logs;
DROP TABLE IF EXISTS im_login_lockouts;
CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT,
+19 -5
View File
@@ -1,15 +1,29 @@
-- 在每个租户业务库执行。用于并发安全地生成 im_users.account_no。
-- 在 meeting_im 共库执行。用于并发安全地生成每个租户的 im_users.account_no。
CREATE TABLE IF NOT EXISTS im_user_account_sequences (
tenant_id BIGINT NOT NULL,
id TINYINT UNSIGNED NOT NULL,
next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
PRIMARY KEY (tenant_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 兼容已有用户数据,从当前最大账号编号继续递增,账号编号最小为 1000。
INSERT INTO im_user_account_sequences (id, next_value)
SELECT 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
-- 如果历史脚本已经创建过单库版 PRIMARY KEY(id),需要先改成共库主键。
ALTER TABLE im_user_account_sequences
DROP PRIMARY KEY,
ADD PRIMARY KEY (tenant_id, id);
-- 兼容已有用户数据,从每个租户当前最大账号编号继续递增,账号编号最小为 1000。
INSERT INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT tenant_id, 1, GREATEST(COALESCE(MAX(account_no), 999), 999)
FROM im_users
WHERE tenant_id > 0
GROUP BY tenant_id
ON DUPLICATE KEY UPDATE
next_value = GREATEST(next_value, VALUES(next_value));
-- 如果当前租户暂时还没有用户,也要先塞一行,否则新注册无法生成账号编号。
INSERT IGNORE INTO im_user_account_sequences (tenant_id, id, next_value)
SELECT id, 1, 999
FROM hotgo.im_tenant
WHERE state = 1;
+3 -30
View File
@@ -17,36 +17,9 @@ CREATE TABLE IF NOT EXISTS im_group_message_shards (
KEY idx_im_group_message_shards_status (status, shard_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
topic VARCHAR(64) NOT NULL,
shard_month CHAR(6) NOT NULL,
min_seq BIGINT NOT NULL,
max_seq BIGINT NOT NULL,
message_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NOT NULL,
max_created_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic, shard_month),
KEY idx_im_group_message_topic_shards_seq (topic, max_seq),
KEY idx_im_group_message_topic_shards_month (shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DROP TABLE IF EXISTS im_group_message_topic_shards;
CREATE TABLE IF NOT EXISTS im_group_message_dedup (
topic VARCHAR(64) NOT NULL,
from_user BIGINT NOT NULL,
client_msg_id VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL,
shard_month CHAR(6) NOT NULL,
message_id BIGINT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic, from_user, client_msg_id),
KEY idx_im_group_message_dedup_expires (expires_at),
KEY idx_im_group_message_dedup_topic_seq (topic, seqid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
DROP TABLE IF EXISTS im_group_message_dedup;
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
message_id BIGINT NOT NULL,
@@ -104,4 +77,4 @@ ON DUPLICATE KEY UPDATE
-- Historical migration is deliberately separate from schema deployment.
-- Backfill im_group_messages into the matching month table in bounded batches,
-- then populate im_group_message_topic_shards before removing legacy reads.
-- then warm Redis topic routes before removing legacy reads.
@@ -1,6 +1,31 @@
# IM 极光推送前后端对接文档
更新时间:2026-09-04
更新时间:2026-09-IM API → PUT https://api.118im.com/v1/im/devices/2386C7EB-FC30-4849-9FC8-7F2B50229F02/push-token [req=ab961f26-e3a9-4f5f-b0f6-2813b8af3597]
请求体 {
"sdk_version": "6.2.0",
"push_env": "test",
"app_version": "1.0",
"token": "1517bfd3f69c6928135",
"platform": "ios",
"notification_permission": "granted",
"provider": "jpush"
}
IM API ← 成功 PUT https://api.118im.com/v1/im/devices/2386C7EB-FC30-4849-9FC8-7F2B50229F02/push-token [req=ab961f26-e3a9-4f5f-b0f6-2813b8af3597, status=200]
响应体 {
"code": 0,
"data": {
"app_version": "1.0",
"device_id": "2386C7EB-FC30-4849-9FC8-7F2B50229F02",
"notification_permission": "granted",
"platform": "ios",
"provider": "jpush",
"push_env": "test",
"sdk_version": "6.2.0",
"updated_at": "2026-09-14T09:49:19.139Z"
},
"request_id": "ab961f26-e3a9-4f5f-b0f6-2813b8af3597",
"server_time": "2026-09-14T09:49:19.167Z"
}04
## 1. 对接边界
@@ -24,7 +49,11 @@
- iOS:配置 APNs 证书或 Token Authentication,申请通知权限。
- HarmonyOS:按鸿蒙 SDK 文档初始化并配置通知分类。
客户端启动后取得 `registration_id`,登录 IM 后把 alias 绑定为当前 `user_id` 的极光安全别名。同一用户多设备允许绑定同一个 alias。
客户端启动后取得 `registration_id`,登录 IM 后调用 IM 后端设备接口上报 Push Token。当前测试环境服务端配置 `jpush_use_alias=0`,使用 `registration_id` 投递,不依赖 alias。
`device_id` 必须稳定,同一台设备不要每次启动变化。切换账号、切换租户、清登录态后必须重新上报 Push Token;客户端本地的上报去重缓存必须按 `tenant_code + user_id + device_id + registration_id` 隔离,避免设备继续绑定在旧账号上。
如果后续切回 alias 模式,客户端再把 alias 绑定为当前 `user_id` 的极光安全别名。同一用户多设备允许绑定同一个 alias。
极光 alias 不支持中横线 `-` 和下划线 `_`,而 IM `user_id` 可能包含这两个字符。客户端和后端统一使用以下映射:
@@ -124,12 +153,14 @@ IM Server 从后台配置读取 `im_jpush` 分组:
"jpush_apns_production": "false",
"jpush_default_ttl_seconds": "86400",
"jpush_call_ttl_seconds": "60",
"jpush_use_alias": "true",
"jpush_use_alias": "false",
"jpush_test_model": "false",
"jpush_classification": "1"
}
```
测试环境当前使用 `registration_id` 模式,`jpush_use_alias=false`。服务端从 `im_device_push_tokens` 按目标用户查询 `provider=jpush` 的 token,并调用极光 `audience.registration_id`。如改为 alias 模式,需要客户端 SDK alias 绑定回调成功,并保持 alias 映射规则一致。
### 3.2 设备记录
设备表建议至少支持:
@@ -185,22 +216,11 @@ Content-Type: application/json
```json
{
"platform": "all",
"platform": "ios",
"audience": {
"alias": ["usr_target"]
"registration_id": ["registration_id_from_jpush"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
@@ -228,25 +248,11 @@ Content-Type: application/json
```json
{
"platform": "all",
"platform": "ios",
"audience": {
"alias": ["usr_callee"]
"registration_id": ["registration_id_from_jpush"]
},
"notification": {
"alert": "收到一通视频通话",
"android": {
"title": "视频通话",
"alert": "张三邀请你视频通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "video",
"caller_id": "usr_caller"
}
},
"ios": {
"alert": {
"title": "视频通话",
@@ -314,8 +320,8 @@ Content-Type: application/json
| 步骤 | 操作 | 验证 |
| --- | --- | --- |
| 1 | 客户端初始化 JPush SDK | 能拿到 `registration_id`。 |
| 2 | 登录 IM 并绑定 alias=`user_id.replace("-", ".").replace("_", "|")` | 极光控制台能查到别名或设备。 |
| 3 | 调用设备上报接口 | 服务端设备记录 provider=`jpush`。 |
| 2 | 登录 IM 后上报 `registration_id` | `im_device_push_tokens` 中 `device_id/token/user_id` 与当前登录用户一致。 |
| 3 | 调用设备上报接口 | 服务端设备记录 provider=`jpush`,并更新到当前登录用户。 |
| 4 | 目标用户退出或断网 | IM 判断为离线或不可 WS 投递。 |
| 5 | 发送 P2P 消息 | 离线用户收到 Push。 |
| 6 | 点击 Push | App 打开对应会话并补齐 `seq`。 |
@@ -323,3 +329,4 @@ Content-Type: application/json
| 8 | 通话取消后点击旧 Push | 客户端查询后显示已取消或未接,不进入通话中。 |
| 9 | 关闭会话免打扰 | 普通消息不发 Push,@提醒按策略执行。 |
| 10 | 极光返回无效 token | 后端禁用设备 token,重新上报后恢复。 |
| 11 | iOS APNs 环境 | 测试环境 `apns_production=false` 且客户端为开发 APNs;生产包需同步切换为 true。 |
@@ -0,0 +1,528 @@
# IM 阿里云实名认证接入方案
## 1. 目标
本文记录当前 IM 实名认证能力的实现情况,以及后续接入阿里云实名认证的后端、前端和数据模型改造方案。接入目标是:用户在客户端提交实名信息后,由 IM Server 统一调用阿里云完成身份核验,客户端只感知认证状态和必要的认证跳转参数,不接触阿里云密钥、AccessKey、服务端回调密钥等敏感配置。
阿里云侧建议优先使用金融级实人认证/ID Verification(Cloudauth)能力。产品选型分两类:
1. **姓名 + 身份证二要素核验**:体验最轻,适合只要求姓名和证件号一致性的场景。结果可同步或准同步返回。
2. **增强版实人认证/活体核身**:服务端发起认证并获取 `CertifyId`,客户端通过阿里云 SDK、H5 或认证 URL 完成活体流程,服务端再查询结果或接收回调。适合强实名、风控和合规要求更高的场景。
当前产品原型只有姓名和证件号码输入页,但考虑后续安全要求,后端状态机需要兼容增强版实人认证。
## 2. 当前已有实现
### 2.1 前端对接接口
现有前后端文档已定义实名认证页面的三个核心入口:
| 能力 | 接口 | 当前状态 |
| --- | --- | --- |
| 查询实名状态 | `GET /v1/im/security/realname` | 已实现 |
| 提交实名信息 | `POST /v1/im/security/realname/submissions` | 已实现 |
| 实名更正申诉 | `POST /v1/im/security/realname/appeals` | 已实现 |
查询接口返回:
```json
{
"status": "unverified",
"can_submit": true
}
```
已提交或已通过时返回 `submission_id`、`masked_name`、`masked_id_number`、`verified_at`、`review_reason` 等字段。
提交接口要求:
- 必须携带 `Authorization: Bearer <token>`。
- 必须携带 `Idempotency-Key`,服务端根据用户、租户和幂等键生成 `submission_id`。
- Body 目前为:
```json
{
"name": "张三",
"id_type": "cn_id",
"id_number": "110101199001011234"
}
```
校验规则:
- `name` 长度 2-32 个字符。
- `id_type` 当前只支持 `cn_id`。
- `id_number` 当前只支持 18 位中国大陆身份证号,末位允许 `X`。
### 2.2 服务端实现
当前代码主要在以下位置:
| 模块 | 文件 | 说明 |
| --- | --- | --- |
| HTTP 路由 | `server/main.go` | 注册 `/v1/im/security/realname`、`/realname/submissions`、`/realname/appeals` |
| HTTP Handler | `server/im_http.go` | 查询、提交、申诉逻辑 |
| Store 接口 | `server/store/store.go` | `GetRealName`、`CreateRealNameSubmission`、`CreateRealNameAppeal` |
| MySQL 实现 | `server/db/mysql/tenant_adapter.go` | 租户库读写实名表 |
| 类型定义 | `server/store/types/security.go` | `RealNameVerification`、`RealNameAppeal` |
| 内部后台查询 | `server/internal_admin_query_resources.go` | `realname-verifications` 查询资源 |
当前实名提交成功后只落库为 `pending`,不会调用第三方,也没有自动审核通过。重复提交时,如果最新状态是 `pending` 或 `verified`,服务端会直接返回当前状态,避免重复创建申请。
### 2.3 当前数据表
租户库已有 `im_realname_verifications`:
| 字段 | 说明 |
| --- | --- |
| `id` | 自增主键 |
| `user_id` | 用户 ID |
| `submission_id` | 实名提交 ID,幂等唯一 |
| `status` | 当前状态,已有逻辑使用 `pending`、`verified`、`rejected`、`unverified` |
| `name_cipher` | 姓名保护存储值 |
| `id_type` | 证件类型 |
| `id_number_cipher` | 证件号保护存储值 |
| `id_number_hash` | 证件号哈希,用于重复身份检测 |
| `masked_name` | 脱敏姓名 |
| `masked_id_number` | 脱敏证件号 |
| `review_reason` | 审核原因或驳回原因 |
| `verified_at` | 通过时间 |
| `created_at` / `updated_at` | 创建和更新时间 |
租户库已有 `im_realname_appeals`:
| 字段 | 说明 |
| --- | --- |
| `id` | 雪花 ID |
| `appeal_id` | 申诉 ID |
| `user_id` | 用户 ID |
| `reason` | 申诉原因 |
| `attachments_json` | 附件列表 |
| `status` | 申诉状态,默认 `pending` |
| `review_comment` | 审核备注 |
| `created_at` / `updated_at` | 创建和更新时间 |
### 2.4 已记录的增强项
`docs/IM-未实现后续增强项.md` 和 `docs/IM-需求实现情况审核报告.md` 已明确:实名状态查询、提交、申诉已经具备,但缺少后台审核流转、第三方认证结果回写、审核结果通知和完整申诉处理工单。
当前缺口如下:
| 缺口 | 影响 |
| --- | --- |
| 未接第三方实名供应商 | 提交后长期停留 `pending`,无法自动通过或拒绝 |
| 缺少 provider 字段 | 无法区分人工审核、阿里云二要素、阿里云活体等来源 |
| 缺少第三方任务 ID | 无法通过 `CertifyId` 或外部 request id 查询认证结果 |
| 缺少回调/查询状态机 | 无法安全处理异步认证完成、重试和重复通知 |
| 缺少后台审核动作 | 人工通过、拒绝、申诉处理还没有完整闭环 |
| 缺少结果通知 | 实名通过或拒绝后,还没有统一系统通知事件 |
## 3. 阿里云接入总体方案
### 3.0 当前联调结论
2026-09-20 已按 `docs/third/三方服务依赖信息.md` 中的阿里云 AccessKey 做过真实接口联调:
| 账号 | 调用接口 | 结果 | 结论 |
| --- | --- | --- | --- |
| 子账号 `t1@1624085816137202.onaliyun.com` | `Id2MetaVerify` | `403 Forbidden.NoPermission`,缺少 `yundun-cloudauth:Id2MetaVerify` | 子账号需要 RAM 授权 |
| 主账号 AccessKey | `Id2MetaVerify` | 业务码 `410`,提示“未开通服务” | 当前阿里云账号尚未开通身份证二要素/ID_2META 服务 |
因此代码已接入真实阿里云 API,但生产可用还需要:
1. 在阿里云开通实人认证服务中的身份证二要素核验 `ID_2META`。
2. 给运行时使用的 RAM 子账号授权 `yundun-cloudauth:Id2MetaVerify`,或授予对应实人认证服务策略。
3. 在 `hotgo.hg_sys_config` 的 `im_realname` 分组写入 `realname_provider=aliyun_cloudauth`、`aliyun_access_key_id`、`aliyun_access_key_secret`、`aliyun_region_id`、`aliyun_endpoint`。
4. 租户 `hotgo.im_tenant_config.realname_review_policy=1` 后,IM Server 才会自动调用阿里云;服务未开通、权限不足或返回不一致时都会进入 `manual_review`。
### 3.1 配置来源
实名认证配置分两层:
1. **全局供应商配置**:阿里云 AccessKey、Endpoint、回调密钥等服务商级参数放在 HotGo 控制库 `hotgo.hg_sys_config`,使用独立分组 `im_realname`。
2. **租户审核策略**:审核方式放在 HotGo 控制库的 `im_tenant_config`。该策略每个租户单独控制,默认 `0=不审核`。
全局供应商配置:
| group | key | 建议值 | 说明 |
| --- | --- | --- | --- |
| `im_realname` | `realname_provider` | `aliyun_cloudauth` | 当前供应商 |
| `im_realname` | `aliyun_region_id` | `cn-hangzhou` | 阿里云地域 |
| `im_realname` | `aliyun_endpoint` | `cloudauth.aliyuncs.com` | OpenAPI Endpoint,按实际产品确认 |
| `im_realname` | `aliyun_access_key_id` | 空 | RAM AccessKey ID,禁止下发客户端 |
| `im_realname` | `aliyun_access_key_secret` | 空 | RAM AccessKey Secret,后台脱敏展示 |
| `im_realname` | `aliyun_scene_id` | 空 | 实人认证场景 ID,控制台创建 |
| `im_realname` | `aliyun_product_code` | 空 | 如产品要求传 product code,则配置 |
| `im_realname` | `callback_secret` | 空 | IM Server 校验阿里云回调的本地密钥 |
| `im_realname` | `result_query_interval_seconds` | `10` | 异步查询间隔 |
| `im_realname` | `result_query_max_attempts` | `30` | 最大查询次数 |
| `im_realname` | `mock_enabled` | `0` | 本地/测试环境 mock 开关 |
AccessKey 建议使用最小权限 RAM 子账号,只授权实人认证相关 OpenAPI。生产环境如果后续接入 KMS/Secrets Manager,`hg_sys_config` 中可只保存密钥引用。
租户审核策略使用 `hotgo.im_tenant.realname_review_policy`:
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `realname_review_policy` | `0` | 审核方式:`0=不审核`,`1=自动审核;自动审核失败/异常/不确定时进入人工审核` |
执行规则:
- 租户 `realname_review_policy=0` 时,不做第三方审核,提交实名后写入 `pending`,等待后台人工通过或拒绝。
- 租户 `realname_review_policy=1` 时,IM Server 自动调用阿里云;通过则写 `verified`,失败、异常或结果不确定则写 `manual_review`,等待后台人工复核。
- 阿里云全局配置缺失时,即使租户开启自动审核,也不得阻断系统启动;提交时返回稳定错误,或降级写入 `manual_review` 交给人工审核。
### 3.2 推荐状态机
本地实名状态建议统一为:
| 状态 | 含义 | 前端展示 |
| --- | --- | --- |
| `unverified` | 未提交或无有效记录 | 可提交 |
| `pending` | 已创建本地申请,等待第三方或人工结果 | 审核中 |
| `processing` | 第三方认证流程已开始,等待客户端完成或服务端查询 | 认证中 |
| `verified` | 第三方或人工审核通过 | 已实名 |
| `rejected` | 第三方或人工审核拒绝 | 未通过,可重新提交或申诉 |
| `manual_review` | 第三方结果不确定,需要后台人工处理 | 审核中 |
| `expired` | 客户端未在有效期完成认证 | 可重新提交 |
| `failed` | 第三方调用失败或系统异常 | 可重试 |
对外接口可以继续兼容现有 `unverified|pending|verified|rejected`,把 `processing/manual_review` 展示为 `pending`,把 `expired/failed` 视租户策略映射为 `rejected` 或允许重新提交。
## 4. 两种接入流程
### 4.1 二要素核验流程
适合当前页面只提交姓名和身份证号的产品形态。
1. 客户端调用 `POST /v1/im/security/realname/submissions`,提交姓名、证件类型、证件号。
2. IM Server 完成本地格式校验、幂等校验、重复 pending/verified 校验。
3. IM Server 创建本地 `im_realname_verifications`,状态为 `pending`。
4. 如果租户 `realname_review_policy=0`,流程到此结束,等待后台人工审核。
5. 如果租户 `realname_review_policy=1`,IM Server 调用阿里云二要素核验 API。
6. 阿里云返回一致/不一致/无法验证。
7. IM Server 将本地状态更新为:
- 一致:`verified`,写入 `verified_at`。
- 不一致:进入 `manual_review`,由后台人工复核。
- 无法验证或供应商异常:进入 `manual_review`,由后台人工复核。
8. 客户端继续调用 `GET /v1/im/security/realname` 获取最终状态。
9. 如接入系统通知,服务端投递 `security.realname.updated`。
这个模式对前端改动最小,但实名强度只覆盖姓名和证件号一致性。
### 4.2 增强版实人认证流程
适合要求活体、人脸和证件一致性校验的场景。
1. 客户端调用 `POST /v1/im/security/realname/submissions`,提交姓名、证件类型、证件号,并附带阿里云客户端 SDK 要求的 `meta_info`。
2. IM Server 创建本地申请,状态为 `pending`。
3. IM Server 调用阿里云发起认证接口,例如 `InitSmartVerify` 或当前产品对应的初始化接口。
4. 阿里云返回 `CertifyId`、认证 URL、认证 Token 或 SDK 所需参数。
5. IM Server 保存 `provider_task_id=CertifyId`,并把客户端需要的非敏感参数返回给前端。
6. 客户端调起阿里云 SDK、H5 或认证 URL 完成活体核身。
7. IM Server 通过以下任一方式拿最终结果:
- 阿里云回调 IM Server。
- IM Server 定时调用结果查询接口,例如 `DescribeSmartVerify` 或当前产品对应的查询接口。
8. IM Server 根据结果更新本地状态为 `verified`、`rejected`、`manual_review`、`expired` 或 `failed`。
9. 客户端轮询 `GET /v1/im/security/realname` 或接收系统通知刷新页面。
增强版模式下,客户端仍然不需要知道 AccessKey、AccessKeySecret、回调密钥等服务端凭据。客户端只使用后端返回的 `certify_id`、`certify_url`、`token`、`expires_at` 等短期认证参数。
## 5. 前后端接口建议
### 5.1 提交实名信息
保持现有接口不变:
`POST /v1/im/security/realname/submissions`
二要素模式请求:
```json
{
"name": "张三",
"id_type": "cn_id",
"id_number": "110101199001011234"
}
```
增强版模式请求可扩展:
```json
{
"name": "张三",
"id_type": "cn_id",
"id_number": "110101199001011234",
"meta_info": "{\"zimVer\":\"...\"}",
"return_url": "meetingim://realname/callback"
}
```
二要素模式响应:
```json
{
"submission_id": "rn_xxx",
"status": "verified",
"can_submit": false
}
```
增强版模式响应:
```json
{
"submission_id": "rn_xxx",
"status": "processing",
"provider": "aliyun_cloudauth",
"mode": "smart_verify",
"certify_id": "xxx",
"certify_url": "https://...",
"token": "短期认证参数,如产品返回",
"expires_at": "2026-09-20T10:00:00Z"
}
```
### 5.2 查询实名状态
继续使用:
`GET /v1/im/security/realname`
建议扩展字段:
```json
{
"status": "pending",
"display_status": "pending",
"provider": "aliyun_cloudauth",
"mode": "smart_verify",
"submission_id": "rn_xxx",
"masked_name": "张*",
"masked_id_number": "110***********1234",
"can_submit": false,
"review_reason": "",
"verified_at": null,
"next_action": "wait"
}
```
`next_action` 建议值:
| 值 | 含义 |
| --- | --- |
| `submit` | 可提交 |
| `continue_provider_verify` | 需要继续完成阿里云认证 |
| `wait` | 等待结果 |
| `appeal` | 可申诉 |
| `none` | 无需操作 |
### 5.3 阿里云回调接口
建议新增:
`POST /v1/im/security/realname/providers/aliyun/callback`
要求:
- 只接收阿里云回调。
- 校验签名、时间戳、nonce 或本地 `callback_secret`。
- 根据 `CertifyId/provider_task_id` 幂等更新状态。
- 原始 payload 写入事件表,敏感字段脱敏或加密。
这个接口不直接暴露给客户端。
## 6. 数据库改造建议
### 6.1 扩展实名主表
建议在 `im_realname_verifications` 增加第三方状态字段:
```sql
ALTER TABLE im_realname_verifications
ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT '' AFTER status,
ADD COLUMN provider_mode VARCHAR(32) NOT NULL DEFAULT '' AFTER provider,
ADD COLUMN provider_scene_id VARCHAR(64) NOT NULL DEFAULT '' AFTER provider_mode,
ADD COLUMN provider_task_id VARCHAR(128) NOT NULL DEFAULT '' AFTER provider_scene_id,
ADD COLUMN provider_request_id VARCHAR(128) NOT NULL DEFAULT '' AFTER provider_task_id,
ADD COLUMN provider_status VARCHAR(32) NOT NULL DEFAULT '' AFTER provider_request_id,
ADD COLUMN provider_error_code VARCHAR(64) NOT NULL DEFAULT '' AFTER provider_status,
ADD COLUMN provider_error_message VARCHAR(256) NOT NULL DEFAULT '' AFTER provider_error_code,
ADD COLUMN provider_result_json JSON NULL AFTER provider_error_message,
ADD COLUMN submitted_at DATETIME(3) NULL AFTER provider_result_json,
ADD COLUMN callback_at DATETIME(3) NULL AFTER submitted_at,
ADD COLUMN expires_at DATETIME(3) NULL AFTER callback_at,
ADD COLUMN manual_review_required TINYINT(1) NOT NULL DEFAULT 0 AFTER expires_at,
ADD COLUMN version BIGINT NOT NULL DEFAULT 1 AFTER manual_review_required,
ADD INDEX idx_im_realname_provider_task (provider, provider_task_id),
ADD INDEX idx_im_realname_status_updated (status, updated_at);
```
注意:当前项目已经改为“一个租户一个库”,租户业务表中不再新增 `tenant_id` 字段。
### 6.2 新增第三方事件表
建议新增 `im_realname_provider_events`,用于记录回调、查询结果和异常响应,便于排查和审计:
```sql
CREATE TABLE IF NOT EXISTS im_realname_provider_events (
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
provider_task_id VARCHAR(128) NOT NULL DEFAULT '',
event_type VARCHAR(32) NOT NULL,
provider_status VARCHAR(32) NOT NULL DEFAULT '',
request_id VARCHAR(128) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
payload_json JSON NULL,
error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_realname_events_submission (submission_id, created_at),
INDEX idx_im_realname_events_provider_task (provider, provider_task_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### 6.3 配置 SQL 建议
HotGo 控制库增加全局供应商配置项:
```sql
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '实名认证 Provider', 'string', 'realname_provider', 'aliyun_cloudauth', 'aliyun_cloudauth', 11, '当前实名认证供应商', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='realname_provider');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 RegionId', 'string', 'aliyun_region_id', 'cn-hangzhou', 'cn-hangzhou', 20, '阿里云实人认证地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_region_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 Endpoint', 'string', 'aliyun_endpoint', 'cloudauth.aliyuncs.com', 'cloudauth.aliyuncs.com', 21, '阿里云实人认证 OpenAPI Endpoint,按开通产品确认', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_endpoint');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 AccessKey ID', 'string', 'aliyun_access_key_id', '', '', 22, '敏感配置,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_access_key_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云 AccessKey Secret', 'string', 'aliyun_access_key_secret', '', '', 23, '敏感配置,后台脱敏展示,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_access_key_secret');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '阿里云认证场景 ID', 'string', 'aliyun_scene_id', '', '', 24, '增强版实人认证场景 ID', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='aliyun_scene_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `remark`, `status`, `created_by`, `created_at`, `updated_at`)
SELECT 'im_realname', '实名认证回调密钥', 'string', 'callback_secret', '', '', 30, 'IM Server 校验回调使用,禁止下发客户端', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group`='im_realname' AND `key`='callback_secret');
```
HotGo 控制库增加租户级审核策略。以下写法用于现有库变更,正式发布时可合并到 `im_tenant` 建表 SQL:
```sql
ALTER TABLE hotgo.im_tenant
ADD COLUMN realname_review_policy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '实名审核策略:0不审核,1自动审核且失败转人工' AFTER max_group_members;
```
默认值要求:
- `realname_review_policy=0`,新租户默认不调用阿里云,也不自动通过;提交后进入 `pending`,等待后台人工审核。
- `realname_review_policy=1`,自动调用阿里云;通过写 `verified`,失败、异常、不确定全部进入 `manual_review`。
- 审核策略只影响当前租户,不影响其他租户。
## 7. 后端开发拆分
### 7.1 第一阶段:配置和状态机
1. 新增 `server/realname` 或 `server/identity` 包,封装实名服务。
2. 从 HotGo `hg_sys_config` 读取 `im_realname` 分组供应商配置,并从 `im_tenant_config` 读取当前租户的审核策略。
3. 扩展 `RealNameVerification` 类型和 MySQL adapter 字段。
4. 新增状态更新方法,例如 `TenantRealNameUpdateProviderResult`。
5. 新增 provider 事件表写入方法。
6. 保持现有接口兼容,未配置第三方时仍可创建 `pending` 申请,不影响系统启动。
### 7.2 第二阶段:阿里云 Client
1. 封装 `AliyunRealNameClient`,支持 mock client 和真实 client。
2. 二要素模式实现:提交姓名、证件号,返回通过/拒绝/人工审核。
3. 增强版模式实现:
- 发起认证:调用 `InitSmartVerify`、`InitFaceVerify` 或开通产品对应的初始化 API。
- 查询结果:调用 `DescribeSmartVerify`、`DescribeFaceVerify` 或开通产品对应的查询 API。
4. 记录阿里云 `RequestId`、`CertifyId`、错误码和错误消息。
### 7.3 第三阶段:回调和异步查询
1. 新增阿里云回调 Handler。
2. 校验回调签名和时间窗口。
3. 以 `provider_task_id` 幂等更新实名状态。
4. 对没有回调或回调失败的任务,后台 worker 定时查询结果。
5. 查询超过最大次数后进入 `manual_review` 或 `failed`。
### 7.4 第四阶段:后台审核和通知
1. 完善后台审核接口:通过、拒绝、申诉处理、审核备注。
2. 实名通过/拒绝后发送系统通知。
3. `GET /v1/im/bootstrap` 和 `GET /v1/im/me` 统一读取真实实名状态。
4. 接入审计日志,记录管理员和第三方状态变更。
## 8. 安全与合规要求
1. 客户端不接触阿里云 AccessKey、AccessKeySecret、回调密钥。
2. IM Server 普通日志不得打印姓名、身份证号、阿里云完整返回 payload。
3. 身份证号只保存哈希、脱敏值和必要保护存储值;如必须保存原文,应使用 KMS 或应用层加密,并设置严格读取权限。
4. `provider_result_json` 和事件表 payload 需要脱敏或加密,禁止存储身份证原文、人脸图片、活体照片。
5. 回调接口必须校验签名、时间戳和幂等,避免伪造通过。
6. AccessKey 使用最小权限 RAM 子账号,建议按环境隔离。
7. 后台查询接口需要按 `security_auditor` 等角色授权,敏感字段默认脱敏。
8. 实名通过后的状态变更需要写审计日志,便于追踪。
## 9. 异常处理策略
| 场景 | 建议处理 |
| --- | --- |
| 阿里云配置缺失 | 接口返回稳定错误 `REALNAME_PROVIDER_NOT_CONFIGURED`,或按配置退回人工 `pending` |
| 阿里云超时 | 本地状态保持 `pending/processing`,后台 worker 重试 |
| 阿里云明确不一致 | 状态改为 `rejected`,返回脱敏驳回原因 |
| 阿里云结果不确定 | 状态改为 `manual_review` |
| 用户重复提交 | 如果已有 `pending/processing/verified`,直接返回最新申请 |
| 用户认证中断 | 到期后改为 `expired`,允许重新提交 |
| 回调重复到达 | 根据 `provider_task_id + provider_status` 幂等忽略 |
## 10. 客户端改动点
1. 继续使用当前实名提交和查询接口。
2. 如果提交响应包含 `certify_url` 或 `token`,客户端调起阿里云 SDK/H5 认证流程。
3. 认证完成后回到 IM 页面,调用 `GET /v1/im/security/realname` 刷新状态。
4. 客户端不缓存原始姓名和证件号,只展示后端返回的脱敏值。
5. 对 `processing/pending/manual_review` 统一展示“审核中/认证中”。
6. 对 `rejected/expired/failed` 根据 `can_submit` 和 `next_action` 决定是否允许重新提交或申诉。
## 11. 建议验收点
1. 未配置阿里云时,IM Server 能正常启动,现有实名提交仍可落 `pending`。
2. 配置 mock provider 后,提交实名可以覆盖通过、拒绝、异常和超时场景。
3. 二要素模式下,提交后能自动更新为 `verified/rejected`。
4. 增强版模式下,提交后返回 `certify_id/certify_url`,完成认证后状态能回写。
5. 重复 `Idempotency-Key` 不产生重复申请。
6. 阿里云回调重复投递不重复更新、不重复通知。
7. 日志、系统通知、Push、搜索索引中均不出现身份证原文。
8. 后台能按状态查看实名申请,并能人工处理 `manual_review` 和申诉。
## 12. 参考文档
- 阿里云 OpenAPI 开发者门户:`https://api.aliyun.com/`
- 阿里云实人认证服务 API:`https://api.aliyun.com/product/Cloudauth`
- `InitSmartVerify`:发起增强版认证请求,返回认证请求 ID。
- `DescribeSmartVerify`:查询增强版认证结果。
- `InitFaceVerify` / `DescribeFaceVerify`:按开通产品和接入形态选择的实人认证初始化与结果查询接口。
最终使用哪个 API 名称和参数,以实际开通的阿里云产品、地域、控制台场景配置和 API 门户为准。
@@ -0,0 +1,8 @@
-- IM 阿里云实名认证:租户级审核策略字段
-- 执行库:hotgo
-- 说明:
-- 1. realname_review_policy=0:不自动审核,实名提交进入 pending,等待后台人工审核。
-- 2. realname_review_policy=1:自动调用阿里云审核,通过写 verified;失败、异常、不确定写 manual_review,等待人工复核。
ALTER TABLE hotgo.im_tenant
ADD COLUMN realname_review_policy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '实名审核策略:0不审核,1自动审核且失败转人工' AFTER max_group_members;
@@ -134,8 +134,8 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
```text
读取客户端预置的 host 和普通 API Key
-> 用户输入/客户端读取 tenant_code
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
-> 登录页由 login_type 确定个人/企业并绑定 tenant_code;注册页由 registration_type 确定 tenant_code
-> 可选:POST /v1/im/tenants/resolve 获取公开配置或历史企业入口信息
-> 带 API Key 完成 WebSocket Upgrade
-> hi(tenant_code)
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
@@ -147,7 +147,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
-> 进入可发送状态
```
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
当前服务端已提供 `/v1/im/tenants/resolve`,用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不把它作为必选前置校验。企业是否存在、是否可用必须在 `{hi}`、登录和注册链路中最终校验。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
`hi` 请求:
@@ -633,22 +633,23 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
<img src="images/企业码登录.png" alt="企业码登录" width="360">
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
**状态:当前客户端不再使用独立企业码进入页;企业码在登录页和注册页内填写,由登录/注册链路校验。企业解析接口保留给服务发现、品牌配置和历史入口。**
**当前系统的执行方式:**
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。
- 前端动作:登录页由“企业用户/个人用户”入口决定租户上下文,basic 登录必须提交 `login_type`,`0` 表示企业登录、`1` 表示个人登录;个人用户入口内部使用 `tenant_code=default`,企业用户入口必须填写企业码且不得为 `default`。注册页由 `registration_type` 明确区分个人注册和企业注册,不能通过企业码是否为空推断。企业码只去掉首尾空格,最长 64 字符,不擅自改变大小写。`default` 是系统保留的个人租户码,不允许用户手动输入。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,直接使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket;当前登录/注册主流程不要求先调用 `POST /v1/im/tenants/resolve`。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"登录/注册页确定的 tenant_code"}}`。
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。
- 当前失败表现:企业码超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。登录/注册页统一展示“企业不存在或不可用”。
**企业解析接口:**
- `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`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
- 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](../IM-个人无企业码注册前后端对接说明.md)。
成功响应示例:
@@ -780,13 +781,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
- 前端动作:企业码、账号、密码和协议勾选在同一个登录页完成;个人用户可不填企业码,企业用户填写企业码。账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:点击登录后先按页面企业码确定 `tenant_code` 并发送 WebSocket `{hi}`;`hi` 成功后发送 `{login scheme:"basic"}`,`secret=base64(username:password)`。Base64 不是加密,必须通过 WSS 传输。同一次登录不得先登录再切换企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- 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`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
- 错误:企业不存在、停用或不允许登录统一显示“企业不存在或不可用”;`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并留在登录页重新选择个人/企业身份;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
登录成功示例:
@@ -826,6 +827,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
@@ -955,7 +957,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
-4
View File
@@ -51,8 +51,6 @@ type adminInternalTenantDTO struct {
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"`
@@ -422,8 +420,6 @@ func (t adminInternalTenantDTO) toStoreTenant() *types.Tenant {
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,
+5 -2
View File
@@ -79,8 +79,11 @@ func (s testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(types.Ten
return 0, s.err
}
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
return s.err
func (s testSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
if s.err != nil {
return nil, s.err
}
return &types.SystemNotification{EventID: eventID, EventType: "system.notice", Title: "通知", Summary: "通知", ReadAt: &readAt, CreatedAt: readAt}, nil
}
func newTestAuthenticator(t *testing.T) *authenticator {
+2 -20
View File
@@ -42,7 +42,7 @@ func recordSessionLoginSuccess(s *Session, rec *auth.Rec, scheme, suppliedAccoun
}
event := &types.LoginAuditEvent{
UserID: rec.Uid, Username: username, AccountType: accountType,
AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
AccountHash: hashLoginAccount(account), AccountMask: account,
LoginType: loginAuditType(scheme), EventType: "login", SessionID: truncateAuditValue(s.sid, 96),
DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf),
AppVersion: truncateAuditValue(versionToString(s.ver), 32), IP: sessionAuditIP(s.remoteAddr),
@@ -79,7 +79,7 @@ func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *au
riskLevel = "high"
}
event := &types.LoginFailureEvent{
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: account,
FailureType: failureType, Reason: reason, Detail: truncateAuditValue(loginFailureDetail(cause), 500),
IP: sessionAuditIP(s.remoteAddr), DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf),
AppVersion: truncateAuditValue(versionToString(s.ver), 32), UserAgent: truncateAuditValue(s.userAgent, 512),
@@ -241,24 +241,6 @@ func isPhoneLike(value string) bool {
return digits >= 7
}
func maskLoginAccount(account string) string {
account = strings.TrimSpace(account)
if account == "" {
return ""
}
if at := strings.LastIndex(account, "@"); at > 0 {
return string([]rune(account[:at])[:1]) + "***" + account[at:]
}
runes := []rune(account)
if isPhoneLike(account) && len(runes) > 7 {
return string(runes[:3]) + "****" + string(runes[len(runes)-4:])
}
if len(runes) <= 2 {
return strings.Repeat("*", len(runes))
}
return string(runes[:1]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1:])
}
func hashLoginAccount(account string) string {
account = strings.ToLower(strings.TrimSpace(account))
if account == "" {
+6 -18
View File
@@ -22,18 +22,7 @@ func TestLoginAuditAccountDoesNotTreatTokenAsAccount(t *testing.T) {
}
}
func TestMaskAndHashLoginAccount(t *testing.T) {
tests := map[string]string{
"13800138000": "138****8000",
"alice@example.com": "a***@example.com",
"alice": "a***e",
"ab": "**",
}
for input, want := range tests {
if got := maskLoginAccount(input); got != want {
t.Errorf("maskLoginAccount(%q)=%q want %q", input, got, want)
}
}
func TestHashLoginAccount(t *testing.T) {
hash := hashLoginAccount(" Alice ")
if len(hash) != 64 || hash != hashLoginAccount("alice") || strings.Contains(hash, "alice") {
t.Fatalf("unexpected account hash %q", hash)
@@ -101,16 +90,15 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
}
func TestLoginAuditQuerySpecsExposeUserIDAsString(t *testing.T) {
for label, spec := range map[string]*internalQuerySpec{
"user-login-logs": internalTenantResourceSpecs["user-login-logs"],
"login-failure-logs": internalTenantResourceSpecs["login-failure-logs"],
} {
spec := internalTenantResourceSpecs["user-login-logs"]
if spec == nil {
t.Fatalf("%s query spec missing", label)
t.Fatal("user-login-logs query spec missing")
}
if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") {
t.Fatalf("%s query must expose 64-bit user IDs as strings: %s", label, spec.Select)
t.Fatalf("user-login-logs query must expose 64-bit user IDs as strings: %s", spec.Select)
}
if internalTenantResourceSpecs["login-failure-logs"] != nil {
t.Fatal("login-failure-logs query spec must be removed with im_login_failure_logs")
}
}
+8
View File
@@ -152,6 +152,10 @@ type MsgClientAcc struct {
Username string `json:"username,omitempty"`
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
Phone string `json:"phone,omitempty"`
// Registration type: 0 enterprise, 1 personal. Required when creating a new account.
RegistrationType *int `json:"registration_type,omitempty"`
// Tenant code submitted by client for enterprise registration. Personal registration keeps it empty.
TenantCode string `json:"tenant_code,omitempty"`
// Deprecated compatibility field. New clients put a five-letter invitation
// code in the shared verification input and submit it as the tel cred response.
InviteCode string `json:"invite_code,omitempty"`
@@ -173,6 +177,10 @@ type MsgClientLogin struct {
Scheme string `json:"scheme,omitempty"`
// Shared secret
Secret []byte `json:"secret"`
// Login type: 0 enterprise, 1 personal. Required for password login.
LoginType *int `json:"login_type,omitempty"`
// Tenant code submitted by client for enterprise login. Personal login keeps it empty or default.
TenantCode string `json:"tenant_code,omitempty"`
// Credntials being verified (email or phone or captcha etc.)
Cred []MsgCredClient `json:"cred,omitempty"`
}
+2 -8
View File
@@ -261,14 +261,8 @@ type TenantRoutedAdapter interface {
}
// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used
// by product HTTP middleware and online business-event delivery.
// by Kafka dead-letter handling.
type TenantHTTPInfrastructureAdapter interface {
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) 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)
@@ -476,7 +470,7 @@ type TenantBusinessAdapter interface {
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, error)
TenantSystemNotificationUnreadCountByEventPrefix(t.TenantID, t.Uid, string) (int, error)
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) (*t.SystemNotification, error)
TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error)
TenantFriendRequestList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendRequestListItem, string, error)
+299 -134
View File
@@ -45,6 +45,7 @@ type adapter struct {
baseConfig *configType
tenantDBRouting bool
tenantScopedDB bool
tenantID t.TenantID
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantMu sync.Mutex
@@ -55,8 +56,8 @@ const (
adpVersion = 123
adapterName = "mysql"
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
defaultDatabase = "tinode"
defaultDSN = "root:@tcp(localhost:3306)/meeting_im?parseTime=true"
defaultDatabase = "meeting_im"
defaultMaxResults = 1024
// This is capped by the Session's send queue limit (128).
@@ -250,6 +251,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
config.Config.DBName = a.dbName
a.baseConfig = &config
a.tenantDBRouting = config.TenantDBRouting
a.tenantID = defaultTenantID
a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB)
a.tenantAdapters = make(map[t.TenantID]*adapter)
@@ -296,7 +298,7 @@ func (a *adapter) Close() error {
var err error
a.tenantMu.Lock()
for tenantID, tenantAdp := range a.tenantAdapters {
if tenantAdp != nil && tenantAdp.db != nil {
if tenantAdp != nil && tenantAdp.db != nil && tenantAdp.db != a.db {
if closeErr := tenantAdp.db.Close(); err == nil {
err = closeErr
}
@@ -419,17 +421,11 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) {
db = a.registryDB
}
err := db.GetContext(ctx, &tenant,
`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
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE code=? LIMIT 1`, code)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if isUnknownColumn(err) {
err = db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE code=? LIMIT 1`, code)
}
if err != nil {
return nil, err
}
@@ -458,21 +454,34 @@ func (a *adapter) TenantListActive(limit int) ([]t.Tenant, error) {
}
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) {
if !a.tenantDBRouting {
if a.tenantID != defaultTenantID && a.tenantID != tenantID {
return nil, nil, t.ErrMalformed
}
if a.registryDB != nil {
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
if tenant.State != t.TenantStateActive {
return nil, nil, t.ErrPermissionDenied
}
}
return a, a, nil
}
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
if tenant.State != t.TenantStateActive {
return nil, nil, t.ErrPermissionDenied
}
a.tenantMu.Lock()
if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil {
a.tenantMu.Unlock()
@@ -480,36 +489,15 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
}
a.tenantMu.Unlock()
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
dbName := strings.TrimSpace(tenant.DBName)
dsn := strings.TrimSpace(tenant.DBDSN)
if a.baseConfig == nil {
return nil, nil, t.ErrFailed
}
db, resolvedDBName, err := openMySQLDB(*a.baseConfig, mysqlDefaultString(dsn, a.dsn), dbName)
if err != nil {
return nil, nil, err
}
if err = db.Ping(); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureUserPrivacySchema(db); err != nil {
db.Close()
return nil, nil, errors.New("mysql adapter failed to ensure user privacy schema for tenant: " + err.Error())
}
registryDB := a.registryDB
if registryDB == nil {
registryDB = a.db
}
tenantAdp := &adapter{
db: db,
db: a.db,
registryDB: registryDB,
dsn: mysqlDefaultString(dsn, a.dsn),
dbName: resolvedDBName,
dsn: a.dsn,
dbName: a.dbName,
maxResults: a.maxResults,
maxMessageResults: a.maxMessageResults,
version: a.version,
@@ -517,12 +505,14 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
txTimeout: a.txTimeout,
baseConfig: a.baseConfig,
tenantDBRouting: false,
tenantScopedDB: true,
tenantID: tenantID,
// Tenant adapters share the same database. Keep tenant predicates and
// explicit tenant_id writes enabled for logical isolation.
tenantScopedDB: false,
}
a.tenantMu.Lock()
if existing := a.tenantAdapters[tenantID]; existing != nil {
a.tenantMu.Unlock()
db.Close()
return existing, existing, nil
}
a.tenantAdapters[tenantID] = tenantAdp
@@ -541,17 +531,11 @@ func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) {
}
var tenant t.Tenant
err := db.GetContext(ctx, &tenant,
`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
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if isUnknownColumn(err) {
err = db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
}
if err != nil {
return nil, err
}
@@ -561,7 +545,7 @@ func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) {
return &tenant, nil
}
func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error {
func createTenantRegistry(tx *sql.Tx, registryDB, _ string) error {
if _, err := tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
return err
}
@@ -570,52 +554,36 @@ func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error {
}
if _, err := tx.Exec(
`CREATE TABLE IF NOT EXISTS im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
id BIGINT NOT NULL,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0,
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0,
max_group_members INT UNSIGNED NOT NULL DEFAULT 0,
realname_review_policy TINYINT(1) NOT NULL DEFAULT 0,
aliyun_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0,
local_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0,
message_encryption_enabled TINYINT(1) NOT NULL DEFAULT 0,
default_policy_group_id BIGINT,
security_policy_version INT UNSIGNED NOT NULL DEFAULT 0,
global_friend_enabled TINYINT(1) NOT NULL DEFAULT 1,
message_curfew_enabled TINYINT(1) NOT NULL DEFAULT 0,
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code),
INDEX idx_im_tenant_db_name(db_name)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE IF NOT EXISTS im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
realname_review_policy TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Real-name review policy: 0 none, 1 auto then manual',
realname_provider_mode VARCHAR(32) NOT NULL DEFAULT 'id_card_2factor' COMMENT 'id_card_2factor or smart_verify',
realname_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Require real-name verification',
realname_allow_skip TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Allow skipping real-name reminder',
realname_remind_after VARCHAR(32) NOT NULL DEFAULT '24h' COMMENT 'Real-name reminder interval',
aliyun_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable Aliyun content moderation',
local_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable local content moderation',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
UNIQUE INDEX uk_im_tenant_code(code)
)`); err != nil {
return err
}
_, err := tx.Exec(
`INSERT INTO im_tenant(id,code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES(1,'default','Default Tenant',NULL,?,'',?,0,0)
ON DUPLICATE KEY UPDATE db_name=VALUES(db_name), state=VALUES(state), updated_by=VALUES(updated_by)`,
businessDB, t.TenantStateActive)
`INSERT INTO im_tenant(id,code,name,tenant_desc,state,created_by,updated_by)
VALUES(1,'default','Default Tenant',NULL,?,0,0)
ON DUPLICATE KEY UPDATE state=VALUES(state), updated_by=VALUES(updated_by)`,
t.TenantStateActive)
return err
}
@@ -1005,20 +973,6 @@ func createIMUserProductTables(tx *sql.Tx) error {
return nil
}
func ensureTenantRoutingColumns(a *adapter) error {
statements := []string{
"ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"UPDATE im_tenant SET db_name='tinode' WHERE code='default' AND db_name=''",
}
for _, stmt := range statements {
if _, err := a.db.Exec(stmt); err != nil && !isDuplicateColumn(err) {
return err
}
}
return nil
}
func upgradeIMMessageActions(a *adapter) error {
statements := []string{
"ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
@@ -1194,25 +1148,6 @@ func createIMSecurityTables(tx *sql.Tx) error {
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_action_tokens_token(token_id),
INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at)
)`); err != nil {
return err
}
_, err := tx.Exec(
`CREATE TABLE im_youth_mode_settings(
id BIGINT NOT NULL,
@@ -1393,6 +1328,16 @@ func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (uint32, bool, error) {
return 0, false, nil
}
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO im_user_account_sequences(id,next_value)
SELECT 1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE account_no>0`); err != nil {
return 0, true, err
}
if _, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
SET next_value=GREATEST(next_value,(SELECT GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE account_no>0))
WHERE id=1`); err != nil {
return 0, true, err
}
result, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
SET next_value=LAST_INSERT_ID(next_value+1) WHERE id=1`)
if err != nil {
@@ -1423,18 +1368,35 @@ func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
if tenantAdp != a {
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
}
var exp *time.Time
if !expires.IsZero() {
exp = &expires
}
var username string
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE tenant_id=? AND id=?", tenantID, store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
return t.ErrNotFound
}
return err
}
return a.AuthAddRecord(uid, "basic", username, authLvl, secret, expires)
authID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, "INSERT INTO im_auth(tenant_id,id,userid,authLvl,secret,expires) VALUES(?,?,?,?,?,?)",
tenantID, authID, store.DecodeUid(uid), authLvl, secret, exp)
if isIDOutOfRange(err) {
_, err = a.db.ExecContext(ctx, "INSERT INTO im_auth(tenant_id,userid,authLvl,secret,expires) VALUES(?,?,?,?,?)",
tenantID, store.DecodeUid(uid), authLvl, secret, exp)
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
// TenantAuthDeleteRecord deletes the Basic authentication record.
@@ -1446,7 +1408,12 @@ func (a *adapter) TenantAuthDeleteRecord(tenantID t.TenantID, uid t.Uid) error {
if tenantAdp != a {
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
}
return a.AuthDelScheme(uid, "basic")
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE tenant_id=? AND userid=?", tenantID, store.DecodeUid(uid))
return err
}
// TenantAuthUpdateRecord updates the Basic authentication record and optional username.
@@ -1459,7 +1426,43 @@ func (a *adapter) TenantAuthUpdateRecord(tenantID t.TenantID, uid t.Uid, usernam
if tenantAdp != a {
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
}
return a.AuthUpdRecord(uid, "basic", username, authLvl, secret, expires)
params := []string{"authLvl=?"}
args := []any{authLvl}
if len(secret) > 0 {
params = append(params, "secret=?")
args = append(args, secret)
}
if !expires.IsZero() {
params = append(params, "expires=?")
args = append(args, expires)
}
decodedUID := store.DecodeUid(uid)
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if username != "" {
if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE tenant_id=? AND id=?", username, t.TimeNow(), tenantID, decodedUID); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
args = append(args, tenantID, decodedUID)
resp, err := tx.ExecContext(ctx, "UPDATE im_auth SET "+strings.Join(params, ",")+" WHERE tenant_id=? AND userid=?", args...)
if err != nil {
return err
}
if count, _ := resp.RowsAffected(); count <= 0 {
return t.ErrNotFound
}
return tx.Commit()
}
// TenantAuthGetRecord retrieves a user's Basic authentication record.
@@ -1473,7 +1476,29 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid) (string,
if tenantAdp != a {
return tenantAdp.TenantAuthGetRecord(tenantID, uid)
}
return a.AuthGetRecord(uid, "basic")
var record struct {
Username string
Authlvl auth.Level
Secret []byte
Expires *time.Time
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
err = a.db.GetContext(ctx, &record, `SELECT u.username,a.secret,a.expires,a.authlvl
FROM im_auth AS a JOIN im_users AS u ON u.tenant_id=a.tenant_id AND u.id=a.userid
WHERE a.tenant_id=? AND a.userid=?`, tenantID, store.DecodeUid(uid))
if err == sql.ErrNoRows {
return "", auth.LevelNone, nil, expires, t.ErrNotFound
}
if err != nil {
return "", auth.LevelNone, nil, expires, err
}
if record.Expires != nil {
expires = *record.Expires
}
return record.Username, record.Authlvl, record.Secret, expires, nil
}
// TenantAuthGetByLogin retrieves a Basic authentication record by tenant-local login.
@@ -1487,7 +1512,34 @@ func (a *adapter) TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid
if tenantAdp != a {
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
}
return a.AuthGetUniqueRecord(login)
var record struct {
Userid int64
Authlvl auth.Level
Secret []byte
Expires *time.Time
}
lookup := login
if len(login) == 11 && asciiDigits(login) {
lookup = "+86" + login
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
err = a.db.GetContext(ctx, &record, `SELECT a.userid,a.secret,a.expires,a.authlvl
FROM im_users AS u JOIN im_auth AS a ON a.tenant_id=u.tenant_id AND a.userid=u.id
WHERE u.tenant_id=? AND (u.username=? OR (u.phone IS NOT NULL AND u.phone=?))
ORDER BY (u.username=?) DESC LIMIT 1`, tenantID, login, lookup, login)
if err == sql.ErrNoRows {
return t.ZeroUid, auth.LevelNone, nil, expires, nil
}
if err != nil {
return t.ZeroUid, auth.LevelNone, nil, expires, err
}
if record.Expires != nil {
expires = *record.Expires
}
return store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil
}
// Add user's authentication record
@@ -2005,9 +2057,9 @@ func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string)
return t.ZeroUid, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.UserGetByCred(method, value)
return tenantAdp.TenantUserGetByCred(tenantID, method, value)
}
if !a.tenantScopedDB && method == "tel" {
if method == "tel" {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
@@ -2023,7 +2075,20 @@ func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string)
}
return store.EncodeUid(decodedUID), nil
}
return a.UserGetByCred(method, value)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var decodedUID int64
err = a.db.GetContext(ctx, &decodedUID,
"SELECT userid FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, method+":"+value)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decodedUID), nil
}
// UserUnreadCount returns the total number of unread messages in all topics with
@@ -3849,9 +3914,73 @@ func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (boo
return false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.CredUpsert(cred)
return tenantAdp.TenantCredUpsert(tenantID, cred)
}
return a.CredUpsert(cred)
if cred.Method == "tel" {
cred.Value, err = normalizeUserPhone(cred.Value)
if err != nil {
return false, err
}
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
now := t.TimeNow()
userID := common.DecodeUidString(cred.User)
synth := cred.Method + ":" + cred.Value
if !cred.Done {
var done bool
err = tx.Get(&done, "SELECT done FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, synth)
if err == nil {
return false, t.ErrDuplicate
}
if err != sql.ErrNoRows {
return false, err
}
synth = cred.User + ":" + synth
if _, err = tx.Exec("UPDATE im_credentials SET deletedat=? WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE",
now, tenantID, userID, cred.Method); err != nil {
return false, err
}
res, updateErr := tx.Exec("UPDATE im_credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE tenant_id=? AND synthetic=?",
cred.UpdatedAt, cred.Resp, tenantID, synth)
if updateErr != nil {
return false, updateErr
}
if rows, _ := res.RowsAffected(); rows > 0 {
return false, tx.Commit()
}
} else if _, err = tx.Exec("DELETE FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, cred.User+":"+synth); err != nil {
return false, err
}
credID, err := nextSnowflakeID()
if err != nil {
return false, err
}
_, err = tx.Exec(`INSERT INTO im_credentials(tenant_id,id,createdat,updatedat,method,value,synthetic,userid,resp,done)
VALUES(?,?,?,?,?,?,?,?,?,?)`, tenantID, credID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userID, cred.Resp, cred.Done)
if isIDOutOfRange(err) {
_, err = tx.Exec(`INSERT INTO im_credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done)
VALUES(?,?,?,?,?,?,?,?,?)`, tenantID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userID, cred.Resp, cred.Done)
}
if err != nil {
if isDupe(err) {
return true, t.ErrDuplicate
}
return true, err
}
if cred.Done && cred.Method == "tel" {
if _, err = tx.Exec("UPDATE im_users SET phone=? WHERE tenant_id=? AND id=? AND (phone IS NULL OR phone='')", cred.Value, tenantID, userID); err != nil {
return true, err
}
}
return true, tx.Commit()
}
func tenantCredDel(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, method, value string) error {
@@ -3983,7 +4112,7 @@ func (a *adapter) TenantCredDel(tenantID t.TenantID, uid t.Uid, method, value st
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.CredDel(uid, method, value)
return tenantAdp.TenantCredDel(tenantID, uid, method, value)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
@@ -4030,7 +4159,7 @@ func (a *adapter) TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method strin
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.CredConfirm(uid, method)
return tenantAdp.TenantCredConfirm(tenantID, uid, method)
}
ctx, cancel := a.getContext()
if cancel != nil {
@@ -4070,7 +4199,7 @@ func (a *adapter) TenantCredFail(tenantID t.TenantID, uid t.Uid, method string)
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.CredFail(uid, method)
return tenantAdp.TenantCredFail(tenantID, uid, method)
}
ctx, cancel := a.getContext()
if cancel != nil {
@@ -4110,9 +4239,24 @@ func (a *adapter) TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method str
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.CredGetActive(uid, method)
return tenantAdp.TenantCredGetActive(tenantID, uid, method)
}
return a.CredGetActive(uid, method)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var cred t.Credential
err = a.db.GetContext(ctx, &cred, `SELECT createdat,updatedat,method,value,resp,done,retries
FROM im_credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL AND method=? AND done=FALSE`,
tenantID, store.DecodeUid(uid), method)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
cred.User = uid.String()
return &cred, nil
}
// CredGetAll returns credential records for the given user and method, all or validated only.
@@ -4153,9 +4297,30 @@ func (a *adapter) TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.CredGetAll(uid, method, validatedOnly)
return tenantAdp.TenantCredGetAll(tenantID, uid, method, validatedOnly)
}
return a.CredGetAll(uid, method, validatedOnly)
query := `SELECT createdat,updatedat,method,value,resp,done,retries
FROM im_credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL`
args := []any{tenantID, store.DecodeUid(uid)}
if method != "" {
query += " AND method=?"
args = append(args, method)
}
if validatedOnly {
query += " AND done=TRUE"
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var credentials []t.Credential
if err = a.db.SelectContext(ctx, &credentials, query, args...); err != nil {
return nil, err
}
for i := range credentials {
credentials[i].User = uid.String()
}
return credentials, nil
}
// FileUploads
@@ -21,33 +21,6 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
INDEX idx_im_user_login_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_login_failure_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT,
account_type VARCHAR(24) NOT NULL DEFAULT '', account_hash CHAR(64) NOT NULL DEFAULT '',
account_mask VARCHAR(128) NOT NULL DEFAULT '', failure_type VARCHAR(32) NOT NULL,
reason VARCHAR(96) NOT NULL DEFAULT '', detail VARCHAR(500) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
device_id VARCHAR(255) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
app_version VARCHAR(32) NOT NULL DEFAULT '', user_agent VARCHAR(512) NOT NULL DEFAULT '',
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium', risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
request_id VARCHAR(64) NOT NULL DEFAULT '', attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_login_failure_account_time(account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time(user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time(ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_login_lockouts (
id BIGINT NOT NULL, user_id BIGINT,
account_hash CHAR(64) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
window_started_at DATETIME(3), locked_until DATETIME(3), last_attempt_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_login_lockouts_account(account_hash),
INDEX idx_im_login_lockouts_user(user_id), INDEX idx_im_login_lockouts_locked(locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
@@ -13,15 +13,20 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 5 {
t.Fatalf("created %d admin governance tables, want 5", len(exec.statements))
if len(exec.statements) != 3 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_login_lockouts", "im_message_admin_actions", "im_user_ip_whitelists"} {
for _, table := range []string{"im_user_login_logs", "im_message_admin_actions", "im_user_ip_whitelists"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
for _, table := range []string{"im_login_failure_logs", "im_login_lockouts"} {
if strings.Contains(joined, table) {
t.Fatalf("hot login state table must not be created: %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("admin governance schema must stay inside the selected tenant database: %s", joined)
}
+42 -77
View File
@@ -13,37 +13,17 @@ import (
t "github.com/tinode/chat/server/store/types"
)
const legacyGroupMessageTable = "im_group_messages"
func ensureGroupMessageShardInfrastructure(exec sqlExecutor) error {
func ensureGroupMessageShardInfrastructure(exec sqlExecutor, tenantID t.TenantID) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_message_shards (
shard_month CHAR(6) NOT NULL, table_name VARCHAR(64) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, shard_month CHAR(6) NOT NULL, table_name VARCHAR(64) NOT NULL,
shard_start DATETIME(3) NOT NULL, shard_end DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'prepared', row_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3), max_created_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(shard_month), UNIQUE INDEX uk_im_group_message_shards_table(table_name),
INDEX idx_im_group_message_shards_status(status, shard_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
topic VARCHAR(64) NOT NULL, shard_month CHAR(6) NOT NULL,
min_seq BIGINT NOT NULL, max_seq BIGINT NOT NULL, message_count BIGINT NOT NULL DEFAULT 0,
min_created_at DATETIME(3) NOT NULL, max_created_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic, shard_month), INDEX idx_im_group_message_topic_shards_seq(topic, max_seq),
INDEX idx_im_group_message_topic_shards_month(shard_month, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_message_dedup (
topic VARCHAR(64) NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64) NOT NULL,
seqid BIGINT NOT NULL, shard_month CHAR(6) NOT NULL, message_id BIGINT,
status VARCHAR(16) NOT NULL DEFAULT 'pending', expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic, from_user, client_msg_id), INDEX idx_im_group_message_dedup_expires(expires_at),
INDEX idx_im_group_message_dedup_topic_seq(topic, seqid)
PRIMARY KEY(tenant_id, shard_month), UNIQUE INDEX uk_im_group_message_shards_table(tenant_id, table_name),
INDEX idx_im_group_message_shards_status(tenant_id, status, shard_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
@@ -52,10 +32,10 @@ func ensureGroupMessageShardInfrastructure(exec sqlExecutor) error {
}
}
return ensureGroupMessageShardWindow(exec, time.Now().UTC())
return ensureGroupMessageShardWindow(exec, tenantID, time.Now().UTC())
}
func ensureGroupMessageShardWindow(exec sqlExecutor, now time.Time) error {
func ensureGroupMessageShardWindow(exec sqlExecutor, tenantID t.TenantID, now time.Time) error {
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
for _, offset := range []int{-1, 0, 1, 2} {
start := first.AddDate(0, offset, 0)
@@ -65,11 +45,11 @@ func ensureGroupMessageShardWindow(exec sqlExecutor, now time.Time) error {
} else if offset == 0 {
status = "writable"
}
if err := ensureGroupMessageShard(exec, start, status); err != nil {
if err := ensureGroupMessageShard(exec, tenantID, start, status); err != nil {
return err
}
}
_, err := exec.Exec("UPDATE im_group_message_shards SET status='sealed' WHERE status='sealing' AND shard_end<?", first.AddDate(0, 0, -7))
_, err := exec.Exec("UPDATE im_group_message_shards SET status='sealed' WHERE tenant_id=? AND status='sealing' AND shard_end<?", tenantID, first.AddDate(0, 0, -7))
return err
}
@@ -80,21 +60,21 @@ func (a *adapter) ensureCurrentGroupMessageShardWindow() error {
if a.groupShardWindow == month {
return nil
}
if err := ensureGroupMessageShardWindow(a.db, time.Now().UTC()); err != nil {
if err := ensureGroupMessageShardWindow(a.db, a.tenantID, time.Now().UTC()); err != nil {
return err
}
a.groupShardWindow = month
return nil
}
func ensureGroupMessageShard(exec sqlExecutor, start time.Time, status string) error {
func ensureGroupMessageShard(exec sqlExecutor, tenantID t.TenantID, start time.Time, status string) error {
month := start.UTC().Format("200601")
table, ok := groupMessageShardTable(month)
table, ok := tenantGroupMessagesTable(tenantID, month)
if !ok {
return fmt.Errorf("mysql: invalid group message shard month %q", month)
}
ddl := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
message_id BIGINT NOT NULL, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, message_id BIGINT NOT NULL, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, content_nonce VARBINARY(12), key_version INT NOT NULL DEFAULT 0,
@@ -117,93 +97,78 @@ func ensureGroupMessageShard(exec sqlExecutor, start time.Time, status string) e
}
end := start.AddDate(0, 1, 0)
_, err := exec.Exec(`INSERT INTO im_group_message_shards
(shard_month,table_name,shard_start,shard_end,status)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE table_name=VALUES(table_name),
(tenant_id,shard_month,table_name,shard_start,shard_end,status)
VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),table_name=VALUES(table_name),
shard_start=VALUES(shard_start),shard_end=VALUES(shard_end),
status=IF(status IN ('sealed','archived'),status,VALUES(status))`,
month, table, start, end, status)
tenantID, month, table, start, end, status)
return err
}
func groupMessageShardTable(month string) (string, bool) {
if len(month) != 6 {
return "", false
}
for i := range month {
if month[i] < '0' || month[i] > '9' {
return "", false
}
}
parsed, err := time.Parse("200601", month)
if err != nil || parsed.Format("200601") != month {
return "", false
}
return "im_group_messages_" + month, true
func groupMessageShardTable(tenantID t.TenantID, month string) (string, bool) {
return tenantGroupMessagesTable(tenantID, month)
}
func groupMessageTablesForTopic(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string) ([]string, error) {
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
redisAvailable := err == nil
if err != nil {
cached = false
}
if !cached {
routes = nil
if err = sqlx.SelectContext(ctx, q, &routes,
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
FROM im_group_message_topic_shards WHERE topic=? ORDER BY shard_month DESC`, topic); err != nil {
if routes, err = groupMessageAllShardRoutes(q, ctx, tenantID); err != nil {
return nil, err
}
if redisAvailable {
for _, route := range routes {
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
}
}
}
tables := make([]string, 0, len(routes)+1)
for _, route := range routes {
if table, ok := groupMessageShardTable(route.Month); ok {
if table, ok := groupMessageShardTable(tenantID, route.Month); ok {
tables = append(tables, table)
}
}
return append(tables, legacyGroupMessageTable), nil
return tables, nil
}
func groupMessageTablesForSeq(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64) ([]string, error) {
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
redisAvailable := err == nil
if err != nil {
cached = false
}
if !cached {
routes = nil
if err = sqlx.SelectContext(ctx, q, &routes,
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
FROM im_group_message_topic_shards WHERE topic=? AND min_seq<=? AND max_seq>=?
ORDER BY shard_month DESC`, topic, seq, seq); err != nil {
if routes, err = groupMessageAllShardRoutes(q, ctx, tenantID); err != nil {
return nil, err
}
if redisAvailable {
for _, route := range routes {
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
}
}
}
tables := make([]string, 0, 2)
for _, route := range routes {
if route.MinSeq <= seq && route.MaxSeq >= seq {
if table, ok := groupMessageShardTable(route.Month); ok {
if table, ok := groupMessageShardTable(tenantID, route.Month); ok {
tables = append(tables, table)
}
}
}
return append(tables, legacyGroupMessageTable), nil
return tables, nil
}
func groupMessageAllShardRoutes(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID) ([]store.GroupMessageShardRoute, error) {
var rows []struct {
Month string `db:"month"`
}
if err := sqlx.SelectContext(ctx, q, &rows, `SELECT shard_month AS month FROM im_group_message_shards WHERE tenant_id=? ORDER BY shard_month DESC`, tenantID); err != nil {
return nil, err
}
routes := make([]store.GroupMessageShardRoute, 0, len(rows))
for _, row := range rows {
if validShardMonth(row.Month) {
routes = append(routes, store.GroupMessageShardRoute{Month: row.Month, MinSeq: 1, MaxSeq: 1<<62 - 1, Count: 0})
}
}
return routes, nil
}
func groupMessageMonthFromTable(table string) string {
const prefix = "im_group_messages_"
if len(table) == len(prefix)+6 && table[:len(prefix)] == prefix {
return table[len(prefix):]
const suffixLength = 6
if len(table) > suffixLength && validShardMonth(table[len(table)-suffixLength:]) {
return table[len(table)-suffixLength:]
}
return ""
}
+14 -23
View File
@@ -4,12 +4,12 @@
package mysql
// ensureIMGroupBusinessTables installs the product group-chat schema in the
// currently selected tenant database. Tenant databases intentionally do not
// carry tenant_id columns.
// currently selected tenant database. Rows still carry tenant_id explicitly;
// database routing remains one tenant per database.
func ensureIMGroupBusinessTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_settings (
id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
description VARCHAR(512) NOT NULL DEFAULT '', group_status VARCHAR(16) NOT NULL DEFAULT 'active',
join_approval TINYINT NOT NULL DEFAULT 0, mute_all TINYINT NOT NULL DEFAULT 0,
@@ -24,7 +24,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) 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,
tenant_id BIGINT NOT NULL DEFAULT 0, 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, content_nonce VARBINARY(12), key_version INT NOT NULL DEFAULT 0,
@@ -45,7 +45,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
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 (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, 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',
nickname VARCHAR(64) NOT NULL DEFAULT '', pinned TINYINT NOT NULL DEFAULT 0,
mute_notifications TINYINT NOT NULL DEFAULT 0, join_source VARCHAR(32) NOT NULL DEFAULT 'manual',
@@ -59,7 +59,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_members_topic_status(topic, member_status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL, target_user_id BIGINT NOT NULL, message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', result VARCHAR(32) NOT NULL DEFAULT '',
join_request_id VARCHAR(64) NOT NULL DEFAULT '', idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
@@ -72,7 +72,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_invitations_topic_status(topic, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
applicant_user_id BIGINT NOT NULL, inviter_user_id BIGINT, source VARCHAR(32) NOT NULL DEFAULT 'manual',
qr_token_hash CHAR(64) NOT NULL DEFAULT '', message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', reject_reason VARCHAR(256) NOT NULL DEFAULT '',
@@ -87,7 +87,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_join_requests_expire(status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
author_user_id BIGINT NOT NULL, content TEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'current',
version INT NOT NULL DEFAULT 1, published_at DATETIME(3) NOT NULL, deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
@@ -99,14 +99,14 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_announcements_topic_time(topic, published_at DESC, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
read_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_group_announcement_reads_announcement_user(announcement_id, user_id),
INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_mutes (
id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
muted_at DATETIME(3) NOT NULL, expires_at DATETIME(3), reason VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active', revoked_by BIGINT, revoked_at DATETIME(3),
version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -116,7 +116,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_mutes_user_status(user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL, created_by BIGINT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL, revoked_by BIGINT, revoked_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -125,7 +125,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
UNIQUE INDEX uk_im_group_qrcodes_hash(token_hash), INDEX idx_im_group_qrcodes_topic_status(topic, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`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,
tenant_id BIGINT NOT NULL DEFAULT 0, 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 BIGINT NOT NULL DEFAULT 0,
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,
@@ -140,7 +140,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_files_topic_name(topic, status, file_name), INDEX idx_im_group_files_uploader(uploader_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
operator_id BIGINT NOT NULL, target_user_id BIGINT, action VARCHAR(64) NOT NULL,
object_type VARCHAR(32) NOT NULL DEFAULT '', object_id VARCHAR(64) NOT NULL DEFAULT '',
before_data JSON, after_data JSON, request_id VARCHAR(128) NOT NULL DEFAULT '',
@@ -149,15 +149,6 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_admin_logs_topic_time(topic, id DESC),
INDEX idx_im_group_admin_logs_operator(operator_id, id DESC), INDEX idx_im_group_admin_logs_object(object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0, result_body JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_idempotency_request(user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created(created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')))
WHERE name LIKE 'grp%' AND JSON_VALID(public)
AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.fn')), '') = ''
@@ -168,5 +159,5 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
return err
}
}
return ensureGroupMessageShardInfrastructure(exec)
return ensureGroupMessageShardInfrastructure(exec, defaultTenantID)
}
+70 -5
View File
@@ -6,6 +6,8 @@ import (
"database/sql"
"strings"
"testing"
types "github.com/tinode/chat/server/store/types"
)
type recordingGroupSchemaExecutor struct {
@@ -22,8 +24,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 25 {
t.Fatalf("executed %d group schema statements, want 25", len(exec.statements))
if len(exec.statements) != 22 {
t.Fatalf("executed %d group schema statements, want 22", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
@@ -34,6 +36,9 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if strings.Contains(joined, "im_group_events_outbox") {
t.Fatal("group event outbox table must not be created")
}
if strings.Contains(joined, "im_group_idempotency") {
t.Fatal("group idempotency table must not be created")
}
for _, expected := range []string{
"primary key(id), unique index uk_im_group_settings_topic(topic)",
"primary key(id), unique index uk_im_group_announcement_reads_announcement_user(announcement_id, user_id)",
@@ -46,20 +51,80 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if !strings.Contains(joined, "json_set(public, '$.fn'") {
t.Fatal("group schema is missing historical public.fn backfill")
}
for _, expected := range []string{"im_group_message_shards", "im_group_message_topic_shards", "im_group_message_dedup", "primary key(message_id)"} {
for _, expected := range []string{"im_group_message_shards", "primary key(message_id)"} {
if !strings.Contains(joined, expected) {
t.Fatalf("group shard schema is missing %q", expected)
}
}
if strings.Contains(joined, "im_group_message_topic_shards") {
t.Fatal("group message topic shard routes must not be created in MySQL")
}
if strings.Contains(joined, "im_group_message_dedup") {
t.Fatal("group message dedup table must not be created")
}
}
func TestGroupMessageShardTableRejectsUnsafeMonth(t *testing.T) {
if table, ok := groupMessageShardTable("202609"); !ok || table != "im_group_messages_202609" {
if table, ok := groupMessageShardTable(1, "202609"); !ok || table != "im_001_group_messages_202609" {
t.Fatalf("unexpected valid shard: table=%q ok=%v", table, ok)
}
for _, month := range []string{"", "20269", "202613", "2026_09", "202609;DROP TABLE im_users"} {
if table, ok := groupMessageShardTable(month); ok || table != "" {
if table, ok := groupMessageShardTable(1, month); ok || table != "" {
t.Fatalf("unsafe shard month accepted: month=%q table=%q", month, table)
}
}
}
func TestTenantPartitionTableNames(t *testing.T) {
tests := []struct {
tenantID types.TenantID
friends string
messages string
topics string
members string
subs string
notices string
group string
}{
{tenantID: 1, friends: "im_001_friendships", messages: "im_001_messages", topics: "im_001_topics", members: "im_001_group_members", subs: "im_001_subscriptions", notices: "im_001_system_notifications", group: "im_001_group_messages_202609"},
{tenantID: 10, friends: "im_010_friendships", messages: "im_010_messages", topics: "im_010_topics", members: "im_010_group_members", subs: "im_010_subscriptions", notices: "im_010_system_notifications", group: "im_010_group_messages_202609"},
{tenantID: 1000, friends: "im_1000_friendships", messages: "im_1000_messages", topics: "im_1000_topics", members: "im_1000_group_members", subs: "im_1000_subscriptions", notices: "im_1000_system_notifications", group: "im_1000_group_messages_202609"},
}
for _, tc := range tests {
if got := tenantFriendshipsTable(tc.tenantID); got != tc.friends {
t.Fatalf("tenant %d friendship table: got %q, want %q", tc.tenantID, got, tc.friends)
}
if got := tenantMessagesTable(tc.tenantID); got != tc.messages {
t.Fatalf("tenant %d message table: got %q, want %q", tc.tenantID, got, tc.messages)
}
if got := tenantTopicsTable(tc.tenantID); got != tc.topics {
t.Fatalf("tenant %d topics table: got %q, want %q", tc.tenantID, got, tc.topics)
}
if got := tenantGroupMembersTable(tc.tenantID); got != tc.members {
t.Fatalf("tenant %d group members table: got %q, want %q", tc.tenantID, got, tc.members)
}
if got := tenantSubscriptionsTable(tc.tenantID); got != tc.subs {
t.Fatalf("tenant %d subscriptions table: got %q, want %q", tc.tenantID, got, tc.subs)
}
if got := tenantSystemNotificationsTable(tc.tenantID); got != tc.notices {
t.Fatalf("tenant %d system notifications table: got %q, want %q", tc.tenantID, got, tc.notices)
}
if got, ok := tenantGroupMessagesTable(tc.tenantID, "202609"); !ok || got != tc.group {
t.Fatalf("tenant %d group message table: got %q, ok=%v, want %q", tc.tenantID, got, ok, tc.group)
}
}
}
func TestTenantShardSQL(t *testing.T) {
query := tenantShardSQL(7, "SELECT * FROM im_topics t JOIN im_group_members gm ON gm.topic=t.name JOIN im_subscriptions s ON s.topic=gm.topic; INSERT INTO im_system_notifications(event_id) VALUES(?)")
for _, expected := range []string{"im_007_topics", "im_007_group_members", "im_007_subscriptions", "im_007_system_notifications"} {
if !strings.Contains(query, expected) {
t.Fatalf("tenant shard query is missing %q: %s", expected, query)
}
}
for _, legacy := range []string{" im_topics", " im_group_members", " im_subscriptions", " im_system_notifications"} {
if strings.Contains(query, legacy) {
t.Fatalf("tenant shard query still references %q: %s", legacy, query)
}
}
}
-27
View File
@@ -5,33 +5,6 @@ package mysql
func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL, route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing', response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB, expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_http_idempotency_scope(user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS 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_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 '',
@@ -12,13 +12,11 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 6 {
t.Fatalf("created %d infrastructure tables, want 6", len(exec.statements))
if len(exec.statements) != 4 {
t.Fatalf("executed %d infrastructure schema statements, want 4", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{
"im_http_idempotency",
"im_kafka_consume_log",
"im_kafka_dead_letters",
"im_realname_appeals",
"im_conversation_settings",
@@ -28,12 +26,18 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") {
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
}
if strings.Contains(joined, "im_http_idempotency") {
t.Fatalf("HTTP idempotency must not create MySQL tables: %s", joined)
}
if strings.Contains(joined, "im_kafka_outbox") {
t.Fatalf("kafka outbox table must not be created: %s", joined)
}
if strings.Contains(joined, "im_kafka_consume_log") {
t.Fatalf("kafka consume state must not create MySQL tables: %s", joined)
}
if strings.Contains(joined, "im_business_events_outbox") {
t.Fatalf("business event outbox table must not be created: %s", joined)
}
+13 -171
View File
@@ -4,203 +4,45 @@
package mysql
import (
"database/sql"
"errors"
"time"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) routedLoginLockoutAdapter(id t.TenantID) (*adapter, error) {
if err := validTenant(id); err != nil {
return nil, err
}
if a.tenantScopedDB {
return a, nil
}
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
return tenantAdp, nil
}
type loginLockoutRow struct {
ID int64 `db:"id"`
UserID sql.NullInt64 `db:"user_id"`
AccountHash string `db:"account_hash"`
FailedAttempts int `db:"failed_attempts"`
WindowStartAt sql.NullTime `db:"window_started_at"`
LockedUntil sql.NullTime `db:"locked_until"`
LastAttemptAt sql.NullTime `db:"last_attempt_at"`
UpdatedAt sql.NullTime `db:"updated_at"`
}
func loginLockoutFromRow(id t.TenantID, row loginLockoutRow) *t.LoginLockout {
out := &t.LoginLockout{
TenantID: id,
ID: row.ID,
AccountHash: row.AccountHash,
FailedAttempts: row.FailedAttempts,
}
if row.UserID.Valid {
out.UserID = row.UserID.Int64
}
if row.WindowStartAt.Valid {
v := row.WindowStartAt.Time
out.WindowStartAt = &v
}
if row.LockedUntil.Valid {
v := row.LockedUntil.Time
out.LockedUntil = &v
}
if row.LastAttemptAt.Valid {
v := row.LastAttemptAt.Time
out.LastAttemptAt = &v
}
if row.UpdatedAt.Valid {
v := row.UpdatedAt.Time
out.UpdatedAt = &v
}
return out
}
const loginLockoutColumns = `id,user_id,account_hash,failed_attempts,window_started_at,locked_until,last_attempt_at,updated_at`
// TenantLoginLockoutGet reads the lockout counter for the given account hash.
// Login lockout counters are runtime security state and are stored in Redis by
// the store layer. The MySQL adapter keeps these methods only to satisfy older
// adapter interfaces while preventing accidental writes to im_login_lockouts.
func (a *adapter) TenantLoginLockoutGet(id t.TenantID, accountHash string) (*t.LoginLockout, error) {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutGet(id, accountHash)
}
if accountHash == "" {
if err := validTenant(id); err != nil || accountHash == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row loginLockoutRow
err = a.db.GetContext(ctx, &row,
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return loginLockoutFromRow(id, row), nil
return nil, t.ErrUnsupported
}
// TenantLoginLockoutRecordFailure atomically inserts or increments the failure
// counter. windowCutoff is "now minus window seconds": an older window start
// resets the counter to 1. The updated row is returned to the caller.
func (a *adapter) TenantLoginLockoutRecordFailure(id t.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*t.LoginLockout, error) {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutRecordFailure(id, accountHash, userID, windowCutoff, now)
}
if accountHash == "" || now.IsZero() {
if err := validTenant(id); err != nil || accountHash == "" || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rowID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
// MySQL evaluates ON DUPLICATE KEY UPDATE assignments left to right, so
// failed_attempts is recomputed before window_started_at is overwritten.
_, err = a.db.ExecContext(ctx, `INSERT INTO im_login_lockouts
(id,user_id,account_hash,failed_attempts,window_started_at,last_attempt_at)
VALUES(?,?,?,1,?,?)
ON DUPLICATE KEY UPDATE
failed_attempts = IF(window_started_at IS NULL OR window_started_at < ?, 1, failed_attempts + 1),
window_started_at = IF(window_started_at IS NULL OR window_started_at < ?, ?, window_started_at),
last_attempt_at = ?,
user_id = COALESCE(user_id, ?)`,
rowID, userID, accountHash, now, now,
windowCutoff, windowCutoff, now, now, userID)
if err != nil {
return nil, err
}
var row loginLockoutRow
err = a.db.GetContext(ctx, &row,
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
if err != nil {
return nil, err
}
return loginLockoutFromRow(id, row), nil
return nil, t.ErrUnsupported
}
// TenantLoginLockoutLock sets locked_until unless a newer lock already covers now.
func (a *adapter) TenantLoginLockoutLock(id t.TenantID, accountHash string, lockedUntil, now time.Time) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutLock(id, accountHash, lockedUntil, now)
}
if accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
if err := validTenant(id); err != nil || accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
`UPDATE im_login_lockouts SET locked_until=?, updated_at=? WHERE account_hash=? AND (locked_until IS NULL OR locked_until < ?)`,
lockedUntil, now, accountHash, now)
return err
return t.ErrUnsupported
}
// TenantLoginLockoutReset removes the counter after a successful login.
func (a *adapter) TenantLoginLockoutReset(id t.TenantID, accountHash string) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutReset(id, accountHash)
}
if accountHash == "" {
if err := validTenant(id); err != nil || accountHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, `DELETE FROM im_login_lockouts WHERE account_hash=?`, accountHash)
return err
return t.ErrUnsupported
}
// TenantLoginLockoutRelease clears the lock and zeroes the counter (admin unlock).
func (a *adapter) TenantLoginLockoutRelease(id t.TenantID, accountHash string, now time.Time) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutRelease(id, accountHash, now)
}
if accountHash == "" || now.IsZero() {
if err := validTenant(id); err != nil || accountHash == "" || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
`UPDATE im_login_lockouts SET failed_attempts=0, window_started_at=NULL, locked_until=NULL, updated_at=? WHERE account_hash=?`,
now, accountHash)
return err
return t.ErrUnsupported
}
+4 -6
View File
@@ -26,9 +26,10 @@ func (a *adapter) RealNamePolicy(tenantID t.TenantID) (*t.RealNamePolicy, error)
defer cancel()
}
var policy t.RealNamePolicy
err := db.GetContext(ctx, &policy, `SELECT tenant_id, realname_review_policy, realname_provider_mode,
realname_required, realname_allow_skip, realname_remind_after
FROM im_tenant_config WHERE tenant_id=? LIMIT 1`, tenantID)
// 商户配置已并入 im_tenant(manager 仓库 im_tenant_merge_config.sql),实名政策兜底直查切到 im_tenant;
// id AS tenant_id 保持扫描列名不变,ErrNoRows/未知列仍回退默认值,兼容阶段一迁移未执行的滚动发布窗口
err := db.GetContext(ctx, &policy, `SELECT id AS tenant_id, realname_review_policy
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
if errors.Is(err, sql.ErrNoRows) {
policy = *defaultRealNamePolicy(tenantID)
return &policy, nil
@@ -48,9 +49,6 @@ func defaultRealNamePolicy(tenantID t.TenantID) *t.RealNamePolicy {
policy := &t.RealNamePolicy{
TenantID: tenantID,
ReviewPolicy: t.RealNameReviewPolicyNone,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}
policy.Normalize()
return policy
+9 -8
View File
@@ -5,6 +5,7 @@ package mysql
import (
"database/sql"
"fmt"
"strconv"
"strings"
"time"
@@ -390,7 +391,7 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
if row.MaxUses > 0 {
var usedCount int
if err = tx.GetContext(txCtx, &usedCount,
"SELECT COUNT(*) FROM im_registration_invite_uses WHERE invite_code_id=?", row.ID); err != nil {
"SELECT COUNT(*) FROM im_registration_invite_uses WHERE tenant_id=? AND invite_code_id=?", id, row.ID); err != nil {
return err
}
if usedCount >= row.MaxUses {
@@ -402,12 +403,12 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
return err
}
if _, err = tx.ExecContext(txCtx, `INSERT INTO im_registration_invite_uses
(id,invite_code_id,user_id,request_id,used_at) VALUES(?,?,?,?,?)`, useID, row.ID,
(tenant_id,id,invite_code_id,user_id,request_id,used_at) VALUES(?,?,?,?,?,?)`, id, useID, row.ID,
store.DecodeUid(uid), requestID, now); err != nil {
if isDupe(err) {
var existingCodeID int64
if lookupErr := tx.GetContext(txCtx, &existingCodeID,
"SELECT invite_code_id FROM im_registration_invite_uses WHERE user_id=?", store.DecodeUid(uid)); lookupErr == nil && existingCodeID == row.ID {
"SELECT invite_code_id FROM im_registration_invite_uses WHERE tenant_id=? AND user_id=?", id, store.DecodeUid(uid)); lookupErr == nil && existingCodeID == row.ID {
err = nil
} else {
return t.ErrDuplicate
@@ -419,7 +420,7 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
if row.BoundUserID != nil && *row.BoundUserID > 0 {
boundUID := t.ParseUserId(strconv.FormatInt(*row.BoundUserID, 10))
if !boundUID.IsZero() && boundUID != uid {
if err = upsertFriendshipPairTx(tx, uid, boundUID, uid.P2PName(boundUID), now); err != nil {
if err = upsertFriendshipPairTx(tx, id, uid, boundUID, uid.P2PName(boundUID), now); err != nil {
return err
}
}
@@ -430,7 +431,7 @@ func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string,
return a.TenantRegistrationInviteSyncUsage(id, row.ID)
}
func upsertFriendshipPairTx(tx *sqlx.Tx, uid1, uid2 t.Uid, topic string, now time.Time) error {
func upsertFriendshipPairTx(tx *sqlx.Tx, tenantID t.TenantID, uid1, uid2 t.Uid, topic string, now time.Time) error {
if uid1.IsZero() || uid2.IsZero() || uid1 == uid2 {
return t.ErrMalformed
}
@@ -443,9 +444,9 @@ func upsertFriendshipPairTx(tx *sqlx.Tx, uid1, uid2 t.Uid, topic string, now tim
return err
}
if _, err = tx.Exec(
"INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+
fmt.Sprintf("INSERT INTO %s(tenant_id,id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ", tenantFriendshipsTable(tenantID))+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil {
tenantID, friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil {
return err
}
}
@@ -474,7 +475,7 @@ func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64)
LastUsedAt *time.Time `db:"last_used_at"`
}
if err = a.db.GetContext(ctx, &aggregate, `SELECT COUNT(*) AS usage_count,MAX(used_at) AS last_used_at
FROM im_registration_invite_uses WHERE invite_code_id=?`, codeID); err != nil {
FROM im_registration_invite_uses WHERE tenant_id=? AND invite_code_id=?`, id, codeID); err != nil {
return err
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes
@@ -158,7 +158,7 @@ func TestTenantFileAccessIncludesAvatarAudienceRules(t *testing.T) {
}
body := string(source)
for _, want := range []string{
"INNER JOIN im_friendships AS fs",
"INNER JOIN %s AS fs",
"fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?",
"INNER JOIN im_group_members AS gm",
"gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?",
+4 -5
View File
@@ -6,16 +6,15 @@
# Current MySQL initialization is generated by server/db/mysql/adapter.go:
#
# - Control database: hotgo
# - im_tenant
# - im_tenant_config
# - im_tenant (includes tenant feature and policy configuration)
# - im_public_uploads, im_tenant_applications and im_tenant_entry_feedbacks
# for unauthenticated enterprise registration and tenant-entry feedback
#
# - Default tenant business database: tinode
# - Shared tenant business database: meeting_im
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
# - im_http_idempotency for HTTP write idempotency
# - Redis short-state keys for HTTP write idempotency
# - im_file_review_tasks for asynchronous content safety and malware checks
# - business tables do not store tenant_id in independent-database mode
# - business tables store tenant_id; high-volume tables are physically partitioned by tenant
#
# The previous shared-table tenant_id draft has been removed from this file to
# avoid accidental execution. If standalone SQL initialization scripts are
File diff suppressed because it is too large Load Diff
+87 -83
View File
@@ -20,12 +20,16 @@ const (
groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,
member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,
created_by,dissolved_by,dissolved_at,created_at,updated_at`
groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at,
groupAnnouncementSelectColumnsBase = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at,
(SELECT COUNT(*) FROM im_group_announcement_reads AS ar WHERE ar.topic=im_group_announcements.topic AND ar.announcement_id=im_group_announcements.announcement_id) AS read_count,
(SELECT COUNT(*) FROM im_group_members AS gm WHERE gm.topic=im_group_announcements.topic AND gm.member_status='active') AS member_count`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
)
func groupAnnouncementSelectColumns(tenantID t.TenantID) string {
return tenantShardSQL(tenantID, groupAnnouncementSelectColumnsBase)
}
func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
@@ -174,7 +178,7 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
return nil, t.ErrNotFound
}
if patch.MuteAll != nil {
if err = syncGroupMuteAllTx(tx, topic, *patch.MuteAll, time.Now().UTC().Round(time.Millisecond)); err != nil {
if err = syncGroupMuteAllTx(tx, id, topic, *patch.MuteAll, time.Now().UTC().Round(time.Millisecond)); err != nil {
return nil, err
}
}
@@ -214,19 +218,19 @@ func (a *adapter) TenantGroupDissolve(id t.TenantID, topic string, actor t.Uid,
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrNotFound
}
if _, err = tx.ExecContext(ctx, `UPDATE im_subscriptions
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_subscriptions
SET updatedat=?,deletedat=?
WHERE topic IN (?,?) AND deletedat IS NULL`, now, now, strings.TrimSpace(topic), t.GrpToChn(strings.TrimSpace(topic))); err != nil {
WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL`), now, now, id, strings.TrimSpace(topic), t.GrpToChn(strings.TrimSpace(topic))); err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_members
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_group_members
SET member_status=?,left_at=?,removed_by=?,version=version+1,updated_at=?
WHERE topic=? AND member_status=?`, t.GroupMemberStatusLeft, now, decodeUID(actor), now, strings.TrimSpace(topic), t.GroupMemberStatusActive); err != nil {
WHERE tenant_id=? AND topic=? AND member_status=?`), t.GroupMemberStatusLeft, now, decodeUID(actor), now, id, strings.TrimSpace(topic), t.GroupMemberStatusActive); err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_topics
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_topics
SET updatedat=?,touchedat=?,state=?,stateat=?,subcnt=0
WHERE name=?`, now, now, t.StateDeleted, now, strings.TrimSpace(topic)); err != nil {
WHERE tenant_id=? AND name=?`), now, now, t.StateDeleted, now, id, strings.TrimSpace(topic)); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
@@ -270,16 +274,16 @@ func (a *adapter) TenantGroupMemberUpsert(id t.TenantID, member *t.GroupMember)
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_members
(topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
_, err := a.db.ExecContext(ctx, tenantShardSQL(id, `INSERT INTO im_group_members
(tenant_id,topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname),pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source),joined_by=VALUES(joined_by),joined_at=IF(member_status='active',joined_at,VALUES(joined_at)),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned, member.MuteNotifications,
), id, member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned, member.MuteNotifications,
member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version, member.CreatedAt, member.UpdatedAt)
if err != nil {
return nil, err
}
_ = a.recountGroupMembers(ctx, member.Topic)
_ = a.recountGroupMembers(ctx, id, member.Topic)
return a.TenantGroupMemberGet(id, member.Topic, member.UserID)
}
@@ -296,7 +300,7 @@ func (a *adapter) TenantGroupMemberGet(id t.TenantID, topic string, uid t.Uid) (
if cancel != nil {
defer cancel()
}
return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid))
return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT * FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid))
}
func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.GroupMemberListOptions) ([]t.GroupMember, string, error) {
@@ -312,8 +316,8 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
opts.Limit = 30
}
memberIndex := "idx_im_group_members_topic_status"
where := []string{"gm.topic=?", "gm.member_status=?"}
args := []any{topic, t.GroupMemberStatusActive}
where := []string{"gm.tenant_id=?", "gm.topic=?", "gm.member_status=?"}
args := []any{id, topic, t.GroupMemberStatusActive}
if opts.Role != "" {
memberIndex = "idx_im_group_members_topic_role"
where = append(where, "gm.role=?")
@@ -343,14 +347,14 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.trusted,
rows, err := a.db.QueryxContext(ctx, tenantShardSQL(id, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`)
JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
JOIN im_users AS u ON u.id=gm.user_id AND u.state!=?
LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id
WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`, append([]any{t.StateDeleted}, args...)...)
JOIN im_subscriptions AS s ON s.tenant_id=gm.tenant_id AND s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
JOIN im_users AS u ON u.tenant_id=gm.tenant_id AND u.id=gm.user_id AND u.state!=?
LEFT JOIN im_user_profiles AS p ON p.tenant_id=gm.tenant_id AND p.user_id=gm.user_id
WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`), append([]any{t.StateDeleted}, args...)...)
if err != nil {
return nil, "", err
}
@@ -422,8 +426,8 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
set = append(set, "removed_by=?")
args = append(args, nullableUID(*patch.RemovedBy))
}
args = append(args, topic, decodeUID(uid))
where := "topic=? AND user_id=?"
args = append(args, id, topic, decodeUID(uid))
where := "tenant_id=? AND topic=? AND user_id=?"
if patch.Version > 0 {
where += " AND version=?"
args = append(args, patch.Version)
@@ -437,7 +441,7 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
return nil, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...)
res, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where), args...)
if err != nil {
return nil, err
}
@@ -447,13 +451,13 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
}
return nil, t.ErrNotFound
}
if err = recountGroupMembersTx(tx, topic); err != nil {
if err = recountGroupMembersTx(tx, id, topic); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid))
return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT * FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid))
}
func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitation) (*t.GroupInvitation, error) {
@@ -572,8 +576,8 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
return &inv, nil
}
var deletedAt sql.NullTime
err = tx.QueryRowxContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
inv.Topic, decodeUID(uid)).Scan(&deletedAt)
err = tx.QueryRowxContext(ctx, tenantShardSQL(id, "SELECT deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=?"),
id, inv.Topic, decodeUID(uid)).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
@@ -581,20 +585,20 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: inv.Topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, deletedAt.Valid); err != nil {
if err = createTenantSubscription(tx, id, sub, deletedAt.Valid); err != nil {
return nil, err
}
}
member := &t.GroupMember{Topic: inv.Topic, UserID: uid, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inv.InviterUserID, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
if err = upsertGroupMemberTx(tx, id, member); err != nil {
return nil, err
}
if err = recountGroupMembersAndBumpVersionTx(tx, inv.Topic); err != nil {
if err = recountGroupMembersAndBumpVersionTx(tx, id, inv.Topic); err != nil {
return nil, err
}
if err = updateGroupTopicSubCountTx(tx, inv.Topic); err != nil {
if err = updateGroupTopicSubCountTx(tx, id, inv.Topic); err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations
@@ -761,13 +765,13 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
sub := &t.Subscription{TenantID: id, User: current.ApplicantUserID.String(), Topic: topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
if err = createTenantSubscription(tx, id, sub, false); err != nil {
return nil, err
}
member := &t.GroupMember{Topic: topic, UserID: current.ApplicantUserID, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: actor, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
if err = upsertGroupMemberTx(tx, id, member); err != nil {
return nil, err
}
}
@@ -776,10 +780,10 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
return nil, err
}
if action == "accept" {
if err = recountGroupMembersAndBumpVersionTx(tx, topic); err != nil {
if err = recountGroupMembersAndBumpVersionTx(tx, id, topic); err != nil {
return nil, err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
if err = updateGroupTopicSubCountTx(tx, id, topic); err != nil {
return nil, err
}
}
@@ -799,7 +803,7 @@ func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t
if cancel != nil {
defer cancel()
}
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
}
func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementID string) (*t.GroupAnnouncement, error) {
@@ -812,7 +816,7 @@ func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementI
if cancel != nil {
defer cancel()
}
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND announcement_id=? LIMIT 1", topic, announcementID)
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE topic=? AND announcement_id=? LIMIT 1", topic, announcementID)
}
func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) {
@@ -1233,7 +1237,7 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(),
Topic: topic.Id, ModeGiven: t.ModeCFull, ModeWant: topic.GetAccess(owner), Private: ownerPrivate}
ownerSub.InitTimes()
if err = createSubscription(tx, ownerSub, false); err != nil {
if err = createTenantSubscription(tx, id, ownerSub, false); err != nil {
return err
}
settings.MemberCount = 1
@@ -1243,7 +1247,7 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, ownerMember); err != nil {
if err = upsertGroupMemberTx(tx, id, ownerMember); err != nil {
return err
}
seen := map[t.Uid]bool{owner: true}
@@ -1255,20 +1259,20 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: topic.Id,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
if err = createTenantSubscription(tx, id, sub, false); err != nil {
return err
}
member := &t.GroupMember{Topic: topic.Id, UserID: uid, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
if err = upsertGroupMemberTx(tx, id, member); err != nil {
return err
}
}
if err = recountGroupMembersTx(tx, topic.Id); err != nil {
if err = recountGroupMembersTx(tx, id, topic.Id); err != nil {
return err
}
if err = updateGroupTopicSubCountTx(tx, topic.Id); err != nil {
if err = updateGroupTopicSubCountTx(tx, id, topic.Id); err != nil {
return err
}
return tx.Commit()
@@ -1291,7 +1295,7 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
}
defer tx.Rollback()
var deletedAt sql.NullTime
err = tx.QueryRowContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=? FOR UPDATE", topic, decodeUID(target)).Scan(&deletedAt)
err = tx.QueryRowContext(ctx, tenantShardSQL(id, "SELECT deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=? FOR UPDATE"), id, topic, decodeUID(target)).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return false, err
}
@@ -1301,7 +1305,7 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
sub := &t.Subscription{TenantID: id, User: target.String(), Topic: topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: private}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
if err = createTenantSubscription(tx, id, sub, false); err != nil {
return false, err
}
invitationID := "ginv_" + store.Store.GetUidString()
@@ -1319,13 +1323,13 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
member := &t.GroupMember{Topic: topic, UserID: target, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inviter, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
if err = upsertGroupMemberTx(tx, id, member); err != nil {
return false, err
}
if err = recountGroupMembersAndBumpVersionTx(tx, topic); err != nil {
if err = recountGroupMembersAndBumpVersionTx(tx, id, topic); err != nil {
return false, err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
if err = updateGroupTopicSubCountTx(tx, id, topic); err != nil {
return false, err
}
if err = tx.Commit(); err != nil {
@@ -1349,21 +1353,21 @@ func (a *adapter) TenantGroupLeave(id t.TenantID, topic string, uid t.Uid, now t
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decodeUID(uid))
res, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"), now, now, id, topic, decodeUID(uid))
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=?
WHERE topic=? AND user_id=? AND member_status=?`, t.GroupMemberStatusLeft, now, now, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil {
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=?
WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=?`), t.GroupMemberStatusLeft, now, now, id, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil {
return err
}
if err = recountGroupMembersAndBumpVersionTx(tx, topic); err != nil {
if err = recountGroupMembersAndBumpVersionTx(tx, id, topic); err != nil {
return err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
if err = updateGroupTopicSubCountTx(tx, id, topic); err != nil {
return err
}
return tx.Commit()
@@ -1385,16 +1389,16 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target))
res, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"),
modeGiven.String(), modeGiven.String(), now, id, topic, decodeUID(target))
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
res, err = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
role, now, topic, decodeUID(target), t.GroupMemberStatusActive)
res, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=?"),
role, now, id, topic, decodeUID(target), t.GroupMemberStatusActive)
if err != nil {
return err
}
@@ -1430,16 +1434,16 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
role string
}{{previousOwner, groupAdminAccessMode(), t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid))
res, execErr := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"),
update.mode.String(), update.mode.String(), now, id, topic, decodeUID(update.uid))
if execErr != nil {
return execErr
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
res, execErr = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
update.role, now, topic, decodeUID(update.uid), t.GroupMemberStatusActive)
res, execErr = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=?"),
update.role, now, id, topic, decodeUID(update.uid), t.GroupMemberStatusActive)
if execErr != nil {
return execErr
}
@@ -1447,7 +1451,7 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
return t.ErrNotFound
}
}
if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET owner=?,updatedat=? WHERE name=?", decodeUID(newOwner), now, topic); err != nil {
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET owner=?,updatedat=? WHERE tenant_id=? AND name=?"), decodeUID(newOwner), now, id, topic); err != nil {
return err
}
args := []any{decodeUID(newOwner), now, topic}
@@ -1504,48 +1508,48 @@ func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error {
return err
}
func upsertGroupMemberTx(tx *sqlx.Tx, member *t.GroupMember) error {
_, err := tx.Exec(`INSERT INTO im_group_members
(topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
func upsertGroupMemberTx(tx *sqlx.Tx, tenantID t.TenantID, member *t.GroupMember) error {
_, err := tx.Exec(tenantShardSQL(tenantID, `INSERT INTO im_group_members
(tenant_id,topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname),
pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source),
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned,
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`),
tenantID, member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned,
member.MuteNotifications, member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version,
member.CreatedAt, member.UpdatedAt)
return err
}
func recountGroupMembersTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic)
func recountGroupMembersTx(tx *sqlx.Tx, tenantID t.TenantID, topic string) error {
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
WHERE tenant_id=? AND topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND topic=?`), tenantID, topic, t.GroupMemberStatusActive, tenantID, topic)
return err
}
func recountGroupMembersAndBumpVersionTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
WHERE topic=? AND member_status=?),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic)
func recountGroupMembersAndBumpVersionTx(tx *sqlx.Tx, tenantID t.TenantID, topic string) error {
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
WHERE tenant_id=? AND topic=? AND member_status=?),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND topic=?`), tenantID, topic, t.GroupMemberStatusActive, tenantID, topic)
return err
}
func updateGroupTopicSubCountTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions
WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?`, topic, t.GrpToChn(topic), topic)
func updateGroupTopicSubCountTx(tx *sqlx.Tx, tenantID t.TenantID, topic string) error {
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions
WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?`), tenantID, topic, t.GrpToChn(topic), tenantID, topic)
return err
}
func syncGroupMuteAllTx(tx *sqlx.Tx, topic string, muteAll bool, now time.Time) error {
func syncGroupMuteAllTx(tx *sqlx.Tx, tenantID t.TenantID, topic string, muteAll bool, now time.Time) error {
mode := t.ModeCPublic
if muteAll {
mode = t.ModeCPublic &^ t.ModeWrite
}
_, err := tx.Exec(`UPDATE im_subscriptions AS s
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_subscriptions AS s
JOIN im_group_members AS gm ON gm.topic=s.topic AND gm.user_id=s.userid
SET s.modewant=?,s.modegiven=?,s.updatedat=?,
gm.version=gm.version+1,gm.updated_at=?
WHERE s.topic=? AND s.deletedat IS NULL
AND gm.member_status=? AND gm.role=?`,
AND gm.member_status=? AND gm.role=?`),
mode.String(), mode.String(), now, now, topic, t.GroupMemberStatusActive, t.GroupMemberRoleMember)
return err
}
@@ -1656,8 +1660,8 @@ func (a *adapter) groupFileByQuery(ctx context.Context, query string, args ...an
return nil, t.ErrNotFound
}
func (a *adapter) recountGroupMembers(ctx context.Context, topic string) error {
_, err := a.db.ExecContext(ctx, "UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?", topic, t.GroupMemberStatusActive, topic)
func (a *adapter) recountGroupMembers(ctx context.Context, tenantID t.TenantID, topic string) error {
_, err := a.db.ExecContext(ctx, tenantShardSQL(tenantID, "UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE tenant_id=? AND topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND topic=?"), tenantID, topic, t.GroupMemberStatusActive, tenantID, topic)
return err
}
+8 -284
View File
@@ -4,15 +4,12 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
@@ -26,213 +23,6 @@ func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) {
return a, nil
}
func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, false, err
}
if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" {
return nil, false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyBegin(id, record)
}
now := t.TimeNow()
if record.ExpiresAt.IsZero() {
record.ExpiresAt = now.Add(24 * time.Hour)
}
_, err = a.db.Exec(`INSERT INTO im_http_idempotency
(user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, decodeUID(record.UserID), record.Method, record.Route, record.Key,
record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now)
if err == nil {
record.State = t.HTTPIdempotencyProcessing
record.CreatedAt = now
record.UpdatedAt = now
return record, true, nil
}
if !isDupe(err) {
return nil, false, err
}
existing, err := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
if err == nil && !existing.ExpiresAt.After(now) {
res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, now)
if deleteErr != nil {
return nil, false, deleteErr
}
if deleted, _ := res.RowsAffected(); deleted > 0 {
return a.TenantHTTPIdempotencyBegin(id, record)
}
current, getErr := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
return current, false, getErr
}
return existing, false, err
}
func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyComplete(id, record)
}
now := t.TimeNow()
res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=?
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, decodeUID(record.UserID),
record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyAbort(id, record)
}
_, err = a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
return err
}
func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) {
row := struct {
UserID int64 `db:"user_id"`
Method string
Route string
Key string `db:"idempotency_key"`
BodyHash string `db:"body_hash"`
State string
ResponseStatus int `db:"response_status"`
ResponseBody []byte `db:"response_body"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ExpiresAt time.Time `db:"expires_at"`
}{}
err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at
FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=?`, decodeUID(uid), method, route, key)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route,
Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus,
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, 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 {
@@ -244,12 +34,7 @@ func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter,
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
if _, err = a.db.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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
@@ -259,13 +44,7 @@ func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter,
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()
return nil
}
func (a *adapter) TenantKafkaDeadLetterList(id t.TenantID, opts t.KafkaDeadLetterListOptions) (*t.KafkaDeadLetterPage, error) {
@@ -389,22 +168,7 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
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=?,
res, err := a.db.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)
@@ -414,13 +178,6 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
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)
}
@@ -468,39 +225,6 @@ func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
}, 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 ""
@@ -512,16 +236,16 @@ func truncateKafkaString(value string, limit int) string {
return string(runes[:limit])
}
func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error {
func insertSystemNotificationTx(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, eventID, eventType, title, summary string, action any) error {
encoded, err := json.Marshal(action)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_system_notifications
(user_id,event_id,event_type,title,summary,action,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title),
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, decodeUID(uid), eventID,
_, err = tx.Exec(tenantShardSQL(tenantID, `INSERT INTO im_system_notifications
(tenant_id,user_id,event_id,event_type,title,summary,action,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title),
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`), tenantID, decodeUID(uid), eventID,
eventType, title, summary, encoded, now, now)
return err
}
+5 -18
View File
@@ -68,21 +68,7 @@ func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureE
if tenantAdp != a {
return tenantAdp.TenantLoginFailureCreate(id, event)
}
metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
var userID any
if !event.UserID.IsZero() {
userID = decodeUID(event.UserID)
}
_, err = a.db.Exec(`INSERT INTO im_login_failure_logs
(user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail,
event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent,
event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata))
return err
return nil
}
func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error {
@@ -134,7 +120,7 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
if action.ConversationType == "group" {
_, err = tx.ExecContext(ctx, "UPDATE "+messageTable+" SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
} else {
_, err = tx.ExecContext(ctx, "UPDATE im_messages SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
_, err = tx.ExecContext(ctx, "UPDATE "+messageTable+" SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
}
if err != nil {
return err
@@ -177,7 +163,8 @@ func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID, con
DeletedAt sql.NullTime `db:"deletedat"`
RecalledAt sql.NullTime `db:"recalled_at"`
}
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM im_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
messageTable := tenantMessagesTable(tenantID)
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM "+messageTable+" WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", "", t.ErrNotFound
}
@@ -188,7 +175,7 @@ func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID, con
if row.DeletedAt.Valid {
status = "deleted"
}
return row.Sender, status, "im_messages", err
return row.Sender, status, messageTable, err
}
func nullableJSON(raw []byte) any {
+65
View File
@@ -0,0 +1,65 @@
//go:build mysql
// +build mysql
package mysql
import (
"fmt"
"strings"
t "github.com/tinode/chat/server/store/types"
)
func tenantShardSQL(tenantID t.TenantID, query string) string {
query = strings.ReplaceAll(query, "im_topics", tenantTopicsTable(tenantID))
query = strings.ReplaceAll(query, "im_group_members", tenantGroupMembersTable(tenantID))
query = strings.ReplaceAll(query, "im_subscriptions", tenantSubscriptionsTable(tenantID))
return strings.ReplaceAll(query, "im_system_notifications", tenantSystemNotificationsTable(tenantID))
}
func tenantTablePrefix(tenantID t.TenantID) string {
return fmt.Sprintf("im_%03d_", tenantID)
}
func tenantFriendshipsTable(tenantID t.TenantID) string {
return tenantTablePrefix(tenantID) + "friendships"
}
func tenantMessagesTable(tenantID t.TenantID) string {
return tenantTablePrefix(tenantID) + "messages"
}
func tenantTopicsTable(tenantID t.TenantID) string {
return tenantTablePrefix(tenantID) + "topics"
}
func tenantGroupMembersTable(tenantID t.TenantID) string {
return tenantTablePrefix(tenantID) + "group_members"
}
func tenantSubscriptionsTable(tenantID t.TenantID) string {
return tenantTablePrefix(tenantID) + "subscriptions"
}
func tenantSystemNotificationsTable(tenantID t.TenantID) string {
return tenantTablePrefix(tenantID) + "system_notifications"
}
func tenantGroupMessagesTable(tenantID t.TenantID, month string) (string, bool) {
if !validShardMonth(month) {
return "", false
}
return fmt.Sprintf("%sgroup_messages_%s", tenantTablePrefix(tenantID), month), true
}
func validShardMonth(month string) bool {
if len(month) != 6 {
return false
}
for i := range month {
if month[i] < '0' || month[i] > '9' {
return false
}
}
return month[4:] >= "01" && month[4:] <= "12"
}
+79 -11
View File
@@ -61,7 +61,7 @@ func TestGroupBusinessTablesCreated(t *testing.T) {
tables := []string{
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
"im_group_files", "im_group_admin_logs", "im_group_idempotency",
"im_group_files", "im_group_admin_logs",
}
args := make([]any, len(tables))
for i, table := range tables {
@@ -84,8 +84,8 @@ func TestGroupBusinessTablesCreated(t *testing.T) {
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("tenant business tables contain %d tenant_id columns", count)
if count != len(tables) {
t.Fatalf("tenant business tables with tenant_id: got %d, want %d", count, len(tables))
}
}
@@ -103,8 +103,8 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) {
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("HTTP infrastructure table count: got %d, want %d", count, len(tables))
if count != 0 {
t.Fatalf("HTTP idempotency must not create MySQL table: got %d", count)
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
@@ -113,7 +113,7 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("HTTP infrastructure tables contain %d tenant_id columns", count)
t.Fatalf("HTTP idempotency tenant_id columns should not exist: got %d", count)
}
}
@@ -263,13 +263,13 @@ func TestTenantAuthIsolation(t *testing.T) {
}
}
}
createTenant := func(code, dbName string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,db_name,state,created_by,updated_by) VALUES(?,?,?,?,?,?)",
code+suffix, code, dbName, types.TenantStateActive, 0, 0)
if err != nil {
createTenant := func(code, _ string) types.TenantID {
var id int64
if err := db.Get(&id, "SELECT COALESCE(MAX(id),0)+1 FROM im_tenant"); err != nil {
t.Fatal(err)
}
id, err := res.LastInsertId()
_, err := db.Exec("INSERT INTO im_tenant(id,code,name,state,created_by,updated_by) VALUES(?,?,?,?,?,?)",
id, code+suffix, code, types.TenantStateActive, 0, 0)
if err != nil {
t.Fatal(err)
}
@@ -644,6 +644,74 @@ func TestTopicCreateP2P(t *testing.T) {
}
}
func TestTenantTopicCreateP2PWritesTenantSubscriptions(t *testing.T) {
tenantAdapter, ok := adp.(adapter.TenantBusinessAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantBusinessAdapter")
}
var tenantID types.TenantID
if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil {
t.Fatal(err)
}
var tenantColumns int
if err := db.Get(&tenantColumns, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_subscriptions' AND column_name='tenant_id'`); err != nil {
t.Fatal(err)
}
if tenantColumns == 0 {
t.Skip("im_subscriptions.tenant_id is missing in the current test database")
}
first := types.ParseUserId("usr" + testData.Users[0].Id)
second := types.ParseUserId("usr" + testData.Users[2].Id)
topic := first.P2PName(second)
if _, err := db.Exec("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("DELETE FROM im_topics WHERE tenant_id=? AND name=?", tenantID, topic); err != nil {
t.Fatal(err)
}
now := types.TimeNow()
one := &types.Subscription{
TenantID: tenantID,
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: first.String(),
Topic: topic,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}
one.SetTouchedAt(now)
two := &types.Subscription{
TenantID: tenantID,
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: second.String(),
Topic: topic,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}
two.SetTouchedAt(now)
if err := tenantAdapter.TenantTopicCreateP2P(tenantID, one, two); err != nil {
t.Fatal(err)
}
var count int
if err := db.Get(&count, "SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("tenant P2P subscriptions: got %d, want 2", count)
}
subs, err := tenantAdapter.TenantUsersForTopic(tenantID, topic, false, nil)
if err != nil {
t.Fatal(err)
}
if len(subs) != 2 {
t.Fatalf("TenantUsersForTopic returned %d subscriptions, want 2", len(subs))
}
}
func TestTopicShare(t *testing.T) {
if err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {
t.Fatal(err)
+61 -26
View File
@@ -31,26 +31,6 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
}
defer conn.Close()
// Fast path: the last two steps of this migration drop the legacy im_usertags
// table and the im_users.tags column. If both are already gone, the migration
// has fully run (or the database was created fresh) and no lock is needed.
stale := false
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
return err
} else if exists {
stale = true
}
if !stale {
if exists, err := identityColumnExists(ctx, conn, "im_users", "tags"); err != nil {
return err
} else if exists {
stale = true
}
}
if !stale {
return nil
}
// PXC strict-mode clusters prohibit GET_LOCK; acquireMigrationLock falls back
// to lock-free execution in that case. The migration body is idempotent.
_, unlock, err := acquireMigrationLock(ctx, conn, userIdentityMigrationLock)
@@ -112,7 +92,7 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return err
}
}
if err = ensureUserAccountNoSchema(ctx, conn); err != nil {
if err = ensureUserAccountNoSchema(ctx, conn, usersScoped); err != nil {
return err
}
if profilesExist {
@@ -173,6 +153,15 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return fmt.Errorf("create unique phone index: %w", err)
}
}
if usersScoped {
if exists, err := identityIndexExists(ctx, conn, "im_users", "users_phone"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP INDEX users_phone"); err != nil {
return err
}
}
}
usernameIndex := "users_username"
usernameColumns := "username"
if usersScoped {
@@ -186,10 +175,19 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return fmt.Errorf("create unique username index: %w", err)
}
}
if usersScoped {
if exists, err := identityIndexExists(ctx, conn, "im_users", "users_username"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP INDEX users_username"); err != nil {
return err
}
}
}
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil {
return err
}
if err = ensureUserAccountSequence(ctx, conn); err != nil {
if err = ensureUserAccountSequence(ctx, conn, usersScoped); err != nil {
return err
}
if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil {
@@ -213,11 +211,24 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return nil
}
func ensureUserAccountSequence(ctx context.Context, conn *sqlx.Conn) error {
func ensureUserAccountSequence(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
exists, err := identityColumnExists(ctx, conn, "im_users", "account_no")
if err != nil || !exists {
return err
}
if scoped {
if _, err = conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS im_user_account_sequences(
tenant_id BIGINT NOT NULL,id TINYINT UNSIGNED NOT NULL,next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id,id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil {
return err
}
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_account_sequences(tenant_id,id,next_value)
SELECT tenant_id,1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users
WHERE tenant_id>0 GROUP BY tenant_id
ON DUPLICATE KEY UPDATE next_value=GREATEST(next_value,VALUES(next_value))`)
return err
}
if _, err = conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS im_user_account_sequences(
id TINYINT UNSIGNED NOT NULL,next_value INT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -363,6 +374,15 @@ func migrateBasicAuthSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) e
}
}
}
if scoped {
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid"); err != nil {
return err
}
}
}
return nil
}
@@ -546,7 +566,7 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho
return out, nil
}
func ensureUserAccountNoSchema(ctx context.Context, conn *sqlx.Conn) error {
func ensureUserAccountNoSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
if exists, err := identityColumnExists(ctx, conn, "im_users", "account_no"); err != nil {
return err
} else if !exists {
@@ -569,13 +589,28 @@ func ensureUserAccountNoSchema(ctx context.Context, conn *sqlx.Conn) error {
WHERE u.account_no=0`, start); err != nil {
return err
}
if exists, err := identityIndexExists(ctx, conn, "im_users", "uk_im_users_account_no"); err != nil {
indexName := "uk_im_users_account_no"
columns := "account_no"
if scoped {
indexName = "uk_im_users_tenant_account_no"
columns = "tenant_id,account_no"
}
if exists, err := identityIndexExists(ctx, conn, "im_users", indexName); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX uk_im_users_account_no ON im_users(account_no)"); err != nil {
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_users("+columns+")"); err != nil {
return fmt.Errorf("create unique user account number index: %w", err)
}
}
if scoped {
if exists, err := identityIndexExists(ctx, conn, "im_users", "uk_im_users_account_no"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP INDEX uk_im_users_account_no"); err != nil {
return err
}
}
}
return nil
}
+201 -26
View File
@@ -52,6 +52,7 @@ const (
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
imReasonIdempotencyService = "IDEMPOTENCY_UNAVAILABLE"
imReasonInternal = "INTERNAL_ERROR"
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
imReasonFileUnavailable = "FILE_UNAVAILABLE"
@@ -63,6 +64,81 @@ const (
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
)
const (
imDefaultTenantCode = "default"
registrationTypeEnterprise = 0
registrationTypePersonal = 1
)
const (
imHTTPIdempotencyTTL = 24 * time.Hour
imHTTPIdempotencyNamespace = "http_idempotency"
)
type imHTTPIdempotencyReplay struct {
BodyHash string `json:"body_hash"`
ResponseStatus int `json:"response_status"`
ResponseBody []byte `json:"response_body"`
}
var errIMHTTPIdempotencyConflict = errors.New("http idempotency conflict")
var (
beginIMHTTPIdempotency = redisBeginIMHTTPIdempotency
completeIMHTTPIdempotency = redisCompleteIMHTTPIdempotency
abortIMHTTPIdempotency = redisAbortIMHTTPIdempotency
)
func imHTTPIdempotencyID(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
return strconv.FormatInt(int64(tenantID), 10) + ":" + uid.String() + ":" + method + ":" + route + ":" + key
}
func redisBeginIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
claimed, sameValue, enabled, err := store.ClaimShortStateValue(imHTTPIdempotencyNamespace, id, bodyHash, imHTTPIdempotencyTTL)
if err != nil || !enabled || claimed {
return nil, claimed, enabled, err
}
if !sameValue {
return nil, false, true, errIMHTTPIdempotencyConflict
}
raw, found, enabled, err := store.GetShortStateValue(imHTTPIdempotencyNamespace, id+":response")
if err != nil || !enabled || !found {
return nil, false, enabled, err
}
var replay imHTTPIdempotencyReplay
if err = json.Unmarshal([]byte(raw), &replay); err != nil {
_, _ = store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
return nil, false, true, err
}
if replay.BodyHash != bodyHash {
return nil, false, true, nil
}
return &replay, false, true, nil
}
func redisCompleteIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
payload, err := json.Marshal(imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: body})
if err != nil {
return err
}
enabled, err := store.SetShortStateValue(imHTTPIdempotencyNamespace, id+":response", string(payload), imHTTPIdempotencyTTL)
if !enabled {
return errors.New("http idempotency redis unavailable")
}
return err
}
func redisAbortIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
if _, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id); err != nil {
return err
}
_, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
return err
}
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
text = strings.TrimSpace(text)
if text == "" {
@@ -226,43 +302,54 @@ func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http
req.Body = io.NopCloser(bytes.NewReader(body))
hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...)
bodyHash := sha256.Sum256(hashInput)
record := &types.HTTPIdempotencyRecord{
UserID: principal.Uid, Method: req.Method, Route: req.URL.Path, Key: key,
BodyHash: fmt.Sprintf("%x", bodyHash[:]), ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !acquired {
if existing == nil || existing.BodyHash != record.BodyHash {
bodyHashText := fmt.Sprintf("%x", bodyHash[:])
existing, acquired, enabled, err := beginIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText)
if errors.Is(err, errIMHTTPIdempotencyConflict) {
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
return
}
if existing.State == types.HTTPIdempotencyCompleted {
if err != nil {
logs.Warn.Printf("im http redis idempotency begin failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, err)
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
return
}
if !enabled {
logs.Warn.Printf("im http redis idempotency unavailable request_id=%s tenant=%d user=%s method=%s route=%s",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path)
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
return
}
if !acquired {
if existing == nil {
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
return
}
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.Header().Set("X-Idempotent-Replay", "true")
wrt.WriteHeader(existing.ResponseStatus)
_, _ = wrt.Write(existing.ResponseBody)
return
}
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
return
}
buffered := newIMBufferedResponseWriter()
next.ServeHTTP(buffered, req)
record.ResponseStatus = buffered.status
record.ResponseBody = append([]byte(nil), buffered.body.Bytes()...)
if buffered.status >= http.StatusInternalServerError {
_ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record)
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
logs.Warn.Printf("im http redis idempotency abort failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
}
buffered.flushTo(wrt)
return
}
if err = store.HTTPInfrastructure.CompleteIdempotency(principal.TenantID, record); err != nil {
_ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
if err = completeIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText, buffered.status, append([]byte(nil), buffered.body.Bytes()...)); err != nil {
logs.Warn.Printf("im http redis idempotency complete failed request_id=%s tenant=%d user=%s method=%s route=%s status=%d err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, buffered.status, err)
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
logs.Warn.Printf("im http redis idempotency abort after complete failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
}
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
return
}
buffered.flushTo(wrt)
@@ -292,6 +379,7 @@ type imTenantEntryFeedbackRequest struct {
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
RegistrationType *int `json:"registration_type,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
@@ -979,7 +1067,13 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
var tenant *types.Tenant
var ok bool
if scene == "register" {
tenant, ok = resolveIMHTTPRegistrationTenant(wrt, req, body)
} else {
tenant, ok = resolveIMHTTPTenant(wrt, req, body.TenantCode)
}
if !ok {
return
}
@@ -2050,9 +2144,22 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
}
release, err := store.AppReleases.Latest(platform, channel)
if err != nil {
if platform == "web" {
logs.Warn.Println("im app release latest: using web fallback", err)
release = &types.AppRelease{
Platform: platform,
Channel: channel,
LatestVersion: currentVersion,
MinSupportedVersion: currentVersion,
UpdateType: "none",
ReleaseNotes: "当前已是最新版本。",
PublishedAt: time.Now().UTC(),
}
} else {
writeIMRelationError(wrt, req, err)
return
}
}
latestParts, latestErr := parseNumericAppVersion(release.LatestVersion)
minimumParts, minimumErr := parseNumericAppVersion(release.MinSupportedVersion)
if latestErr != nil || minimumErr != nil {
@@ -2376,7 +2483,8 @@ func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
}
readAt = parsed.UTC().Round(time.Millisecond)
}
if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil {
notification, err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
return
@@ -2384,7 +2492,9 @@ func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)})
payload := systemNotificationPayload(*notification)
payload["status"] = "ok"
writeIMSuccess(wrt, req, payload)
}
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
@@ -7942,7 +8052,7 @@ func favoritePayload(item types.FavoriteItem) map[string]any {
"favorite_id": item.FavoriteID,
"type": item.ItemType,
"snapshot": item.Snapshot,
"source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq},
"source": favoriteSourcePayload(item.SourceTopic, item.SourceSeq, types.ZeroUid),
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
"availability": item.Availability,
}
@@ -7979,7 +8089,27 @@ func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem
item.Availability = "removed"
}
}
return favoritePayload(item), true
return favoritePayloadForViewer(principal.Uid, item), true
}
func favoritePayloadForViewer(viewer types.Uid, item types.FavoriteItem) map[string]any {
payload := favoritePayload(item)
payload["source"] = favoriteSourcePayload(item.SourceTopic, item.SourceSeq, viewer)
return payload
}
func favoriteSourcePayload(topic string, seq int64, viewer types.Uid) map[string]any {
source := map[string]any{"topic": topic, "seq": seq}
if viewer.IsZero() || types.GetTopicCat(topic) != types.TopicCatP2P {
return source
}
peerTopic, err := types.P2PNameForUser(viewer, topic)
if err != nil || strings.TrimSpace(peerTopic) == "" {
return source
}
source["peer_user_id"] = peerTopic
source["display_topic"] = peerTopic
return source
}
func legacyFavoriteSnapshotOnly(item types.FavoriteItem) bool {
@@ -9891,6 +10021,51 @@ func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCod
return tenant, true
}
func resolveIMHTTPRegistrationTenant(wrt http.ResponseWriter, req *http.Request, body imVerificationCreateRequest) (*types.Tenant, bool) {
if body.RegistrationType == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
return nil, false
}
headerCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
bodyCode := strings.TrimSpace(body.TenantCode)
var tenantCode string
switch *body.RegistrationType {
case registrationTypePersonal:
if bodyCode != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if headerCode != "" && !strings.EqualFold(headerCode, imDefaultTenantCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
tenantCode = imDefaultTenantCode
case registrationTypeEnterprise:
if bodyCode == "" {
bodyCode = headerCode
} else if headerCode != "" && !strings.EqualFold(headerCode, bodyCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if bodyCode == "" || len(bodyCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if strings.EqualFold(bodyCode, imDefaultTenantCode) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
tenantCode = bodyCode
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
return nil, false
}
return resolveIMHTTPTenant(wrt, req, tenantCode)
}
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
Code: 0,
+152 -48
View File
@@ -41,7 +41,6 @@ type testVerificationStore struct {
}
type testHTTPInfrastructureStore struct {
records map[string]*types.HTTPIdempotencyRecord
deadLetters []types.KafkaDeadLetter
}
@@ -104,39 +103,6 @@ func (h *testPublicMediaHandler) UploadPublic(def *media.PublicFileDef, file io.
return def.Location, int64(len(data)), nil
}
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
if s.records == nil {
s.records = make(map[string]*types.HTTPIdempotencyRecord)
}
key := s.recordKey(record)
if existing := s.records[key]; existing != nil {
copy := *existing
copy.ResponseBody = append([]byte(nil), existing.ResponseBody...)
return &copy, false, nil
}
copy := *record
copy.State = types.HTTPIdempotencyProcessing
s.records[key] = &copy
return &copy, true, nil
}
func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
copy := *record
copy.State = types.HTTPIdempotencyCompleted
copy.ResponseBody = append([]byte(nil), record.ResponseBody...)
s.records[s.recordKey(record)] = &copy
return nil
}
func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
delete(s.records, s.recordKey(record))
return nil
}
func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record *types.KafkaConsumeRecord, _ time.Time) (*types.KafkaConsumeRecord, bool, error) {
copy := *record
copy.Status = types.KafkaConsumeProcessing
@@ -236,7 +202,7 @@ func (s testRealNameConfigStore) Policy(tenantID types.TenantID) (*types.RealNam
return nil, s.err
}
if s.policy == nil {
return &types.RealNamePolicy{TenantID: tenantID, ReviewPolicy: types.RealNameReviewPolicyNone, ProviderMode: "id_card_2factor", AllowSkip: true, RemindAfter: "24h"}, nil
return &types.RealNamePolicy{TenantID: tenantID, ReviewPolicy: types.RealNameReviewPolicyNone}, nil
}
policy := *s.policy
policy.TenantID = tenantID
@@ -265,6 +231,7 @@ type testProfilesStore struct {
type testAppReleaseStore struct {
release *types.AppRelease
err error
}
type testDevicePushTokenStore struct {
@@ -691,8 +658,8 @@ func (s *testUserSecurityStore) CountUnreadSystemNotificationsByEventPrefix(_ ty
return s.unreadByPrefix[prefix], nil
}
func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, _ string, _ time.Time) error {
return nil
func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
return &types.SystemNotification{EventID: eventID, EventType: "system.notice", Title: "通知", Summary: "通知", ReadAt: &readAt, CreatedAt: readAt}, nil
}
func (s *testProfilesStore) Get(_ types.TenantID, uid types.Uid) (*types.UserProfile, error) {
@@ -756,6 +723,9 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
}
func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) {
if s.err != nil {
return nil, s.err
}
if s.release == nil {
now := time.Now().UTC().Round(time.Millisecond)
s.release = &types.AppRelease{
@@ -1225,10 +1195,45 @@ func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldInfrastructure := store.HTTPInfrastructure
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
defer func() { store.HTTPInfrastructure = oldInfrastructure }()
oldBegin, oldComplete, oldAbort := beginIMHTTPIdempotency, completeIMHTTPIdempotency, abortIMHTTPIdempotency
type cachedResponse struct {
bodyHash string
replay *imHTTPIdempotencyReplay
}
records := map[string]*cachedResponse{}
recordKey := func(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
return imHTTPIdempotencyID(tenantID, uid, method, route, key)
}
beginIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
id := recordKey(tenantID, uid, method, route, key)
if existing := records[id]; existing != nil {
if existing.bodyHash != bodyHash {
return nil, false, true, errIMHTTPIdempotencyConflict
}
if existing.replay == nil {
return nil, false, true, nil
}
copy := *existing.replay
copy.ResponseBody = append([]byte(nil), existing.replay.ResponseBody...)
return &copy, false, true, nil
}
records[id] = &cachedResponse{bodyHash: bodyHash}
return nil, true, true, nil
}
completeIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
records[recordKey(tenantID, uid, method, route, key)] = &cachedResponse{
bodyHash: bodyHash,
replay: &imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: append([]byte(nil), body...)},
}
return nil
}
abortIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
delete(records, recordKey(tenantID, uid, method, route, key))
return nil
}
defer func() {
beginIMHTTPIdempotency, completeIMHTTPIdempotency, abortIMHTTPIdempotency = oldBegin, oldComplete, oldAbort
}()
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
@@ -1736,7 +1741,7 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -1786,7 +1791,7 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -1798,6 +1803,55 @@ func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *tes
}
}
func TestServeIMVerificationCodesPersonalUsesDefaultTenant(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 1, Code: imDefaultTenantCode, State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
t.Fatal("registration SMS sender must not be called for fixed-code registration")
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","registration_type":1,"phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK || verifications.created == nil {
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
}
if verifications.created.TenantID != 1 {
t.Fatalf("tenant: got %d, want 1", verifications.created.TenantID)
}
}
func TestServeIMVerificationCodesRejectsEnterpriseDefaultTenant(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"default","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMVerificationCodes(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 != imReasonTenantUnavailable {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
}
}
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -1883,7 +1937,7 @@ func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testin
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
@@ -1922,7 +1976,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -2129,9 +2183,6 @@ func TestServeIMRealNameSubmissionAutoReviewFallsBackToManual(t *testing.T) {
store.Security = security
store.RealNameConfig = testRealNameConfigStore{policy: &types.RealNamePolicy{
ReviewPolicy: types.RealNameReviewPolicyAutoManual,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}}
store.ThirdPartyConfig = testThirdPartyConfigStore{}
defer func() {
@@ -2693,6 +2744,26 @@ func TestServeIMAppReleaseLatestSupportsWeb(t *testing.T) {
}
}
func TestServeIMAppReleaseLatestFallsBackForWebConfigError(t *testing.T) {
oldReleases := store.AppReleases
store.AppReleases = &testAppReleaseStore{err: types.ErrUnsupported}
defer func() {
store.AppReleases = oldReleases
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=web&current_version=1.0.0", nil)
rec := httptest.NewRecorder()
serveIMAppReleaseLatest(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["platform"] != "web" || data["latest_version"] != "1.0.0" || data["update_type"] != "none" || data["has_update"] != false {
t.Fatalf("unexpected fallback release payload: %#v", data)
}
}
func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) {
oldReleases := store.AppReleases
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
@@ -3015,6 +3086,26 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
if security.readTenant != 7 || security.readUser != 42 || security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
t.Fatalf("notification was not marked read: %#v", security)
}
readData := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if readData["id"] != "evt-1" || readData["status"] != "ok" || readData["read_at"] != security.notifications[0].ReadAt.UTC().Format(time.RFC3339Nano) {
t.Fatalf("read response did not return persisted notification state: %#v", readData)
}
expectIMBearer(t, ctrl, "token-read-again", 7, 42)
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-2/read", bytes.NewBufferString(`{"read_at":"2026-09-01T00:00:00Z"}`))
req.Header.Set("Authorization", "Bearer token-read-again")
req.Header.Set("Idempotency-Key", "read-2")
rec = httptest.NewRecorder()
serveIMSystemNotificationRead(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("read again status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
readAgainData := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if readAgainData["id"] != "evt-2" || readAgainData["read_at"] != readAt.UTC().Format(time.RFC3339Nano) {
t.Fatalf("read again response should keep persisted read_at: %#v", readAgainData)
}
}
func TestServeIMMessageReceipts(t *testing.T) {
@@ -5141,6 +5232,19 @@ func TestServeIMFavoriteBatchCreateNormalizesP2PUserTopic(t *testing.T) {
}
}
func TestFavoriteSourcePayloadIncludesP2PPeer(t *testing.T) {
viewer := types.Uid(42)
peer := types.Uid(43)
topic := viewer.P2PName(peer)
source := favoriteSourcePayload(topic, 17, viewer)
if source["topic"] != topic || source["seq"] != int64(17) {
t.Fatalf("favorite source should keep stored topic and seq: %#v", source)
}
if source["peer_user_id"] != peer.UserId() || source["display_topic"] != peer.UserId() {
t.Fatalf("favorite source should expose addressable p2p peer: %#v", source)
}
}
func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+1 -8
View File
@@ -68,11 +68,6 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
},
"login-failure-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,account_hash,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
"audit-logs": {
Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent,
JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`,
@@ -439,9 +434,7 @@ func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
selects = append(selects, `SELECT id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user,
msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status,
content,head,reply_seqid,mentioned_user_ids,created_at,updated_at
FROM im_group_messages AS legacy WHERE NOT EXISTS (
SELECT 1 FROM im_group_message_topic_shards AS route
WHERE route.topic=legacy.topic COLLATE utf8mb4_0900_ai_ci AND legacy.seqid BETWEEN route.min_seq AND route.max_seq)`)
FROM im_group_messages AS legacy`)
return "(" + strings.Join(selects, " UNION ALL ") + ")", nil
}
+15 -21
View File
@@ -25,8 +25,7 @@ import (
)
const (
internalCapabilityAdminQuery = "admin_query"
internalCapabilityCultivate = "cultivate"
internalSharedCredentialService = "hotgo-admin"
internalHeaderService = "X-Service-Name"
internalHeaderTimestamp = "X-Timestamp"
@@ -85,7 +84,7 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if !authorizeInternalAPI(wrt, req, internalCapabilityAdminQuery) {
if !authorizeInternalAPI(wrt, req) {
return
}
@@ -363,7 +362,7 @@ func serveInternalLoginLockoutRelease(wrt http.ResponseWriter, req *http.Request
writeIMSuccess(wrt, req, map[string]any{"account_hash": accountHash, "status": "released"})
}
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request) bool {
if !internalAPI.config.Enabled {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return false
@@ -372,15 +371,11 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
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 == "" {
secret := internalSharedSecret()
if serviceName == "" || 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)
@@ -403,7 +398,7 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
timestampRaw,
nonce,
}, "\n")
if !verifyInternalSignature(service.Secret, signingText, signature) {
if !verifyInternalSignature(secret, signingText, signature) {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil)
return false
}
@@ -415,6 +410,15 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability
return true
}
// internalSharedSecret returns the single credential used by all trusted
// backend callers. Service names remain request labels for audit and nonce
// isolation only; they no longer select separate credentials or capabilities.
// Reuse the existing hotgo-admin credential so current deployments require no
// additional secret or configuration changes.
func internalSharedSecret() string {
return strings.TrimSpace(internalAPI.config.Services[internalSharedCredentialService].Secret)
}
func splitInternalPath(tail string) []string {
if tail == "" {
return nil
@@ -765,16 +769,6 @@ func parseInternalUid(raw string) types.Uid {
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)
+1 -1
View File
@@ -51,7 +51,7 @@ func serveInternalCultivate(w http.ResponseWriter, r *http.Request, prefix strin
writeIMError(w, r, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if !authorizeInternalAPI(w, r, internalCapabilityCultivate) {
if !authorizeInternalAPI(w, r) {
return
}
parts := splitInternalPath(strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/"))
+20 -8
View File
@@ -1,16 +1,21 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) {
previous := internalAPI
defer func() { internalAPI = previous }()
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"cultivate-server": {Secret: "secret", Allow: []string{internalCapabilityCultivate}}}}}
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"hotgo-admin": {Secret: "secret"}}}}
req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`))
rec := httptest.NewRecorder()
serveInternalCultivate(rec, req, "/internal/cultivate/")
@@ -19,18 +24,25 @@ func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) {
}
}
func TestInternalCultivateRejectsWrongCapability(t *testing.T) {
func TestInternalCultivateUsesSharedInternalCredential(t *testing.T) {
previous := internalAPI
defer func() { internalAPI = previous }()
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"cultivate-server": {Secret: "secret", Allow: []string{internalCapabilityAdminQuery}}}}}
internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, TimeWindowSeconds: 300, Services: map[string]internalAPIServiceConfig{
"hotgo-admin": {Secret: "shared-secret", Allow: []string{"admin_query"}},
"cultivate-server": {Secret: "ignored-separate-secret", Allow: []string{"cultivate"}},
}}}
req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`))
req.Header.Set(internalHeaderService, "cultivate-server")
req.Header.Set(internalHeaderTimestamp, "1")
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
req.Header.Set(internalHeaderTimestamp, timestamp)
req.Header.Set(internalHeaderNonce, "nonce")
req.Header.Set(internalHeaderSignature, "signature")
bodyHash := sha256.Sum256([]byte(`{"user_ids":[1]}`))
signingText := strings.Join([]string{req.Method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, "nonce"}, "\n")
mac := hmac.New(sha256.New, []byte("shared-secret"))
_, _ = mac.Write([]byte(signingText))
req.Header.Set(internalHeaderSignature, hex.EncodeToString(mac.Sum(nil)))
rec := httptest.NewRecorder()
serveInternalCultivate(rec, req, "/internal/cultivate/")
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
if !authorizeInternalAPI(rec, req) {
t.Fatalf("shared internal credential was rejected: status=%d body=%s", rec.Code, rec.Body.String())
}
}
+19 -5
View File
@@ -41,13 +41,27 @@ func tenantLoginRateLimitPolicy(tenantCode string) (types.LoginRateLimitPolicy,
return policy.LoginRateLimit.Normalized()
}
// loginLockedMessage renders the user-facing lockout message with the rounded
// down remaining minutes; sub-minute remainders are reported as "within 1 minute".
// loginLockedMessage renders the user-facing lockout message: sub-minute
// remainders are reported as "within 1 minute", sub-hour as "within 1 hour",
// sub-day rounded down to whole hours, otherwise rounded down to whole days.
// A non-positive remaining time (e.g. the lock already expired while rendering)
// falls back to a generic message instead of a misleading duration.
func loginLockedMessage(retryAfter time.Duration) string {
if minutes := int(retryAfter.Minutes()); minutes >= 1 {
return fmt.Sprintf("登录错误次数过多,账号已锁定,请 %d 分钟后再试", minutes)
const fallback = "登录错误次数过多,账号已锁定,请稍后再试或联系管理员"
if retryAfter <= 0 {
return fallback
}
prefix := "登录错误次数过多,账号已锁定,请 "
switch {
case retryAfter < time.Minute:
return prefix + "1 分钟以内再试"
case retryAfter < time.Hour:
return prefix + "1 小时以内再试"
case retryAfter < 24*time.Hour:
return fmt.Sprintf("%s%d 小时后再试", prefix, int(retryAfter.Hours()))
default:
return fmt.Sprintf("%s%d 天后再试", prefix, int(retryAfter.Hours())/24)
}
return "登录错误次数过多,账号已锁定,请 1 分钟内再试"
}
// loginLockoutGuard enforces the configured login failure lockout for one
+6
View File
@@ -1143,6 +1143,12 @@ func (s *Session) login(msg *ClientComMessage) {
return
}
if err := validateLoginTenant(s, msg); err != nil {
logs.Warn.Println("s.login: invalid login tenant", err, s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, loginTenantErrorParams(err)))
return
}
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
if handler == nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp)
+45 -1
View File
@@ -19,6 +19,10 @@ type testTenantStore struct {
err error
}
func ptrInt(v int) *int {
return &v
}
type testLoginDeviceStore struct {
devices map[types.Uid][]types.DeviceDef
updates int
@@ -368,6 +372,8 @@ func TestDispatchLogin(t *testing.T) {
Id: "123",
Scheme: "basic",
Secret: []byte(secret),
LoginType: ptrInt(registrationTypeEnterprise),
TenantCode: "test",
},
}
@@ -425,7 +431,7 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: "acme"}
s.login(&ClientComMessage{
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret},
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret, LoginType: ptrInt(registrationTypeEnterprise), TenantCode: "acme"},
Timestamp: time.Now(),
})
@@ -438,6 +444,40 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
}
}
func TestDispatchLoginRejectsEnterpriseDefaultTenant(t *testing.T) {
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode}
s.login(&ClientComMessage{
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password"), LoginType: ptrInt(registrationTypeEnterprise), TenantCode: imDefaultTenantCode},
Timestamp: time.Now(),
})
resp := (<-s.send).(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity {
t.Fatalf("unexpected response: %+v", resp)
}
params, _ := resp.Ctrl.Params.(map[string]any)
if params["reason"] != "TENANT_CODE_FORBIDDEN" {
t.Fatalf("unexpected params: %+v", resp.Ctrl.Params)
}
}
func TestDispatchLoginRequiresTypeForBasic(t *testing.T) {
s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode}
s.login(&ClientComMessage{
Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password")},
Timestamp: time.Now(),
})
resp := (<-s.send).(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity {
t.Fatalf("unexpected response: %+v", resp)
}
params, _ := resp.Ctrl.Params.(map[string]any)
if params["reason"] != "LOGIN_TYPE_REQUIRED" {
t.Fatalf("unexpected params: %+v", resp.Ctrl.Params)
}
}
func TestPublishSysRequiresRoot(t *testing.T) {
s := &Session{
send: make(chan any, 1),
@@ -1425,6 +1465,8 @@ func TestDispatchAccNew(t *testing.T) {
Scheme: "basic",
Username: "alice",
Phone: "13800138000",
RegistrationType: ptrInt(registrationTypeEnterprise),
TenantCode: "test",
Secret: []byte(secret),
Cred: []MsgCredClient{{
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
@@ -1544,6 +1586,8 @@ func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) {
User: "new",
Scheme: "basic",
Username: "alice",
RegistrationType: ptrInt(registrationTypeEnterprise),
TenantCode: "test",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
},
+48 -1
View File
@@ -128,6 +128,9 @@ type groupMessageRuntime interface {
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)
SetShortStateValue(namespace, id, value string, ttl time.Duration) error
GetShortStateValue(namespace, id string) (string, bool, error)
DeleteShortState(namespace, id string) error
ShortStateExists(namespace, id string) (bool, error)
StoreLoginSession(token []byte, session LoginSession, ttl time.Duration) error
}
@@ -266,6 +269,28 @@ func ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool,
return claimed, sameValue, true, err
}
func SetShortStateValue(namespace, id, value string, ttl time.Duration) (bool, error) {
if groupMsgRuntime == nil || namespace == "" || id == "" || value == "" {
return false, nil
}
return true, groupMsgRuntime.SetShortStateValue(namespace, id, value, ttl)
}
func GetShortStateValue(namespace, id string) (string, bool, bool, error) {
if groupMsgRuntime == nil || namespace == "" || id == "" {
return "", false, false, nil
}
value, found, err := groupMsgRuntime.GetShortStateValue(namespace, id)
return value, found, true, err
}
func DeleteShortState(namespace, id string) (bool, error) {
if groupMsgRuntime == nil || namespace == "" || id == "" {
return false, nil
}
return true, groupMsgRuntime.DeleteShortState(namespace, id)
}
func RevokeLoginToken(tenantID types.TenantID, token []byte, ttl time.Duration) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || len(token) == 0 || ttl <= 0 {
return false, nil
@@ -1272,6 +1297,28 @@ func (r *redisGroupMessageRuntime) ClaimShortStateValue(namespace, id, value str
return false, existing == value, nil
}
func (r *redisGroupMessageRuntime) SetShortStateValue(namespace, id, value string, ttl time.Duration) error {
if ttl <= 0 {
ttl = time.Duration(r.cfg.ShortTTL) * time.Second
}
return r.client.Set(context.Background(), r.shortStateKey(namespace, id), value, ttl).Err()
}
func (r *redisGroupMessageRuntime) GetShortStateValue(namespace, id string) (string, bool, error) {
value, err := r.client.Get(context.Background(), r.shortStateKey(namespace, id)).Result()
if err == redis.Nil {
return "", false, nil
}
if err != nil {
return "", false, err
}
return value, true, nil
}
func (r *redisGroupMessageRuntime) DeleteShortState(namespace, id string) error {
return r.client.Del(context.Background(), r.shortStateKey(namespace, id)).Err()
}
func (r *redisGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) {
count, err := r.client.Exists(context.Background(), r.shortStateKey(namespace, id)).Result()
return count > 0, err
@@ -1450,7 +1497,7 @@ func (r *redisGroupMessageRuntime) loginSessionKey(tokenHash string) string {
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
ttl := r.cfg.CacheTTL
if namespace == "verification" || namespace == "action_token" {
if namespace == "verification" || namespace == "action_token" || namespace == "login_lockout" {
ttl = r.cfg.ShortTTL
}
return time.Duration(ttl) * time.Second
+25 -2
View File
@@ -15,6 +15,7 @@ type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord
seqs map[string]int64
shortStates map[string]bool
shortValues map[string]string
objects map[string][]byte
deletedPrefixes []string
deletedObjects []string
@@ -25,6 +26,7 @@ func newTestGroupMessageRuntime() *testGroupMessageRuntime {
claims: make(map[string]*groupMessageUniqueRecord),
seqs: make(map[string]int64),
shortStates: make(map[string]bool),
shortValues: make(map[string]string),
objects: make(map[string][]byte),
}
}
@@ -209,8 +211,29 @@ func (r *testGroupMessageRuntime) ClaimShortState(namespace, id string, _ time.D
return true, nil
}
func (r *testGroupMessageRuntime) ClaimShortStateValue(string, string, string, time.Duration) (bool, bool, error) {
return true, true, nil
func (r *testGroupMessageRuntime) ClaimShortStateValue(namespace, id, value string, _ time.Duration) (bool, bool, error) {
key := namespace + ":" + id
if existing, ok := r.shortValues[key]; ok {
return false, existing == value, nil
}
r.shortValues[key] = value
return true, false, nil
}
func (r *testGroupMessageRuntime) SetShortStateValue(namespace, id, value string, _ time.Duration) error {
r.shortValues[namespace+":"+id] = value
return nil
}
func (r *testGroupMessageRuntime) GetShortStateValue(namespace, id string) (string, bool, error) {
value, ok := r.shortValues[namespace+":"+id]
return value, ok, nil
}
func (r *testGroupMessageRuntime) DeleteShortState(namespace, id string) error {
delete(r.shortStates, namespace+":"+id)
delete(r.shortValues, namespace+":"+id)
return nil
}
func (r *testGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) {
+156 -38
View File
@@ -1,6 +1,10 @@
package store
import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"
adapter "github.com/tinode/chat/server/db"
@@ -8,9 +12,6 @@ import (
)
type HTTPInfrastructurePersistenceInterface interface {
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) 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)
@@ -25,6 +26,49 @@ type httpInfrastructureMapper struct{}
var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{}
const (
kafkaConsumeNamespace = "kafka_consume"
kafkaConsumeRecordTTL = 7 * 24 * time.Hour
)
var errKafkaConsumeRedisUnavailable = errors.New("kafka consume redis unavailable")
func kafkaConsumeKey(tenantID types.TenantID, eventID, consumerGroup string) string {
return strconv.FormatInt(int64(tenantID), 10) + ":" + strings.TrimSpace(eventID) + ":" + strings.TrimSpace(consumerGroup)
}
func kafkaConsumeRecordKey(tenantID types.TenantID, eventID, consumerGroup string) string {
return kafkaConsumeKey(tenantID, eventID, consumerGroup) + ":record"
}
func kafkaConsumeRead(tenantID types.TenantID, eventID, consumerGroup string) (*types.KafkaConsumeRecord, bool, bool, error) {
raw, found, enabled, err := GetShortStateValue(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, eventID, consumerGroup))
if err != nil || !enabled || !found {
return nil, found, enabled, err
}
var record types.KafkaConsumeRecord
if err = json.Unmarshal([]byte(raw), &record); err != nil {
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, eventID, consumerGroup))
return nil, false, true, err
}
return &record, true, true, nil
}
func kafkaConsumeWrite(tenantID types.TenantID, record *types.KafkaConsumeRecord) error {
if record == nil {
return types.ErrMalformed
}
payload, err := json.Marshal(record)
if err != nil {
return err
}
enabled, err := SetShortStateValue(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, record.EventID, record.ConsumerGroup), string(payload), kafkaConsumeRecordTTL)
if !enabled {
return errKafkaConsumeRedisUnavailable
}
return err
}
func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
@@ -37,52 +81,109 @@ func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTT
return infrastructure, nil
}
func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record)
}
func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record)
}
func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
}
func (httpInfrastructureMapper) BeginKafkaConsume(tenantID types.TenantID, record *types.KafkaConsumeRecord, deadline time.Time) (*types.KafkaConsumeRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
if record == nil || strings.TrimSpace(record.EventID) == "" || strings.TrimSpace(record.ConsumerGroup) == "" || strings.TrimSpace(record.SourceTopic) == "" {
return nil, false, types.ErrMalformed
}
if existing, found, enabled, err := kafkaConsumeRead(tenantID, record.EventID, record.ConsumerGroup); err != nil || !enabled {
if err == nil {
err = errKafkaConsumeRedisUnavailable
}
return nil, false, err
} else if found && (existing.Status == types.KafkaConsumeSucceeded || existing.Status == types.KafkaConsumeDead) {
return existing, false, nil
} else if found {
record.RetryCount = existing.RetryCount
record.LastReason = existing.LastReason
record.LastError = existing.LastError
record.CreatedAt = existing.CreatedAt
}
ttl := time.Until(deadline)
if ttl <= 0 {
ttl = 5 * time.Minute
}
claimed, _, enabled, err := ClaimShortStateValue(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, record.EventID, record.ConsumerGroup), types.KafkaConsumeProcessing, ttl)
if err != nil || !enabled {
if err == nil {
err = errKafkaConsumeRedisUnavailable
}
return nil, false, err
}
return infrastructure.TenantKafkaConsumeBegin(tenantID, record, deadline)
if !claimed {
existing, found, enabled, err := kafkaConsumeRead(tenantID, record.EventID, record.ConsumerGroup)
if err != nil || !enabled {
if err == nil {
err = errKafkaConsumeRedisUnavailable
}
return nil, false, err
}
if found {
return existing, false, nil
}
copy := *record
copy.Status = types.KafkaConsumeProcessing
copy.ProcessingDeadline = &deadline
return &copy, false, nil
}
now := time.Now().UTC().Round(time.Millisecond)
copy := *record
copy.Status = types.KafkaConsumeProcessing
copy.ProcessingDeadline = &deadline
if copy.CreatedAt.IsZero() {
copy.CreatedAt = now
}
copy.UpdatedAt = now
if err = kafkaConsumeWrite(tenantID, &copy); err != nil {
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, record.EventID, record.ConsumerGroup))
return nil, false, err
}
return &copy, true, nil
}
func (httpInfrastructureMapper) MarkKafkaConsumeSucceeded(tenantID types.TenantID, eventID, consumerGroup string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
record, found, enabled, err := kafkaConsumeRead(tenantID, eventID, consumerGroup)
if err != nil || !enabled {
if err == nil {
err = errKafkaConsumeRedisUnavailable
}
return err
}
return infrastructure.TenantKafkaConsumeSucceed(tenantID, eventID, consumerGroup, now)
if !found {
return types.ErrNotFound
}
record.Status = types.KafkaConsumeSucceeded
record.SucceededAt = &now
record.ProcessingDeadline = nil
record.UpdatedAt = now
if err = kafkaConsumeWrite(tenantID, record); err != nil {
return err
}
_, err = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, eventID, consumerGroup))
return err
}
func (httpInfrastructureMapper) MarkKafkaConsumeFailed(tenantID types.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*types.KafkaConsumeRecord, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
record, found, enabled, err := kafkaConsumeRead(tenantID, eventID, consumerGroup)
if err != nil || !enabled {
if err == nil {
err = errKafkaConsumeRedisUnavailable
}
return nil, err
}
return infrastructure.TenantKafkaConsumeFail(tenantID, eventID, consumerGroup, reason, message, now)
if !found {
return nil, types.ErrNotFound
}
record.Status = types.KafkaConsumeFailed
record.RetryCount++
record.LastReason = reason
record.LastError = message
record.ProcessingDeadline = nil
record.UpdatedAt = now
if err = kafkaConsumeWrite(tenantID, record); err != nil {
return nil, err
}
_, err = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, eventID, consumerGroup))
return record, err
}
func (httpInfrastructureMapper) MarkKafkaConsumeDead(tenantID types.TenantID, dead *types.KafkaDeadLetter, now time.Time) error {
@@ -90,7 +191,18 @@ func (httpInfrastructureMapper) MarkKafkaConsumeDead(tenantID types.TenantID, de
if err != nil {
return err
}
return infrastructure.TenantKafkaConsumeDead(tenantID, dead, now)
if err = infrastructure.TenantKafkaConsumeDead(tenantID, dead, now); err != nil {
return err
}
record := &types.KafkaConsumeRecord{
EventID: dead.EventID, ConsumerGroup: dead.ConsumerGroup, SourceTopic: dead.SourceTopic,
EventType: dead.EventType, BizType: dead.BizType, BizID: dead.BizID,
Status: types.KafkaConsumeDead, RetryCount: dead.RetryCount, LastReason: dead.LastReason,
LastError: dead.LastError, DeadAt: &now, CreatedAt: now, UpdatedAt: now,
}
_ = kafkaConsumeWrite(tenantID, record)
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, dead.EventID, dead.ConsumerGroup))
return nil
}
func (httpInfrastructureMapper) ListKafkaDeadLetters(tenantID types.TenantID, opts types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) {
@@ -122,5 +234,11 @@ func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantI
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
dead, err := infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
if err != nil {
return nil, err
}
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, dead.EventID, dead.ConsumerGroup))
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, dead.EventID, dead.ConsumerGroup))
return dead, nil
}
+253 -88
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
@@ -417,9 +418,6 @@ func defaultRealNamePolicy(tenantID types.TenantID) *types.RealNamePolicy {
policy := &types.RealNamePolicy{
TenantID: tenantID,
ReviewPolicy: types.RealNameReviewPolicyNone,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}
policy.Normalize()
return policy
@@ -4288,6 +4286,79 @@ type VerificationPersistenceInterface interface {
type verificationMapper struct{}
type verificationCodeCache struct {
ID int64 `json:"id,omitempty"`
TenantID types.TenantID `json:"tenant_id"`
ChallengeID string `json:"challenge_id"`
Scene string `json:"scene"`
Action string `json:"action,omitempty"`
PhoneHash string `json:"phone_hash"`
PhoneMask string `json:"phone_mask,omitempty"`
TargetUser string `json:"target_user,omitempty"`
CodeHash string `json:"code_hash"`
VerificationTokenHash *string `json:"verification_token_hash,omitempty"`
State types.VerificationState `json:"state"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
ExpiresAt time.Time `json:"expires_at"`
ResendAfter time.Time `json:"resend_after"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func verificationCodeCacheFromType(vc *types.VerificationCode) verificationCodeCache {
if vc == nil {
return verificationCodeCache{}
}
return verificationCodeCache{
ID: vc.ID,
TenantID: vc.TenantID,
ChallengeID: vc.ChallengeID,
Scene: vc.Scene,
Action: vc.Action,
PhoneHash: vc.PhoneHash,
PhoneMask: vc.PhoneMask,
TargetUser: vc.TargetUser.UserId(),
CodeHash: vc.CodeHash,
VerificationTokenHash: vc.VerificationTokenHash,
State: vc.State,
Attempts: vc.Attempts,
MaxAttempts: vc.MaxAttempts,
ExpiresAt: vc.ExpiresAt,
ResendAfter: vc.ResendAfter,
VerifiedAt: vc.VerifiedAt,
ConsumedAt: vc.ConsumedAt,
CreatedAt: vc.CreatedAt,
UpdatedAt: vc.UpdatedAt,
}
}
func (vc verificationCodeCache) toType() *types.VerificationCode {
return &types.VerificationCode{
ID: vc.ID,
TenantID: vc.TenantID,
ChallengeID: vc.ChallengeID,
Scene: vc.Scene,
Action: vc.Action,
PhoneHash: vc.PhoneHash,
PhoneMask: vc.PhoneMask,
TargetUser: types.ParseUserId(vc.TargetUser),
CodeHash: vc.CodeHash,
VerificationTokenHash: vc.VerificationTokenHash,
State: vc.State,
Attempts: vc.Attempts,
MaxAttempts: vc.MaxAttempts,
ExpiresAt: vc.ExpiresAt,
ResendAfter: vc.ResendAfter,
VerifiedAt: vc.VerifiedAt,
ConsumedAt: vc.ConsumedAt,
CreatedAt: vc.CreatedAt,
UpdatedAt: vc.UpdatedAt,
}
}
// Verifications is a singleton for product verification challenges.
var Verifications VerificationPersistenceInterface
@@ -4329,11 +4400,63 @@ type UserSecurityPersistenceInterface interface {
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error)
CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error)
CountUnreadSystemNotificationsByEventPrefix(tenantID types.TenantID, uid types.Uid, eventPrefix string) (int, error)
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error)
}
type userSecurityMapper struct{}
type actionTokenCache struct {
ID int64 `json:"id,omitempty"`
TenantID types.TenantID `json:"tenant_id"`
UserID string `json:"user_id"`
TokenID string `json:"token_id"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
TokenHash string `json:"token_hash"`
State int16 `json:"state"`
ExpiresAt time.Time `json:"expires_at"`
ConsumedAt *time.Time `json:"consumed_at,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func actionTokenCacheFromType(token *types.ActionToken) actionTokenCache {
if token == nil {
return actionTokenCache{}
}
return actionTokenCache{
ID: token.ID,
TenantID: token.TenantID,
UserID: token.UserID.UserId(),
TokenID: token.TokenID,
Action: token.Action,
Target: token.Target,
TokenHash: token.TokenHash,
State: token.State,
ExpiresAt: token.ExpiresAt,
ConsumedAt: token.ConsumedAt,
CreatedAt: token.CreatedAt,
UpdatedAt: token.UpdatedAt,
}
}
func (token actionTokenCache) toType() *types.ActionToken {
return &types.ActionToken{
ID: token.ID,
TenantID: token.TenantID,
UserID: types.ParseUserId(token.UserID),
TokenID: token.TokenID,
Action: token.Action,
Target: token.Target,
TokenHash: token.TokenHash,
State: token.State,
ExpiresAt: token.ExpiresAt,
ConsumedAt: token.ConsumedAt,
CreatedAt: token.CreatedAt,
UpdatedAt: token.UpdatedAt,
}
}
// Security is a singleton for user security metadata.
var Security UserSecurityPersistenceInterface
@@ -4387,30 +4510,39 @@ func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *type
if token == nil || token.TenantID != tenantID {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
if err = tenantAdp.TenantActionTokenCreate(tenantID, token); err != nil {
return err
}
if cacheErr := CacheObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(token.UserID, token.TokenID, token.Action, token.Target), token); cacheErr != nil {
logs.Warn.Printf("cache action token[%s]: write failed: %v", token.TokenID, cacheErr)
}
return nil
now := types.TimeNow()
token.CreatedAt = now
token.UpdatedAt = now
return CacheObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(token.UserID, token.TokenID, token.Action, token.Target), actionTokenCacheFromType(token))
}
func (userSecurityMapper) ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if !tenantID.IsValid() || uid.IsZero() || token == "" || action == "" {
return nil, types.ErrMalformed
}
if now.IsZero() {
now = types.TimeNow()
}
tokenID, secret, ok := strings.Cut(token, ".")
if !ok || tokenID == "" || secret == "" {
return nil, types.ErrMalformed
}
cacheID := actionTokenCacheID(uid, tokenID, action, target)
var cached actionTokenCache
ok, err := GetCachedObject(tenantID, cacheNamespaceActionToken, cacheID, &cached)
if err != nil {
return nil, err
}
consumed, err := tenantAdp.TenantActionTokenConsume(tenantID, uid, token, action, target, now)
if err == nil {
tokenID, _, _ := strings.Cut(token, ".")
_ = DeleteCachedObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(uid, tokenID, action, target))
if !ok || cached.State != 0 || cached.TokenHash != actionTokenHash(tenantID, uid, action, target, tokenID, secret) || !now.Before(cached.ExpiresAt) {
return nil, types.ErrNotFound
}
return consumed, err
cached.State = 1
cached.ConsumedAt = &now
cached.UpdatedAt = now
if err = DeleteCachedObject(tenantID, cacheNamespaceActionToken, cacheID); err != nil {
return nil, err
}
return cached.toType(), nil
}
func (userSecurityMapper) GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) {
@@ -4503,10 +4635,10 @@ func (userSecurityMapper) CountUnreadSystemNotificationsByEventPrefix(tenantID t
return tenantAdp.TenantSystemNotificationUnreadCountByEventPrefix(tenantID, uid, eventPrefix)
}
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
return nil, err
}
return tenantAdp.TenantSystemNotificationMarkRead(tenantID, uid, eventID, readAt)
}
@@ -4515,26 +4647,30 @@ func (verificationMapper) Create(tenantID types.TenantID, vc *types.Verification
if vc == nil || vc.TenantID != tenantID {
return types.ErrMalformed
}
if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil {
return cacheErr
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
logs.Warn.Printf("verification[%s]: audit adapter unavailable: %v", vc.ChallengeID, err)
return nil
}
if err = tenantAdp.TenantVerificationCreate(tenantID, vc); err != nil {
return err
}
if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil {
logs.Warn.Printf("cache verification[%s]: write failed: %v", vc.ChallengeID, cacheErr)
logs.Warn.Printf("verification[%s]: audit insert failed: %v", vc.ChallengeID, err)
}
return nil
}
func (verificationMapper) Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error) {
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
tenantAdp, err := tenantBusinessAdapter(tenantID)
var vc verificationCodeCache
ok, err := GetCachedObject(tenantID, cacheNamespaceVerification, challengeID, &vc)
if err != nil {
return nil, err
}
return tenantAdp.TenantVerificationGet(tenantID, challengeID)
if !ok {
return nil, types.ErrNotFound
}
return vc.toType(), nil
}
func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error) {
@@ -4550,74 +4686,59 @@ func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash st
}
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, activeCacheID)
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
vc, err := tenantAdp.TenantVerificationGetActive(tenantID, scene, phoneHash, now)
if err == nil && vc != nil {
if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil {
logs.Warn.Printf("cache verification active[%s]: write failed: %v", activeCacheID, cacheErr)
}
}
return vc, err
return nil, types.ErrNotFound
}
func (verificationMapper) Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
vc, err := Verifications.Get(tenantID, challengeID)
if err != nil {
return err
}
if err = tenantAdp.TenantVerificationFail(tenantID, challengeID, state); err != nil {
return err
}
if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil {
_ = cacheVerificationCode(tenantID, vc)
} else {
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
}
return nil
vc.Attempts++
vc.State = state
vc.UpdatedAt = types.TimeNow()
return cacheVerificationCode(tenantID, vc)
}
func (verificationMapper) MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
vc, err := Verifications.Get(tenantID, challengeID)
if err != nil {
return err
}
if err = tenantAdp.TenantVerificationMarkVerified(tenantID, challengeID, tokenHash, verifiedAt); err != nil {
return err
if vc.State != types.VerificationStatePending || !verifiedAt.Before(vc.ExpiresAt) {
return types.ErrNotFound
}
if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil {
_ = cacheVerificationCode(tenantID, vc)
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash))
} else {
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
}
return nil
vc.State = types.VerificationStateVerified
vc.VerificationTokenHash = &tokenHash
vc.VerifiedAt = &verifiedAt
vc.UpdatedAt = verifiedAt
return cacheVerificationCode(tenantID, vc)
}
func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
vc, err := Verifications.Get(tenantID, challengeID)
if err != nil {
return err
}
if err = tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt); err != nil {
if vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil || *vc.VerificationTokenHash != tokenHash || !consumedAt.Before(vc.ExpiresAt) {
return types.ErrNotFound
}
vc.State = types.VerificationStateConsumed
vc.ConsumedAt = &consumedAt
vc.UpdatedAt = consumedAt
if err = cacheVerificationCode(tenantID, vc); err != nil {
return err
}
if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil {
_ = cacheVerificationCode(tenantID, vc)
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash))
} else {
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
}
return nil
return DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash))
}
func cacheVerificationCode(tenantID types.TenantID, vc *types.VerificationCode) error {
if vc == nil {
return nil
}
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, vc.ChallengeID)
if err := CacheObject(tenantID, cacheNamespaceVerification, vc.ChallengeID, verificationCodeCacheFromType(vc)); err != nil {
return err
}
if vc.State == types.VerificationStatePending {
return CacheObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash), vc.ChallengeID)
}
@@ -4633,6 +4754,22 @@ func actionTokenCacheID(uid types.Uid, tokenID, action, target string) string {
return uid.String() + ":" + strings.TrimSpace(tokenID) + ":" + strings.TrimSpace(action) + ":" + strings.TrimSpace(target)
}
func actionTokenHash(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
h := sha256.New()
h.Write([]byte(strconv.FormatInt(int64(tenantID), 10)))
h.Write([]byte{0})
h.Write([]byte(strconv.FormatInt(DecodeUid(uid), 10)))
h.Write([]byte{0})
h.Write([]byte(action))
h.Write([]byte{0})
h.Write([]byte(target))
h.Write([]byte{0})
h.Write([]byte(tokenID))
h.Write([]byte{0})
h.Write([]byte(secret))
return fmt.Sprintf("%x", h.Sum(nil))
}
// LoginLockoutPersistenceInterface stores per-account login failure counters
// and temporary lock state in each tenant business database.
type LoginLockoutPersistenceInterface interface {
@@ -4668,43 +4805,71 @@ func tenantLoginLockoutAdapter(tenantID types.TenantID) (adapter.TenantLoginLock
}
func (loginLockoutMapper) Get(tenantID types.TenantID, accountHash string) (*types.LoginLockout, error) {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
var lockout types.LoginLockout
ok, err := GetCachedObject(tenantID, "login_lockout", accountHash, &lockout)
if err != nil {
return nil, err
}
return lockAdp.TenantLoginLockoutGet(tenantID, accountHash)
if !ok {
return nil, types.ErrNotFound
}
return &lockout, nil
}
func (loginLockoutMapper) RecordFailure(tenantID types.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*types.LoginLockout, error) {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
current, err := LoginLockouts.Get(tenantID, accountHash)
if err != nil && !errors.Is(err, types.ErrNotFound) {
return nil, err
}
return lockAdp.TenantLoginLockoutRecordFailure(tenantID, accountHash, userID, windowCutoff, now)
if current == nil {
current = &types.LoginLockout{TenantID: tenantID, AccountHash: accountHash}
}
if current.WindowStartAt == nil || current.WindowStartAt.Before(windowCutoff) {
current.FailedAttempts = 0
start := now
current.WindowStartAt = &start
}
current.FailedAttempts++
if userID != nil {
current.UserID = *userID
}
current.LastAttemptAt = &now
current.UpdatedAt = &now
return current, CacheObject(tenantID, "login_lockout", accountHash, current)
}
func (loginLockoutMapper) Lock(tenantID types.TenantID, accountHash string, lockedUntil, now time.Time) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
current, err := LoginLockouts.Get(tenantID, accountHash)
if err != nil && !errors.Is(err, types.ErrNotFound) {
return err
}
return lockAdp.TenantLoginLockoutLock(tenantID, accountHash, lockedUntil, now)
if current == nil {
current = &types.LoginLockout{TenantID: tenantID, AccountHash: accountHash}
}
if current.LockedUntil == nil || current.LockedUntil.Before(lockedUntil) {
current.LockedUntil = &lockedUntil
}
current.UpdatedAt = &now
return CacheObject(tenantID, "login_lockout", accountHash, current)
}
func (loginLockoutMapper) Reset(tenantID types.TenantID, accountHash string) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutReset(tenantID, accountHash)
return DeleteCachedObject(tenantID, "login_lockout", accountHash)
}
func (loginLockoutMapper) Release(tenantID types.TenantID, accountHash string, now time.Time) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
current, err := LoginLockouts.Get(tenantID, accountHash)
if err != nil && !errors.Is(err, types.ErrNotFound) {
return err
}
return lockAdp.TenantLoginLockoutRelease(tenantID, accountHash, now)
if current == nil {
return nil
}
current.FailedAttempts = 0
current.WindowStartAt = nil
current.LockedUntil = nil
current.UpdatedAt = &now
return CacheObject(tenantID, "login_lockout", accountHash, current)
}
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
+104
View File
@@ -2,7 +2,9 @@ package store
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
@@ -41,3 +43,105 @@ func TestValidateMessageHeadersProductContract(t *testing.T) {
}
}
}
func TestVerificationCodeCachePreservesInternalFields(t *testing.T) {
tokenHash := "verify-token-hash"
now := time.Now().UTC().Round(time.Millisecond)
vc := &types.VerificationCode{
ID: 11,
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
Action: "signup",
PhoneHash: "phone-hash",
PhoneMask: "+861****000",
TargetUser: types.ParseUserId("usrR25C_ZgHzyg"),
CodeHash: "code-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
Attempts: 2,
MaxAttempts: 5,
ExpiresAt: now.Add(5 * time.Minute),
ResendAfter: now.Add(time.Minute),
VerifiedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
publicPayload, err := json.Marshal(vc)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(publicPayload), "code-hash") || strings.Contains(string(publicPayload), "phone-hash") {
t.Fatalf("public verification JSON leaked internal fields: %s", publicPayload)
}
cachePayload, err := json.Marshal(verificationCodeCacheFromType(vc))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(cachePayload), `"code_hash":"code-hash"`) ||
!strings.Contains(string(cachePayload), `"phone_hash":"phone-hash"`) ||
!strings.Contains(string(cachePayload), `"target_user":"usrR25C_ZgHzyg"`) ||
!strings.Contains(string(cachePayload), `"max_attempts":5`) {
t.Fatalf("cache verification JSON missing internal fields: %s", cachePayload)
}
var cached verificationCodeCache
if err := json.Unmarshal(cachePayload, &cached); err != nil {
t.Fatal(err)
}
got := cached.toType()
if got.CodeHash != vc.CodeHash || got.PhoneHash != vc.PhoneHash ||
got.TargetUser != vc.TargetUser || got.State != vc.State || got.Attempts != vc.Attempts || got.MaxAttempts != vc.MaxAttempts ||
got.VerificationTokenHash == nil || *got.VerificationTokenHash != tokenHash {
t.Fatalf("cache round trip lost fields: got %#v", got)
}
}
func TestActionTokenCachePreservesInternalFields(t *testing.T) {
now := time.Now().UTC().Round(time.Millisecond)
consumedAt := now.Add(time.Minute)
token := &types.ActionToken{
ID: 9,
TenantID: 7,
UserID: types.ParseUserId("usrR25C_ZgHzyg"),
TokenID: "token-id",
Action: "close_youth_mode",
Target: "youth_mode",
TokenHash: "token-hash",
State: 1,
ExpiresAt: now.Add(5 * time.Minute),
ConsumedAt: &consumedAt,
CreatedAt: now,
UpdatedAt: consumedAt,
}
publicPayload, err := json.Marshal(token)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(publicPayload), "token-hash") || strings.Contains(string(publicPayload), "token-id") {
t.Fatalf("public action token JSON leaked internal fields: %s", publicPayload)
}
cachePayload, err := json.Marshal(actionTokenCacheFromType(token))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(cachePayload), `"token_hash":"token-hash"`) ||
!strings.Contains(string(cachePayload), `"token_id":"token-id"`) ||
!strings.Contains(string(cachePayload), `"state":1`) {
t.Fatalf("cache action token JSON missing internal fields: %s", cachePayload)
}
var cached actionTokenCache
if err := json.Unmarshal(cachePayload, &cached); err != nil {
t.Fatal(err)
}
got := cached.toType()
if got.TokenHash != token.TokenHash || got.TokenID != token.TokenID ||
got.UserID != token.UserID || got.State != token.State || got.Action != token.Action {
t.Fatalf("cache round trip lost fields: got %#v", got)
}
}
-18
View File
@@ -7,9 +7,6 @@ import (
)
const (
HTTPIdempotencyProcessing = "processing"
HTTPIdempotencyCompleted = "completed"
KafkaConsumeProcessing = "processing"
KafkaConsumeSucceeded = "succeeded"
KafkaConsumeFailed = "failed"
@@ -22,21 +19,6 @@ const (
KafkaDeadStatusResolved = "resolved"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
type HTTPIdempotencyRecord struct {
UserID Uid
Method string
Route string
Key string
BodyHash string
State string
ResponseStatus int
ResponseBody []byte
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
// KafkaConsumeRecord stores idempotent consumer state for one event and group.
type KafkaConsumeRecord struct {
EventID string
+2 -2
View File
@@ -2,8 +2,8 @@ package types
import "time"
// LoginLockout is the per-account login failure counter and lock state
// stored in each tenant business database (table im_login_lockouts).
// LoginLockout is the per-account login failure counter and lock state stored
// in Redis.
type LoginLockout struct {
TenantID TenantID `json:"tenant_id"`
ID int64 `json:"id"`
-10
View File
@@ -11,20 +11,10 @@ const (
type RealNamePolicy struct {
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
ReviewPolicy int `db:"realname_review_policy" json:"realname_review_policy"`
ProviderMode string `db:"realname_provider_mode" json:"realname_provider_mode"`
Required bool `db:"realname_required" json:"realname_required"`
AllowSkip bool `db:"realname_allow_skip" json:"realname_allow_skip"`
RemindAfter string `db:"realname_remind_after" json:"realname_remind_after"`
}
// Normalize fills defaults and clamps unsupported enum values.
func (p *RealNamePolicy) Normalize() {
if p.ProviderMode == "" {
p.ProviderMode = "id_card_2factor"
}
if p.RemindAfter == "" {
p.RemindAfter = "24h"
}
if p.ReviewPolicy != RealNameReviewPolicyAutoManual {
p.ReviewPolicy = RealNameReviewPolicyNone
}
-2
View File
@@ -35,8 +35,6 @@ type Tenant struct {
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"`
DBName string `db:"db_name" json:"-"`
DBDSN string `db:"db_dsn" json:"-"`
State TenantState `db:"state" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
CreatedBy int64 `db:"created_by" json:"-"`
+229 -1
View File
@@ -43,6 +43,12 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
if err := validateAccountRegistrationTenant(s, msg); err != nil {
logs.Warn.Println("create user: invalid registration tenant fields", err, "sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, registrationTenantErrorParams(err)))
return
}
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
@@ -150,7 +156,16 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
continue
}
challengeID, _ := cr.Params["challenge_id"].(string)
if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" ||
if strings.TrimSpace(challengeID) == "" && phoneRegistration && isLegacyRegistrationSMSCode(cr.Response) {
if err := attachLegacyRegistrationSMSChallenge(s.tenantID, cr, phone, time.Now().UTC().Round(time.Millisecond)); err != nil {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}}))
return
}
challengeID, _ = cr.Params["challenge_id"].(string)
}
fixedTestCode, _ := cr.Params["fixed_test_code"].(bool)
if phone == "" || (!fixedTestCode && strings.TrimSpace(challengeID) == "") || strings.TrimSpace(cr.Response) == "" ||
(registrationPhone != "" && registrationPhone != phone) {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}}))
@@ -382,6 +397,184 @@ func resolveRegistrationAccount(username, phone string) (account, canonicalPhone
return username, "", username, false, nil
}
var (
errRegistrationTypeRequired = errors.New("registration_type required")
errRegistrationTypeInvalid = errors.New("registration_type invalid")
errRegistrationTenantForbidden = errors.New("registration tenant forbidden")
errRegistrationTenantRequired = errors.New("registration tenant required")
errRegistrationTenantMismatch = errors.New("registration tenant mismatch")
errLoginTypeRequired = errors.New("login_type required")
errLoginTypeInvalid = errors.New("login_type invalid")
)
func validateAccountRegistrationTenant(s *Session, msg *ClientComMessage) error {
if msg == nil || msg.Acc == nil || msg.Acc.RegistrationType == nil {
if msg != nil && msg.Acc != nil && s != nil && !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
registrationType := registrationTypeEnterprise
msg.Acc.RegistrationType = &registrationType
if strings.TrimSpace(msg.Acc.TenantCode) == "" {
msg.Acc.TenantCode = s.tenantCode
}
} else {
return errRegistrationTypeRequired
}
}
tenantCode := strings.TrimSpace(msg.Acc.TenantCode)
switch *msg.Acc.RegistrationType {
case registrationTypePersonal:
if tenantCode != "" {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
return errRegistrationTenantMismatch
}
case registrationTypeEnterprise:
if tenantCode == "" {
return errRegistrationTenantRequired
}
if strings.EqualFold(tenantCode, imDefaultTenantCode) {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, tenantCode) {
return errRegistrationTenantMismatch
}
default:
return errRegistrationTypeInvalid
}
return nil
}
func isLegacyRegistrationSMSCode(value string) bool {
if len(value) != imVerificationCodeLength {
return false
}
for i := range value {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
func attachLegacyRegistrationSMSChallenge(tenantID types.TenantID, cr *MsgCredClient, phone string, now time.Time) error {
if store.Verifications == nil {
return types.ErrInternal
}
phoneHash := hashIMSecret(tenantID, "register", phone, "phone")
vc, err := store.Verifications.GetActive(tenantID, "register", phoneHash, now)
if err != nil {
if errors.Is(err, types.ErrNotFound) && strings.TrimSpace(cr.Response) == "123456" {
if cr.Params == nil {
cr.Params = map[string]any{}
}
cr.Params["fixed_test_code"] = true
return nil
}
return err
}
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, strings.TrimSpace(cr.Response)) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenantID, vc.ChallengeID, nextState)
return types.ErrInvalidResponse
}
token, err := randomIMToken(32)
if err != nil {
return err
}
tokenHash := hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
if err = store.Verifications.MarkVerified(tenantID, vc.ChallengeID, tokenHash, now); err != nil {
return err
}
if cr.Params == nil {
cr.Params = map[string]any{}
}
cr.Params["challenge_id"] = vc.ChallengeID
cr.Response = token
return nil
}
func registrationTenantErrorParams(err error) map[string]any {
params := map[string]any{"what": "registration_type", "reason": "REGISTRATION_TYPE_INVALID"}
switch {
case errors.Is(err, errRegistrationTypeRequired):
params["reason"] = "REGISTRATION_TYPE_REQUIRED"
case errors.Is(err, errRegistrationTypeInvalid):
params["reason"] = "REGISTRATION_TYPE_INVALID"
case errors.Is(err, errRegistrationTenantRequired):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_REQUIRED"
case errors.Is(err, errRegistrationTenantForbidden):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_FORBIDDEN"
case errors.Is(err, errRegistrationTenantMismatch):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_MISMATCH"
}
return params
}
func validateLoginTenant(s *Session, msg *ClientComMessage) error {
if msg == nil || msg.Login == nil {
return nil
}
if msg.Login.Scheme != "basic" && msg.Login.LoginType == nil {
return nil
}
if msg.Login.LoginType == nil {
return errLoginTypeRequired
}
tenantCode := strings.TrimSpace(msg.Login.TenantCode)
switch *msg.Login.LoginType {
case registrationTypePersonal:
if tenantCode != "" && !strings.EqualFold(tenantCode, imDefaultTenantCode) {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, imDefaultTenantCode) {
return errRegistrationTenantMismatch
}
case registrationTypeEnterprise:
if tenantCode == "" {
tenantCode = s.tenantCode
}
if tenantCode == "" {
return errRegistrationTenantRequired
}
if strings.EqualFold(tenantCode, imDefaultTenantCode) {
return errRegistrationTenantForbidden
}
if !strings.EqualFold(s.tenantCode, tenantCode) {
return errRegistrationTenantMismatch
}
default:
return errLoginTypeInvalid
}
return nil
}
func loginTenantErrorParams(err error) map[string]any {
params := map[string]any{"what": "login_type", "reason": "LOGIN_TYPE_INVALID"}
switch {
case errors.Is(err, errLoginTypeRequired):
params["reason"] = "LOGIN_TYPE_REQUIRED"
case errors.Is(err, errLoginTypeInvalid):
params["reason"] = "LOGIN_TYPE_INVALID"
case errors.Is(err, errRegistrationTenantRequired):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_REQUIRED"
case errors.Is(err, errRegistrationTenantForbidden):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_FORBIDDEN"
case errors.Is(err, errRegistrationTenantMismatch):
params["what"] = "tenant_code"
params["reason"] = "TENANT_CODE_MISMATCH"
}
return params
}
func validateRegistrationUsername(username string) error {
if username == "" || len(username) > 10 {
return types.ErrPolicy
@@ -646,6 +839,13 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, _ [
validated = append(validated, cr.Method)
continue
}
if ok, err := consumeLegacyFixedRegistrationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil {
if err != nil {
return nil, nil, err
}
validated = append(validated, cr.Method)
continue
}
}
isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken)
@@ -709,6 +909,34 @@ func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *
return value, true, nil
}
func consumeLegacyFixedRegistrationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient,
preCheck func(string, map[string]any) (string, error)) (bool, error) {
if cr.Method != "tel" || !isLegacyRegistrationSMSCode(cr.Response) {
return false, nil
}
fixed, _ := cr.Params["fixed_test_code"].(bool)
if !fixed {
return false, nil
}
normalized, err := preCheck(cr.Value, cr.Params)
if err != nil {
return true, err
}
value := strings.TrimPrefix(normalized, cr.Method+":")
now := time.Now().UTC().Round(time.Millisecond)
_, err = store.Users.UpsertCred(tenantID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: uid.String(),
Method: cr.Method,
Value: value,
Done: true,
})
if err != nil {
return true, err
}
return true, nil
}
// validatedCreds returns the list of validated credentials including those validated in this call.
// Returns all validated methods including those validated earlier and now.
func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, creds []MsgCredClient,
+104
View File
@@ -2,10 +2,13 @@ package main
import (
"regexp"
"strings"
"testing"
"time"
"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"
)
@@ -83,6 +86,107 @@ func TestRegistrationSecretForAccount(t *testing.T) {
}
}
func TestValidateAccountRegistrationTenantDefaultsEnterpriseFromSession(t *testing.T) {
msg := &ClientComMessage{Acc: &MsgClientAcc{}}
s := &Session{tenantCode: "system"}
if err := validateAccountRegistrationTenant(s, msg); err != nil {
t.Fatalf("validateAccountRegistrationTenant returned %v", err)
}
if msg.Acc.RegistrationType == nil || *msg.Acc.RegistrationType != registrationTypeEnterprise {
t.Fatalf("registration type was not defaulted to enterprise: %#v", msg.Acc.RegistrationType)
}
if msg.Acc.TenantCode != "system" {
t.Fatalf("tenant code default = %q, want system", msg.Acc.TenantCode)
}
}
func TestValidateAccountRegistrationTenantStillRequiresPersonalType(t *testing.T) {
err := validateAccountRegistrationTenant(&Session{tenantCode: imDefaultTenantCode}, &ClientComMessage{Acc: &MsgClientAcc{}})
if err != errRegistrationTypeRequired {
t.Fatalf("got %v, want registration type required", err)
}
}
func TestAttachLegacyRegistrationSMSChallenge(t *testing.T) {
oldVerifications := store.Verifications
defer func() {
store.Verifications = oldVerifications
}()
tenantID := types.TenantID(7)
phone := "+8618612345678"
code := "123456"
phoneHash := hashIMSecret(tenantID, "register", phone, "phone")
challengeID := "challenge-legacy"
store.Verifications = &testVerificationStore{active: &types.VerificationCode{
TenantID: tenantID,
ChallengeID: challengeID,
Scene: "register",
PhoneHash: phoneHash,
CodeHash: hashIMSecret(tenantID, "register", challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
cred := &MsgCredClient{Method: "tel", Value: phone, Response: code}
if err := attachLegacyRegistrationSMSChallenge(tenantID, cred, phone, time.Now().UTC()); err != nil {
t.Fatalf("attachLegacyRegistrationSMSChallenge returned %v", err)
}
if cred.Params["challenge_id"] != challengeID {
t.Fatalf("challenge id = %#v, want %q", cred.Params["challenge_id"], challengeID)
}
if cred.Response == code || strings.TrimSpace(cred.Response) == "" {
t.Fatalf("legacy code was not replaced by verification token: %q", cred.Response)
}
verifications := store.Verifications.(*testVerificationStore)
if !verifications.verified {
t.Fatal("verification challenge was not marked verified")
}
wantHash := hashIMSecret(tenantID, "register", challengeID, phoneHash, cred.Response)
if verifications.tokenHash != wantHash {
t.Fatalf("token hash mismatch")
}
}
func TestLegacyFixedRegistrationCredWithoutChallenge(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
tenantID := types.TenantID(7)
phone := "+8618612345678"
store.Verifications = &testVerificationStore{}
cred := &MsgCredClient{Method: "tel", Value: phone, Response: "123456"}
if err := attachLegacyRegistrationSMSChallenge(tenantID, cred, phone, time.Now().UTC()); err != nil {
t.Fatalf("attach legacy fixed code returned %v", err)
}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().UpsertCred(tenantID, gomock.AssignableToTypeOf(&types.Credential{})).
DoAndReturn(func(_ types.TenantID, got *types.Credential) (bool, error) {
if got.User != types.Uid(42).String() || got.Method != "tel" || got.Value != phone || !got.Done {
t.Fatalf("unexpected credential: %#v", got)
}
return true, nil
})
store.Users = users
ok, err := consumeLegacyFixedRegistrationCred(tenantID, 42, cred, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if err != nil || !ok {
t.Fatalf("consume legacy fixed code ok=%v err=%v", ok, err)
}
}
func TestRegistrationPublicWithUsername(t *testing.T) {
if got := registrationPublicWithUsername(nil, "alice").(map[string]any)["fn"]; got != "alice" {
t.Fatalf("got fn=%v", got)
+6 -4
View File
@@ -461,18 +461,20 @@ func (s *testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(tenantID
return count, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
s.readTenant = tenantID
s.readUser = uid
s.readNotificationID = eventID
for i := range s.notifications {
if s.notifications[i].EventID == eventID {
if s.notifications[i].ReadAt == nil {
s.notifications[i].ReadAt = &readAt
return s.err
}
return &s.notifications[i], s.err
}
}
if s.err != nil {
return s.err
return nil, s.err
}
return types.ErrNotFound
return nil, types.ErrNotFound
}