Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1fab5065 | ||
|
|
137d6fb6da | ||
|
|
e37b52c3c0 | ||
|
|
1ec2feee47 | ||
|
|
62856194e5 | ||
|
|
1909074898 | ||
|
|
9ddf6ec5ee | ||
|
|
49e43aa3cf | ||
|
|
8e04f00e96 | ||
|
|
e4416da04a | ||
|
|
b8c93e8d6a | ||
|
|
76b462e334 | ||
|
|
2e0612acbb | ||
|
|
865b436264 | ||
|
|
ac10b31aa1 | ||
|
|
3276c6d1cd | ||
|
|
dc36ce292e | ||
|
|
186cfa21c8 | ||
|
|
fc393cd685 | ||
|
|
b30cce950a | ||
|
|
4abd6604d7 | ||
|
|
eca2ee56c0 | ||
|
|
9820e89e1c | ||
|
|
55c59878cf | ||
|
|
a147dc718a |
@@ -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
@@ -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版需要审核,这两个可能会是卡点
|
||||
|
||||
+11
-10
@@ -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)
|
||||
})
|
||||
@@ -446,13 +446,14 @@ func (a *audit) wsRegister(username, phone, password, challenge, verification st
|
||||
accID := "acc-" + username
|
||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
err = conn.WriteJSON(map[string]any{"acc": map[string]any{
|
||||
"id": accID,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"id": accID,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"registration_type": 1,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"cred": []map[string]any{{
|
||||
"meth": "tel",
|
||||
"val": phone,
|
||||
|
||||
+15
-13
@@ -637,13 +637,14 @@ func (a *audit) register(username, phone, password string) (string, string, erro
|
||||
id := "acc-" + username
|
||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
if err = c.write(map[string]any{"acc": map[string]any{
|
||||
"id": id,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"id": id,
|
||||
"user": "new",
|
||||
"scheme": "basic",
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"registration_type": 1,
|
||||
"secret": secret,
|
||||
"login": true,
|
||||
"cred": []map[string]any{{
|
||||
"meth": "tel",
|
||||
"val": phone,
|
||||
@@ -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)
|
||||
@@ -827,9 +828,10 @@ func (a *audit) openBasicLoggedIn(name, username, password string) (*wsClient, e
|
||||
loginID := "login-" + strings.ToLower(name)
|
||||
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
if err = c.write(map[string]any{"login": map[string]any{
|
||||
"id": loginID,
|
||||
"scheme": "basic",
|
||||
"secret": secret,
|
||||
"id": loginID,
|
||||
"scheme": "basic",
|
||||
"login_type": 1,
|
||||
"secret": secret,
|
||||
}}); err != nil {
|
||||
_ = c.conn.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
+15
-13
@@ -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"}` |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
+10
-22
@@ -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"],
|
||||
} {
|
||||
if spec == nil {
|
||||
t.Fatalf("%s query spec missing", label)
|
||||
}
|
||||
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)
|
||||
}
|
||||
spec := internalTenantResourceSpecs["user-login-logs"]
|
||||
if spec == nil {
|
||||
t.Fatal("user-login-logs query spec missing")
|
||||
}
|
||||
if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+308
-143
@@ -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
|
||||
`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)
|
||||
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,
|
||||
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 '',
|
||||
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,
|
||||
id BIGINT NOT NULL,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
tenant_desc VARCHAR(256),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=?",
|
||||
|
||||
@@ -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
|
||||
|
||||
+615
-567
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+207
-32
@@ -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),
|
||||
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
|
||||
}
|
||||
existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", 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 || existing.BodyHash != record.BodyHash {
|
||||
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
|
||||
if existing == nil {
|
||||
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
|
||||
return
|
||||
}
|
||||
if existing.State == types.HTTPIdempotencyCompleted {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -291,10 +378,11 @@ type imTenantEntryFeedbackRequest struct {
|
||||
}
|
||||
|
||||
type imVerificationCreateRequest struct {
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
Scene string `json:"scene"`
|
||||
Phone string `json:"phone"`
|
||||
Country string `json:"country,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type imVerificationVerifyRequest struct {
|
||||
@@ -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,8 +2144,21 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
release, err := store.AppReleases.Latest(platform, channel)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
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)
|
||||
@@ -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,
|
||||
|
||||
+154
-50
@@ -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 ©, false, nil
|
||||
}
|
||||
copy := *record
|
||||
copy.State = types.HTTPIdempotencyProcessing
|
||||
s.records[key] = ©
|
||||
return ©, 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)] = ©
|
||||
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 ©, 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¤t_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()
|
||||
@@ -8242,12 +8346,12 @@ func TestIMMessageTypeDraftyEntities(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "legacy flat file_id content still works",
|
||||
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
|
||||
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "legacy flat url content still works",
|
||||
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
|
||||
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
|
||||
want: "link",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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), "/"))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+60
-16
@@ -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
|
||||
@@ -365,9 +369,11 @@ func TestDispatchLogin(t *testing.T) {
|
||||
|
||||
msg := &ClientComMessage{
|
||||
Login: &MsgClientLogin{
|
||||
Id: "123",
|
||||
Scheme: "basic",
|
||||
Secret: []byte(secret),
|
||||
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),
|
||||
@@ -1420,12 +1460,14 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
public := "public name"
|
||||
msg := &ClientComMessage{
|
||||
Acc: &MsgClientAcc{
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
Phone: "13800138000",
|
||||
Secret: []byte(secret),
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
Phone: "13800138000",
|
||||
RegistrationType: ptrInt(registrationTypeEnterprise),
|
||||
TenantCode: "test",
|
||||
Secret: []byte(secret),
|
||||
Cred: []MsgCredClient{{
|
||||
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
|
||||
Params: map[string]any{"challenge_id": "challenge-1"},
|
||||
@@ -1540,12 +1582,14 @@ func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) {
|
||||
|
||||
msg := &ClientComMessage{
|
||||
Acc: &MsgClientAcc{
|
||||
Id: "username-only",
|
||||
User: "new",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
|
||||
Id: "username-only",
|
||||
User: "new",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
RegistrationType: ptrInt(registrationTypeEnterprise),
|
||||
TenantCode: "test",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ©, 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, ©); err != nil {
|
||||
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, record.EventID, record.ConsumerGroup))
|
||||
return nil, false, err
|
||||
}
|
||||
return ©, 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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = ®istrationType
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
s.notifications[i].ReadAt = &readAt
|
||||
return s.err
|
||||
if s.notifications[i].ReadAt == nil {
|
||||
s.notifications[i].ReadAt = &readAt
|
||||
}
|
||||
return &s.notifications[i], s.err
|
||||
}
|
||||
}
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
return nil, s.err
|
||||
}
|
||||
return types.ErrNotFound
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user