Compare commits

...
26 Commits
Author SHA1 Message Date
richjohnrambo e61058ec03 feat: add tenant entry registration APIs 2026-08-24 15:08:47 +08:00
richjohnrambo ed9413d923 feat: add file safety config integration 2026-08-23 23:50:21 +08:00
richjohnrambo 7b942da975 feat: add aliyun oss media handler 2026-08-23 23:20:33 +08:00
richjohnrambo a578acebf7 fix: handle nullable recalled_by in mysql messages 2026-08-23 23:08:56 +08:00
richjohnrambo 641697e755 docs: update IM integration contract examples 2026-08-23 23:05:45 +08:00
richjohnrambo d14942b1a1 fix: preserve group member user ids 2026-08-23 22:29:22 +08:00
richjohnrambo c89c466387 feat: implement first batch im enhancements 2026-08-21 11:42:45 +08:00
richjohnrambo 14abe58b88 test: add websocket protocol audit 2026-08-21 00:42:54 +08:00
richjohnrambo ff616b9c5d fix: align favorite batch endpoint 2026-08-20 23:29:10 +08:00
richjohnrambo 2b3480fa5f fix: stabilize test environment IM endpoints 2026-08-20 23:06:54 +08:00
richjohnrambo 6c6d2f6d3b fix: stabilize im audit endpoints 2026-08-20 22:34:36 +08:00
richjohnrambo 3e1ce5df26 feat: allow viewing user profile QR codes 2026-08-20 17:54:12 +08:00
richjohnrambo 6db2938672 feat: add bootstrap entry badges 2026-08-20 15:59:16 +08:00
richjohnrambo d467323ecb docs: clarify test registration code 2026-08-20 14:10:05 +08:00
richjohnrambo c09ea2ac53 fix: stabilize container validator startup 2026-08-20 13:28:28 +08:00
richjohnrambo 43e8af68d6 fix: adjust phone registration username flow 2026-08-20 12:08:55 +08:00
richjohnrambo 73ddb2d9fa fix: improve IM user account search 2026-08-19 17:23:36 +08:00
richjohnrambo b3d18f9d8d Merge remote-tracking branch 'origin/dev' into dev 2026-08-19 17:02:34 +08:00
richjohnrambo 405dad2eef fix: use fixed code for registration verification 2026-08-19 16:52:20 +08:00
richjohnrambo 4e75cfc65e fix: restore verification target user ids 2026-08-19 01:17:09 +08:00
richjohnrambo 219ecadf4c feat: complete registration invitation and auth redesign 2026-08-19 00:30:11 +08:00
richjohnrambo e063e7879e fix: canonicalize phone numbers as E.164 2026-08-18 01:23:18 +08:00
richjohnrambo bf562c7d56 feat: migrate IM identity and snowflake primary keys 2026-08-18 01:15:21 +08:00
richjohnrambo db322d80dd docs: sync IM integration guide with current behavior 2026-08-17 15:28:00 +08:00
richjohnrambo b116adc6a1 docs: clarify IM registration account payload 2026-08-17 15:23:13 +08:00
richjohnrambo c60ed2e9ed fix: page friend list by peer id 2026-08-17 15:20:02 +08:00
99 changed files with 19893 additions and 913 deletions
+7 -1
View File
@@ -30,4 +30,10 @@ admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理;
3. 熟悉群消息扩散策略,如读扩散、写扩散;
4. 理解多端同步、消息漫游、消息推送等 IM 核心机制;
5. 有 OpenIM 或类似 IM 项目经验优先。
5. 有 OpenIM 或类似 IM 项目经验优先。
1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行
2.验证码验证作为开关 关闭的情况下的话后台不想第三方发送请求,前端正常ui的倒计时,但是收不到验证码
3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册
用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段
+611
View File
@@ -0,0 +1,611 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"time"
"github.com/gorilla/websocket"
)
type imResp struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
Data map[string]any `json:"data"`
RequestID string `json:"request_id"`
}
type result struct {
Chapter string
ID string
Name string
Method string
Path string
Status int
Code int
Reason string
Pass bool
Note string
Body string
}
type audit struct {
baseURL string
wsURL string
apiKey string
tenantCode string
client *http.Client
results []result
tokenA string
userA string
usernameA string
phoneA string
passA string
tokenB string
userB string
usernameB string
phoneB string
passB string
groupTopic string
groupVer int
groupQR string
friendReq string
p2pTopic string
meQR string
meQRPurpose string
meetingID string
meetingVer int
dailyID string
dailyVer int
monthlyID string
monthlyVer int
mailboxNoticeVersion string
actionTok string
abuseReason string
profileVer int
privacyVer int
feedbackCat string
}
func main() {
baseURL := flag.String("base", "https://api.118im.com", "HTTP base URL")
wsURL := flag.String("ws", "wss://api.118im.com/v0/channels", "WebSocket URL")
apiKey := flag.String("apikey", "AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy", "Tinode API key")
tenant := flag.String("tenant", "default", "tenant code")
out := flag.String("out", "docs/test/im_frontend_backend_1_11_testenv_results_2026-08-20.md", "report path")
flag.Parse()
a := &audit{
baseURL: strings.TrimRight(*baseURL, "/"),
wsURL: *wsURL,
apiKey: *apiKey,
tenantCode: *tenant,
client: &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
},
},
}
a.run()
if err := os.WriteFile(*out, []byte(a.report()), 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", *out)
}
func (a *audit) run() {
suffix := fmt.Sprintf("%08d", time.Now().UnixNano()%100000000)
a.usernameA, a.phoneA, a.passA = "a"+suffix, "155"+suffix, "AuditPass123"
a.usernameB, a.phoneB, a.passB = "b"+suffix, "156"+suffix, "AuditPass123"
a.testHTTP("1", "1.1", "企业码解析", "POST", "/v1/im/tenants/resolve", map[string]any{"tenant_code": a.tenantCode, "platform": "ios", "app_version": "1.0.0"}, "", true, []int{200}, func(r imResp) string {
return fmt.Sprintf("tenant=%v endpoints=%v", dig(r.Data, "tenant", "code"), dig(r.Data, "endpoints", "api"))
})
a.register("1", "1.2A", a.usernameA, a.phoneA, a.passA, true)
a.register("1", "1.2B", a.usernameB, a.phoneB, a.passB, false)
a.testHTTP("1", "1.6a", "忘记密码发码", "POST", "/v1/im/auth/verification-codes", map[string]any{"scene": "reset_password", "phone": a.phoneA}, "", true, []int{200}, func(r imResp) string {
challenge := asString(r.Data["challenge_id"])
if challenge == "" {
return "未返回 challenge_id"
}
a.testHTTP("1", "1.6b", "忘记密码验证码校验", "POST", "/v1/im/auth/password-reset/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(rr imResp) string {
return "reset_token=" + mask(asString(rr.Data["reset_token"]))
})
return "challenge_id=" + mask(challenge)
})
a.testHTTP("1", "1.7a", "二次身份验证发起", "POST", "/v1/im/security/challenges", map[string]any{"scene": "close_youth_mode", "channel": "sms"}, a.tokenA, true, []int{200}, func(r imResp) string {
challenge := asString(r.Data["challenge_id"])
if challenge == "" {
challenge = asString(r.Data["id"])
}
if challenge != "" {
a.testHTTP("1", "1.7b", "二次身份验证码校验", "POST", "/v1/im/security/challenges/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, a.tokenA, true, []int{200}, func(rr imResp) string {
a.actionTok = asString(rr.Data["action_token"])
return "action_token=" + mask(a.actionTok)
})
}
return "challenge_id=" + mask(challenge)
})
a.testHTTP("1", "1.8", "实名状态查询", "GET", "/v1/im/security/realname", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("1", "1.8-submit", "实名提交", "POST", "/v1/im/security/realname/submissions", map[string]any{"name": "张三", "id_type": "cn_id", "id_number": "110101199001011234"}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("1", "1.11", "设置独立密码", "POST", "/v1/im/settings/independent-password", map[string]any{"password": "1234", "purpose": "youth_mode", "action_token": a.actionTok}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("1", "1.12", "应用版本查询", "GET", "/v1/im/app/releases/latest?platform=ios&current_version=1.0.0&channel=stable", nil, "", false, []int{200}, nil)
a.testHTTP("1", "1.logout", "退出登录", "POST", "/v1/im/auth/logout", map[string]any{}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("2", "2.1", "Bootstrap", "GET", "/v1/im/bootstrap", nil, a.tokenA, false, []int{200}, func(r imResp) string {
return fmt.Sprintf("entries=%v limits=%v", r.Data["entries"] != nil, r.Data["limits"] != nil)
})
a.testHTTP("2", "2.search", "会话搜索", "GET", "/v1/im/search/conversations?q=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("2", "2.sys", "系统通知列表", "GET", "/v1/im/system-notifications?limit=20", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("2", "2.sys-read", "系统通知已读不存在 ID", "POST", "/v1/im/system-notifications/nonexistent/read", map[string]any{"read_at": time.Now().UTC().Format(time.RFC3339)}, a.tokenA, true, []int{200, 404}, nil)
a.testHTTP("5", "5.search", "用户搜索 B", "GET", "/v1/im/users/search?q="+url.QueryEscape(a.usernameB)+"&type=account&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("5", "5.profile", "查看 B 资料", "GET", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/profile", nil, a.tokenA, false, []int{200, 403, 404}, nil)
a.testHTTP("5", "5.user-qr", "查看 B 加好友二维码", "POST", "/v1/im/users/"+url.PathEscape(a.usernameB)+"/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200, 403, 404}, nil)
a.testHTTP("5", "5.friend-create", "创建好友申请 A->B", "POST", "/v1/im/friend-requests", map[string]any{"target_user_id": a.userB, "message": "联调好友申请", "source": "search"}, a.tokenA, true, []int{200}, func(r imResp) string {
a.friendReq = asString(r.Data["request_id"])
return "request_id=" + a.friendReq
})
if a.friendReq != "" {
a.testHTTP("5", "5.friend-accept", "B 同意好友申请", "POST", "/v1/im/friend-requests/"+url.PathEscape(a.friendReq)+"/accept", map[string]any{"version": 1}, a.tokenB, true, []int{200, 409}, func(r imResp) string {
a.p2pTopic = asString(r.Data["topic"])
return "topic=" + a.p2pTopic
})
}
a.testHTTP("3", "3.context", "P2P 会话上下文", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/context", nil, a.tokenA, false, []int{200, 400, 404}, nil)
a.testHTTP("3", "3.p2p-create", "创建 P2P 会话目标接口", "POST", "/v1/im/conversations/p2p", map[string]any{"peer_user_id": a.userB}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("3", "3.conv-settings", "会话设置查询", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/settings", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("3", "3.conv-messages", "会话消息列表", "GET", "/v1/im/conversations/"+url.PathEscape(a.p2pTopic)+"/messages?since=1&limit=20", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.forward-targets", "转发目标列表", "GET", "/v1/im/forward-targets?query=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("4", "4.forward", "消息逐条转发", "POST", "/v1/im/messages/forward", map[string]any{"mode": "separate", "sources": []map[string]any{{"topic": a.p2pTopic, "seq": 1}}, "target_topics": []string{"slf"}}, a.tokenA, true, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.bundle", "合并转发详情", "GET", "/v1/im/forward-bundles/fbundle_missing?cursor=", nil, a.tokenA, false, []int{200, 404, 405}, nil)
a.testHTTP("4", "4.recall", "消息撤回不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/recall", map[string]any{"version": 1, "reason": "audit"}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
a.testHTTP("4", "4.revision", "消息编辑不存在 seq", "POST", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/revisions", map[string]any{"content": map[string]any{"text": "edit"}, "client_mid": "audit-mid", "version": 1}, a.tokenA, true, []int{200, 400, 404, 403}, nil)
a.testHTTP("4", "4.receipts", "消息回执查询", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/messages/1/receipts?cursor=", nil, a.tokenA, false, []int{200, 400, 404, 403}, nil)
a.testHTTP("5", "5.friends", "好友列表", "GET", "/v1/im/friends?query=&initial=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("5", "5.requests", "好友申请列表", "GET", "/v1/im/friend-requests?direction=inbound&status=all&cursor=", nil, a.tokenB, false, []int{200}, nil)
a.testHTTP("5", "5.blocks", "黑名单列表", "GET", "/v1/im/blocks?cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("5", "5.block-put", "拉黑 B", "PUT", "/v1/im/blocks/"+url.PathEscape(a.userB), map[string]any{}, a.tokenA, true, []int{200}, nil)
a.testHTTP("5", "5.block-del", "解除拉黑 B", "DELETE", "/v1/im/blocks/"+url.PathEscape(a.userB), nil, a.tokenA, true, []int{200}, nil)
a.testHTTP("5", "5.me-qr", "我的二维码", "POST", "/v1/im/me/qrcodes", map[string]any{"purpose": "add_friend", "expires_in_seconds": 86400}, a.tokenA, true, []int{200}, func(r imResp) string {
a.meQR = asString(r.Data["qr_token"])
a.meQRPurpose = "add_friend"
return "qr_token=" + mask(a.meQR)
})
if a.meQR != "" {
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.groupTopic = asString(r.Data["topic"])
a.groupVer = asInt(r.Data["version"])
return fmt.Sprintf("topic=%s version=%d", a.groupTopic, a.groupVer)
})
groupPath := "/v1/im/groups/" + url.PathEscape(a.groupTopic)
a.testHTTP("6", "6.list", "群列表", "GET", "/v1/im/groups?category=owned&query=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("6", "6.detail", "群详情", "GET", groupPath, nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.patch", "更新群资料", "PATCH", groupPath, map[string]any{"description": "自动化测试群已更新", "version": a.groupVer}, a.tokenA, true, []int{200, 409, 404}, func(r imResp) string {
if asInt(r.Data["version"]) > 0 {
a.groupVer = asInt(r.Data["version"])
}
return fmt.Sprintf("version=%d", a.groupVer)
})
a.testHTTP("6", "6.settings", "群设置", "GET", groupPath+"/settings", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.member-settings", "群个人设置", "PATCH", groupPath+"/member-settings", map[string]any{"pinned": true, "mute_notifications": false}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.my-profile", "群内昵称", "PUT", groupPath+"/members/me/profile", map[string]any{"nickname": "联调昵称", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.members", "群成员", "GET", groupPath+"/members?query=&cursor=&mentionable=true", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.member-profile", "群成员资料", "GET", groupPath+"/members/"+url.PathEscape(a.userB)+"/profile", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.qrcode", "群二维码", "POST", groupPath+"/qrcodes", map[string]any{"expires_in_seconds": 86400}, a.tokenA, true, []int{200, 404}, func(r imResp) string {
a.groupQR = asString(r.Data["qr_token"])
return "qr_token=" + mask(a.groupQR)
})
a.testHTTP("6", "6.invite", "群邀请", "POST", groupPath+"/invitations", map[string]any{"user_ids": []string{a.userB}, "message": "邀请入群"}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.join-requests", "入群申请列表", "GET", groupPath+"/join-requests?status=pending&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.summary", "群概要", "GET", groupPath+"/summary", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.ann-latest", "群公告最新", "GET", groupPath+"/announcements/latest", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.ann-put", "发布群公告", "PUT", groupPath+"/announcements/current", map[string]any{"content": "联调公告", "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.governance", "群治理配置", "GET", groupPath+"/governance", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.governance-patch", "更新群治理配置", "PATCH", groupPath+"/governance", map[string]any{"join_approval": true, "member_invite": true, "version": 1}, a.tokenA, true, []int{200, 409, 404}, nil)
a.testHTTP("6", "6.role-limits", "管理员数量限制", "GET", groupPath+"/role-limits", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.admins", "管理员列表", "GET", groupPath+"/members?role=admin&cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.mutes", "禁言列表", "GET", groupPath+"/mutes?cursor=", nil, a.tokenA, false, []int{200, 404}, nil)
a.testHTTP("6", "6.files", "群文件列表", "GET", groupPath+"/files?query=&type=&cursor=", nil, a.tokenA, false, []int{200, 404, 501}, nil)
a.testHTTP("7", "7.favorites", "收藏列表", "GET", "/v1/im/favorites?type=all&q=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("7", "7.favorite-batch", "批量收藏不存在消息", "POST", "/v1/im/favorites/batch", map[string]any{"messages": []map[string]any{{"topic": a.groupTopic, "seq": 1}}}, a.tokenA, true, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.message-search", "聊天记录搜索", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q=%E8%81%94%E8%B0%83&type=text&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.media", "会话媒体列表", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/media?type=image&cursor=", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.msg-context", "消息上下文", "GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/1/context?before=20&after=20", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("7", "7.file-download", "文件下载不存在文件", "GET", "/v0/file/s/nonexistent", nil, a.tokenA, false, []int{200, 400, 401, 403, 404}, nil)
a.testHTTP("8", "8.calls", "通话记录列表", "GET", "/v1/im/calls?limit=5", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("8", "8.topic-calls", "会话通话记录", "GET", "/v1/im/topics/"+url.PathEscape(a.p2pTopic)+"/calls?limit=5", nil, a.tokenA, false, []int{200, 400, 403, 404}, nil)
a.testHTTP("9", "9.workbench", "工作台", "GET", "/v1/im/workbench", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.depts", "组织部门", "GET", "/v1/im/organization/departments?parent_id=&cursor=", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.org-search-empty", "组织空关键词搜索", "GET", "/v1/im/organization/search?q=", nil, a.tokenA, false, []int{200, 400}, nil)
start := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute)
end := start.Add(time.Hour)
a.testHTTP("9", "9.meeting-create", "创建会议", "POST", "/v1/im/meetings", map[string]any{"title": "联调会议", "start_at": start.Format(time.RFC3339), "end_at": end.Format(time.RFC3339), "location": "线上", "attendee_user_ids": []string{a.userB}, "reminder_minutes": []int{10}}, a.tokenA, true, []int{200}, func(r imResp) string {
a.meetingID = asString(r.Data["meeting_id"])
a.meetingVer = asInt(r.Data["version"])
return fmt.Sprintf("meeting_id=%s version=%d", a.meetingID, a.meetingVer)
})
a.testHTTP("9", "9.meeting-list", "会议列表", "GET", "/v1/im/meetings?limit=5", nil, a.tokenA, false, []int{200, 405}, nil)
if a.meetingID != "" {
a.testHTTP("9", "9.meeting-get", "会议详情", "GET", "/v1/im/meetings/"+url.PathEscape(a.meetingID), nil, a.tokenA, false, []int{200, 404}, nil)
}
today := time.Now().UTC().Format("2006-01-02")
a.testHTTP("9", "9.daily-list", "日报列表", "GET", "/v1/im/reports/daily?limit=5", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.daily-create", "创建日报草稿", "POST", "/v1/im/reports/daily", map[string]any{"report_date": today}, a.tokenA, true, []int{200, 409}, func(r imResp) string {
a.dailyID = asString(r.Data["report_id"])
a.dailyVer = asInt(r.Data["version"])
return fmt.Sprintf("report_id=%s version=%d", a.dailyID, a.dailyVer)
})
a.testHTTP("9", "9.daily-stats", "日报统计", "GET", "/v1/im/reports/daily/statistics?date="+today, nil, a.tokenA, false, []int{200}, nil)
month := time.Now().UTC().Format("2006-01")
a.testHTTP("9", "9.monthly-list", "月报列表", "GET", "/v1/im/reports/monthly?limit=5", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.monthly-create", "创建月报草稿", "POST", "/v1/im/reports/monthly", map[string]any{"month": month}, a.tokenA, true, []int{200, 409}, func(r imResp) string {
a.monthlyID = asString(r.Data["report_id"])
a.monthlyVer = asInt(r.Data["version"])
return fmt.Sprintf("report_id=%s version=%d", a.monthlyID, a.monthlyVer)
})
a.testHTTP("9", "9.monthly-stats", "月报统计", "GET", "/v1/im/reports/monthly/statistics?month="+month, nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("9", "9.mailbox-config", "总经理信箱配置", "GET", "/v1/im/executive-mailbox/config", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.mailboxNoticeVersion = asString(dig(r.Data, "notice", "version"))
return "notice_version=" + a.mailboxNoticeVersion
})
a.testHTTP("9", "9.mailbox-submit", "总经理信箱提交", "POST", "/v1/im/executive-mailbox/submissions", map[string]any{"subject": "联调建议", "content": "自动化测试提交", "anonymous": false, "attachment_file_ids": []string{}, "notice_version": a.mailboxNoticeVersion}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("10", "10.me", "我的账号", "GET", "/v1/im/me", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("10", "10.profile", "个人资料", "GET", "/v1/im/me/profile", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.profileVer = asInt(r.Data["version"])
return fmt.Sprintf("version=%d", a.profileVer)
})
a.testHTTP("10", "10.profile-patch", "更新个人资料", "PATCH", "/v1/im/me/profile", map[string]any{"nickname": "联调用户A", "signature": "audit", "version": a.profileVer}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("10", "10.privacy", "隐私设置", "GET", "/v1/im/me/privacy", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.privacyVer = asInt(r.Data["version"])
return fmt.Sprintf("version=%d", a.privacyVer)
})
a.testHTTP("10", "10.privacy-patch", "更新隐私设置", "PATCH", "/v1/im/me/privacy", map[string]any{"add_me_methods": map[string]any{"account": true, "phone": true, "qr": true, "group": true}, "profile_visibility": "everyone", "last_seen_visibility": "friends", "read_receipts": true, "version": a.privacyVer}, a.tokenA, true, []int{200, 409}, nil)
a.testHTTP("10", "10.youth", "青少年模式", "GET", "/v1/im/settings/youth-mode", nil, a.tokenA, false, []int{200}, nil)
a.testHTTP("10", "10.independent-verify", "独立密码验证", "POST", "/v1/im/settings/independent-password/verify", map[string]any{"password": "1234", "purpose": "youth_mode"}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("10", "10.push", "设备 Push Token", "PUT", "/v1/im/devices/audit-device-"+suffix+"/push-token", map[string]any{"provider": "apns", "token": "audit-token-" + suffix, "platform": "ios", "app_version": "1.0.0"}, a.tokenA, true, []int{200}, nil)
a.testHTTP("11", "11.url", "URL 风险检查", "POST", "/v1/im/security/urls/check", map[string]any{"url": "https://example.com"}, a.tokenA, true, []int{200}, nil)
a.testHTTP("11", "11.abuse-reasons", "举报原因", "GET", "/v1/im/reports/abuse/reasons?target_type=user", nil, a.tokenA, false, []int{200}, func(r imResp) string {
if items, ok := r.Data["items"].([]any); ok && len(items) > 0 {
if first, ok := items[0].(map[string]any); ok {
a.abuseReason = asString(first["reason_code"])
if a.abuseReason == "" {
a.abuseReason = asString(first["code"])
}
}
}
return "reason=" + a.abuseReason
})
a.testHTTP("11", "11.abuse-submit", "提交举报", "POST", "/v1/im/reports/abuse", map[string]any{"target_type": "user", "target_id": a.userB, "reason_code": a.abuseReason, "description": "联调举报", "evidence": []map[string]any{}}, a.tokenA, true, []int{200, 400, 403}, nil)
a.testHTTP("11", "11.feedback-config", "反馈配置", "GET", "/v1/im/feedback/config", nil, a.tokenA, false, []int{200}, func(r imResp) string {
a.feedbackCat = "bug"
if items, ok := r.Data["categories"].([]any); ok && len(items) > 0 {
if s := asString(items[0]); s != "" {
a.feedbackCat = s
}
}
return "category=" + a.feedbackCat
})
a.testHTTP("11", "11.feedback", "提交反馈", "POST", "/v1/im/feedback", map[string]any{"category": a.feedbackCat, "content": "自动化联调反馈", "contact": "", "attachment_file_ids": []string{}, "client_context": map[string]any{"source": "imtestaudit"}, "include_diagnostics": true}, a.tokenA, true, []int{200}, nil)
}
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 {
challenge = asString(r.Data["challenge_id"])
return "challenge_id=" + mask(challenge)
})
if challenge != "" {
a.testHTTP(chapter, id+"-verify", "注册验证码校验 "+username, "POST", "/v1/im/auth/verification-codes/"+url.PathEscape(challenge)+"/verify", map[string]any{"code": "123456"}, "", true, []int{200}, func(r imResp) string {
verification = asString(r.Data["verification_token"])
return "verification_token=" + mask(verification)
})
}
token, userID, note, err := a.wsRegister(username, phone, password, challenge, verification)
res := result{Chapter: chapter, ID: id + "-ws-acc", Name: "WebSocket hi+acc 注册 " + username, Method: "WS", Path: "/v0/channels", Status: 0, Code: 0, Pass: err == nil, Note: note}
if err != nil {
res.Reason = err.Error()
}
a.results = append(a.results, res)
if primary {
a.tokenA, a.userA = token, userID
} else {
a.tokenB, a.userB = token, userID
}
}
func (a *audit) wsRegister(username, phone, password, challenge, verification string) (string, string, string, error) {
u, _ := url.Parse(a.wsURL)
q := u.Query()
q.Set("apikey", a.apiKey)
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return "", "", "", err
}
defer conn.Close()
deadline := time.Now().Add(15 * time.Second)
_ = conn.SetReadDeadline(deadline)
_ = conn.SetWriteDeadline(deadline)
hiID := "hi-" + username
if err = conn.WriteJSON(map[string]any{"hi": map[string]any{"id": hiID, "ver": "0.25", "ua": "imtestaudit/1.0", "platf": "go", "lang": "zh-CN", "tenant": a.tenantCode}}); err != nil {
return "", "", "", err
}
if _, err = waitCtrl(conn, hiID); err != nil {
return "", "", "", fmt.Errorf("hi: %w", err)
}
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,
"cred": []map[string]any{{
"meth": "tel",
"val": phone,
"resp": verification,
"params": map[string]any{"challenge_id": challenge},
}},
"desc": map[string]any{"public": map[string]any{"fn": username}},
}})
if err != nil {
return "", "", "", err
}
ctrl, err := waitCtrl(conn, accID)
if err != nil {
return "", "", "", fmt.Errorf("acc: %w", err)
}
params, _ := ctrl["params"].(map[string]any)
token := asString(params["token"])
user := asString(params["user"])
return token, user, "user=" + user + " token=" + mask(token), nil
}
func waitCtrl(conn *websocket.Conn, id string) (map[string]any, error) {
for i := 0; i < 20; i++ {
var msg map[string]any
if err := conn.ReadJSON(&msg); err != nil {
return nil, err
}
ctrl, ok := msg["ctrl"].(map[string]any)
if !ok || asString(ctrl["id"]) != id {
continue
}
code := asInt(ctrl["code"])
if code < 200 || code >= 300 {
return ctrl, fmt.Errorf("ctrl code=%d text=%s", code, asString(ctrl["text"]))
}
return ctrl, nil
}
return nil, fmt.Errorf("ctrl %s not received", id)
}
func (a *audit) testHTTP(chapter, id, name, method, path string, body any, token string, idem bool, want []int, note func(imResp) string) {
status, parsed, raw, err := a.call(method, path, body, token, idem)
r := result{Chapter: chapter, ID: id, Name: name, Method: method, Path: path, Status: status, Code: parsed.Code, Reason: parsed.Reason, Body: compact(raw)}
if err != nil {
r.Reason = err.Error()
} else {
for _, s := range want {
if status == s {
r.Pass = true
break
}
}
if note != nil {
r.Note = note(parsed)
}
}
a.results = append(a.results, r)
}
func (a *audit) call(method, path string, body any, token string, idem bool) (int, imResp, string, error) {
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
target := path
if strings.HasPrefix(path, "/") {
target = a.baseURL + path
}
req, err := http.NewRequestWithContext(ctx, method, target, reader)
if err != nil {
return 0, imResp{}, "", err
}
req.Header.Set("X-Tinode-APIKey", a.apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "audit-"+strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", ""))
req.Header.Set("X-Tenant-Code", a.tenantCode)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
if strings.HasPrefix(path, "/v0/file/") {
req.Header.Set("X-Tinode-Auth", "token "+base64.StdEncoding.EncodeToString([]byte(token)))
}
}
if idem {
req.Header.Set("Idempotency-Key", "audit-"+fmt.Sprint(time.Now().UnixNano()))
}
resp, err := a.client.Do(req)
if err != nil {
return 0, imResp{}, "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
var parsed imResp
_ = json.Unmarshal(b, &parsed)
return resp.StatusCode, parsed, string(b), nil
}
func (a *audit) report() string {
var b strings.Builder
now := time.Now().Format("2006-01-02 15:04:05 -0700")
fmt.Fprintf(&b, "# IM 前后端 1-11 章测试环境接口测试结果(2026-08-20)\n\n")
fmt.Fprintf(&b, "## 1. 测试范围\n\n")
fmt.Fprintf(&b, "- 对照文档:`docs/IM-前后端对接文档.md`\n")
fmt.Fprintf(&b, "- 测试时间:`%s`\n", now)
fmt.Fprintf(&b, "- 测试环境:`%s`\n", a.baseURL)
fmt.Fprintf(&b, "- WebSocket:`%s`\n", a.wsURL)
fmt.Fprintf(&b, "- 租户:`%s`\n", a.tenantCode)
fmt.Fprintf(&b, "- 测试账号 A:`%s` / `%s` / `%s`\n", a.usernameA, a.phoneA, a.userA)
fmt.Fprintf(&b, "- 测试账号 B:`%s` / `%s` / `%s`\n", a.usernameB, a.phoneB, a.userB)
fmt.Fprintf(&b, "- API Key 与 Token 已脱敏,不在文档中展开。\n\n")
pass, fail := 0, 0
chapters := map[string][2]int{}
for _, r := range a.results {
pair := chapters[r.Chapter]
if r.Pass {
pass++
pair[0]++
} else {
fail++
pair[1]++
}
chapters[r.Chapter] = pair
}
fmt.Fprintf(&b, "## 2. 总体结论\n\n")
fmt.Fprintf(&b, "本轮按前后端对接文档从第 1 章到第 11 章直接访问测试环境接口,共执行 `%d` 项,符合预期 `%d` 项,不符合预期 `%d` 项。符合预期不等于业务全链路完全验收;部分接口以 404/405/409/403 作为当前文档标注的未实现、无数据或权限分支记录。\n\n", len(a.results), pass, fail)
fmt.Fprintf(&b, "| 章 | 符合预期 | 不符合预期 |\n| --- | ---: | ---: |\n")
keys := make([]string, 0, len(chapters))
for k := range chapters {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return chapterOrder(keys[i]) < chapterOrder(keys[j]) })
for _, k := range keys {
pair := chapters[k]
fmt.Fprintf(&b, "| %s | %d | %d |\n", k, pair[0], pair[1])
}
fmt.Fprintf(&b, "\n## 3. 需要关注的问题\n\n")
hasFail := false
for _, r := range a.results {
if !r.Pass {
hasFail = true
fmt.Fprintf(&b, "- `%s` %s `%s`:HTTP `%d`,code `%d`,reason `%s`。%s\n", r.ID, r.Method, r.Path, r.Status, r.Code, r.Reason, r.Note)
}
}
if !hasFail {
fmt.Fprintf(&b, "- 本轮自动化黑盒测试未发现非预期失败。\n")
}
fmt.Fprintf(&b, "\n## 4. 分章明细\n\n")
for _, k := range keys {
fmt.Fprintf(&b, "### 第 %s 章\n\n", k)
fmt.Fprintf(&b, "| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |\n| --- | --- | --- | --- | ---: | --- | --- | --- |\n")
for _, r := range a.results {
if r.Chapter != k {
continue
}
status := "通过"
if !r.Pass {
status = "未通过"
}
reason := strings.TrimSpace(fmt.Sprintf("%d/%s", r.Code, r.Reason))
fmt.Fprintf(&b, "| `%s` | %s | `%s` | `%s` | %d | `%s` | %s | %s |\n", r.ID, esc(r.Name), r.Method, esc(r.Path), r.Status, esc(reason), status, esc(r.Note))
}
fmt.Fprintf(&b, "\n")
}
fmt.Fprintf(&b, "## 5. 测试产生的数据\n\n")
fmt.Fprintf(&b, "- 用户 A:`%s`,username `%s`,phone `%s`\n", a.userA, a.usernameA, a.phoneA)
fmt.Fprintf(&b, "- 用户 B:`%s`,username `%s`,phone `%s`\n", a.userB, a.usernameB, a.phoneB)
fmt.Fprintf(&b, "- 好友申请:`%s`\n", a.friendReq)
fmt.Fprintf(&b, "- P2P Topic:`%s`\n", a.p2pTopic)
fmt.Fprintf(&b, "- 群 Topic:`%s`\n", a.groupTopic)
fmt.Fprintf(&b, "- 会议:`%s`\n", a.meetingID)
fmt.Fprintf(&b, "- 日报草稿:`%s`\n", a.dailyID)
fmt.Fprintf(&b, "- 月报草稿:`%s`\n", a.monthlyID)
fmt.Fprintf(&b, "\n这些数据位于测试环境租户 `%s`,可按需清理。\n", a.tenantCode)
return b.String()
}
func asString(v any) string {
switch t := v.(type) {
case string:
return t
default:
return ""
}
}
func asInt(v any) int {
switch t := v.(type) {
case int:
return t
case float64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
default:
return 0
}
}
func dig(m map[string]any, path ...string) any {
var cur any = m
for _, p := range path {
obj, ok := cur.(map[string]any)
if !ok {
return nil
}
cur = obj[p]
}
return cur
}
func mask(s string) string {
if len(s) <= 8 {
return s
}
return s[:4] + "..." + s[len(s)-4:]
}
func compact(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len(s) > 220 {
return s[:220] + "..."
}
return s
}
func esc(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func chapterOrder(s string) int {
var n int
_, _ = fmt.Sscanf(s, "%d", &n)
return n
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -45,6 +45,7 @@ ENV MEDIA_HANDLER=fs
ENV FS_CORS_ORIGINS='["*"]'
ENV AWS_CORS_ORIGINS='["*"]'
ENV SMTP_HOST_URL='http://localhost:6060'
ENV TEL_HOST_URL='http://localhost:6060'
ENV TLS_ENABLED=false
ENV FCM_PUSH_ENABLED=false
ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true
@@ -68,6 +69,7 @@ WORKDIR /opt/tinode
COPY docker/tinode/config.template .
COPY docker/tinode/entrypoint.sh .
COPY tinode-db/credentials.sh .
COPY server/templ ./templ
COPY --from=build /out/tinode .
COPY --from=build /out/init-db .
COPY --from=build /out/apply-hotgo-tenant-db .
@@ -0,0 +1,747 @@
# IM HotGo 注册短信开关与邀请码管理改造交接文档
> 文档状态:已按数据库直连方案实现,作为现状与后续 Kafka 演进说明
> 对应实施顺序:注册改造计划第 4 项(HotGo 后台)
> HotGo 项目:`/Users/youshunlai/go_workspace/im_manager`
> IM 服务项目:`/Users/youshunlai/go_workspace/im_server`
> 编写日期:2026-08-18
## 1. 文档用途
本文供新的开发会话直接接手 HotGo 项目使用。阅读本文后,应能明确:
1. 为什么要在 HotGo 中增加注册短信开关和邀请码管理。
2. 哪些数据属于 HotGo 控制库,哪些数据属于 IM 租户业务库。
3. HotGo 后端、前端、菜单权限、统一租户身份、审计和配置需要修改什么。
4. HotGo 与 IM 如何通过租户业务库协作,以及后续如何迁移到 Kafka。
5. 如何验证实现没有破坏租户隔离,也没有泄露邀请码明文。
本文指导 HotGo 项目改造。HotGo 负责邀请码生成和管理;IM 仍独占客户端注册、短信验证码和邀请码使用记录事务。
## 2. 开发前必须阅读
在修改 `/Users/youshunlai/go_workspace/im_manager` 前,先阅读以下文档:
1. `/Users/youshunlai/go_workspace/im_server/docs/IM-注册、邀请码与认证数据模型改造需求文档.md`
2. `/Users/youshunlai/go_workspace/im_server/docs/IM-前后端对接文档.md` 中 1.4、1.5 注册章节
3. `/Users/youshunlai/go_workspace/im_server/docs/IM-后台需求文档.md` 中租户权限、用户管理和审计章节
4. `/Users/youshunlai/go_workspace/im_server/docs/IM-独立库多租户改造方案.md`
规则冲突时按以下优先级处理:
1. 本文针对 HotGo 的落地边界和接口约定。
2. 《IM 注册、邀请码与认证数据模型改造需求文档》。
3. 《IM 独立库多租户改造方案》。
4. 其他较早的需求和设计文档。
`IM-服务端多租户改造方案.md` 已废弃,不得作为实现依据。当前多租户方案是“一套 IM 服务、一个 HotGo 控制库、每租户一个独立 IM 业务库”。
## 3. 需求前因后果
### 3.1 注册页面最终规则
客户端注册页面同时展示用户名、手机号、短信验证码、密码和邀请码,但用户名与手机号是二选一关系:
1. 手机号非空时,以手机号方式为准,忽略用户名输入。
2. 手机号方式注册后,`im_users.username` 和 `im_users.phone` 都保存规范化后的 `+86` 手机号。
3. 手机号为空时使用普通用户名,用户名必须匹配 `^[a-z0-9]{1,10}$`,此时 `phone=NULL`。
4. 普通用户名方式不能自助找回密码,只能由企业管理员重置。
5. 邀请码固定为 5 位 ASCII 大小写英文字母,区分大小写、租户内唯一,有效期内可供多人使用。
### 3.2 为什么需要短信开关
短信服务在不同部署环境中可能暂未启用,因此需要平台级开关:
| 场景 | 短信开关开启 | 短信开关关闭 |
| --- | --- | --- |
| 点击获取验证码 | IM 服务真实调用短信服务商 | IM 服务返回正常挑战和倒计时,但不调用服务商 |
| 手机号注册 | 必须验证短信;邀请码不能绕过 | 必须提供有效邀请码,可无验证码注册 |
| 用户名注册 | 不发送短信,必须提供有效邀请码 | 不发送短信,必须提供有效邀请码 |
| 客户端是否知道开关 | 不直接暴露 | 不直接暴露 |
HotGo 只负责管理开关值。是否调用短信服务商、是否允许注册,全部由 IM 服务端判定。
### 3.3 为什么当前改为 HotGo 直接访问租户库
邀请码属于 IM 租户业务数据:
1. 每个租户有独立业务库。
2. 邀请码批次和主表位于 `hotgo` 控制库,使用明细位于对应租户业务库。
3. HotGo 邀请码管理直接访问控制库;密码重置等租户业务功能仍通过 `im_tenant.id` 和服务端保存的 DSN 选择租户库。
4. 当前部署中 HotGo 到 IM 的内部 HTTP 地址不可达,继续维护 HMAC 管理 API 会增加一套没有必要的调用链。
因此当前采用“控制库主数据 + 租户库使用事实”的混合边界:HotGo 负责邀请码生成、查询、导出和撤销;IM 从控制库校验邀请码,并在租户库记录使用关系和 outbox 事件。两端各自只写自己负责的数据。
密码重置等需要 IM 立即感知的操作写入 `im_business_events_outbox`。当前 IM 轮询消费,后续改为 Kafka 时保留事件契约,只替换传输层。
## 4. 系统边界
```mermaid
flowchart LR
A["HotGo 管理后台浏览器"] -->|HotGo 登录 Token| B["HotGo 后端"]
B -->|读写全局开关| C[("hotgo.hg_sys_config")]
B -->|读取管理员 tenant_id| D[("hotgo.im_tenant")]
B -->|管理邀请码| G[("hotgo.im_registration_invite_batches")]
B -->|管理邀请码| H[("hotgo.im_registration_invite_codes")]
B -->|其他租户业务按服务端 DSN| F[("租户 IM 业务库")]
F --> I["im_registration_invite_uses"]
J["IM 注册与发码接口"] -->|短时缓存读取| C
J -->|按 tenant_id 校验| H
J -->|记录使用关系与事件| F
B -->|写 security.password_reset| K["im_business_events_outbox"]
F --> K
K -->|当前轮询,后续 Kafka| J
```
### 4.1 HotGo 负责
1. 在 `hg_sys_config` 保存全局注册短信开关。
2. 只允许平台超级管理员修改开关。
3. 为邀请码提供管理页面、表单、权限和审计入口。
4. 从当前登录管理员身份解析可信的 IM `tenant_id`。
5. 通过 `imtenant.DB()` 直连所选租户业务库,不接受浏览器传入 DSN。
6. 对邀请码明文查询、查看和导出执行独立权限校验。
### 4.2 IM 服务负责
1. 读取并缓存短信开关。
2. 实际发送或抑制短信。
3. 注册时大小写敏感地校验邀请码,并记录邀请码与新用户的使用关系。
4. 邀请码使用后不改变 active 状态。
5. 消费 `security.password_reset` 事件并清退在线会话。
6. 登录时以 `auth_version` 拒绝密码重置前签发的旧 Token。
7. 消费 `registration.invite_used`,将租户库使用统计幂等投影到控制库。
### 4.3 明确禁止
1. 浏览器直接连接租户数据库。
2. 浏览器通过 Header、Query 或 Body 自报可信 `tenant_id`。
3. HotGo 后端根据浏览器传入的 `db_name` 或 `db_dsn` 连接数据库。
4. HotGo 修改 `im_registration_invite_uses`;该表只允许 IM 注册事务写入。
5. HotGo 绕过 outbox 直接修改在线会话状态。
6. 在普通访问日志、错误日志、埋点或 URL 中记录完整邀请码。
## 5. 当前 HotGo 项目现状
目标仓库 `/Users/youshunlai/go_workspace/im_manager` 基于 GoFrame 2、Vue 3 和 Naive UI。
已确认的现状:
1. 通用配置后端位于 `server/api/admin/config`、`server/internal/controller/admin/sys/config.go`、`server/internal/logic/sys/config.go`。
2. 通用配置前端位于 `web/src/views/system/config` 和 `web/src/api/sys/config.ts`。
3. `hg_sys_config` 支持 `group/key/type/value/default_value`,前端不能通过通用接口新增不存在的配置项。
4. `sys_switch` 字典实际定义为“开启=`1`、关闭=`2`”。
5. HotGo 管理接口权限按“路由路径 + HTTP Method”使用 Casbin 校验,前端按钮权限也使用路由路径,例如 `hasPermission(['/notice/edit'])`。
6. `hotgo.im_tenant` 是全系统唯一租户注册表,由 HotGo 维护,不存在另一套需要映射的“IM 租户”。
7. 后台管理员通过 `hg_admin_member.tenant_id` 直接引用 `im_tenant.id`,登录身份必须携带该字段;不再使用独立租户绑定表。
8. `hg_sys_log` 会保存 GET Query 和 POST Body;现有逻辑只自动掩码名称包含 `password` 的 POST 字段,邀请码查询参数会泄漏,必须补充脱敏。
## 6. 建议开发顺序
### 阶段 A:先完成 HotGo 基础边界
1. 增加全局短信开关配置种子和专用读写接口。
2. 增加 `hg_admin_member.tenant_id` 外键与登录身份解析,统一使用 `im_tenant.id`。
3. 增加租户库直连、Snowflake ID 和数据库事件写入能力。
4. 增加邀请码相关菜单、路由权限和后台代理接口。
5. 增加邀请码管理页面。
6. 增加敏感字段日志脱敏和操作审计。
### 阶段 B:与 IM 服务联调
1. 核对 IM 租户库迁移已执行且表结构一致。
2. 核对服务凭证、租户上下文和操作人上下文可被 IM 验证。
3. 联调单个生成、批量生成、分页查询、撤销、详情和导出。
4. 联调短信开关在 IM 服务缓存时间内生效。
IM 管理接口未完成时,可以通过接口 Client 抽象和测试 Stub 开发页面,但不得用 HotGo 直写租户数据库作为临时方案。
## 7. 全局注册短信开关
### 7.1 数据定义
在 HotGo 控制库 `hg_sys_config` 中增加且只保留一条配置:
```text
group = im
name = 注册短信发送
type = bool
key = register_sms_enabled
value = 1
default_value = 1
status = 1
is_default = 1
```
建议的 MySQL 幂等种子:
```sql
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT
'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10,
'控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM hg_sys_config
WHERE `group` = 'im' AND `key` = 'register_sms_enabled'
);
```
同时为 `(group, key)` 增加唯一约束。加索引前先检查并清理重复配置,不能让 IM 服务随机读到多条值。
```sql
ALTER TABLE hg_sys_config
ADD UNIQUE KEY uk_hg_sys_config_group_key (`group`, `key`);
```
新装数据库种子、实际部署迁移及项目保留的 MySQL/PostgreSQL/SQLite 初始化脚本应按项目发布策略同步,不能只改一份示例 SQL。
### 7.2 `sys_switch` 映射陷阱
`sys_switch` 字典值与真实配置值不相同:
| UI 字典值 | UI 含义 | `hg_sys_config.value` |
| --- | --- | --- |
| `1` | 开启 | `1` |
| `2` | 关闭 | `0` |
实现要求:
1. 页面加载时将后端 bool 值映射为字典值:`true -> 1`,`false -> 2`。
2. 页面提交时反向映射:`1 -> true`,`2 -> false`。
3. 不得把字典值 `2` 原样写入 bool 配置;GoFrame 的 bool 转换可能把非零值当成 true。
4. 数据库只允许保存可解释为 `1/0` 的值。
### 7.3 后端接口
不要直接让页面复用通用 `/config/update`。新增专用接口,以便强制执行平台管理员校验:
| 方法 | HotGo 路径 | 权限 |
| --- | --- | --- |
| GET | `/imRegistration/config/get` | 平台超级管理员 |
| POST | `/imRegistration/config/update` | 平台超级管理员 |
读取响应:
```json
{
"registerSmsEnabled": true,
"updatedAt": "2026-08-18T16:00:00+08:00"
}
```
更新请求:
```json
{
"registerSmsEnabled": false
}
```
后端必须在业务逻辑层再次调用 `service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))`,不能只依赖菜单是否可见。更新时只允许修改固定的 `group=im/key=register_sms_enabled`,不接受前端传 group 或 key。
### 7.4 页面位置
建议在 `web/src/views/system/config/system.vue` 增加“IM 注册”设置项,并新增:
```text
web/src/views/system/config/ImRegistrationSetting.vue
```
页面只展示一个“注册短信发送”开关和保存按钮。选项通过 `dict.loadOptions(['sys_switch'])` 加载,并按 7.2 做显式映射。
### 7.5 IM 服务读取约定
HotGo 不需要额外暴露公开配置接口。IM 服务直接从控制库读取:
```sql
SELECT value
FROM hg_sys_config
WHERE `group` = 'im'
AND `key` = 'register_sms_enabled'
AND status = 1
LIMIT 1;
```
约定:
1. 种子默认值为开启。
2. IM 服务缓存建议为 30 秒,缓存时长可配置。
3. 临时读取失败时优先使用最近一次成功值。
4. 进程启动后无缓存且配置缺失、停用或非法时,按“开启”处理,防止邀请码绕过短信验证,并记录告警。
5. HotGo 更新后不承诺立即生效,验收标准为 30 秒内生效。
## 8. 统一租户身份
### 8.1 唯一租户主键
`hotgo.im_tenant.id` 是 HotGo 和 IM 服务共同使用的唯一 `tenant_id`。HotGo 负责维护租户注册信息和业务库路由,IM 服务根据该 ID 选择租户业务库。不得再创建 `im_admin_tenant_binding` 或其他租户映射表。
### 8.2 管理员租户归属
后台管理员记录直接引用统一租户:
```sql
ALTER TABLE hg_admin_member
ADD COLUMN tenant_id BIGINT NULL COMMENT '统一租户ID,引用 im_tenant.id',
ADD INDEX idx_hg_admin_member_tenant (tenant_id),
ADD CONSTRAINT fk_hg_admin_member_im_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id);
```
平台账号的 `tenant_id` 可以为空。租户管理员及其创建的下级后台账号必须继承同一个 `im_tenant.id`。只有平台超级管理员可以为后台账号选择或修改租户,租户域管理员提交的 `tenantId` 必须被后端忽略并以当前登录身份为准。
### 8.3 解析规则
租户管理员请求邀请码接口时:
1. 从 HotGo 登录上下文取得 `member_id`、`tenant_id` 和部门类型。
2. 仅允许合法的租户级管理员角色进入;普通用户和商户角色不得管理邀请码。
3. `tenant_id` 必须大于 0,并且对应的 `im_tenant` 必须存在且为 active。
4. 将该 `tenant_id` 写入 IM 服务调用上下文。
5. 忽略并拒绝浏览器提交的 `tenantId`。
平台超级管理员可以跨租户查看邀请码;列表和导出未选择租户时查询全部。创建、批次查看、撤销及其他写操作必须先选择一个 `im_tenant`。此选择只对 `VerifySuperId` 通过的管理员开放,HotGo 后端仍要检查目标租户存在且 active。
后台用户管理应提供仅平台超级管理员可见的租户选择控件。租户管理员未关联 `im_tenant.id` 时返回明确错误“当前管理员未关联租户”,不能回退到默认租户。
## 9. 租户库直连与事件通道
### 9.1 数据库选择
HotGo 从可信登录上下文取得 `im_tenant.id`。邀请码批次和邀请码主表直接通过 `imtenant.RegistryDB()` 访问 HotGo 控制库;只有密码重置等租户业务操作才通过 `imtenant.DB()` 打开租户业务库。浏览器传入的 `tenantId` 只允许平台超级管理员选择,租户管理员始终使用登录关系中的租户 ID;任何请求都不能提交或覆盖 DSN。
若配置了 `database.imRegistry`,租户注册表从该连接读取;未配置时回退到 HotGo 默认库。业务操作始终使用按租户动态建立的连接组。
### 9.2 Snowflake 配置
HotGo 直接新增 IM 表记录时使用与 IM 相同位布局的 Snowflake ID:
```yaml
imDirectDb:
snowflakeWorkerId: 1023
```
也可由 `IM_DIRECT_DB_SNOWFLAKE_WORKER_ID` 覆盖。每个 HotGo 写库实例必须使用唯一 worker ID,并且不能与 IM 节点重叠。当前单实例默认预留 `1023`,多实例部署前必须分配 worker ID。
### 9.3 写入职责
HotGo 可以写:
1. HotGo 控制库中的 `im_registration_invite_batches`、`im_registration_invite_codes`。
2. 管理员重置密码时的 `im_auth.secret` 和 `im_user_security`。
3. 管理操作产生的 `im_business_events_outbox`。
IM 独占写入:
1. 客户端注册事务及用户创建。
2. 租户业务库中的 `im_registration_invite_uses` 以及同事务的 `registration.invite_used` outbox 事件。
3. 客户端登录、改密、短信找回等认证事务。
### 9.4 Kafka 演进
管理员重置密码在数据库事务内写入:
```json
{
"event_type": "security.password_reset",
"payload": {
"user_id": 198000000000000001,
"auth_version": 2,
"operator_id": 10001,
"request_id": "..."
}
}
```
当前 IM 在注册写入 outbox 后立即触发一次消费,并每秒按在线会话涉及的租户轮询 pending 事件。`security.password_reset` 消费后清退该用户会话;`registration.invite_used` 消费时从租户使用明细重新聚合 `COUNT/MAX(used_at)`,覆盖 HotGo 控制库的 `usage_count/last_used_at`,成功后标记 delivered。离线用户不依赖密码重置轮询,后续登录时由 `auth_version` 直接拒绝旧 Token。
邀请码使用事件契约:
```json
{
"event_type": "registration.invite_used",
"payload": {
"tenant_id": 1,
"invite_code_id": 198000000000000001,
"user_id": 198000000000000002,
"request_id": "acc-request-id",
"used_at": "2026-08-19T10:00:00.000Z"
}
}
```
迁移 Kafka 时,生产者继续在业务事务内写 outbox,由独立投递器发布同一事件;消费者保持事件类型与 payload 不变。Kafka 投递确认后再标记 outbox delivered,避免数据库提交成功但消息丢失。
## 10. 邀请码接口契约
### 10.1 数据表契约
HotGo 在控制库直接维护带 `tenant_id` 的 `im_registration_invite_batches` 和 `im_registration_invite_codes`。邀请码状态仅为 `active/expired/revoked`,使用后仍为 active;`im_registration_invite_uses` 只由 IM 注册事务写入租户库。控制库中的 `usage_count/last_used_at` 由 outbox 消费器幂等投影,HotGo 列表不跨租户库做联表聚合。
平台超级管理员在列表和导出接口中可不传 `tenantId` 以查询全部租户;创建、批次查看、撤销等写操作必须明确选择一个 active 租户。租户管理员只能查询和操作登录关系绑定的 `im_tenant.id`。
邀请码明文不得放入 URL。查询完整邀请码时必须使用 HotGo POST Body,普通请求日志需对 `inviteCode` 脱敏。
### 10.2 HotGo 对浏览器接口
沿用 HotGo 现有命名风格,建议增加:
| 方法 | HotGo 路径 | 说明 |
| --- | --- | --- |
| POST | `/imInvite/list` | 分页查询,过滤条件放在 JSON Body |
| POST | `/imInvite/create` | 单个生成 |
| POST | `/imInvite/batchCreate` | 批量生成 |
| GET | `/imInvite/batchView` | 批次详情 |
| POST | `/imInvite/revoke` | 单个撤销 |
| POST | `/imInvite/batchRevoke` | 批量撤销 |
| POST | `/imInvite/export` | 明文导出,过滤条件放在 JSON Body |
HotGo controller 只做 DTO 转换、调用 service 和包装响应。租户解析、平台管理员判断、权限复核、审计和租户库访问放在 logic/service 层。
### 10.3 单个生成
HotGo 请求:
```json
{
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"idempotencyKey": "7be3de87-994f-46c6-86bd-844ffb54f83f"
}
```
规则:
1. `expiresAt` 必填,必须晚于服务端当前时间。
2. `remark` 最长 255 字符。
3. 推荐单个生成也传幂等键,避免提交成功但响应超时时重复创建。
4. 邀请码由 HotGo 使用 `crypto/rand` 生成并依靠租户库唯一索引兜底,HotGo 不接受用户自定义 code。
响应数据:
```json
{
"id": 198000000000000001,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"createdBy": 10001,
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.4 批量生成
HotGo 请求:
```json
{
"quantity": 100,
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "2026年8月新员工入职",
"idempotencyKey": "4dc7f13e-448c-4b49-b056-57a2fde18f95"
}
```
规则:
1. `quantity` 为 1 到 1000,由 HotGo 校验。
2. 同一个提交动作必须复用同一个幂等键,直到得到明确成功响应。
3. 前端可在打开弹窗时生成 UUID,失败重试时复用;成功或关闭弹窗后废弃。
4. HotGo 在批次表保存幂等键;相同参数重试返回原批次,不重复生成。
响应数据:
```json
{
"batchId": 198000000000000100,
"quantity": 100,
"activeCount": 100,
"usageCount": 0,
"revokedCount": 0,
"expiredCount": 0,
"expiresAt": "2026-09-01T00:00:00+08:00",
"createdAt": "2026-08-18T16:15:00+08:00"
}
```
### 10.5 分页查询
列表请求使用 POST JSON Body,支持字段:
```text
page, perPage, inviteCode, status, batchId, createdBy,
createdAtStart, createdAtEnd, expiresAtStart, expiresAtEnd
```
要求:
1. `inviteCode` 仅支持完整 5 位精确查询,不做模糊查询,不转换大小写。
2. `status` 为 `active/expired/revoked`,邀请码使用后仍保持 `active`。
3. HotGo 查询前将 `expires_at <= now` 的 active 记录更新为 expired,不依赖定时任务。
4. 默认按 `created_at DESC, id DESC` 排序。
5. `perPage` 设置合理上限,建议不超过 100。
列表字段至少包括:
```json
{
"id": 198000000000000001,
"batchId": 198000000000000100,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"usageCount": 12,
"lastUsedAt": "2026-08-20T09:30:00+08:00",
"createdBy": 10001,
"remark": "新员工入职",
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.6 撤销
1. 只有 `active` 且未过期的邀请码可撤销;已有使用记录不影响撤销。
2. 已过期、已撤销的邀请码不能恢复为 active。
3. 批量撤销只撤销批次内仍可撤销的码,并返回各状态统计。
4. 页面操作前显示确认框,确认内容不必重复显示完整邀请码。
5. 最终状态由租户库条件更新结果决定,不以前端状态为准。
### 10.7 导出
1. 导出必须具备独立 `/imInvite/export` 权限。
2. 导出过滤条件与列表一致。
3. HotGo 后端分页读取租户库并流式写出,不把整份文件加载到内存。
4. 建议输出带 UTF-8 BOM 的 CSV,字段至少包含邀请码、状态、批次、到期时间、累计使用次数、最后使用时间、创建人、创建时间和备注。
5. 文件名建议为 `registration-invite-codes-YYYYMMDD-HHmmss.csv`。
6. 每次导出必须写审计,但审计详情不得包含邀请码明文列表。
## 11. 页面和交互
建议页面路径:
```text
web/src/views/im/registrationInvite/index.vue
web/src/views/im/registrationInvite/model.ts
web/src/views/im/registrationInvite/CreateModal.vue
web/src/views/im/registrationInvite/BatchCreateModal.vue
web/src/views/im/registrationInvite/BatchViewModal.vue
web/src/api/im/registrationInvite.ts
```
页面放在“IM 管理 / 注册邀请码”菜单下。使用现有 `BasicForm`、`BasicTable`、`TableAction`、`usePermission` 和 Naive UI 组件。
页面必须具备:
1. 完整邀请码、状态、批次 ID、创建人、创建时间和到期时间筛选。
2. 单个生成按钮。
3. 批量生成按钮。
4. 导出按钮。
5. 列表展示完整邀请码、状态、到期时间、批次、使用情况、创建信息和备注。
6. active 行提供撤销操作,其他状态不显示撤销操作。
7. 批次详情展示总数、active、used、expired、revoked 统计。
8. 所有日期时间明确按本地时区展示,提交时发送带时区的 RFC 3339 时间。
9. 加载、空数据、接口错误、重复提交和无权限状态处理完整。
邀请码区分大小写,任何输入组件、复制操作和查询参数都不得自动转为大写或小写。
## 12. 菜单与权限
HotGo 的权限标识使用接口路径。建议创建以下按钮权限:
```text
/imRegistration/config/get
/imRegistration/config/update
/imInvite/list
/imInvite/create
/imInvite/batchCreate
/imInvite/batchView
/imInvite/revoke
/imInvite/batchRevoke
/imInvite/export
```
权限分配:
| 能力 | 平台超级管理员 | 租户管理员 | 其他角色 |
| --- | --- | --- | --- |
| 查看/修改短信开关 | 允许 | 禁止 | 禁止 |
| 选择任意 IM 租户管理邀请码 | 允许 | 禁止 | 禁止 |
| 管理当前绑定租户邀请码 | 允许 | 按权限允许 | 禁止 |
| 查看邀请码明文 | 允许 | 需独立权限 | 禁止 |
| 导出邀请码明文 | 允许 | 需独立权限 | 禁止 |
服务端 Casbin、业务逻辑层租户范围校验、前端按钮隐藏三层都要实现。前端隐藏按钮不是安全边界。
## 13. 日志脱敏与审计
### 13.1 必须修复现有访问日志泄露
`server/internal/logic/sys/log.go` 当前会保存 Query 和 Body,且只掩码 password。至少增加以下字段名的大小写不敏感脱敏:
```text
inviteCode
invite_code
serviceSecret
service_secret
authorization
```
建议抽成统一的递归敏感字段过滤器,同时处理 Query、JSON Body、Form 和 Header。Header 中已有简化逻辑也必须确认不会保存内部服务凭证。
对 `/imInvite/list` 的明文精确查询,邀请码只能放在 POST Body,`hg_sys_log.post_data` 中只能出现 `******`。`hg_sys_log.get_data`、Nginx URL 和其他访问日志中都不能出现真实邀请码。HTTP Client 的 debug 日志同样不得打印完整 URL、Query、请求体或响应体。
### 13.2 业务审计
仅靠普通访问日志不够。建议在 HotGo 控制库增加独立审计表,或者接入项目已有的等价不可变审计设施。最低字段为:
```sql
CREATE TABLE im_admin_operation_audit (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
tenant_id BIGINT NULL,
operator_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NULL,
result VARCHAR(16) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
detail_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_im_admin_audit_tenant_time (tenant_id, created_at),
KEY idx_im_admin_audit_operator_time (operator_id, created_at),
UNIQUE KEY uk_im_admin_audit_request_action (request_id, action)
);
```
必须审计:
1. 短信开关修改,记录旧值和新值。
2. 单个生成和批量生成,记录 code ID 或 batch ID、数量和到期时间,但不记录 code 明文。
3. 邀请码明文查询和详情查看,记录查询行为,但不记录搜索明文。
4. 单个撤销和批量撤销。
5. 明文导出,记录过滤条件摘要和导出数量,不记录明文内容。
6. 后台管理员租户归属变更。
IM 服务也应写自己的业务审计。HotGo 审计记录后台操作者,IM 审计记录实际租户库操作,两者通过 `request_id` 关联。
## 14. 错误处理
HotGo 应将数据库错误稳定映射为后台可理解的错误,不向浏览器返回 DSN、SQL、堆栈或内部配置。
至少处理:
| 场景 | HotGo 行为 |
| --- | --- |
| 当前管理员未关联租户 | 返回明确业务错误,不使用默认租户 |
| 目标租户停用 | 拒绝操作 |
| 无查看/导出权限 | 403 |
| 邀请码已过期或已撤销 | 显示不可撤销,不本地修改状态 |
| 幂等键重复且请求相同 | 返回原结果 |
| 幂等键重复但参数不同 | 返回幂等冲突 |
| 租户库连接失败 | 返回“租户业务库暂不可用”,保留 Request ID |
| 数据库写入失败 | 事务回滚,返回统一后台错误并记录脱敏日志 |
| 导出数据过大 | 使用服务端限制或异步导出,不耗尽内存 |
## 15. 建议代码落点
遵循现有 HotGo 分层,建议新增或修改:
```text
server/api/admin/imregistration/
server/api/admin/iminvite/
server/internal/controller/admin/admin/im_registration.go
server/internal/controller/admin/admin/im_invite.go
server/internal/model/input/adminin/im_registration.go
server/internal/model/input/adminin/im_invite.go
server/internal/service/im.go
server/internal/logic/admin/im_registration.go
server/internal/logic/admin/im_invite.go
server/internal/library/imtenant/db.go
server/internal/library/imtenant/snowflake.go
server/internal/router/admin.go
server/internal/logic/sys/log.go
web/src/api/im/registrationInvite.ts
web/src/views/im/registrationInvite/
web/src/views/system/config/ImRegistrationSetting.vue
web/src/views/system/config/system.vue
```
如果新增 `im_admin_operation_audit`,按 HotGo 现有 GoFrame DAO 生成流程生成 `dao/do/entity`,不要手工修改标有 “Code generated” 的文件。禁止新增 `im_admin_tenant_binding`。
同时提交:
1. 生产数据库幂等迁移。
2. 新装数据库初始化数据。
3. 菜单和按钮权限种子。
4. 默认角色授权或明确的部署授权步骤。
5. Snowflake worker 配置示例,但不提交真实 DSN。
## 16. 测试清单
### 16.1 短信开关
1. 无配置时 IM 按开启处理并告警。
2. 平台超级管理员可查看和修改。
3. 租户管理员即使手工调用接口也不能修改。
4. UI 选择“关闭”后数据库保存 `0`,不是 `2`。
5. UI 重新加载时 `0` 正确显示为“关闭”。
6. IM 服务在 30 秒内读取到新值。
7. 开关审计包含操作者、旧值、新值和 Request ID。
### 16.2 租户隔离
1. 租户 A 管理员只能看到租户 A 邀请码。
2. 修改 Query、Body 或 Header 中的 tenant ID 不能访问租户 B。
3. 未绑定管理员不能回退到 default 租户。
4. 停用租户不能生成、查询、撤销或导出。
5. 平台超级管理员选择租户后只能操作所选租户,切换时列表立即清空并重新请求。
### 16.3 邀请码
1. 单个生成返回恰好 5 位 `[A-Za-z]`。
2. 批量生成数量正确,最大值限制生效。
3. `AbCdE` 和 `abcde` 可作为不同邀请码精确查询。
4. 重复提交相同幂等键不产生第二个批次。
5. active 可撤销,expired/revoked 不可撤销。
6. 同一 active 邀请码可供多个不同用户注册,累计使用次数正确增长且状态保持 active。
7. 列表、详情和导出权限相互独立。
8. 导出文件数量、过滤条件和明文字段正确。
### 16.4 安全和日志
1. `hg_sys_log.get_data/post_data/header_data` 中找不到真实邀请码和服务 secret。
2. 应用日志中找不到邀请码明文、内部 Token 和 Authorization。
3. 每个敏感操作都有 HotGo 审计和 IM 审计,并可通过 Request ID 对应。
4. 无权限接口返回 403,不能只依赖前端按钮隐藏。
5. 数据库连接和 SQL 错误不泄露内部实现信息。
## 17. 完成标准
满足以下条件才能认为 HotGo 第 4 项完成:
1. 全局注册短信开关可由平台超级管理员配置,关闭值正确保存为 `0`。
2. IM 服务能在约定缓存时间内读取开关,缺失和异常时采用安全默认值。
3. HotGo 管理员身份能可靠映射到 `im_tenant.id`,不存在默认租户回退。
4. 邀请码单个、批量、查询、详情、撤销和导出全部通过所选租户库完成。
5. 平台与租户权限、跨租户隔离和服务端复核全部通过测试。
6. 邀请码明文只出现在授权页面和授权导出中,不进入普通日志。
7. 配置修改和邀请码敏感操作均有可关联的审计记录。
8. 后端 Go 测试、前端类型检查/构建和核心页面联调通过。
## 18. 新会话可直接使用的任务说明
在新的开发会话中可直接发送:
```text
请修改 HotGo 项目 /Users/youshunlai/go_workspace/im_manager。
先完整阅读 /Users/youshunlai/go_workspace/im_server/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md,
再阅读其中列出的上游 IM 文档。按照交接文档的开发顺序完成 HotGo 的注册短信全局开关、
统一租户身份、租户库直连、邀请码管理后台、菜单权限、日志脱敏和审计。
只允许通过服务端 `im_tenant` 注册信息选择租户库,不要信任浏览器自报 tenant_id 或 DSN,
不要修改 `im_registration_invite_uses`,不要把邀请码明文写入普通日志。
开始前先检查 im_manager 当前工作区已有改动,并保留与本任务无关的用户修改。
```
@@ -0,0 +1,446 @@
# IM bootstrap 入口与未读数改造需求文档
## 1. 背景
当前 `GET /v1/im/bootstrap` 已返回登录后的功能开关、权限、工作台模块、系统 Topic 和安全摘要。前端同事在接入时提出以下问题:
1. 新朋友未处理数、群列表、文件助手和系统消息入口是否都由 bootstrap 控制显示。
2. 未读数字段分别从哪里读取。
3. 工作台是否需要总未读数。
4. 文件助手和系统消息是否完全依赖 WebSocket Topic 推送,还是 bootstrap 也要返回入口和初始未读。
5. 文档需要给出完整响应示例,避免入口字段和里层结构继续口头确认。
现有前后端对接文档已有基础约定:
1. 登录后客户端必须执行 `sub me`,从 `meta.sub` 获取每个会话 Topic 的 `seq/read/recv`。
2. 普通聊天会话未读数使用 `max(seq - read, 0)`。
3. `bootstrap` 返回固定入口、功能开关、权限和系统 Topic。
4. `workbench.modules[].unread_count` 已在结构中定义,但当前实现固定为 0。
缺口在于:bootstrap 没有明确返回通讯录入口、系统消息入口、文件助手入口、工作台总未读和业务 badge;文档也没有明确哪些未读来自 Topic,哪些未读来自业务表统计。
## 2. 目标
1. 明确 `bootstrap` 是登录后首屏入口配置和初始 badge 的聚合接口。
2. 明确 Topic 类未读和业务类未读的分工。
3. 为前端提供稳定响应结构,减少后续沟通成本。
4. 保持聊天实时链路轻量:普通聊天、群聊、文件助手和系统消息的实时增量仍通过 Tinode WebSocket Topic 更新。
5. 为后续工作台 badge、系统通知和好友申请的实时事件留出兼容空间。
## 3. 非目标
1. 不把所有会话列表塞入 bootstrap;普通会话列表仍通过 `sub me` 和会话搜索/列表能力获取。
2. 不用 bootstrap 替代 WebSocket 增量更新。
3. 不让客户端根据本地按钮状态绕过服务端权限校验。
4. 不要求本期实现复杂实时聚合服务;可先返回准确初始值,再通过已有 Topic 或系统事件增量更新。
## 4. 概念定义
| 名称 | 含义 |
| --- | --- |
| 入口 entry | 登录后首页、通讯录、工作台等固定可见入口配置 |
| Topic 未读 | 由 Tinode Topic 的 `seq/read/recv` 推导出的消息未读 |
| 业务 badge | 不能用 Topic `seq/read` 表达的业务待处理数量,例如新朋友待处理申请 |
| 工作台模块未读 | `modules[].unread_count`,表示单个工作台模块的业务未读 |
| 工作台总未读 | 所有可见工作台模块未读汇总,供底部 Tab 或一级入口展示 |
| 系统消息 | 服务端可信系统通知,展示页可查 HTTP 列表,实时通知可走系统 Topic 或系统事件 |
| 文件助手 | 用户自己的固定会话入口,用于收藏/文件转发/个人文件消息;若实现为 Topic,则未读遵循 Topic 规则 |
## 5. 未读数来源规则
### 5.1 Topic 类未读
以下未读原则上来自 Tinode Topic:
1. 单聊会话。
2. 群聊会话。
3. 系统消息入口,如果系统消息以 `system_topic.topic` 作为可信 Topic 承载。
4. 文件助手入口,如果文件助手以固定 Topic 承载。
计算规则:
```text
topic_unread_count = max(seq - read, 0)
```
客户端登录后必须执行:
```text
WebSocket Upgrade -> hi -> token login -> sub me -> get sub/data
```
初始会话列表从 `meta.sub` 获取,后续通过 `{data}`、`{meta}`、`{pres}`、`{info}` 和 `note read/recv` 更新本地状态。
### 5.2 业务类未读
以下未读不适合用 Topic `seq/read` 计算,应由 HTTP 业务接口或 bootstrap 返回:
1. 新朋友待处理申请数。
2. 工作台模块待处理数,例如会议、日报、月报、总经理信箱。
3. 未来的审批、公告、任务等非聊天会话类 badge。
业务类未读的真源在业务表,bootstrap 返回登录后的初始聚合值;后续增量可通过系统事件通知前端刷新或局部更新。
## 6. bootstrap 响应结构扩展
### 6.1 顶层新增字段
`GET /v1/im/bootstrap` 的 `data` 建议新增:
```json
{
"entry_badges": {
"contacts_total_unread": 2,
"new_friends_pending_count": 2,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": 0,
"workbench_total_unread": 5
},
"entries": {
"new_friends": {
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": 2
},
"groups": {
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0
},
"file_assistant": {
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic"
},
"system_messages": {
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": 0,
"unread_source": "business"
}
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `entry_badges.contacts_total_unread` | int | 通讯录一级入口红点总数;本期可等于 `new_friends_pending_count` |
| `entry_badges.new_friends_pending_count` | int | 当前用户待处理入站好友申请数 |
| `entry_badges.groups_unread_count` | int | 群入口业务未读;若只是群聊消息未读,则可为 0,由会话列表负责 |
| `entry_badges.file_assistant_unread_count` | int | 文件助手入口初始未读 |
| `entry_badges.system_unread_count` | int | 系统消息入口初始未读 |
| `entry_badges.workbench_total_unread` | int | 工作台可见模块未读总和 |
| `entries.*.enabled` | bool | 是否展示入口 |
| `entries.*.route` | string | 前端内部路由,只允许白名单内部路径 |
| `entries.*.topic` | string | 若入口对应 Topic,则返回 Topic 名;没有 Topic 时省略 |
| `entries.*.unread_count` | int | 当前入口初始未读或待处理数 |
| `entries.*.unread_source` | string | `topic` 或 `business`,用于前端判断后续更新来源 |
### 6.2 unread_source 约定
| unread_source | 含义 | 后续更新方式 |
| --- | --- | --- |
| `topic` | 未读来自 Topic `seq/read` | WebSocket Topic 增量和 `note read/recv` |
| `business` | 未读来自业务表统计 | 业务 HTTP 刷新或系统事件 |
| `none` | 入口无未读概念 | 不展示 badge |
`new_friends.unread_source` 可省略,默认按 `business` 处理。若为了统一,也可以显式返回:
```json
{
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": 2,
"unread_source": "business"
}
```
## 7. 工作台 unread 规则
当前 `modules` 结构保留:
```json
{
"code": "meetings",
"name": "会议通知",
"icon_file_id": "",
"route": "/meetings",
"unread_count": 3,
"enabled": true,
"permission": "enabled"
}
```
新增规则:
1. `modules[].unread_count` 是模块级业务未读。
2. `entry_badges.workbench_total_unread` 是所有可见且 `enabled=true` 模块的未读总和。
3. 隐藏或无权限模块不计入总未读。
4. 后续增量事件使用 `workbench.badge.updated`:
```json
{
"type": "system_event",
"event_type": "workbench.badge.updated",
"event_id": "evt_...",
"created_at": "2026-08-20T12:00:00.000Z",
"params": {
"module_code": "meetings",
"unread_count": 4,
"workbench_total_unread": 6,
"workbench_version": 1787153039
}
}
```
前端收到后只更新对应模块和工作台总未读;如果 `workbench_version` 大于本地版本,应重新调用 `GET /v1/im/workbench` 或 `GET /v1/im/bootstrap`。
## 8. 系统消息规则
系统消息有两个视角:
1. 列表查询:`GET /v1/im/system-notifications?cursor=` 返回历史通知列表。
2. 实时增量:通过服务端可信系统事件推送。
bootstrap 返回:
```json
{
"system_topic": {
"topic": "sys",
"type": "system"
},
"entries": {
"system_messages": {
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": 0,
"unread_source": "business"
}
}
}
```
实现要求:
1. 普通用户不能向系统 Topic 发布可信系统消息。
2. 客户端不能把普通用户伪造的 `system_event` 渲染为可信通知。
3. 本期系统消息未读使用 `im_system_notifications.read_at` 统计,`unread_source` 固定为 `business`,并由服务端返回准确 `system_unread_count`。
本期约定:系统消息入口保留 `topic:"sys"` 作为入口标识和后续事件通道标识,初始未读数以系统通知业务表为准。若后续确认普通用户不能订阅 `sys`,应改为每用户系统通知 Topic 或纯 HTTP 通知列表 + business badge。
## 9. 文件助手规则
文件助手需要先明确产品实现形态:
### 9.1 Topic 形态
如果文件助手是用户自己的固定 Topic,例如 `slf` 或专用 `file_assistant_topic`:
```json
{
"file_assistant": {
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic"
}
}
```
未读通过 `sub me` 中该 Topic 的 `seq/read` 计算。
### 9.2 纯入口形态
如果文件助手只是本地入口,不对应消息 Topic:
```json
{
"file_assistant": {
"enabled": true,
"route": "/files/assistant",
"unread_count": 0,
"unread_source": "none"
}
}
```
此时不展示消息未读,只展示入口。
本期建议采用 Topic 形态,便于复用消息、文件、转发、未读和多端同步能力。
## 10. 通讯录入口规则
通讯录页包含以下固定入口:
1. 新朋友。
2. 群聊。
3. 文件助手。
4. 系统消息。
bootstrap 控制入口展示和初始 badge。具体列表仍由各自接口负责:
| 入口 | 展示控制 | 初始 badge | 列表数据 |
| --- | --- | --- | --- |
| 新朋友 | `entries.new_friends.enabled` | `new_friends_pending_count` | `GET /v1/im/friend-requests` |
| 群聊 | `entries.groups.enabled` | 群业务 badge,群消息未读走 Topic | 群列表接口或 `sub me` 中群 Topic |
| 文件助手 | `entries.file_assistant.enabled` | Topic 或 none | Topic 消息或文件助手专用接口 |
| 系统消息 | `entries.system_messages.enabled` | Topic 或 business | `GET /v1/im/system-notifications` |
## 11. 完整 bootstrap 示例
```json
{
"code": 0,
"data": {
"features": {
"create_group": true,
"message_edit": true,
"message_recall": true,
"scan_qr": true,
"system_notifications": true,
"user_search": true
},
"permissions": {
"create_group": true,
"scan_qr": true,
"user_search": true
},
"entry_badges": {
"contacts_total_unread": 2,
"new_friends_pending_count": 2,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": 1,
"workbench_total_unread": 5
},
"entries": {
"new_friends": {
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": 2,
"unread_source": "business"
},
"groups": {
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0,
"unread_source": "business"
},
"file_assistant": {
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic"
},
"system_messages": {
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": 1,
"unread_source": "business"
}
},
"modules": [
{
"code": "organization",
"enabled": true,
"icon_file_id": "",
"name": "组织架构",
"permission": "enabled",
"route": "/organization",
"unread_count": 0
},
{
"code": "meetings",
"enabled": true,
"icon_file_id": "",
"name": "会议通知",
"permission": "enabled",
"route": "/meetings",
"unread_count": 3
}
],
"workbench_version": 1787153039,
"system_topic": {
"topic": "sys",
"type": "system"
},
"message_policy": {
"edit_window_seconds": 300,
"recall_window_seconds": 120,
"supported_actions": ["copy", "quote", "delete", "recall", "edit"]
},
"security": {
"realname": {
"allow_skip": true,
"remind_after": "24h",
"required": false,
"status": "unverified"
}
}
},
"request_id": "req_xxx",
"server_time": "2026-08-20T12:00:00.000Z"
}
```
## 12. 前端处理规则
1. bootstrap 成功后,先渲染固定入口和初始 badge。
2. 随后完成 WebSocket `sub me`,用 Topic `seq/read` 覆盖 Topic 类入口未读。
3. `unread_source=topic` 的入口以后以 Topic 状态为准。
4. `unread_source=business` 的入口以后以业务接口刷新或系统事件为准。
5. 切租户、退出登录、切账号时必须清空本地入口和 badge 缓存。
6. 前端不得把 `tenant_id`、内部数据库 ID 或未在白名单中的 route 写入跳转。
## 13. 服务端实现要求
1. `serveIMBootstrap` 增加 `entry_badges` 和 `entries`。
2. `serveIMBootstrap` 的入口数据必须按当前 Token 的租户和用户权限裁剪。
3. `route` 必须走服务端白名单,不能从数据库任意透传外部 URL。
4. 新朋友待处理数按当前租户、当前用户、入站、待处理状态统计。
5. 工作台 `modules[].unread_count` 从业务统计或模块 unread provider 获取,不再固定为 0。
6. `workbench_total_unread` 只汇总当前用户可见模块。
7. 文件助手和系统消息的 `topic` 必须是当前租户和当前用户有权限访问的 Topic。
8. 如果某入口当前未实现,仍可返回 `enabled:false`,前端隐藏入口。
## 14. 后端建议实现顺序
1. 扩展 bootstrap 响应结构,先返回固定入口和 0 badge,确保前端结构稳定。
2. 增加新朋友待处理数统计。
3. 增加工作台模块 unread provider 和总未读。
4. 明确文件助手 Topic,补齐入口 topic 和未读来源。
5. 明确系统消息未读来源;若 `sys` 不适合普通用户订阅,则改为业务 unread。
6. 增加系统事件 `workbench.badge.updated` 和入口 badge 更新事件。
7. 更新前后端对接文档,把本需求中的响应示例合并到 bootstrap 章节。
## 15. 验收用例
1. 新用户登录调用 bootstrap,返回 `entries`、`entry_badges`、`modules`、`workbench_version`。
2. 有 2 条待处理好友申请时,`new_friends_pending_count=2`,`entries.new_friends.unread_count=2`。
3. 没有待处理好友申请时,新朋友入口仍展示但 unread 为 0。
4. 工作台有两个可见模块未读分别为 3 和 2 时,`workbench_total_unread=5`。
5. 隐藏模块未读不计入 `workbench_total_unread`。
6. `unread_source=topic` 的入口在 `sub me` 返回后可用 Topic `seq/read` 覆盖初始 unread。
7. 切换租户后 bootstrap 返回另一租户入口和 badge,本地旧 badge 不复用。
8. 普通用户构造外部 route 或系统消息字段不能影响 bootstrap 返回。
9. 未实现入口返回 `enabled:false` 时前端隐藏入口。
10. 接口响应字段保持向后兼容,旧客户端忽略新增字段仍可正常工作。
## 16. 待确认问题
1. 文件助手最终使用 `slf` Topic,还是新增专用文件助手 Topic。
2. 普通用户系统消息入口是否继续使用 `sys`,还是改为每用户系统通知 Topic 或纯 HTTP 通知列表。
3. 群聊入口是否需要业务未读,还是完全由群 Topic 会话列表承担。
4. 工作台每个模块的 unread provider 由 IM 直接查库,还是由 HotGo/业务库提供统计。
5. 是否需要新增统一 badge 增量事件,例如 `entry.badge.updated`。
+14 -4
View File
@@ -24,7 +24,7 @@
功能说明:
展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。
展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。登录成功后安全保存 Token 和过期时间;应用重启或断线重连时优先使用未过期 Token 自动登录,并用服务端返回的新 Token 覆盖旧值。Token 过期或失效后返回账号密码登录。
### 1.3 登录页面-密码错误.png
@@ -40,7 +40,17 @@
功能说明:
提供用户名/手机号、短信验证码、密码和确认密码输入。需支持获取验证码、密码显隐、两次密码一致性校验、已有账号返回登录以及协议勾选。
提供独立的用户名和手机号输入框,两个字段二选一,允许同时填写但手机号优先。需支持获取验证码、密码显隐、已有账号返回登录以及协议确认。
业务规则:
1. 普通用户名只允许 1~10 位小写英文字母或数字,允许纯数字,不允许大写、中文、空格和符号。
2. 手机号只允许 11 位数字,服务端固定补 `+86`。
3. 手机号非空时走手机号方式并忽略用户名输入;注册后 `username` 和 `phone` 均保存规范化手机号。手机号为空时才走用户名方式,`phone=NULL`。
4. 邀请码固定为 5 位大小写英文字母,区分大小写,不允许数字和符号。
5. 用户名方式不发送短信,必须填写有效邀请码;注册前提示“未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置”。
6. 短信开关开启时,手机号方式必须完成验证码验证,邀请码不能绕过验证码。
7. 短信开关关闭时,点击获取验证码仍显示正常倒计时,但服务端不发送短信;手机号方式必须填写有效邀请码才能无验证码注册。
### 1.5 注册页面-倒计时.png
@@ -48,7 +58,7 @@
功能说明:
展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。
展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。短信开关关闭时前端交互和倒计时保持不变,但用户不会收到短信,最终是否允许注册由服务端结合邀请码判断。
### 1.6 忘记密码.png
@@ -56,7 +66,7 @@
功能说明:
通过手机号、验证码、新密码和再次确认完成密码重置。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录。
通过手机号、验证码、新密码和再次确认完成密码重置,仅适用于已保存手机号的账号。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录;无手机号的用户名账号应提示联系当前企业管理员重置密码。
### 1.7 身份验证(安卓).png
+66
View File
@@ -0,0 +1,66 @@
-- IM 企业入口注册与问题反馈 SQL
-- 更新时间:2026-08-24
--
-- 执行范围:hotgo 控制库。
-- 说明:未登录企业注册、入口问题反馈发生在租户绑定前,因此公共附件和申请单不放入租户业务库。
-- 文件对象通过 IM 服务端当前 media handler 存储;生产环境应配置 media.use_handler=oss。
USE hotgo;
CREATE TABLE IF NOT EXISTS im_public_uploads (
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
mime_type VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
etag VARCHAR(128) NOT NULL DEFAULT '',
location VARCHAR(2048) NOT NULL DEFAULT '',
expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id),
INDEX idx_im_public_uploads_purpose(purpose, created_at),
INDEX idx_im_public_uploads_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_tenant_applications (
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
license_file_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
estimated_review_hour INT NOT NULL DEFAULT 24,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_applications_app(application_id),
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
INDEX idx_im_tenant_applications_credit(credit_code),
INDEX idx_im_tenant_applications_status(status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks (
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(255) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
screenshot_file_ids JSON,
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_entry_feedbacks_feedback(feedback_id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
INDEX idx_im_tenant_entry_feedbacks_status(status, id),
INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+596 -34
View File
@@ -58,7 +58,7 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或
| --- | --- | --- | --- |
| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 |
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <access_token>` | 好友、群、收藏、搜索、工作台和设置 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
@@ -135,6 +135,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
```text
读取客户端预置的 host 和普通 API Key
-> 用户输入/客户端读取 tenant_code
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
-> 带 API Key 完成 WebSocket Upgrade
-> hi(tenant_code)
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
@@ -146,7 +147,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
-> 进入可发送状态
```
当前服务端没有 `/v1/im/tenants/resolve`,因此现阶段企业码是否有效是在第一帧 `{hi}` 中校验的。只有 WebSocket Upgrade 返回 `101 Switching Protocols` 后才能发送下面的 JSON 文本帧。
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
`hi` 请求:
@@ -164,7 +165,9 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。
{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}}
```
`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。
`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。
当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。
`hi` 成功示例:
@@ -249,9 +252,9 @@ X-Tinode-APIKey: <预置的普通 API Key>
`sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。
#### 0.5.3 目标产品流程
#### 0.5.3 企业解析与产品流程
第 1.1 节规划的 `POST /v1/im/tenants/resolve` 属于新增接口。实现后目标流程调整为:
第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为:
```text
读取预置 API Key
@@ -261,7 +264,125 @@ X-Tinode-APIKey: <预置的普通 API Key>
-> login -> sub me -> 恢复会话
```
企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。
企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。
#### 0.5.4 WSS 契约输入输出示例
本节用于 WebSocket 联调。除特别说明外:
- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-Tinode-APIKey`。
- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。
- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。
- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。
- 成功控制包一般为 `{ctrl.code=2xx}`;失败控制包一般为 `{ctrl.code=4xx/5xx}`,产品稳定错误优先读 `ctrl.params.reason`,没有时按 `code/text` 兜底。
##### 0.5.4.1 连接、握手、登录和注册
| 场景 | 客户端发送 | 服务端返回示例 |
| --- | --- | --- |
| WebSocket Upgrade | `GET /v0/channels?apikey=AQ...` | HTTP `101 Switching Protocols`;API Key 无效时 HTTP `403` |
| 租户握手 `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":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-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"}}}` |
| 登录失败 | `{"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"}}}` |
##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 |
| --- | --- | --- |
| 订阅本人 `me` | `{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}` | `{"ctrl":{"id":"sub-me-1","code":200,"text":"ok"}}`,随后可能收到 `{"meta":{"id":"sub-me-1","topic":"me","desc":{"public":{"fn":"alice"}},"sub":[{"topic":"grp...","public":{"name":"项目群"},"seq":12,"read":10,"recv":12,"updated":"2026-08-20T09:00:00Z"}]}}` |
| 订阅查找 `fnd` | `{"sub":{"id":"sub-fnd-1","topic":"fnd"}}` | `{"ctrl":{"id":"sub-fnd-1","code":200,"text":"ok"}}` |
| 订阅会话并拉历史 | `{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":1,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"ctrl":{"id":"sub-topic-1","code":200,"text":"ok"}}`,随后 `{"meta":{"id":"sub-topic-1","topic":"grp...","desc":{"public":{"name":"项目群"},"seq":12},"sub":[{"user":"usr...","mode":"JRWP"}]}}` 和多条 `{data}` |
| 断线恢复增量 | `{"sub":{"id":"resume-grp-1","topic":"grp...","get":{"what":"data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"data":{"topic":"grp...","seq":125,"from":"usr...","ts":"2026-08-20T09:00:00Z","content":{"text":"恢复后的第一条"}}}` |
| 只拉元数据 | `{"get":{"id":"get-topic-1","topic":"grp...","what":"desc sub"}}` | `{"meta":{"id":"get-topic-1","topic":"grp...","desc":{"seq":12,"public":{"name":"项目群"}},"sub":[{"user":"usr...","read":10,"recv":12}]}}` |
| 离开/取消订阅 | `{"leave":{"id":"leave-1","topic":"grp...","unsub":false}}` | `{"ctrl":{"id":"leave-1","code":200,"text":"ok"}}` |
##### 0.5.4.3 发送消息、媒体卡片、名片和转发卡片
| 类型 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` |
| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` |
| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` |
| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` |
| 名片消息 | `{"pub":{"id":"pub-card-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"contact_card","x-im-client-mid":"mid_card_1"},"content":{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan"}}}` | `{"ctrl":{"id":"pub-card-1","code":202,"params":{"seq":16}}}` |
| 合并转发卡片 | `{"pub":{"id":"pub-fwd-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"forward_bundle","x-im-client-mid":"mid_fwd_1"},"content":{"bundle_id":"fbundle_1","title":"聊天记录","item_count":3,"preview":[{"from_display":"张三","summary":"你好"}]}}}` | `{"ctrl":{"id":"pub-fwd-1","code":202,"params":{"seq":17}}}` |
| @成员消息 | `{"pub":{"id":"pub-mention-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_at_1","mentions":[{"user_id":"usr...","offset":0,"length":3}]},"content":{"text":"@张三 看一下"}}}` | `{"ctrl":{"id":"pub-mention-1","code":202,"params":{"seq":18}}}` |
| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` |
| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 |
##### 0.5.4.4 回执、正在输入、删除和清空记录
| 场景 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 送达回执 | `{"note":{"topic":"p2p...","what":"recv","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"recv","seq":12}}` |
| 已读回执 | `{"note":{"topic":"p2p...","what":"read","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"read","seq":12}}`;客户端本地把该 topic `read` 更新为 12 |
| 正在输入 | `{"note":{"topic":"p2p...","what":"kp"}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"kp"}}`,本地超时消失 |
| 正在录音可选 | `{"note":{"topic":"grp...","what":"kpa"}}` | 对端收到 `{"info":{"topic":"grp...","from":"usr...","what":"kpa"}}`,不落库 |
| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` |
| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` |
##### 0.5.4.5 Topic 资料、个人群设置和标签
| 场景 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 更新群公开资料 | `{"set":{"id":"set-grp-1","topic":"grp...","desc":{"public":{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2}}}}` | `{"ctrl":{"id":"set-grp-1","code":200,"text":"ok"}}`;成员收到 `{"pres":{"topic":"grp...","what":"upd"}}` |
| 更新个人资料 | `{"set":{"id":"set-me-1","topic":"me","desc":{"public":{"fn":"新昵称","avatar_file_id":"file_avatar"}}}}` | `{"ctrl":{"id":"set-me-1","code":200,"text":"ok"}}` |
| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` |
| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` |
##### 0.5.4.6 系统事件、好友和群业务下行
| 事件 | 服务端下行示例 | 客户端动作 |
| --- | --- | --- |
| 好友申请创建 | `{"data":{"topic":"sys","seq":101,"from":"usr_system","head":{"mime":"application/json","x-im-type":"system_event"},"content":{"event_type":"friend.request.created","request_id":"frq_1","applicant":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","action":{"type":"friend_request","request_id":"frq_1"}}}}` | 系统消息入库/展示,刷新 `friend-requests` 和 `bootstrap.entry_badges` |
| 好友关系变化 | `{"data":{"topic":"sys","seq":102,"head":{"x-im-type":"system_event"},"content":{"event_type":"friend_relationship_changed","peer_user_id":"usr_a","relationship":"friend","topic":"p2p..."}}}` | 刷新会话 context,解锁或锁定输入区 |
| 群邀请 | `{"data":{"topic":"sys","seq":103,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_invitation","invitation_id":"ginv_1","group":{"topic":"grp...","name":"项目群"},"inviter":{"user_id":"usr_a","display_name":"张三"},"status":"pending","expires_at":"2026-08-27T09:00:00Z"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 |
| 入群申请 | `{"data":{"topic":"grp...","seq":20,"head":{"x-im-type":"system_event"},"content":{"event_type":"group.join_request.created","request_id":"gjr_1","applicant":{"user_id":"usr_a","display_name":"张三"},"source":"qr"}}}` | 管理员刷新 `join-requests` 和 `summary.pending_join_request_count` |
| 群成员变化 | `{"data":{"topic":"grp...","seq":21,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_membership_changed","operator":"usr_owner","target_users":["usr_a"],"action":"join","result":"joined"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 |
| 工作台未读变化 | `{"data":{"topic":"sys","seq":104,"head":{"x-im-type":"system_event"},"content":{"event_type":"workbench.badge.updated","modules":[{"code":"meetings","unread_count":2}],"workbench_total_unread":2}}}` | 更新工作台 badge;必要时重新拉 `GET /v1/im/workbench` |
##### 0.5.4.7 WebRTC 通话信令
| 场景 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 发起语音通话 | `{"pub":{"id":"call-pub-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call","x-im-client-mid":"mid_call_1"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-pub-1","code":202,"text":"accepted","params":{"seq":30}}}`;被叫收到同一 `{data seq=30}` |
| 被叫响铃 | `{"note":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | 主叫收到 `{"info":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` |
| 被叫接听 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":30,"payload":{"callId":"call_1"}}}` | 双方收到通话状态 `accept`;原消息可被服务端替换为 `head.webrtc:"accepted"` |
| SDP offer | `{"note":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` |
| SDP answer | `{"note":{"topic":"p2p...","what":"call","event":"answer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `event:"answer"` |
| ICE candidate | `{"note":{"topic":"p2p...","what":"call","event":"ice-candidate","seq":30,"payload":{"callId":"call_1","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}}` | 对端收到 `event:"ice-candidate"` |
| 挂断/取消 | `{"note":{"topic":"p2p...","what":"call","event":"hang-up","seq":30,"payload":{"callId":"call_1","reason":"cancelled"}}}` | 服务端替换原通话消息:`{"data":{"topic":"p2p...","seq":30,"head":{"replace":":30","webrtc":"missed","webrtc-duration":0,"x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","status":"missed"}}}` |
| 异常事件 | `{"note":{"topic":"p2p...","what":"call","event":"media-state","seq":30,"payload":{"callId":"call_1"}}}` | 当前未开放时返回 `{"ctrl":{"code":400,"text":"malformed","params":{"reason":"CALL_EVENT_UNSUPPORTED"}}}` 或直接忽略,客户端不要提前依赖 |
##### 0.5.4.8 WSS 错误、重试和客户端处理
| 场景 | 服务端返回示例 | 客户端处理 |
| --- | --- | --- |
| API Key 无效 | Upgrade HTTP `403 {"ctrl":{"code":403,"text":"valid API key required"}}` | 停止重连,上报环境配置错误 |
| 未先 `hi` 就登录 | `{"ctrl":{"id":"login-1","code":400,"text":"tenant required","params":{"reason":"TENANT_REQUIRED"}}}` | 关闭连接,从 Upgrade + `hi` 重来 |
| 登录失败 | `{"ctrl":{"id":"login-1","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | 清密码,保留账号,显示统一账号或密码错误 |
| Token 过期 | `{"ctrl":{"id":"login-token-1","code":401,"text":"authentication failed","params":{"reason":"TOKEN_EXPIRED"}}}` | 清当前租户 token,回登录页 |
| 无 Topic 权限 | `{"ctrl":{"id":"pub-1","code":403,"text":"permission denied","params":{"reason":"MESSAGE_NOT_ALLOWED"}}}` | 发送气泡置为失败,不伪装已发送 |
| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 |
| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 |
#### 0.5.5 Long Polling 与文件基础示例
##### 0.5.5.1 WebSocket 与文件基础接口
| 接口 | 输入示例 | 成功输出示例 |
| --- | --- | --- |
| `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"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-Tinode-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-Tinode-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
### 0.6 Topic 与产品消息
@@ -294,7 +415,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。
2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。
3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。
3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。
4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。
5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。
@@ -304,19 +425,19 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/企业码登录.png" alt="企业码登录" width="360">
**状态:新增企业解析接口;WebSocket 租户握手已实现。**
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
**当前系统的执行方式:**
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
- 客户端从构建/环境配置读取 `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":"用户输入的企业码"}}`。
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。
**目标产品新增方式:**
**企业解析接口:**
- 新增 `POST /v1/im/tenants/resolve`,未登录但必须带 `X-Tinode-APIKey`,Header 传 `X-Tenant-Code: acme`;Body 为 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- 成功数据:`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。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
@@ -342,16 +463,121 @@ X-Tinode-APIKey: <预置的普通 API Key>
}
```
### 1.1.1 企业注册-登录入口.png
<img src="images/企业注册-登录入口.png" alt="企业注册-登录入口" width="360">
**状态:已实现。**
- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。
- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。
- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。
- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。
- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。
### 1.1.2 企业注册-填写注册信息.png
<img src="images/企业注册-填写注册信息.png" alt="企业注册-填写注册信息" width="360">
**状态:已实现。**
- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。
- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。
- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。
- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。
- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。
- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体:
```json
{
"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",
"device_model": "Pixel 8"
}
}
```
- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。
```json
{
"application_id": "entapp_01K...",
"status": "received",
"estimated_review_hours": 24,
"submitted_at": "2026-08-24T08:30:15.123Z"
}
```
- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。
### 1.1.3 企业注册-提交成功提示.png
<img src="images/企业注册-提交成功提示.png" alt="企业注册-提交成功提示" width="360">
**状态:已实现。**
- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。
- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。
- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。
- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。
- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。
### 1.1.4 企业注册-问题反馈.png
<img src="images/企业注册-问题反馈.png" alt="企业注册-问题反馈" width="360">
**状态:已实现。**
- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。
- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。
- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。
- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体:
```json
{
"description": "企业码 acme 提示不可用,无法进入企业。",
"contact": "user@example.com",
"tenant_code_hint": "acme",
"screenshot_file_ids": ["file_01K..."],
"client_context": {
"platform": "ios",
"app_version": "1.0.0",
"network": "wifi"
}
}
```
- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。
```json
{
"feedback_id": "entryfb_01K...",
"status": "received",
"submitted_at": "2026-08-24T08:35:15.123Z"
}
```
- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。
### 1.2 登录页面.png
<img src="images/登录页面.png" alt="登录页面" width="360">
**状态:基本账号密码登录已实现;协议勾选和安全 Token 生命周期需改造。**
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token。
- 待改造:登录成功后签发短期 access token 与可轮换 refresh token;Token 绑定 `tenant_id/user_id/auth_version/device_id`。修改密码、封禁账号后旧 Token 失效。
- 协议:已完成 `hi` 的 WebSocket 发送 `{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-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- Token:当前复用 Tinode 单一登录 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` 按专用页面处理。
登录成功示例:
@@ -389,17 +615,42 @@ X-Tinode-APIKey: <预置的普通 API Key>
<img src="images/注册页面.png" alt="注册页面" width="360">
**状态:Tinode `{acc}` 基础注册已实现;短信预校验与租户配额需改造。**
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138****0000"}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。
- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。
- WebSocket:把一次性验证结果放在 `{acc}.cred`,不得写入 `desc.private`。服务端必须在当前 Session 租户内消费该 Token。
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。
- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。
- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
用户名方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(username:password)","login":true,"tags":["tel:+86138..."],"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}}
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}}
```
- 成功:返回新 `user` 和登录 Token,自动 `sub me`。创建用户前原子校验 `im_tenant_config.max_user_count`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。
手机号验证方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
仅手机号注册方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
### 1.5 注册页面-倒计时.png
@@ -417,11 +668,14 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增。**
- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。
- 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。
- 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。
- 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。
- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。
- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。
- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。
- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。
- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。
### 1.7 身份验证(安卓).png
@@ -476,6 +730,33 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。
- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。
### 1.12 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 1.12.1 未登录、企业与账号安全
| 接口 | 输入示例 | 成功 `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/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"}` |
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` |
| `GET /v1/im/app/releases/latest?platform=ios\|android&current_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` |
## 2. 消息首页、全局导航与系统通知
### 2.1 消息.png
@@ -486,7 +767,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。
- 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。
- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。
- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。
- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。
### 2.2 消息-无网络状态.png
@@ -532,6 +813,25 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 2.6.1 登录后启动、消息首页与系统通知
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
## 3. 单聊会话、会话建立与消息状态
### 3.1 个人对话框(对方的状态).png
@@ -651,7 +951,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。
- `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。
- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。
- 点击卡片调用 `GET /v1/im/users/{identifier}/profile`;`identifier` 可传 `user_id`、`username` 或 `public_account`,用户不可见或已离职时显示失效卡片,不跨租户查询。
## 4. 消息输入、媒体内容与消息操作
@@ -846,6 +1146,34 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
### 4.21 会话与消息接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 4.21.1 会话上下文、消息操作与检索
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` |
| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` |
| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` |
| `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","file":{"file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf"},"availability":"available"}],"next_cursor":""}` |
| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` |
| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
## 5. 通讯录、好友关系与黑名单
### 5.1 通讯录.png
@@ -855,8 +1183,9 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增产品通讯录接口。**
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。
- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。
- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。
- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
### 5.2 新朋友.png
@@ -885,7 +1214,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增。**
- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。
- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。
- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。
- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。
### 5.5 添加好友-输入精准用户ID.png
@@ -914,7 +1243,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
@@ -988,6 +1318,39 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
### 5.15 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 5.15.1 通讯录、好友、黑名单与二维码
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/friends?query=&initial=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","initial":"Z","topic":"p2p...","relationship":"friend","online_visibility":"unknown","capabilities":{"can_send":true,"can_call":true}}],"next_cursor":""}` |
| `GET /v1/im/friends?query=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","topic":"p2p...","relationship":"friend"}],"next_cursor":""}` |
| `GET /v1/im/friend-requests?direction=inbound&status=all&cursor=` | Query only | `{"items":[{"request_id":"frq_1","direction":"inbound","applicant_id":"usr_a","target_user_id":"usr_b","peer":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","source":"search","status":"pending","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/friend-requests` | `{"target_user_id":"usr...","message":"验证留言","source":"search","source_topic":""}` | `{"request_id":"frq_1","applicant_id":"usr_me","target_user_id":"usr...","message":"验证留言","source":"search","status":"pending","topic":"p2p...","created_at":"2026-08-20T09:00:00Z","expires_at":"2026-08-27T09:00:00Z"}` |
| `POST /v1/im/friend-requests/{request_id}/accept` | `{"version":1}` | `{"request_id":"frq_1","status":"accepted","topic":"p2p...","handled_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `PUT /v1/im/blocks/{user_id}` | `{}` | `{"user_id":"usr...","blocked":true,"blocked_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/blocks/{user_id}` | Path user ID | `{"user_id":"usr...","blocked":false}` |
| `POST /v1/im/blocks/batch-delete` | `{"user_ids":["usr_a","usr_b"]}` | `{"items":[{"user_id":"usr_a","blocked":false}],"failed":[]}` |
| `POST /v1/im/me/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三"}}` |
| `POST /v1/im/users/{identifier}/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三","public_account":"zhangsan"}}` |
| `POST /v1/im/qrcodes/resolve` | `{"token":"token...","purpose":"add_friend"}` | `{"purpose":"add_friend","user":{"user_id":"usr...","display_name":"张三"},"relationship":"none","can_add":true}` |
## 6. 群聊创建、资料维护与成员治理
### 6.1 群聊.png
@@ -997,7 +1360,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。**
- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。
- 创建:`POST /v1/im/groups`,Body `{"name":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。
- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。
- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。
### 6.2 空白页.png
@@ -1263,6 +1626,61 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 6.29.1 群聊、群成员与群治理
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/groups?category=owned&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"owner","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/groups?category=owned\|managed\|joined&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"admin","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` |
| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` |
| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` |
| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` |
| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` |
| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` |
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` |
| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` |
| `GET /v1/im/groups/{topic}/announcements/latest` | Path topic | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":1,"published_at":"2026-08-20T09:00:00Z","permissions":{"can_edit":true}}` |
| `PUT /v1/im/groups/{topic}/announcements/current` | `{"content":"新的公告","version":1}` | `{"announcement_id":"ann_1","topic":"grp...","content":"新的公告","version":2,"published_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/groups/{topic}/announcements/{announcement_id}` | Path announcement | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":2,"published_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/announcements/{id}/read` | `{}` | `{"announcement_id":"ann_1","read_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/groups/{topic}/governance` | Path topic | `{"join_approval":false,"mute_all":false,"member_upload_files":true,"member_private_chat":true,"member_invite":true,"version":1}` |
| `PATCH /v1/im/groups/{topic}/governance` | `{"join_approval":true,"member_invite":false,"version":1}` | `{"join_approval":true,"member_invite":false,"version":2}` |
| `GET /v1/im/groups/{topic}/role-limits` | Path topic | `{"max_admin_count":10,"current_admin_count":1}` |
| `GET /v1/im/groups/{topic}/members?role=admin&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr_admin","display_name":"管理员","role":"admin"}],"next_cursor":""}` |
| `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","role":"member","eligible":true}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/admins/batch` | `{"user_ids":["usr..."],"version":1}` | `{"succeeded":[{"user_id":"usr...","role":"admin"}],"admins":[{"user_id":"usr...","role":"admin"}],"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/groups/{topic}/admins/{user_id}` | Header `If-Match: 2` 或 Body version | `{"user_id":"usr...","role":"member","updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/mutes/batch` | `{"members":[{"user_id":"usr...","expires_at":null}],"reason":"刷屏"}` | `{"topic":"grp...","items":[{"user_id":"usr...","muted":true,"muted_at":"2026-08-20T09:00:00Z","expires_at":null}]}` |
| `GET /v1/im/groups/{topic}/mutes?cursor=` | Query only | `{"topic":"grp...","items":[{"user":{"user_id":"usr...","display_name":"张三"},"muted_by":"usr_admin","muted_at":"2026-08-20T09:00:00Z","expires_at":null}],"next_cursor":""}` |
| `DELETE /v1/im/groups/{topic}/mutes/{user_id}` | Path user | `{"user_id":"usr...","muted":false}` |
| `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/owner-transfer` | Header `X-Action-Token: action_token`;Body `{"target_user_id":"usr...","version":2}` | `{"owner":"usr...","previous_owner":"usr_old","version":3}` |
| `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 待修复 | Query only | `{"topic":"grp...","items":[{"group_file_id":"gfile_1","file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf","uploader":{"user_id":"usr...","display_name":"张三"},"created_at":"2026-08-20T09:00:00Z","download_state":"available","permissions":{"can_delete":true}}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/files` 待修复 | `{"file_id":"file_1"}` | `{"group_file_id":"gfile_1","file_id":"file_1","topic":"grp...","created_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 待修复 | Path group file ID | `{"group_file_id":"gfile_1","deleted":true}` |
## 7. 文件下载、收藏管理与聊天记录检索
### 7.1 我的收藏.png
@@ -1359,7 +1777,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。
- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。
- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。
- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。
### 7.11 文件-下载完成.png
@@ -1416,6 +1834,32 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。
- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。
### 7.17 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 7.17.1 收藏、转发与文件安全
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/favorites?type=all&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"text","snapshot":{"text":"收藏内容"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
| `GET /v1/im/favorites?type=all\|text\|image\|video\|voice\|file\|link&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"image","snapshot":{"thumbnail_file_id":"file_thumb","file_id":"file_img"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
| `POST /v1/im/favorites/batch` | `{"messages":[{"topic":"grp...","seq":"12"}]}` | `{"created_count":1,"existing_count":0,"failed":[]}` |
| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` |
| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` |
| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` |
| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` |
| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` |
| `POST /v1/im/messages/forward` 待实现 | `{"mode":"separate","sources":[{"topic":"grp...","seq":"12"}],"target_topics":["p2p..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":"12"},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` |
| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` |
| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` |
## 8. 实时语音与视频通话
### 8.1 各种通话提示状态.png
@@ -1527,6 +1971,25 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。
- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。
### 8.12 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 8.12.1 实时通话与通话记录
| 接口 | 输入示例 | 成功输出示例 |
| --- | --- | --- |
| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` |
| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` |
| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` |
| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
## 9. 企业工作台与办公协同
### 9.1 工作台.png
@@ -1536,6 +1999,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增。**
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。
@@ -1657,6 +2121,51 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。
- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。
### 9.14 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 9.14.1 工作台、组织、会议、日报、月报、信箱
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` |
| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` |
| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` |
| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` |
| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` |
| `PATCH /v1/im/meetings/{id}` | `{"title":"新标题","version":1}` | `{"meeting_id":"mtg_1","title":"新标题","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` |
| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` |
| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` |
| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` |
| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` |
| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
| `GET /v1/im/reports/monthly/statistics?month=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` |
| `GET /v1/im/reports/monthly/{report_id}` | Path report ID | `{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"work_summary":"总结","next_plan":"计划","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` |
| `POST /v1/im/reports/monthly/{id}/reopen` 待实现 | `{"version":3,"reason":"补充内容"}` | `{"report_id":"mr_1","status":"draft","version":4}` |
| `GET /v1/im/executive-mailbox/config` | Query only | `{"enabled":true,"allow_anonymous":true,"required_fields":["subject","content"],"max_length":1000,"attachment_policy":{"max_files":5,"purpose":"executive_mailbox"},"notice":{"title":"总经理信箱","content":"请如实反馈","updated_at":"2026-08-20T09:00:00Z"}}` |
| `POST /v1/im/executive-mailbox/submissions` | Header `Idempotency-Key`;Body `{"subject":"建议","content":"建议内容","anonymous":false,"attachment_file_ids":[]}` | `{"submission_id":"mail_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` |
## 10. 个人资料、通用设置、隐私与青少年模式
### 10.1 我的.png
@@ -1675,7 +2184,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
@@ -1723,7 +2232,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:菜单纯客户端;数据接口部分新增。**
- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。
- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。
- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。
- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。
### 10.8 设置(安卓).png
@@ -1859,6 +2368,38 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。
- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。
### 10.22 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 10.22.1 个人资料、设置、隐私、青少年模式与设备
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/security/challenges` | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges/{id}/verify` | `{"code":"123456"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"fcm","token":"push_token","platform":"android","app_version":"1.0.0"}` | `{"device_id":"device_1","provider":"fcm","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` |
## 11. 反诈安全、举报与意见反馈
### 11.1 反诈弹窗(安卓).png
@@ -1911,6 +2452,27 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。
- 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。
### 11.6 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 11.6.1 反诈、举报与意见反馈
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm` | `{"decision":"continue"}` | `{"risk_action_token":"risk_token","action":"send_message","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/reports/abuse/reasons?target_type=user\|group\|message` 需修复 | Query only | `{"items":[{"reason_code":"fraud","label":"诈骗","requires_description":true,"allows_evidence":true},{"reason_code":"harassment","label":"骚扰","requires_description":false,"allows_evidence":true}]}` |
| `POST /v1/im/reports/abuse` | `{"target":{"type":"message","topic":"grp...","seq":"12"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file_1"]}` | `{"report_id":"abr_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/feedback/config` | Query only | `{"categories":["bug","suggestion","complaint","other"],"max_length":1000,"allow_contact":true,"attachment_policy":{"max_files":5,"purpose":"feedback"},"diagnostics_notice":"仅在你同意时上传脱敏诊断信息。","updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/feedback` | `{"category":"bug","content":"页面异常","contact":"15500000000","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"14"},"include_diagnostics":false}` | `{"feedback_id":"fb_1","status":"received","created_at":"2026-08-20T09:00:00Z"}` |
## 12. 联调实施清单
### 12.1 当前可直接联调的基础能力
@@ -1926,7 +2488,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
| 优先级 | 改造项 | 阻塞图片章节 |
| --- | --- | --- |
| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 |
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 |
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 |
| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 |
| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 |
| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 |
@@ -1950,7 +2512,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 |
| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 |
| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 |
| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 |
| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 |
| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 |
| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 |
+6 -3
View File
@@ -223,10 +223,11 @@
1. 用户列表、搜索、详情。
2. 创建、启用、停用、离职、恢复用户。
3. 重置密码、撤销 Token、踢下线。
3. 重置密码、撤销 Token、踢下线;无手机号账号忘记密码时只能由当前租户管理员执行重置。
4. 查看登录设备、登录 Session、安全事件。
5. 查看用户实名状态、青少年状态、隐私设置摘要。
6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。
7. 支持注册邀请码单个/批量生成、有效期设置、撤销、明文精确查询和导出;邀请码固定为 5 位大小写英文字母,区分大小写且租户内不可重复。
建议接口:
@@ -244,8 +245,10 @@
验收点:
1. 停用用户不影响其他租户相同手机号或相同设备用户。
2. 重置密码必须递增 `auth_version` 并撤销旧 Token。
3. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。
2. 管理员只能重置当前租户用户,不得根据用户名跨租户查找或操作。
3. 重置密码必须递增 `auth_version`,撤销旧 Token 和在线 Session,并写入包含操作者、目标用户、时间和结果的安全审计。
4. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。
5. 邀请码明文查看、查询和导出必须校验独立权限并写审计;普通日志不得记录邀请码。
### 5.3 组织架构管理
+638
View File
@@ -0,0 +1,638 @@
# IM 增强项第一批开发
> 编写日期:2026-08-21
> 来源依据:`docs/IM-未实现后续增强项.md`、`docs/IM-前后端对接文档.md`、当前 `server/main.go` 和 `server/im_http.go` 路由实现。
> 目标:从后续增强项中筛选出当前可以直接开发、且不依赖短信服务商、FCM/APNs、TURN/STUN、SFU、病毒扫描服务等第三方能力的内容,作为第一批排期范围。
## 1. 筛选原则
第一批只纳入满足以下条件的增强项:
1. 只依赖当前 IM 服务、Tinode/IM 租户库、HotGo 控制库或已有 Redis/MySQL 基础设施。
2. 不依赖真实短信供应商、离线推送供应商、音视频媒体服务、病毒扫描引擎等第三方服务。
3. 优先补齐前后端对接文档中已定义,但当前代码未实现或实现不完整的接口。
4. 优先选择能直接提升前端联调完整度、生产可用性和接口一致性的项目。
以下内容不进入第一批:
1. 真实短信发送、短信模板、供应商回执和供应商失败重试。
2. FCM/APNs 离线 Push 投递和供应商错误码处理。
3. TURN/STUN 实际服务接入、SFU、群音视频媒体房间。
4. 第三方病毒扫描或文件内容安全服务。
5. 必须依赖 HotGo 管理后台页面完整改造才能闭环的运营后台功能。
## 2. 第一批开发范围总览
### 2.1 当前开发进度
> 进度更新:2026-08-21
| 开发项 | 状态 | 备注 |
| --- | --- | --- |
| 组织全局成员接口 | 已实现 | 新增 `GET /v1/im/organization/members`,支持部门、关键词、分页和 `has_more`。 |
| 月报 reopen | 已实现 | 新增 `POST /v1/im/reports/monthly/{id}/reopen`,按本人报告和 version 回退草稿。 |
| 实名更正申诉 | 已实现 | 新增 `POST /v1/im/security/realname/appeals` 和 `im_realname_appeals` 表。 |
| 群邀请外部接受 | 已实现 | 新增 `POST /v1/im/group-invitations/{id}/accept`。 |
| 主动创建/获取 P2P 会话 | 已实现 | 新增 `POST /v1/im/conversations/p2p`,复用好友/黑名单策略。 |
| 会话设置 | 已实现 | 新增 `GET/PATCH /v1/im/conversations/{topic}/settings` 和 `im_conversation_settings` 表。 |
| 多端云草稿 | 已实现 | 新增 `GET/PUT /v1/im/topics/{topic}/draft` 和 `im_topic_drafts` 表。 |
| 群文件列表、登记、删除 | 已实现 | 新增 `GET/POST/DELETE /v1/im/groups/{topic}/files`,复用现有群文件表。 |
| 普通消息转发 | 未实现 | 待下一步接入消息快照、目标权限校验和消息落库。 |
| 合并转发 Bundle 详情 | 未实现 | 待下一步新增 bundle 表和详情接口。 |
| 登出与指定会话撤销 | 未实现 | 当前 token 缺少独立 session 标识,需要先统一 session 模型。 |
| 列表 `has_more` 统一 | 部分实现 | 新增/触达接口已补,历史接口需继续横向清理。 |
| 风控 token 单次消费接入业务接口 | 未实现 | 待和具体业务动作一起接入。 |
### 2.2 范围表
| 模块 | 优先级 | 开发项 | 是否依赖第三方 | 当前状态 |
| --- | --- | --- | --- | --- |
| 账号安全 | P0 | 登出与指定会话撤销 | 否 | 文档有接口,代码未见路由 |
| 消息转发 | P0 | 普通消息转发 | 否 | 仅转发目标接口已实现 |
| 消息转发 | P0 | 合并转发 Bundle 详情 | 否 | 文档有接口,代码未见路由 |
| 群聊 | P0 | 群文件列表、登记、删除 | 否 | 路径已不误兜底,但业务未实现 |
| 单聊 | P1 | 主动创建/获取 P2P 会话 | 否 | 文档有接口,代码未见路由 |
| 会话 | P1 | 会话设置 | 否 | 文档有接口,代码未见路由 |
| 会话 | P1 | 多端云草稿 | 否 | 文档有接口,代码未见路由 |
| 群聊 | P1 | 群邀请外部接受 | 否 | 群内邀请已有,外部接受接口未见路由 |
| 安全实名 | P1 | 实名更正申诉 | 否 | 实名提交/查询已有,申诉未见路由 |
| 组织架构 | P1 | 组织全局成员接口 | 否 | 部门成员和组织搜索已有,独立全局成员接口未见路由 |
| 办公协同 | P1 | 月报 reopen | 否 | 月报草稿/提交/详情已有,reopen 未见路由 |
| 协议一致性 | P1 | 列表 `has_more` 统一 | 否 | 部分列表仍只返回 `items,next_cursor` |
| 安全风控 | P1 | 风控 token 单次消费接入业务接口 | 否 | 可签发 `risk_action_token`,业务消费仍需补齐 |
## 3. 详细需求
### 3.1 登出与指定会话撤销
#### 目标
补齐客户端退出登录和安全页踢出指定设备能力,避免只能通过 `auth_version` 做全量失效。
#### 接口
1. `POST /v1/im/auth/logout`
- 退出当前登录设备。
- 需要使当前 token 对应 session 失效。
- 不影响同账号其他设备。
2. `DELETE /v1/im/security/sessions/{id}`
- 撤销指定 session。
- 只能撤销本人 session。
- 撤销后该 session 对应 token 立即不可用。
#### 数据建议
如当前没有独立 session 表,建议新增:
```sql
CREATE TABLE im_user_sessions (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
session_id VARCHAR(64) NOT NULL,
device_id VARCHAR(128) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT '',
token_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
last_seen_at DATETIME(3) NULL,
revoked_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_user_sessions_session (tenant_id, session_id),
KEY idx_im_user_sessions_user_status (tenant_id, user_id, status)
);
```
主键使用已有雪花 ID 工具生成。
#### 验收点
1. 当前设备登出后,当前 token 访问登录态接口返回未登录或 token 失效。
2. 其他设备 token 不受影响。
3. 删除指定 session 后,被删除设备无法继续访问。
4. 重复登出或重复删除返回幂等成功或稳定错误码。
### 3.2 普通消息转发
#### 目标
补齐普通聊天消息转发能力。现有 `GET /v1/im/forward-targets` 只解决目标选择,不等同于真正转发。
#### 接口
`POST /v1/im/messages/forward`
建议请求字段:
```json
{
"source_topic": "grp_xxx",
"message_seqs": [101, 102],
"target_topics": ["usrA-usrB", "grp_yyy"],
"mode": "single",
"client_mid": "uuid"
}
```
`mode` 建议支持:
1. `single`:逐条转发。
2. `bundle`:合并转发,生成转发卡片和 bundle。
#### 关键规则
1. 校验当前用户对源消息有查看权限。
2. 校验当前用户对每个目标会话有发送权限。
3. 不允许转发已被策略禁止转发的消息类型。
4. 文件消息需要重新校验文件访问权限。
5. 使用 HTTP 幂等能力,避免重复转发。
#### 验收点
1. 单条消息可转发到好友、群和文件助手。
2. 多条消息可逐条转发。
3. 无权限源消息、无权限目标会话返回稳定错误。
4. 同一幂等键重试不会重复投递。
### 3.3 合并转发 Bundle
#### 目标
支持合并转发卡片详情查看,使客户端可以打开“聊天记录”样式的转发包。
#### 接口
`GET /v1/im/forward-bundles/{id}`
建议响应结构:
```json
{
"id": "fb_123",
"title": "聊天记录",
"source_summary": {
"topic": "grp_xxx",
"message_count": 12
},
"items": [],
"next_cursor": "",
"has_more": false
}
```
#### 数据建议
建议新增:
```sql
CREATE TABLE im_forward_bundles (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
bundle_no VARCHAR(64) NOT NULL,
owner_user_id BIGINT NOT NULL,
source_topic VARCHAR(128) NOT NULL,
title VARCHAR(128) NOT NULL DEFAULT '',
message_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_forward_bundles_no (tenant_id, bundle_no),
KEY idx_im_forward_bundles_owner (tenant_id, owner_user_id, created_at)
);
CREATE TABLE im_forward_bundle_items (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
bundle_id BIGINT NOT NULL,
source_topic VARCHAR(128) NOT NULL,
source_seq INT NOT NULL,
sender_user_id BIGINT NOT NULL,
content_json JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_im_forward_bundle_items_bundle (tenant_id, bundle_id, id)
);
```
#### 验收点
1. 合并转发后生成 bundle。
2. 接收方可分页查看 bundle 内容。
3. Bundle 内容使用生成时快照,不因源消息后续不可见而展示异常。
4. 原始会话无权限用户不能直接借 bundle 访问源会话。
### 3.4 群文件
#### 目标
补齐群文件列表、上传登记和删除管理能力。当前 `/groups/{topic}/files` 已不再误返回群详情,但业务仍缺失。
#### 接口
1. `GET /v1/im/groups/{topic}/files`
- 支持分页、关键词、文件类型筛选。
2. `POST /v1/im/groups/{topic}/files`
- 将已上传文件登记为群文件。
3. `DELETE /v1/im/groups/{topic}/files/{file_id}`
- 删除或移除群文件。
#### 数据建议
可复用现有文件上传表,并新增群文件索引表:
```sql
CREATE TABLE im_group_files (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
topic VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
uploader_user_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
mime VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
KEY idx_im_group_files_topic (tenant_id, topic, status, created_at),
UNIQUE KEY uk_im_group_files_file (tenant_id, topic, file_id)
);
```
#### 权限规则
1. 只有群成员可查看群文件。
2. 只有群成员可登记群文件。
3. 删除权限建议第一批按群主、管理员、上传者放行。
4. 退群后不可继续通过群文件入口访问。
#### 验收点
1. 群成员可分页查看群文件。
2. 上传者登记文件后列表可见。
3. 普通成员不能删除其他人的文件,管理员和群主可以删除。
4. 非群成员访问返回无权限。
### 3.5 主动创建/获取 P2P 会话
#### 目标
补齐前端从好友资料页或通讯录进入单聊时的显式会话创建入口。
#### 接口
`POST /v1/im/conversations/p2p`
建议请求:
```json
{
"peer_user_id": "usr_xxx"
}
```
#### 规则
1. 必须校验双方好友关系。
2. 必须校验黑名单状态。
3. 如果 P2P topic 已存在,直接返回已有 topic。
4. 如果不存在,创建或确保 Tinode P2P 会话关系存在。
#### 验收点
1. 好友之间调用返回稳定 P2P topic。
2. 非好友或黑名单关系返回不可发起会话。
3. 重复调用返回同一个 topic。
### 3.6 会话设置
#### 目标
支持置顶、免打扰、会话备注等跨端同步设置。
#### 接口
1. `GET /v1/im/conversations/{topic}/settings`
2. `PATCH /v1/im/conversations/{topic}/settings`
建议字段:
```json
{
"pinned": true,
"muted": false,
"remark": "",
"version": 3
}
```
#### 数据建议
```sql
CREATE TABLE im_conversation_settings (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
topic VARCHAR(128) NOT NULL,
pinned TINYINT(1) NOT NULL DEFAULT 0,
muted TINYINT(1) NOT NULL DEFAULT 0,
remark VARCHAR(128) NOT NULL DEFAULT '',
version BIGINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_conversation_settings_user_topic (tenant_id, user_id, topic)
);
```
#### 验收点
1. 设置后同账号其他端读取一致。
2. 版本冲突返回稳定错误或按后写覆盖策略处理。
3. 非会话参与者不可设置。
### 3.7 多端云草稿
#### 目标
支持同账号多端同步草稿。
#### 接口
`PUT /v1/im/topics/{topic}/draft`
建议请求:
```json
{
"content": "draft text",
"entities": [],
"version": 1
}
```
#### 数据建议
```sql
CREATE TABLE im_topic_drafts (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
topic VARCHAR(128) NOT NULL,
content TEXT NULL,
entities_json JSON NULL,
version BIGINT NOT NULL DEFAULT 1,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_im_topic_drafts_user_topic (tenant_id, user_id, topic)
);
```
#### 验收点
1. 保存草稿后其他端可读取。
2. 清空草稿后其他端同步清空。
3. 非会话参与者不可写入草稿。
### 3.8 群邀请外部接受
#### 目标
支持被邀请人从邀请卡片或外部入口接受群邀请。
#### 接口
`POST /v1/im/group-invitations/{id}/accept`
#### 规则
1. 校验邀请存在、未过期、未失效。
2. 校验当前登录用户是被邀请人。
3. 按群治理策略决定直接入群或进入审批。
4. 重复接受应幂等返回当前状态。
#### 验收点
1. 被邀请人可接受有效邀请。
2. 非被邀请人不可接受。
3. 已过期或已撤销邀请返回稳定错误。
4. 需要审批的群不会绕过审批。
### 3.9 实名更正申诉
#### 目标
实名审核驳回或用户发现实名信息有误时,可提交更正申诉。
#### 接口
`POST /v1/im/security/realname/appeals`
建议请求:
```json
{
"reason": "证件姓名填写错误",
"attachments": ["file_1"]
}
```
#### 数据建议
```sql
CREATE TABLE im_realname_appeals (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '',
attachments_json JSON NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
reviewer_id BIGINT NULL,
review_comment VARCHAR(500) NOT NULL DEFAULT '',
reviewed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_im_realname_appeals_user (tenant_id, user_id, created_at),
KEY idx_im_realname_appeals_status (tenant_id, status, created_at)
);
```
#### 验收点
1. 用户可提交申诉。
2. 重复待处理申诉按策略限制。
3. 申诉记录可供后续后台审核使用。
### 3.10 组织全局成员接口
#### 目标
补齐前端需要全局组织成员列表时的统一入口,避免只能从部门成员或搜索接口拼装。
#### 接口
`GET /v1/im/organization/members`
建议查询参数:
1. `department_id`
2. `keyword`
3. `cursor`
4. `limit`
#### 规则
1. 复用组织成员表和组织搜索能力。
2. 按租户、部门、成员状态过滤。
3. 字段返回遵循现有隐私策略。
#### 验收点
1. 不传部门时返回全局成员分页。
2. 传部门时返回指定部门成员。
3. keyword 可搜索姓名、账号、手机号等允许搜索字段。
4. 返回 `items,next_cursor,has_more`。
### 3.11 月报 reopen
#### 目标
支持已提交月报按权限退回草稿,便于用户修正后重新提交。
#### 接口
`POST /v1/im/reports/monthly/{id}/reopen`
#### 规则
1. 只能 reopen 本人的月报,或由具备管理权限的用户 reopen。
2. 已归档、已锁定周期不可 reopen。
3. reopen 后状态回到草稿或待修改状态。
4. 保留审计记录。
#### 验收点
1. 已提交月报可按权限 reopen。
2. reopen 后可以继续保存草稿并重新提交。
3. 无权限用户不可 reopen 他人月报。
### 3.12 列表 `has_more` 统一
#### 目标
统一所有 HTTP 列表接口分页协议,减少前端针对每个接口单独判断。
#### 范围
第一批建议优先覆盖新增接口,以及以下已有高频接口:
1. 好友列表、新朋友列表、黑名单列表。
2. 群列表、群成员、群申请、群公告。
3. 会议列表、日报列表、月报列表。
4. 系统通知、举报/反馈相关列表。
5. 搜索类接口。
#### 响应要求
```json
{
"items": [],
"next_cursor": "",
"has_more": false
}
```
#### 验收点
1. 空列表返回 `items: []`、`has_more: false`。
2. 最后一页返回 `has_more: false`。
3. 有下一页时返回非空 `next_cursor` 和 `has_more: true`。
### 3.13 风控 token 单次消费
#### 目标
当前风控确认接口可签发 `risk_action_token`,第一批补齐业务接口消费该 token 的闭环。
#### 范围
优先接入以下业务动作:
1. 打开或发送高风险 URL。
2. 高频添加好友。
3. 高频群邀请或入群申请。
4. 举报提交中的高风险目标。
#### 规则
1. token 必须绑定租户、用户、动作、目标和过期时间。
2. token 只能消费一次。
3. token 只能用于原业务动作,不可跨动作复用。
#### 验收点
1. 未确认风险时业务接口返回 challenge。
2. 确认后携带 token 可继续原动作。
3. token 重复使用返回已消费。
4. token 用于其他动作返回无效。
## 4. 建议开发顺序
### 第一阶段:低风险补齐路由
1. 组织全局成员接口。
2. 月报 reopen。
3. 实名更正申诉。
4. 群邀请外部接受。
这些接口业务边界相对清楚,可以最快完成前端缺口补齐。
### 第二阶段:会话能力
1. 主动创建/获取 P2P 会话。
2. 会话设置。
3. 多端云草稿。
4. 登出与指定会话撤销。
这些能力涉及用户状态和多端一致性,建议一起设计数据表和事件同步。
### 第三阶段:消息与文件增强
1. 普通消息转发。
2. 合并转发 Bundle。
3. 群文件。
4. 收藏分享权限二次校验。
5. 文件下载短时签名 URL。
这些能力涉及消息权限、文件权限和幂等,建议放在同一阶段统一处理。
### 第四阶段:协议一致性与风控闭环
1. 列表 `has_more` 统一。
2. 风控 token 单次消费。
3. 好友申请、入群申请、@ 全体等限频。
4. 二维码吊销与扫码审计。
5. 关系操作审计。
这一阶段适合在主要业务接口补齐后做横向治理。
## 5. 本批次不处理但需保留的增强项
以下增强项仍然重要,但不纳入第一批:
1. 真实短信供应商发送、失败重试和发送审计。
2. FCM/APNs 离线 Push 投递。
3. TURN/STUN 配置实际接入和 SFU 群通话。
4. 病毒扫描和第三方内容安全检测。
5. HotGo 管理后台完整审核流、组织维护界面、信箱后台流转界面。
6. 大规模性能压测和专项压测报告。
这些项目建议作为第二批或外部依赖就绪后的专项处理。
## 6. 文档同步要求
每完成一个开发项,需要同步更新:
1. `docs/IM-前后端对接文档.md`
2. `docs/IM-系统设计文档.md`
3. `docs/IM-未实现后续增强项.md`
4. 必要时补充数据库变更到 `docs/IM-数据库变更汇总.sql`
已经完成的增强项应从 `IM-未实现后续增强项.md` 的待办中移除或标记为已进入基线,避免重复排期。
+106 -7
View File
@@ -7,6 +7,7 @@
-- 2. Tenant-scoped business tables live in each tenant database and do not include tenant_id.
-- 3. This file is for review and manual execution. Existing legacy columns/indexes should be checked before ALTER/DROP.
-- 4. Table definitions are aligned with server/db/mysql/adapter.go plus im_verification_codes used by chapter 1 verification APIs.
-- 5. Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator.
SET NAMES utf8mb4;
@@ -54,12 +55,14 @@ CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config (
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_security (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_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 (user_id)
PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_security_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_verification_codes (
@@ -125,6 +128,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_action_tokens (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
@@ -136,7 +140,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
locked_until 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 (user_id, purpose)
PRIMARY KEY (id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose (user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
@@ -234,6 +239,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_friend_requests (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_friendships (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
@@ -241,16 +247,19 @@ CREATE TABLE IF NOT EXISTS tinode.im_friendships (
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, peer_id),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_friendships_pair (user_id, peer_id),
INDEX idx_im_friendships_peer (peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_user_blocks (
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT 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 (blocker_id, blocked_id),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_blocks_pair (blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked (blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -553,6 +562,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions (
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
@@ -560,14 +570,100 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id)
PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_profiles_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing database migration (the application performs the same migration
-- idempotently at startup):
-- 1. Add im_users.phone VARCHAR(32) NULL and im_users.username VARCHAR(32).
-- 2. Backfill username from im_auth.uname after removing the basic: prefix;
-- use phone or reserved_<id> only when no Basic login exists.
-- 3. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''.
-- 4. Backfill phone/email from validated im_credentials first, then use
-- tel:/email: rows in im_usertags as a legacy fallback.
-- 5. Verify uniqueness, then make username NOT NULL and add UNIQUE(username).
-- 6. Drop im_auth.uname and im_auth.scheme; add UNIQUE(userid).
-- 7. Drop im_usertags and im_users.tags only after the backfill is verified.
-- im_topics.tags and im_topictags are topic discovery data and must be retained.
CREATE TABLE IF NOT EXISTS hotgo.im_registration_invite_batches (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
quantity INT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key),
INDEX idx_im_registration_invite_batch_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS hotgo.im_registration_invite_codes (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
batch_id BIGINT,
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL,
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
last_used_at DATETIME(3),
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code),
INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at),
INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id),
INDEX idx_im_registration_invite_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_registration_invite_uses (
id BIGINT NOT NULL,
invite_code_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
used_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
INDEX idx_im_registration_invite_use_code (invite_code_id),
UNIQUE INDEX uk_im_registration_invite_use_user (user_id),
INDEX idx_im_registration_invite_use_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Legacy default-tenant migration. For additional tenant databases, repeat
-- this copy with that database name and its hotgo.im_tenant.id before drop.
SET @im_sql := IF(
(SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_registration_invite_batches') > 0,
'INSERT INTO hotgo.im_registration_invite_batches (id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at) SELECT id,1,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at FROM tinode.im_registration_invite_batches ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql := IF(
(SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_registration_invite_codes') > 0,
'INSERT INTO hotgo.im_registration_invite_codes (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) SELECT c.id,1,c.batch_id,c.code,CASE WHEN c.status=''used'' THEN IF(c.expires_at<=NOW(3),''expired'',''active'') ELSE c.status END,c.expires_at,COUNT(u.id),MAX(u.used_at),c.created_by,c.remark,c.created_at,c.updated_at FROM tinode.im_registration_invite_codes c LEFT JOIN tinode.im_registration_invite_uses u ON u.invite_code_id=c.id GROUP BY c.id ON DUPLICATE KEY UPDATE usage_count=VALUES(usage_count),last_used_at=VALUES(last_used_at),updated_at=VALUES(updated_at)',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
DROP TABLE IF EXISTS tinode.im_registration_invite_codes;
DROP TABLE IF EXISTS tinode.im_registration_invite_batches;
CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
@@ -576,7 +672,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id)
PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_privacy_settings_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_app_releases (
@@ -599,6 +696,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_app_releases (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens (
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
@@ -607,7 +705,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens (
platform VARCHAR(32) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (device_id),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_device_push_tokens_device (device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token (provider, token),
INDEX idx_im_device_push_tokens_user (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+149 -108
View File
@@ -1,116 +1,161 @@
# IM 第一至十一章后续增强项
> 更新日期:2026-08-17
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
> 状态说明:2026-08-17 已完成的 P0/P1 已移入各章“当前已完成基线”,不再作为后续待办。完整修复记录见 `IM-需求实现情况审核报告.md`。
> 更新日期:2026-08-21
> 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。
> 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。
## 0. 本次核对结论
### 0.1 已从待办移入基线
1. 注册、找回密码和敏感操作验证码在短信未接入时均可用固定测试码 `123456` 完成联调;真实短信通道仍是后续增强项。
2. `GET /v1/im/bootstrap` 已返回 `entries`、`entry_badges`、`modules`、`message_policy`、`system_topic`、`security` 和 `limits.max_file_size/max_text_length/max_forward_targets`。
3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。
4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。
5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。
6. `GET /v1/im/groups/{topic}/files` 和 `/announcements` 不再误落到群详情;未实现路径返回 404。
7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。
8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。
9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。
### 0.2 当前仍明确缺口及分流状态
| 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 |
| --- | --- | --- | --- | --- |
| P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 |
| P0 | 群文件完整接口 | 6.27、6.28 | `/groups/{topic}/files` 已不误兜底,但列表/登记/删除未实现 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 登出与会话撤销 | 10.7 | `POST /v1/im/auth/logout`、`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 单聊主动建会话/会话设置/云草稿 | 3.2、4.5、4.21 | `GET /conversations/{topic}/context` 已有,`POST /conversations/p2p`、`/settings`、`PUT /topics/{topic}/draft` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 群邀请外部接受接口 | 6.8 | 群内 `/groups/{topic}/invitations` 已有,`POST /v1/im/group-invitations/{id}/accept` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 实名更正申诉 | 1.9、10.22 | `POST /v1/im/security/realname/appeals` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 组织全局成员接口 | 5.1、9.2 | 部门成员与组织搜索已具备,`GET /v1/im/organization/members` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P1 | 月报 reopen | 9.10 | 月报草稿/提交/详情已具备,`POST /reports/monthly/{id}/reopen` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
### 0.3 已抽入第一批开发的项目
以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。它们当前代码上仍未完全落地,但不再按“未分流的未实现项”处理,后续应以第一批开发文档作为实现、验收和同步更新依据。
1. 登出与指定会话撤销。
2. 普通消息转发。
3. 合并转发 Bundle 详情。
4. 群文件列表、登记和删除。
5. 主动创建/获取 P2P 会话。
6. 会话设置。
7. 多端云草稿。
8. 群邀请外部接受。
9. 实名更正申诉。
10. 组织全局成员接口。
11. 月报 reopen。
12. 列表 `has_more` 统一。
13. 风控 token 单次消费接入业务接口。
## 1. 第一章:登录、注册与安全
### 1.1 当前已完成基线
1. 企业码解析、租户状态校验和公开租户配置返回。
2. 注册、登录前验证码发送与验证。
3. 注册验证码 Token 消费,避免绕过验证码直接注册。
4. 忘记密码挑战验证与重置确认。
5. Token 绑定 `auth_version`,密码重置后旧 Token 失效。
6. 登录后敏感操作二次验证挑战。
7. 实名认证状态查询与提交,证件信息脱敏/保护存储。
8. 独立密码设置与验证,包含弱密码规则和失败锁定。
9. 登录后 bootstrap 返回实名、安全与菜单基础能力。
10. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。
11. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。
12. 风险确认已生成可重试安全事件和用户系统通知。
2. WebSocket `hi` 租户绑定、`basic` 登录和 `{acc user:new}` 注册已具备。
3. 注册支持用户名/手机号二选一;手机号注册写入 `im_users.phone`,用户名写入 `im_users.username`。
4. 用户名格式校验、手机号规范化、服务端随机用户名生成规则已具备。
5. 注册验证码发码、校验、一次性 `verification_token` 消费已具备。
6. 找回密码挑战验证与重置确认已具备。
7. 敏感操作二次验证挑战和 `action_token` 签发已具备。
8. 短信未接入时注册、找回密码、敏感操作均可用测试码 `123456` 联调。
9. 邀请码主数据在 HotGo 控制库,注册明细在 IM 租户库,支持可复用邀请码。
10. Token 绑定 `auth_version`,密码重置后旧 Token 失效。
11. 实名状态查询与提交已具备,证件信息脱敏/保护存储。
12. 独立密码设置与验证已具备,包含弱密码规则和失败锁定。
13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。
14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。
15. 风险确认可生成安全事件和系统通知。
### 1.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 |
| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入统一事件和系统通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件且重试不重复入箱。 |
| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 |
| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 |
| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 |
| P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 |
| P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P1 | 实名审核与申诉 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过状态变更和 `realname/appeals`。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有工单状态。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
| P2 | 风控与反诈策略接入 | 第一章安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险动作触发二次验证或反诈提示,低风险动作直通。 |
| P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 |
## 2. 第二章:消息首页、全局导航与系统通知
### 2.1 当前已完成基线
1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。
2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。
3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。
4. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。
5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。
6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。
7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。
8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。
9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。
10. 消息编辑/撤回已在事务内写入通用 Outbox,并向 Topic 在线成员派发可去重事件。
11. 编辑/撤回已接入全局 HTTP 幂等和响应重放。
12. 好友、群、会议和安全模块已可信生产系统通知。
1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限、入口、红点和 `message_policy`。
2. `bootstrap.limits` 已返回 `max_file_size`、`max_text_length`、`max_forward_targets`。
3. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。
4. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。
5. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。
6. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。
7. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。
8. 撤回、编辑接口已具备,并接入幂等与 Outbox。
9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read` 已具备。
10. 好友、群、会议和安全模块已可信生产系统通知。
### 2.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 |
| P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 |
| P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
## 3. 第三章:单聊关系、权限与会话上下文
### 3.1 当前已完成基线
1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。
1. `GET /v1/im/conversations/{topic}/context` 返回双方关系、发送能力、对端公开资料和 P2P Topic。
2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。
3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。
6. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
7. 好友申请/审批关键路径已在业务事务内写入通知和 Outbox,其他可重复关系写由全局 HTTP 幂等保证可补写。
8. 非好友资料查询已执行 `profile_visibility`。
5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
6. 非好友资料查询已执行 `profile_visibility`。
### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 |
| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 资料策略继续扩展 | 好友/非好友可见性已执行,企业字段级可见性和青少年模式仍需纳入同一策略。 | 企业受限字段与青少年用户无法从其他入口绕过策略。 |
| P1 | 主动建立 P2P 会话接口 | 对接文档有 `POST /v1/im/conversations/p2p`,当前未见路由。 | 好友关系成立时可显式获取/创建 P2P topic。 |
| P1 | 会话设置接口 | 对接文档有 `GET/PATCH /v1/im/conversations/{topic}/settings`,当前未见路由。 | 置顶、免打扰、备注、保留策略跨端同步。 |
| P1 | 多端草稿 | 对接文档有 `PUT /v1/im/topics/{topic}/draft`,当前仍建议本地加密保存。 | 同账号多端草稿同步,版本冲突可处理。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 |
| P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 |
## 4. 第四章:群消息、群成员与 @ 能力
## 4. 第四章:消息输入、媒体内容、转发与检索
### 4.1 当前已完成基线
1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。
2. 返回成员角色、公开资料、ACL 和是否可被 @。
3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。
6. 群昵称、角色、禁言、成员和公告等群业务变更已写入群 Outbox,并生成在线事件与系统通知。
1. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
2. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
3. 群成员列表支持基础分页、搜索和 `mentionable` 过滤。
4. `GET /v1/im/forward-targets` 已具备,返回文件助手和可转发的会话目标。
5. 消息撤回、编辑、回执、搜索、上下文定位、媒体列表接口已挂在 `/v1/im/topics/` 下。
6. 收藏入口与收藏转发基础接口已具备。
### 4.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 |
| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 |
| P0 | 消息转发接口 | 对接文档有 `POST /v1/im/messages/forward`,当前未见路由;收藏转发不等同普通消息转发。 | 单条/多条消息可分别转发到好友、群和文件助手。 |
| P0 | 合并转发 Bundle | 对接文档有 `GET /v1/im/forward-bundles/{id}`,当前未见路由。 | 合并转发卡片可分页查看,权限隔离原会话。 |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在线、离线、多端场景均收到一次提醒。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频。 | 超限返回稳定错误码,管理员可配置限额。 |
| P1 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 |
| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 |
| P1 | 消息检索稳定游标 | 搜索和媒体列表需要稳定游标,避免新消息插入造成跳页。 | 搜索期间新增消息不影响当前翻页结果。 |
## 5. 第五章:通讯录、好友关系与黑名单
@@ -120,26 +165,25 @@
2. 新朋友列表、好友申请、通过、拒绝链路已具备。
3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。
4. 添加好友二维码支持短期签名 Token 生成与解析。
5. 好友个人主页返回关系、资料和操作能力。
5. 个人主页支持 `user_id/username/public_account` identifier,返回关系、资料和操作能力。
6. 群内非好友个人主页返回群角色、关系、资料和操作能力。
7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。
8. 黑名单列表和批量移出黑名单已具备。
9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。
10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。
11. 好友个人主页已执行 `profile_visibility`。
11. 用户搜索已改用昵称、公开账号、登录账号和 `im_users.phone` 等结构化字段,不再依赖用户标签。
### 5.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P1 | 用户搜索租户策略扩展 | 个人添加方式已执行,后续还需增加租户级 `allow_user_search`、通讯录授权和存在性防探测策略。 | 租户关闭搜索或未授权通讯录时,客户端无法绕过且不泄露用户存在性。 |
| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 基础 `everyone/friends/nobody` 可见性已执行,后续需按群内关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 |
| P1 | 二维码吊销与扫码审计 | 当前二维码是短期签名 Token,主动失效、刷新覆盖、扫码审计需要落库或接 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 基础可见性已执行,后续需按群关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致。 |
| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 |
| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 |
| P1 | 关系操作审计 | 关键关系系统通知已接入,添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 |
| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 |
| P1 | 关系操作审计 | 添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 |
## 6. 第六章:群聊创建、资料维护与成员治理
@@ -148,25 +192,25 @@
1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。
2. 群设置、个人群设置、退出群、群昵称接口已具备。
3. 群二维码生成与扫码解析支持 `join_group` 场景。
4. 群邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。
4. 群内邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。
5. 群公告最新查询、发布/更新、详情和已读接口已具备。
6. 群治理配置 `GET/PATCH /governance` 已具备。
7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 12 张 `im_group_*` 业务表已纳入 MySQL 租户库自动初始化,独立租户库模式不保存 `tenant_id`。
10. 群邀请、入群申请/审批、成员变更、角色变更、群主转让和退群已收口到 MySQL 事务。
11. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。
12. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。
13. 群公告已使用独立表并记录成员已读时间。
9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。
10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。
11. 群文件和群公告未实现路径已不再误返回群详情。
### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 群文件索引与管理 | 对接文档要求 `GET/POST/DELETE /v1/im/groups/{topic}/files`,当前未实现,只修复了错误兜底。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 |
| P1 | 群邀请外部接受接口 | 对接文档有 `POST /v1/im/group-invitations/{id}/accept`,当前未见路由。 | 被邀请人可通过邀请卡入群或进入审批。 |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
| P1 | 群成员 `user_id` 完整性回归 | 测试报告曾发现 owner 成员 `user_id` 为空,需要专门回归确认。 | 群成员列表所有成员均返回稳定 `usr...`。 |
| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 |
## 7. 第七章:文件下载、收藏管理与聊天记录检索
@@ -174,26 +218,25 @@
### 7.1 当前已完成基线
1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。
2. 收藏批量创建和基础分享前的数据结构已具备。
3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。
4. 消息上下文定位接口可围绕指定 seq 返回前后消息。
5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。
6. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。
2. 收藏批量创建、批量删除和收藏转发接口已具备。
3. 转发目标列表 `GET /v1/im/forward-targets` 已具备。
4. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。
5. 消息上下文定位接口可围绕指定 seq 返回前后消息。
6. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。
7. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。
### 7.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P1 | 收藏业务去重策略 | 同一 HTTP 请求的超时重试已由全局幂等保证;使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 |
| P0 | 普通消息转发与合并转发 | `/favorites/forward` 已有,但 `/messages/forward`、`/forward-bundles/{id}` 未实现。 | 普通消息可单条/多条/合并转发,Bundle 可分页查看。 |
| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 |
| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 |
| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 |
| P1 | 收藏业务去重策略 | 使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 |
| P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 |
| P1 | 聊天记录稳定游标 | 消息搜索和媒体列表需要稳定游标,避免新消息插入导致翻页重复或漏项。 | 搜索期间新增消息不影响当前翻页结果。 |
| P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 |
| P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 |
| P2 | 聊天记录高亮与命中片段 | 搜索结果可返回命中摘要、高亮范围和消息类型摘要。 | 客户端无需二次解析即可展示命中片段。 |
| P2 | 收藏导出与批量管理 | 后续可支持收藏批量删除、批量转发、导出和云端容量统计。 | 批量操作有部分失败语义和可重试结果。 |
## 8. 第八章:实时语音与视频通话
@@ -211,27 +254,26 @@
| --- | --- | --- | --- |
| P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 |
| P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 |
| P0 | 通话事件 Outbox 与通知 | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 |
| P0 | 通话事件 Outbox 与离线 Push | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 |
| P0 | TURN/STUN 配置与媒体准入 | 服务端应按租户/区域返回可用 ICE 配置,并限制未授权用户创建媒体会话。 | 客户端拉取到有效 TURN;无权限通话被服务端拒绝。 |
| P1 | 结束原因标准化 | 需要统一 `missed`、`declined`、`busy`、`timeout`、`network_error` 等原因和客户端展示文案。 | 不同端同一结束原因展示一致,统计口径一致。 |
| P1 | 通话质量指标 | 需要采集建立耗时、ICE 成功率、丢包、抖动、断连等指标。 | 后台可按租户和版本查看通话质量趋势。 |
| P1 | 群通话/会议房间拆分 | 群音视频不宜复用 P2P call,需要会议房间、参与人状态和 SFU 接入。 | 群通话有独立 room_id、成员列表和退出/重进状态。 |
| P1 | 通话权限统一策略 | 通话入口应复用 P2P/群关系策略、黑名单、禁言、青少年模式和企业策略。 | 被拉黑、非好友或受限制用户无法发起通话。 |
| P2 | 通话记录聚合与清理 | 通话记录可按会话、日期、未接分类聚合,并支持保留周期清理。 | 记录列表分页稳定,超过保留期按策略清理。 |
## 9. 第九章:企业工作台与办公协同
### 9.1 当前已完成基线
1. 工作台接口 `GET /v1/im/workbench` 已具备,返回模块、布局版本和企业功能开关。
2. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。
3. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。
4. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。
5. 部门列表 `GET /v1/im/organization/departments`、部门成员 `GET /v1/im/organization/departments/{id}/members`、组织搜索 `GET /v1/im/organization/search` 已具备。
6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。
7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
9. 工作台 `tenant_code` 已返回租户注册表的公开编码,不再暴露内部 ID。
2. `GET /v1/im/bootstrap` 已返回工作台总未读 `entry_badges.workbench_total_unread`。
3. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。
4. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。
5. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。
6. 部门列表、部门成员、组织搜索已具备。
7. 会议通知创建、列表、详情、更新、取消、提醒、参会回应已具备基线。
8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
### 9.2 后续增强项
@@ -239,25 +281,24 @@
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 |
| P0 | 工作台未读角标事件 | 文档要求 `workbench.badge.updated`,后续需要模块未读聚合和实时增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 |
| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 |
| P1 | 会议离线 Push 联调 | 会议在线事件、系统通知和 Outbox 已完成,后续需使用真实 Firebase 凭据和移动端验证离线通知。 | 参与人离线时收到一次提醒,点击可跳转至正确会议。 |
| P1 | 组织全局成员接口 | 对接文档提到 `GET /v1/im/organization/members`,当前可由部门成员和组织搜索替代,未见独立路由。 | 前端如需要全局成员列表,可直接分页查询并按部门过滤。 |
| P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 |
| P1 | 月报 reopen | 对接文档有 `POST /v1/im/reports/monthly/{id}/reopen`,当前未见路由。 | 已提交月报可按权限退回草稿并保留审计。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
| P2 | 办公模块统一附件策略 | 会议、日报、月报、信箱附件需要复用第七章文件 ACL、用途和病毒扫描。 | 附件上传、查看、下载权限与业务状态一致。 |
## 10. 个人设置与客户端能力
## 10. 第十章:个人设置与客户端能力
### 10.1 当前已完成基线
1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。
2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。
3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。
4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
4. 青少年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。
7. 用户搜索/加好友、个人资料、消息回执及 Tinode P2P 最后在线输出已执行对应隐私设置。
@@ -266,15 +307,15 @@
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 精准登出与会话撤销 | `auth/logout` 与 `security/sessions/{id}` 未见路由,现有 token 主要靠 `auth_version` 批量失效。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 |
| P0 | 精准登出与会话撤销 | 现有 token 主要靠 `auth_version` 批量失效,后续需要 access/refresh/session 表支持当前设备登出和指定 session 删除。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | 青少年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 |
| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 |
| P1 | 隐私策略入口扩展 | 产品主要搜索、资料、回执和最后在线入口已执行隐私设置,组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 |
| P1 | 隐私策略入口扩展 | 组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 |
| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 |
## 11. 反诈安全、举报与意见反馈
## 11. 第十一章:反诈安全、举报与意见反馈
### 11.1 当前已完成基线
@@ -300,7 +341,7 @@
## 12. 统一处理建议
1. 优先剩余 P0:验证码真实发送与多维限频、消息撤回历史占位、P2P 文件/通话策略统一、@通知与频控、文件安全与短时授权、通话强状态机、工作台/组织权限和风控 token 业务消费。
2. 再做剩余 P1:租户策略配置、实名审核、设备/会话管理、字段级隐私矩阵、关系审计、群文件与解散状态机、通话质量指标、报表/信箱后台流转和真实 FCM 离线联调。
3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。
4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和 `IM-需求实现情况审核报告.md`,避免已完成项继续出现在待办清单。
1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、群文件、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。
2. 第二优先级补齐前端文档缺口:`conversations/p2p`、会话设置、云草稿、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。
3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。
4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。
@@ -0,0 +1,539 @@
# IM 注册、邀请码与认证数据模型改造需求文档
> 版本:V1.0 评审稿
> 编写日期:2026-08-18
> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL
> 关联文档:IM-前后端对接文档.md 1.4、IM-系统设计文档.md
## 1. 文档目的
本文统一定义以下改造:
1. 注册账号支持“用户名、手机号、用户名加手机号”三种输入组合。
2. 手机号注册支持短信验证,后端固定补中国国家号 +86。
3. HotGo 后台增加注册短信发送开关。
4. 邀请码作为无短信验证时的注册放行凭证。
5. 邀请码支持单个生成、批量生成、自定义有效期、撤销和使用审计。
6. im_users 增加非空 username,phone 改为可空。
7. im_auth 删除数据库字段 scheme 和 uname,只按 userid 保存 Basic 密码凭据。
## 2. 核心业务定义
| 名称 | 定义 |
| --- | --- |
| 用户名输入 | 注册页面独立的用户名输入框 |
| 手机号输入 | 注册页面独立的手机号输入框,只接受 11 位 ASCII 数字 |
| 放行优先级 | 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭或未填写手机号时以邀请码为注册放行依据 |
| 已验证手机号 | 已成功消费 register 场景 verification_token,并建立已完成 tel credential 的手机号 |
| 用户名注册 | 保存用户填写的 username;未填写手机号时依靠有效邀请码放行,phone 为 NULL |
| 手机号注册 | 手机号字段非空的注册;保存用户填写的 username 和规范化 phone,未填写 username 时由服务端随机生成 username;短信验证状态由 tel credential 区分 |
| 注册短信开关 | 控制服务端是否真正调用短信服务商的全局后台配置 |
| 邀请码 | 租户内生成的 5 位大小写英文字母明文码,区分大小写、不可重复,有效期内可供多人使用、可撤销 |
## 3. 用户账号数据规则
### 3.1 im_users 字段
目标字段:
| 字段 | 约束 | 说明 |
| --- | --- | --- |
| username | 非空、租户内唯一 | 主登录账号;用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`。未填写 username 但填写手机号时,由服务端随机生成 3 位小写字母 + 6 位数字的系统 username |
| phone | 可空、租户内唯一 | 用户填写手机号时的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 |
落库规则:
| 注册方式 | username | phone |
| --- | --- | --- |
| 用户名加手机号,验证码注册 | alice | +8613800138000 |
| 仅手机号,验证码注册 | abc123456 | +8613800138000 |
| 普通用户名加邀请码注册 | alice | NULL |
| 用户名加手机号,短信关闭邀请码注册 | alice | +8613800138000 |
| 仅手机号,短信关闭邀请码注册 | abc123456 | +8613800138000 |
普通用户名注册规则:
1. 必须匹配 `^[a-z0-9]{1,10}$`,长度为 1~10 位。
2. 只允许 ASCII 小写英文字母 `a-z` 和数字 `0-9`,允许纯字母、字母数字组合或纯数字。
3. 大写字母、中文、空格、点、下划线、冒号及其他符号均不允许;服务端校验原始输入,不通过静默转小写来放行不合规注册。
4. 前端按相同规则限制输入并提示,服务端必须再次校验,不能只依赖客户端。
5. 该规则只约束用户填写的普通用户名。服务端随机生成的系统 username 固定为 9 位 `^[a-z]{3}[0-9]{6}$`,属于系统值例外;存量迁移生成的内部保留账号也不适用此正则,不能作为普通用户名注册。
### 3.2 字段保存和手机号规范化
1. 页面分别展示用户名输入框和手机号输入框。
2. 用户名字段非空时必须按原值校验并保存到 `im_users.username`,不能因为同时填写手机号而忽略用户名。
3. 手机号字段非空时必须校验并保存到 `im_users.phone`;手机号字段为空时 `phone` 保持 NULL。
4. 手机号字段使用 ^[0-9]{11}$ 校验;手机号非空但格式错误时直接拒绝,不能降级为用户名方式。
5. 发送短信和落库前,后端把 11 位数字规范化为 +86xxxxxxxxxxx。
6. 用户名和手机号同时填写时,`username` 保存用户填写的用户名,`phone` 保存规范化手机号。
7. 仅手机号注册且 username 为空时,服务端随机生成 `^[a-z]{3}[0-9]{6}$` 格式的 username,检查租户内唯一,冲突时重新生成;`phone` 保存规范化手机号。
8. 不提供国家和区号选择,本期固定中国 +86。
9. 登录页面仍使用统一的“用户名/手机号”输入框;先精确查询 username,未命中时将 11 位输入补 +86 后查询 phone。
10. 注册、绑定手机号时必须检查 username 和 phone 两个命名空间,防止同一输入命中两个用户。
11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;用户名字段非空且格式错误时必须拒绝,不能因手机号合法而跳过用户名校验。
12. 注册创建公开资料时,账户昵称暂时默认等于最终保存的 username;客户端未传 `desc.public.fn` 或传入值为空时,服务端使用最终 username 填充昵称。
## 4. 注册放行规则
### 4.1 决策表
| 用户名字段 | 手机号字段 | 短信开关 | 有效验证票据 | 邀请码 | 服务端结果 |
| --- | --- | --- | --- | --- | --- |
| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,保存 username,phone=NULL |
| 已填 | 空 | 任意 | 不适用 | 未填或无效 | 拒绝 |
| 已填 | 11 位 | 开 | 有 | 未填 | 放行,保存 username 和 phone |
| 已填 | 11 位 | 开 | 有 | 有效 | 放行,保存 username 和 phone,并记录邀请码使用关系 |
| 空 | 11 位 | 开 | 有 | 未填或有效 | 放行,随机生成 username,phone 写手机号 |
| 任意 | 11 位 | 开 | 无 | 任意 | 拒绝,邀请码不能绕过已开启的短信验证 |
| 已填 | 11 位 | 关 | 无 | 有效 | 放行,保存 username 和 phone |
| 空 | 11 位 | 关 | 无 | 有效 | 放行,随机生成 username,phone 写手机号 |
| 任意 | 11 位 | 关 | 无 | 未填或无效 | 拒绝 |
| 已填 | 格式错误 | 任意 | 任意 | 任意 | 拒绝,不能降级为用户名方式 |
| 空 | 空 | 任意 | 任意 | 任意 | 拒绝,必须填写用户名或手机号 |
补充规则:
1. 有效 verification_token 的优先级高于当前短信开关。开关关闭不能让已验证票据失效。
2. 短信开关开启时,邀请码不能绕过手机号验证。
3. 用户主动填写邀请码后,服务端必须校验;不能在邀请码无效时静默忽略。
4. 手机号字段非空且短信开关开启时,以验证码为准,邀请码可不填;手机号字段为空或短信开关关闭时,必须填写有效邀请码。
5. 短信关闭时邀请码替代短信验证作为注册授权,但不建立已完成的 tel credential。
6. 用户创建、租户用户配额校验、邀请码使用记录、手机号票据消费应原子完成。
### 4.2 客户端页面行为
1. 页面分别显示“请输入用户名”和“请输入手机号”。
2. 用户名和手机号是独立字段;两个都填写时都参与注册,用户名保存为主登录账号,手机号保存为绑定号码。
3. 手机号为空时不发送短信,使用用户名加邀请码方式注册。
4. 手机号为 11 位数字时允许点击“获取验证码”。
5. 用户名方式不发送短信,验证码字段不作为提交项。
6. 邀请码字段始终显示,文案为“请输入邀请码(非必填)”。
7. 短信开关关闭时,发码接口仍返回正常倒计时,前端进入与开关开启时相同的倒计时状态。
8. 前端不自行决定最终是否可以免验证码注册,最终放行完全由服务端判断。
9. 用户名方式注册前提示:未绑定手机号时无法自助找回密码,只能联系企业管理员重置。
## 5. 注册短信发送开关
### 5.1 HotGo 配置
HotGo 后台增加全局配置:
~~~text
group = im
key = register_sms_enabled
type = bool
value = 1 / 0
~~~
实现约定:
1. 管理界面的开关选项复用 HotGo 已有 sys_switch 字典。
2. 当前开关值保存到 hg_sys_config;字典只负责提供开/关选项,不作为配置值真源。
3. 只有平台管理员可以修改。
4. 修改操作写 HotGo 后台审计日志。
5. IM 服务端在可控缓存时间内读取最新值。
6. 本期为全局开关;未来需要按企业控制时再迁移到 im_tenant_config。
### 5.2 开关开启
1. 服务端生成强随机验证码。
2. 创建 register 场景挑战并执行限频。
3. 调用短信服务商发送验证码。
4. 短信服务商失败时返回稳定错误 SMS_DELIVERY_FAILED。
5. 禁止继续使用固定测试码 123456。
### 5.3 开关关闭
1. 发码接口仍创建限频挑战。
2. 返回 challenge_id、expires_in_seconds 和 resend_after_seconds。
3. 不调用短信服务商。
4. 不生成客户端可猜测或可验证的固定验证码。
5. 前端仍正常显示倒计时,但用户不会收到短信。
6. 记录内部 delivery_suppressed 审计事件,不向客户端暴露开关状态。
## 6. 邀请码管理
### 6.1 基本规则
1. 邀请码按租户隔离,不能跨企业使用。
2. 邀请码固定为 5 位 ASCII 大小写英文字母,格式为 `^[A-Za-z]{5}$`,不包含数字和符号。
3. 邀请码区分大小写,`AbCdE` 与 `abcde` 是两个不同的邀请码;客户端和服务端不得自动转换大小写。
4. 邀请码由服务端使用密码学安全随机数从 `A-Z`、`a-z` 字符集中生成,不能使用递增数字、用户 ID 或批次 ID。
5. 同一租户内邀请码不能重复,数据库唯一索引是最终约束;单个或批量生成发生冲突时必须重新生成,不得返回重复码。
6. 邀请码以明文落库,并使用完整明文进行大小写敏感的精确查询和注册校验。
7. 每个邀请码在有效期内可以成功注册多个账号,不设置使用次数上限。
8. 管理员创建时自行设置 expires_at,必须晚于当前时间。
9. 状态包括 active、expired、revoked,不存在 used 状态。
10. expired、revoked 状态不可恢复为 active。
11. 邀请码校验必须同时检查租户、完整明文、状态和到期时间,不因已有其他用户使用记录而失效。
### 6.2 单个生成
请求示例:
~~~json
{
"expires_at": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职"
}
~~~
服务端返回一个 5 位邀请码。具备邀请码查看权限的租户管理员可在后续列表和详情中查看完整明文。
### 6.3 批量生成
请求示例:
~~~json
{
"quantity": 100,
"expires_at": "2026-09-01T00:00:00+08:00",
"remark": "2026年8月新员工入职"
}
~~~
规则:
1. 一次批量生成共用 batch_id 和 expires_at。
2. 每个邀请码均为独立生成的 5 位大小写英文字母,租户内唯一且区分大小写。
3. 批量请求必须支持幂等键,重试不能生成重复批次。
4. 单批数量由后台配置限制,建议默认上限 1000。
5. 生成成功后支持明文导出;后续再次查询或导出必须校验邀请码查看/导出权限并写审计。
### 6.4 查询、撤销和审计
管理后台支持:
1. 按完整邀请码进行大小写敏感的精确查询。
2. 按状态、批次、创建人、创建时间、到期时间查询。
3. 列表和详情返回邀请码明文,接口必须校验当前租户的邀请码查看权限。
4. 单个撤销仍处于 active 的邀请码;撤销不删除历史使用记录。
5. 按 batch_id 批量撤销仍处于 active 的邀请码。
6. 查看邀请码的累计使用次数、使用用户、使用时间和请求 ID。
7. 所有生成、查询、查看、导出、撤销操作写后台审计日志。
### 6.5 HotGo 管理接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | /imInvite/create | 单个生成 |
| POST | /imInvite/batchCreate | 批量生成 |
| POST | /imInvite/list | 分页或明文精确查询 |
| POST | /imInvite/revoke | 单个撤销 |
| POST | /imInvite/batchRevoke | 批量撤销 |
| GET | /imInvite/batchView | 批次详情 |
| POST | /imInvite/export | 按过滤条件导出 CSV |
HotGo 根据可信登录上下文取得 `im_tenant.id`,通过 `im_tenant.db_dsn/db_name` 选择租户业务库并直接维护邀请码管理数据。浏览器不能提交可信 DSN 或决定实际租户库。
IM 不再提供 `/admin/v1/im/*` HMAC 管理接口。注册校验和 `im_registration_invite_uses` 写入仍由 IM 在用户注册事务中完成,HotGo 不参与注册事务。
管理员重置密码时,HotGo 在同一个租户库事务中更新 `im_auth.secret`、递增 `im_user_security.auth_version`,并写入 `im_business_events_outbox` 的 `security.password_reset` 事件。IM 当前轮询此事件并清退在线会话;后续迁移 Kafka 时保持事件类型和 payload 不变,只替换投递与消费通道。
## 7. 客户端注册协议
### 7.1 发码
~~~http
POST /v1/im/auth/verification-codes
~~~
~~~json
{
"scene": "register",
"phone": "13800138000"
}
~~~
后端补 +86 后发送并保存手机号哈希。开关关闭时返回字段和倒计时保持一致。
### 7.2 提交注册
在现有 WebSocket acc 消息中增加 username、phone 和 invite_code:
~~~json
{
"acc": {
"id": "acc-1",
"user": "new",
"scheme": "basic",
"username": "alice",
"phone": "",
"secret": "base64(account:password)",
"invite_code": "AbCdE",
"login": true,
"cred": []
}
}
~~~
用户名方式时,phone 为空,secret 使用 username:password,cred 为空且 invite_code 必须有效。
手机号字段非空时,短信开关开启则必须通过验证码放行,cred 传递一次性 verification_token;短信开关关闭则走邀请码放行,cred 可为空且 invite_code 必须有效。用户名字段非空时保存用户名并使用 username:password 生成 secret;用户名字段为空时由服务端随机生成 3 位小写字母 + 6 位数字的 username,客户端提交 secret 时仍使用 phone:password。短信关闭路径不建立已完成的 tel credential。
服务端必须校验 secret 中的登录名与客户端可确定的注册账号一致:username 非空时必须匹配 username;username 为空且 phone 非空时必须匹配规范化手机号。服务端生成的随机 username 在账号创建后返回给客户端,并作为后续登录主账号。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。
账户昵称暂时与最终 username 保持一致:服务端创建用户资料时以最终 username 写入 `desc.public.fn` 或资料表昵称;仅手机号注册时昵称等于随机生成的 username,不使用手机号作为昵称。
客户端不得提交 phone_account、sms_enabled、skip_verification 等服务端决策字段。
## 8. im_auth 改造
### 8.1 改造边界
当前只有 Basic 密码凭据写入 im_auth:
1. token 是无状态签名,不写 im_auth。
2. code 使用临时缓存,不写 im_auth。
3. anonymous 不保存凭据。
4. rest 调用外部认证服务。
因此数据库中的 scheme=basic 是冗余信息。登录账号真源迁移到 im_users 后,uname 和 basic: 前缀同样冗余。
协议层仍保留 acc.scheme 和 login.scheme,用于选择 basic 或 token 认证处理器。本次只删除数据库字段。
这里删除的 uname 仅指 `im_auth.uname` 数据库列。Basic 协议仍从 `secret` 中解析登录账号和密码,登录账号改为查询 `im_users.username` 或 `im_users.phone`;除非后续同时改造客户端和协议消息结构,否则 `account:password` 格式及用户名禁止冒号的技术约束继续保留。
### 8.2 目标表结构
~~~sql
CREATE TABLE im_auth (
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
authlvl INT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY (id),
UNIQUE INDEX auth_userid (userid),
CONSTRAINT fk_im_auth_user
FOREIGN KEY (userid) REFERENCES im_users(id)
);
~~~
变更内容:
1. 删除 scheme。
2. 删除 uname。
3. 删除 auth_uname 和 auth_userid_scheme。
4. 增加 UNIQUE(userid)。
5. 保留 authlvl,以兼容普通用户和 ROOT 账号。
6. 保留 secret 和 expires。
### 8.3 登录和密码处理
1. Basic 登录先规范化账号输入。
2. 先从 im_users.username 查询 userid。
3. 未命中且输入为 11 位数字时,补 +86 后从 im_users.phone 查询 userid。
4. 按 userid 从 im_auth 读取 bcrypt 密码摘要。
5. 修改密码、重置密码、删除认证和 ROOT 提升都按 userid 操作。
6. Store 和 Adapter 接口不再传递 scheme、unique 或 basic: 前缀。
### 8.4 密码找回
1. im_users.phone 非空的账号可以使用短信找回密码。
2. 找回流程向 im_users.phone 发送 reset_password 场景验证码,成功消费验证码后才允许设置新密码。
3. 如果该手机号注册时未建立完成状态的 tel credential,成功完成密码找回验证后可以补建该凭证。
4. im_users.phone 为 NULL 的用户名账号不提供短信或其他自助找回方式。
5. 无手机号账号忘记密码后,只能联系当前租户管理员执行后台密码重置。
6. 管理员重置密码必须递增 auth_version、撤销旧 Token 和在线 Session,并写安全审计。
7. 管理员只能重置当前租户用户,不能通过用户名跨租户查找或操作。
### 8.5 密码与登录 Token
1. 客户端按现有 Basic 协议提交 `base64(account + ":" + password)`。Base64 只是协议编码,不是密码加密;账号注册和登录必须使用 HTTPS/WSS。
2. 服务端解析出密码后使用 bcrypt 单向哈希,当前成本参数为 `bcrypt.DefaultCost=10`;`im_auth.secret` 只保存 bcrypt 哈希,不保存可逆密文或明文。
3. 登录 Token 继续沿用现有 Tinode HMAC 签名 Token,默认有效期为 14 天,服务端不保存 Token 明文。
4. 客户端保存登录响应中的 `params.token` 和 `params.expires`,Token 必须放入 iOS Keychain 或 Android Keystore,不得写入普通数据库、日志或埋点。
5. 每次 Basic 登录或有效 Token 登录成功后,服务端都返回新的 14 天 Token;客户端必须用新 Token 覆盖旧 Token。
6. 当前不增加 Refresh Token 和独立刷新接口。Token 未过期时可通过 `login.scheme=token` 登录并取得新 Token;Token 已过期或校验失败时必须重新进行账号密码登录。
7. Token 内绑定 `tenant_id`、`uid`、`auth_version` 和过期时间。密码重置、管理员重置或账号安全状态变化后递增 `auth_version`,使旧 Token 失效。
## 9. 数据库设计
### 9.1 邀请码批次
~~~sql
CREATE TABLE im_registration_invite_batches (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
quantity INT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key)
);
~~~
### 9.2 邀请码
~~~sql
CREATE TABLE im_registration_invite_codes (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
batch_id BIGINT,
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
expires_at DATETIME(3) NOT NULL,
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
last_used_at DATETIME(3),
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code),
INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at),
INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id)
);
~~~
### 9.3 邀请码使用记录
~~~sql
CREATE TABLE im_registration_invite_uses (
id BIGINT NOT NULL,
invite_code_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
used_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
INDEX idx_im_registration_invite_use_code (invite_code_id),
UNIQUE INDEX uk_im_registration_invite_use_user (user_id)
);
~~~
`im_registration_invite_batches` 和 `im_registration_invite_codes` 位于 HotGo 控制库,使用 `tenant_id` 隔离租户,便于平台统一查询、导出和管理。`usage_count`、`last_used_at` 是租户使用明细的查询投影,不是使用事实源。
`im_registration_invite_uses` 位于对应租户业务库,是邀请码使用事实源。注册成功时,IM 在同一租户库事务中写入使用明细和 `registration.invite_used` outbox 事件;当前由 IM 数据库轮询器按该邀请码的租户明细重新聚合并覆盖控制库统计,重试不会重复计数。后续切换 Kafka 时保持事件类型和 payload 不变。三张表的 BIGINT 主键均使用服务端雪花 ID。
邀请码以明文保存在 HotGo 控制库中。`ascii_bin` 保证查询和唯一索引区分大小写;所有校验必须同时使用 `tenant_id` 和完整 5 位邀请码精确匹配,禁止 `LOWER(code)`、`UPPER(code)` 或模糊查询。
## 10. 主要错误码
| 业务码 | 含义 |
| --- | --- |
| ACCOUNT_REQUIRED | 账号输入为空 |
| ACCOUNT_POLICY_VIOLATION | 账号违反最低技术约束 |
| ACCOUNT_ALREADY_EXISTS | 用户名或手机号命名空间冲突 |
| PHONE_VERIFICATION_REQUIRED | 手机号注册缺少有效验证票据 |
| INVITE_CODE_REQUIRED | 用户名注册缺少邀请码 |
| INVITE_CODE_INVALID | 邀请码不存在 |
| INVITE_CODE_EXPIRED | 邀请码已过期 |
| INVITE_CODE_REVOKED | 邀请码已撤销 |
| INVITE_CODE_RATE_LIMITED | 邀请码校验过于频繁 |
| SMS_DELIVERY_FAILED | 短信服务商发送失败 |
| USERNAME_INVALID_FORMAT | 用户名不是 1~10 位小写英文字母或数字 |
邀请码无效、过期、已撤销可对客户端统一显示“邀请码无效”,详细原因仅写安全审计,降低枚举风险。
## 11. 存量迁移
采用“扩容、回填、切换、收缩”方式:
1. 为 im_users 增加可空 username。
2. 去除 im_auth.uname 中的 basic: 前缀后回填 im_users.username。
3. 将现有 phone 统一规范化为 E.164。
4. 对无 Basic 认证记录的内部、已删除或异常用户生成 reserved_<userid>,并输出迁移报告供人工复核。
5. 检查重复 username、重复 phone 和跨字段冲突。
6. 新版本短期双读旧 im_auth 和新 im_users 账号字段。
7. 切换注册、登录、修改密码、密码重置和 ROOT 工具链。
8. 将 username 改为非空并增加唯一索引。
9. 观察期结束后删除 im_auth.scheme、im_auth.uname 和旧索引。
回滚时在观察期内继续保留旧列和旧读路径,不允许通过重新拼接 basic: 猜测恢复数据。
## 12. 安全与已知风险
1. 5 位邀请码属于短授权码,必须使用密码学安全随机数生成、设置有效期、可撤销并可审计;注册校验必须严格限频以降低枚举风险。邀请码允许多人使用意味着泄露后的影响面更大,应缩短有效期并及时撤销异常邀请码。
2. 邀请码校验按租户、IP、设备和账号输入限频。
3. 邀请码虽按业务要求明文落库和授权查询,但不得写入普通日志、埋点或错误响应;密码、短信验证码和 verification_token 同样禁止记录。
4. 邀请码使用记录、用户创建、票据消费和租户配额扣减应在同一事务完成;邀请码本身不核销、不切换为 used。
5. 短信开关关闭时不能保留固定测试验证码。
6. 批量生成、批量导出和批量撤销必须限制数量并单独授权。
### 12.1 邀请码手机号注册风险
短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号写入 phone;如果用户同时填写 username,username 保存用户填写值,否则由服务端随机生成 3 位小写字母 + 6 位数字的 username。
为避免把“已填写手机号”和“已验证手机号”混为一谈:
1. im_users.phone 保存手机号方式注册时的号码。
2. 只有成功消费 verification_token 时才建立完成状态的 tel credential。
3. 短信关闭加邀请码注册不建立完成状态的 tel credential。
4. phone 非空账号可以通过 reset_password 短信挑战证明当前持有手机号后自助重置密码。
5. phone=NULL 的账号不能自助找回密码,只能由当前租户管理员重置。
6. 其他要求“已验证手机号”的敏感操作必须检查 tel credential,不能只检查 phone 非空。
7. 后续完成短信验证后,可为同一用户补建 tel credential。
8. 邀请码不证明手机号所有权,允许该路径意味着平台接受手机号被提前占用的业务风险。
## 13. 验收用例
### 13.1 注册
1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。
2. 只填写手机号,短信开启但没有验证票据,即使邀请码有效也不能注册。
3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。
4. 只填写手机号,短信关闭且不填邀请码不能注册。
5. 只填写用户名并提供有效邀请码可以注册,phone=NULL。
6. 只填写用户名但不填邀请码不能注册。
7. 用户名和手机号同时填写且短信开启时,验证码验证成功后可以注册,username 保存用户填写的用户名,phone 保存规范化手机号。
8. 用户名和手机号同时填写且短信关闭时,邀请码有效即可注册,username 保存用户填写的用户名,phone 保存规范化手机号。
9. 手机号非空但格式错误时拒绝,不能回退到用户名方式。
10. 用户名和手机号同时为空时拒绝注册。
11. 过期、已撤销邀请码均不能注册。
12. 同一有效邀请码并发注册时可以有多个不同用户成功,每个用户分别写入一条使用记录,邀请码保持 active。
13. 用户名账号 phone=NULL 时,短信找回接口不能重置密码。
14. 管理员可以重置 phone=NULL 用户的密码,重置后旧 Token 和 Session 全部失效。
15. 普通用户名 `alice01`、`a`、`1234567890` 可以进入注册校验。
16. 普通用户名 `Alice`、`alice_1`、`alice.1`、`中文`、空字符串或超过 10 位时返回 `USERNAME_INVALID_FORMAT`。
17. 用户名格式不合法但手机号合法且非空时仍拒绝,不能忽略用户名输入。
18. 仅手机号注册时,随机 username 必须在租户内唯一;发生唯一索引冲突时服务端重新生成并重试。
19. 注册成功后的账户昵称等于最终 username:填写用户名时昵称等于该用户名;仅手机号注册时昵称等于随机 username。
### 13.2 短信开关
1. 开关关闭后发码接口不调用短信服务商。
2. 开关关闭后发码接口仍返回正常倒计时。
3. 开关从关闭改为开启后,新请求恢复调用短信服务商。
4. 开关修改在规定缓存时间内对 IM 服务生效。
### 13.3 邀请码管理
1. 单个生成可设置有效期。
2. 单个和批量生成的邀请码均匹配 `^[A-Za-z]{5}$`,不包含数字或符号。
3. 批量生成数量正确,同一租户所有邀请码互不重复;并发生成冲突时由唯一索引拦截并重试。
4. `AbCdE` 和 `abcde` 按两个不同邀请码精确查询和校验,不得自动转换大小写。
5. 有权限的管理员可以按完整邀请码查询,并在列表、详情和导出中取得明文。
6. 批量请求重试不会生成第二批数据。
7. 单个和批量撤销立即生效,历史使用用户不受影响。
8. 无权限用户不能查看、查询或导出邀请码明文。
9. 管理员可以查看批次使用统计和单码使用记录。
### 13.4 认证回归
1. 存量 Basic 用户迁移后可使用原账号和密码登录。
2. 登录、注册、修改密码、密码重置和 ROOT 提升不再依赖 im_auth.scheme、im_auth.uname 或 basic: 前缀。
3. 同一 username 或 phone 在不同租户仍可独立注册。
4. im_auth 每个 userid 最多保留一条记录。
## 14. 评审结论
本方案的核心决策为:
1. 注册页的用户名和手机号是两个独立输入框,用户名非空时必须保存,手机号非空时必须保存。
2. username 是所有用户非空的主登录账号。
3. 用户名和手机号同时填写时,username 保存用户名,phone 保存规范化手机号;仅手机号注册时 username 由服务端随机生成 3 位小写字母 + 6 位数字。
4. 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭时以邀请码为注册放行依据。
5. 短信关闭时,手机号输入搭配有效邀请码可注册,但不建立已完成的 tel credential。
6. 邀请码为 5 位大小写英文字母,区分大小写,以明文落库并在租户内唯一;有效期内可供多人使用,支持单个、批量、自定义有效期、明文查询/导出、撤销和使用审计。
7. im_auth 只按 userid 保存 Basic 密码凭据,数据库删除 scheme、uname 和 basic: 前缀。
8. 协议层仍保留 basic/token scheme,不与数据库结构混淆。
9. phone=NULL 的用户名账号不能自助找回密码,只能由租户管理员重置。
10. HotGo 与 IM 当前以共享租户业务库集成,不再使用内部 HMAC 管理 HTTP;跨进程安全事件先经数据库 outbox,后续迁移 Kafka。
评审通过后,实施工作拆分为数据库迁移、IM 注册与认证、HotGo 后台、客户端对接和全量回归五个任务。
@@ -0,0 +1,223 @@
# IM 测试环境接口审核报告(2026-08-20)
## 1. 审核范围
- 对照文档:`docs/IM-前后端对接文档.md`
- 测试环境:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels`
- API Key:已使用测试环境普通 Key,报告中不展开。
- 租户:`default`
- 测试账号:本轮通过短信验证码固定码 `123456` 和 WebSocket `{acc}` 临时注册,用户 ID `usrIPhpZJeJDMI`。
- 本轮只新增本报告,不修改原前后端对接文档。
## 2. 总体结论
测试环境已能跑通基础启动、注册、登录、bootstrap、通讯录空列表、建群、工作台、日报/月报草稿、个人资料、隐私、反馈、设备推送 Token 等主链路。整体方向和产品需求基本匹配:Tinode WebSocket 承担消息与 Topic,`/v1/im/*` 承担产品化业务接口。
但当前仍不建议直接按整份对接文档全量联调。主要风险是:部分文档接口没有路由或路由兜底错误,部分配置类接口在测试库空数据时返回 500/502,部分通用响应约定没有统一落到所有列表接口,办公协同和转发/群文件相关接口还存在明显缺口。
## 3. P0/P1 问题
处理备注(2026-08-20):
- 已在提交 `6c6d2f6 fix: stabilize im audit endpoints` 中处理本节前 6 项,以及 `bootstrap.limits` 缺失问题。
- 已用本地 Docker 启动服务并调用本地 API 验证通过;测试环境需发布该提交后再复测公网结果。
- 当前短信供应商未接入时,注册、找回密码和敏感操作验证码均按测试码 `123456` 验证;生产接入短信供应商后应切回真实短信策略。
| 优先级 | 问题 | 测试结果 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| P0 | 密码重置发码 `scene=reset_password` 返回 502 | `POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"15509271990"}`,带 `Idempotency-Key`,返回 Cloudflare `502` | 忘记密码无法联调,且不是统一 JSON 错误 | 已处理:短信未接入时保留 challenge,并用测试码 `123456` 验证;本地 API 验证返回 200 |
| P0 | 登录后二次身份验证挑战返回 502 | `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`,带 `Idempotency-Key`,返回 Cloudflare `502` | 关闭青少年模式、敏感操作二次验证不可用 | 已处理:短信发送失败不再使 challenge 失败;带 `Idempotency-Key` 创建和 `123456` verify 本地均返回 200 |
| P0 | 总经理信箱配置和提交返回 500 | `GET /v1/im/executive-mailbox/config` 返回 `INTERNAL_ERROR`;按文档提交也返回 500 | 工作台入口开启但页面不可用 | 已处理:修复 `im_mailbox_config`、`im_mailbox_submissions` 的 `SELECT *` 映射问题;本地 config/submit 均返回 200 |
| P1 | 举报原因返回 500 | `GET /v1/im/reports/abuse/reasons?target_type=user` 返回 `INTERNAL_ERROR` | 举报页无法打开 | 已处理:补齐 `AbuseReason` 数据库字段映射;本地返回 200 和 `items` |
| P1 | 转发目标接口不存在 | `GET /v1/im/forward-targets?query=` 返回 404 | 4.17~4.20 转发目标页无法联调 | 已处理:新增 `/v1/im/forward-targets`,返回文件助手和可转发的 P2P/群会话;本地返回 200 |
| P1 | 群文件接口被群详情兜底吞掉 | `GET /v1/im/groups/{topic}/files` 返回群详情 JSON,而不是文件列表或 404 | 前端会误判接口成功,群文件页数据结构错误 | 已处理:未知一级 tail 不再落到群详情;`/files` 未实现时本地返回 404 |
## 4. 章节审核
### 0. 公共对接约定
通过项:
- `POST /v1/im/tenants/resolve` 可未登录调用,返回统一 `code/data/request_id/server_time`。
- `endpoints` 已返回正确公网地址:`https://api.118im.com/v1/im`、`wss://api.118im.com/v0/channels`、`https://api.118im.com/v0/file`。
- 缺 token 访问 `GET /v1/im/bootstrap` 返回统一 JSON:`401 AUTH_REQUIRED`。
问题:
- 公共约定写分页统一返回 `items,next_cursor,has_more`,但很多接口只返回 `items,next_cursor`,例如 `friends`、`friend-requests`、`system-notifications`、`favorites`、`calls`。
- 写请求确实依赖 `Idempotency-Key`,但部分章节没有重复强调;前端实现时必须按公共约定统一加。
### 1. 账号注册、登录与身份安全
通过项:
- 企业解析通过,且不泄漏 `tenant_id`。
- 注册短信发码通过:返回 `challenge_id/expires_in_seconds/resend_after_seconds/phone_mask`。
- 固定验证码 `123456` 验证通过:返回 `verification_token`。
- WebSocket `hi(tenant=default)` 返回 `201`,`acc user:new login:true` 注册成功,返回 `user/token/expires/username`。
- `GET /v1/im/security/realname` 返回 `status=unverified, can_submit=true`。
问题:
- 已处理待发版复测:`reset_password` 发码不再因短信供应商未接入返回 502,本地返回 200。
- 已处理待发版复测:`POST /v1/im/security/challenges` 不再因短信供应商未接入返回 502,本地创建和校验均返回 200。
- 文档目标错误 reason 很完整,但 WS 登录/注册仍主要是 Tinode `ctrl.text/code`;稳定 `params.reason` 是否全链路返回还需要继续核。
### 2. 消息首页、全局导航与系统通知
通过项:
- `GET /v1/im/bootstrap` 已返回 `features/permissions/message_policy/security/modules/system_topic`。
- 最新测试环境已补 `entries` 和 `entry_badges`,包括 `new_friends/groups/file_assistant/system_messages` 入口与未读数字段,能支撑通讯录和首页入口显示。
- `GET /v1/im/system-notifications?limit=5` 返回 `items,next_cursor`。
- `GET /v1/im/search/conversations?q=a` 可返回已创建群会话,包含 `seq/read/unread/public/private`。
问题:
- 已处理待发版复测:`bootstrap` 已补 `limits.max_file_size/max_text_length/max_forward_targets`,本地返回 200。
- 系统通知没有聚合未读接口,当前依赖 `bootstrap.entry_badges.system_unread_count`,需要确认其真实计算来源和实时更新事件。
### 3. 单聊会话、会话建立与消息状态
通过项:
- 路由存在 `GET /v1/im/conversations/{topic}/context`。
- 好友申请接口存在:`POST/GET /v1/im/friend-requests`,列表空数据返回正常。
- 好友列表 `GET /v1/im/friends` 返回正常空列表。
问题:
- 文档出现 `POST /v1/im/conversations/p2p`、`GET /v1/im/conversations/{topic}/messages`、`PATCH /v1/im/conversations/{topic}/settings`,路由清单未看到对应明确 handler,需要进一步实测或补实现。
- 单聊发送约束最终仍要靠 WebSocket `pub` 校验,HTTP 文档里的关系状态和 WS 发布路径需要做端到端用例。
### 4. 消息输入、媒体内容与消息操作
通过项:
- 收藏基础接口存在,`GET /v1/im/favorites?limit=5` 正常返回空列表。
- 消息撤回/编辑/回执类路由挂在 `/v1/im/topics/` 下,代码有统一 handler。
问题:
- 已处理待发版复测:`GET /v1/im/forward-targets?query=` 已新增,本地返回 200。
- 文档里的 `POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{bundle_id}` 未在路由表看到。
- 文件上传仍主要使用 `/v0/file`,但业务 `purpose`、附件 ACL、群文件登记接口还没有从测试结果证明可用。
- 已处理待发版复测:`bootstrap.limits.max_forward_targets/max_file_size/max_text_length` 本地已返回。
### 5. 通讯录、好友关系与黑名单
通过项:
- `GET /v1/im/friends`、`GET /v1/im/friend-requests`、`GET /v1/im/blocks` 均返回统一成功空列表。
- `GET /v1/im/users/search` 可调用,空结果返回 `items:[]`。
- `bootstrap.entries.new_friends` 和 `entry_badges.new_friends_pending_count` 已能控制入口和未处理数。
问题:
- 本轮新注册用户搜索自己的 username 返回空列表,可能是“排除自己”的合理策略,也可能是搜索索引/隐私导致,需要用第二个用户互搜确认。
- 新朋友未处理数现在在 `bootstrap.entry_badges` 返回,但需要确认好友申请创建/处理后是否实时准确更新。
### 6. 群聊创建、资料维护与成员治理
通过项:
- `POST /v1/im/groups` 已支持截图里的 `name + description` 创建群聊,返回 `topic/name/description/owner/member_count/version/permissions`。
- `GET /v1/im/groups`、`GET /v1/im/groups/{topic}`、`GET /summary`、`GET /members`、`POST /qrcodes`、`GET /governance`、`GET /role-limits`、`GET /mutes` 均可用。
- 群二维码返回 `qr_token/qr_payload/expires_at/group_snapshot`,符合需求。
问题:
- `GET /v1/im/groups/{topic}/members` 返回的 owner 成员 `user_id` 为空字符串,这不适合前端成员列表和后续管理员/转让操作,应返回 `usr...`。
- 已处理待发版复测:`GET /v1/im/groups/{topic}/files` 不再返回群详情,本地返回 404。
- 已处理待发版复测:`GET /v1/im/groups/{topic}/announcements` 不再返回群详情,未知一级 tail 统一返回 404。
- 文档里的 `POST /v1/im/group-invitations/{invitation_id}/accept` 未在路由表看到;当前更像是走群内 `/invitations` 和扫码 join-request。
### 7. 文件下载、收藏管理与聊天记录检索
通过项:
- `GET /v1/im/favorites` 可用。
- 收藏批量、删除、转发相关 handler 在 `/favorites` 和 `/favorites/` 下存在。
问题:
- 群文件接口不可用,且被错误兜底。
- 文档中的聊天记录搜索、媒体筛选、上下文接口挂在 `/v1/im/topics/{topic}/...`,需要有真实消息后再端到端验证;本轮未创建聊天消息。
- 文件下载鉴权复用 `/v0/file`,本轮未上传真实附件,ACL 未验证。
### 8. 实时语音与视频通话
通过项:
- `GET /v1/im/calls?limit=5` 返回正常空列表。
- 文档对通话主体设计合理:WebSocket 信令为主,HTTP 只做记录查询。
问题:
- 需要真机或至少两个 WS 用户做 call `started/ringing/accept/hang-up` 端到端;本轮只验证 HTTP 通话记录列表。
- Push、好友关系、拉黑、并发通话校验没有通过测试环境验证。
### 9. 企业工作台与办公协同
通过项:
- `GET /v1/im/workbench` 返回模块列表和 `layout_version`。
- `GET /v1/im/organization/departments` 返回正常空列表。
- 日报列表、日报统计、日报草稿创建通过。
- 月报列表、月报统计、月报草稿创建通过。
- 会议创建按文档 Body 通过。
问题:
- `GET /v1/im/meetings?limit=5` 返回 405;文档没明确会议列表接口,但会议通知页面通常需要列表,设计上应补 `GET /v1/im/meetings`。
- `GET /v1/im/organization/search?q=` 空关键词返回 400;文档示例写 `q=`,需明确前端空关键词应不请求还是后端返回空列表。
- `GET /v1/im/executive-mailbox/config` 和提交均返回 500,总经理信箱不可联调。
- `modules[].unread_count` 当前全 0,需要确认是否真实 badge,还是静态占位。
### 10. 个人资料、通用设置、隐私与青少年模式
通过项:
- `GET /v1/im/me`、`GET /v1/im/me/profile`、`GET /v1/im/me/privacy`、`GET /v1/im/settings/youth-mode` 均可用。
- `POST /v1/im/me/qrcodes` 可用,返回 `qr_token/qr_payload/display_profile/expires_at`。
- `PUT /v1/im/devices/{device_id}/push-token` 可用,能登记 APNs 测试 token。
- `GET /v1/im/app/releases/latest?platform=ios&version=1.0.0` 可用。
问题:
- 文档写应用更新参数为 `current_version`,测试使用 `version` 也能返回;实现似乎不消费当前版本参数,建议统一字段名,避免客户端多套逻辑。
- 青少年模式关闭依赖二次验证,而二次验证挑战当前 502。
- Android 系统权限说明多为客户端能力,本轮无法通过服务端验证。
### 11. 反诈安全、举报与意见反馈
通过项:
- `POST /v1/im/security/urls/check` 可用,安全 URL 返回 `decision=allow/risk_level=low`。
- `GET /v1/im/feedback/config` 可用。
- `POST /v1/im/feedback` 可用,返回 `feedback_id/status=received`。
问题:
- `GET /v1/im/reports/abuse/reasons?target_type=user` 返回 500,举报页阻塞。
- 风控 challenge confirm 路由存在,但需要先有业务接口返回 `RISK_CONFIRMATION_REQUIRED` 后才能完整验证。
- 举报提交需要真实目标对象和原因数据,本轮因原因接口 500 未继续提交。
### 12. 联调实施清单
建议把当前验收顺序调整为:
1. 已处理待发版复测:密码重置 502、二次验证 502、总经理信箱 500、举报原因 500。
2. 部分已处理待发版复测:转发目标已补、群文件路径错误兜底已修;消息转发/Forward Bundle 缺路由、群成员 `user_id` 为空仍待处理。
3. 统一公共协议:所有列表是否必须返回 `has_more`;所有写接口是否都必须 `Idempotency-Key`;`bootstrap.limits` 是否需要补。
4. 两用户端到端:好友申请、同意、P2P context、普通消息、已读、拉黑、删除好友。
5. 群端到端:邀请成员、入群审批、公告、群文件、管理员、禁言、群主转让。
6. 真机端到端:文件上传下载、APNs/FCM 推送、WebRTC 通话。
## 5. 代表性实测结果
| 接口 | 方法 | 状态 | 结论 |
| --- | --- | --- | --- |
| `/v1/im/tenants/resolve` | POST | 200 | 企业解析正常,endpoint host 正确 |
| `/v1/im/auth/verification-codes` register | POST | 200 | 注册发码正常 |
| `/v1/im/auth/verification-codes/{id}/verify` | POST | 200 | 固定验证码 `123456` 正常 |
| WebSocket `hi + acc` | WS | 201/200 | 注册并登录成功 |
| `/v1/im/auth/verification-codes` reset_password | POST | 502 | 已处理待发版复测;本地返回 200 |
| `/v1/im/security/challenges` | POST | 502 | 已处理待发版复测;本地创建和 `123456` 校验返回 200 |
| `/v1/im/bootstrap` | GET | 200 | 已包含 `entries/entry_badges`;已补 `limits` 待发版复测 |
| `/v1/im/groups` | POST | 200 | 创建群聊含 `description` 正常 |
| `/v1/im/groups/{topic}/members` | GET | 200 | 可用,但成员 `user_id` 为空 |
| `/v1/im/groups/{topic}/files` | GET | 200 | 已处理待发版复测;未实现时本地返回 404,不再误返回群详情 |
| `/v1/im/forward-targets` | GET | 404 | 已处理待发版复测;本地返回 200 |
| `/v1/im/executive-mailbox/config` | GET | 500 | 已处理待发版复测;本地 config/submit 返回 200 |
| `/v1/im/reports/abuse/reasons` | GET | 500 | 已处理待发版复测;本地返回 200 |
| `/v1/im/feedback` | POST | 200 | 意见反馈提交正常 |
| `/v1/im/devices/{device_id}/push-token` | PUT | 200 | 推送 token 登记正常 |
## 6. 本轮测试产生的数据
- 创建临时用户:`usrIPhpZJeJDMI`
- 创建群:`grpcNdeRoa_VO0`
- 创建会议:`mtg_vN4182ncLyi5wp4A`
- 创建日报草稿:`dr_MdAFCj2fYTkBjiq8`
- 创建月报草稿:`mr_eWjm2hpuzSsqcIcH`
- 创建反馈:`fb_TEJYaStZn9i0JSun`
- 登记设备:`audit-device`
这些数据均在测试租户 `default` 下,可按需要由测试库清理。
@@ -3,6 +3,7 @@
-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。
-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。
-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。
-- 本文件中的非自增 BIGINT 主键由服务端雪花算法生成。
SET NAMES utf8mb4;
@@ -79,13 +80,15 @@ CREATE TABLE IF NOT EXISTS im_verification_codes(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_security(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_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(tenant_id,user_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_security_tenant_user(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -132,6 +135,7 @@ CREATE TABLE IF NOT EXISTS im_action_tokens(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_youth_mode_settings(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
@@ -140,7 +144,8 @@ CREATE TABLE IF NOT EXISTS im_youth_mode_settings(
locked_until 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(tenant_id, user_id, purpose),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_settings_tenant_user_purpose(tenant_id, user_id, purpose),
CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -192,6 +197,7 @@ CREATE UNIQUE INDEX uk_im_friend_requests_idempotency
ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_friendships(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
@@ -200,18 +206,21 @@ CREATE TABLE IF NOT EXISTS im_friendships(
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, user_id, peer_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_tenant_pair(tenant_id, user_id, peer_id),
INDEX idx_im_friendships_peer(tenant_id, peer_id, status),
CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_blocks(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT 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(tenant_id, blocker_id, blocked_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_tenant_pair(tenant_id, blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id),
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -312,5 +321,6 @@ CREATE TABLE IF NOT EXISTS im_message_revisions(
-- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic;
-- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid);
-- im_usertags/im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id;
-- im_usertags 已废弃;im_users.phone 是手机号真源,邮箱存放于 im_user_profiles.email。
-- im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id;
-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。
+11 -1
View File
@@ -205,7 +205,7 @@ HTTP API 通用规则:
| 模块 | 目标表 | 主要职责 |
| --- | --- | --- |
| 好友关系 | `im_friendships`、`im_friend_requests`、`im_user_blocks` | 好友申请、同意、拒绝、删除、拉黑 |
| 用户资料 | `im_user_profiles`、`im_user_privacy_settings` | 昵称、生日、签名、可见性和添加方式 |
| 用户资料 | `im_users`、`im_user_profiles`、`im_user_privacy_settings` | 注册手机号、昵称、邮箱、生日、签名、可见性和添加方式 |
| 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_announcements`、`im_group_mutes`、`im_group_admin_logs` | 入群审批、公告、禁言、管理员、转让群主 |
| 群文件 | `im_group_files` | 群文件目录、搜索、删除权限 |
| 收藏 | `im_favorites` | 收藏去重、分类、来源、搜索和分享 |
@@ -234,6 +234,12 @@ HTTP API 通用规则:
| 群成员 | `im_group_members` | 群成员业务状态、角色、入群来源和操作者 |
| 消息版本 | `im_message_revisions` | 编辑前内容、版本、操作者和变更原因 |
MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键。`im_user_security`、`im_kvmeta`、用户资料/隐私、设备 Push Token、好友/黑名单、青少年模式以及群设置/公告已读/禁言等原先使用自然键或复合主键的表,统一改为服务端雪花算法生成的 `BIGINT id` 主键;原业务键保留唯一索引,查询和幂等语义不变。服务启动时会对当前租户库执行幂等迁移,为存量行补齐雪花 ID 后再替换主键。迁移期间不得同时运行不写入 `id` 的旧版本服务。
用户身份字段约定:`im_users.username` 是非空的主登录账号,`im_users.phone` 是手机号方式的注册号码,允许为 `NULL`;是否完成短信验证由已完成的 `tel credential` 判断。用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`,服务端按原始输入校验;手机号方式写入的 E.164 账号和迁移生成的内部保留账号属于系统值例外。注册页面分别展示用户名和手机号,手机号非空时优先走手机号方式并忽略用户名输入:规范化后的 E.164 手机号同时写入 `username` 和 `phone`;只有手机号为空时才使用用户名方式并只写 `username`。短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册并写两个字段,但不建立已完成的 `tel credential`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。
注册邀请码固定为 5 位 ASCII 大小写英文字母,使用密码学安全随机数生成并区分大小写。邀请码明文存入租户业务库,`code` 字段使用 `ascii_bin` 排序规则和唯一索引;注册时按完整明文精确查询,不允许大小写转换或模糊匹配。邀请码查看和导出必须授权并审计,普通日志不得记录邀请码。
### 7.3 企业码与登录流程
1. 未认证客户端先通过 `hi.tenant` 提交企业码;服务端只按 `im_tenant.code` 查找启用租户并把 `TenantID` 固定到 Session。
@@ -246,6 +252,8 @@ HTTP API 通用规则:
验证码按 `register`、`login`、`reset_password`、`sensitive_action` 用途隔离,相同验证码不能跨用途使用。数据库只保存验证码强哈希和手机号规范化哈希,明文只在发送时短暂存在。
`im_users.phone` 非空的账号可以通过 `reset_password` 短信挑战自助找回密码;成功验证即证明当前持有该号码,不要求注册时已经存在完成状态的 `tel credential`,并可在重置成功后补建凭证。`phone=NULL` 的用户名账号不提供自助找回,只能由当前租户管理员重置。管理员不得跨租户操作,重置行为必须进入安全审计。
密码重置必须在同一事务中完成:
1. 消费有效验证码或二次验证挑战。
@@ -506,6 +514,8 @@ end_reason, created_at, updated_at
| --- | --- |
| 头像、昵称、签名 | User Public + `im_user_profiles` |
| 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 |
| 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 |
| 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 |
| 实名姓名、证件号 | 实名表加密存储,不进入 Public |
| 群昵称 | `im_group_members`,按需投影到 Subscription Private |
+10 -3
View File
@@ -3,10 +3,12 @@
-- Database: the currently selected tenant database.
-- Important: one tenant owns one database, so these tables intentionally do not contain tenant_id.
-- This SQL is for review first. Do not execute directly in production before confirming indexes and migration order.
-- Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator.
-- 1. Group profile and governance truth.
-- Topic/Subscription remains the communication projection; this table is the product business truth.
CREATE TABLE IF NOT EXISTS im_group_settings (
id BIGINT NOT NULL,
topic VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '',
@@ -27,7 +29,8 @@ CREATE TABLE IF NOT EXISTS im_group_settings (
dissolved_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 (topic),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_settings_topic (topic),
INDEX idx_im_group_settings_owner (owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated (group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -135,19 +138,22 @@ CREATE TABLE IF NOT EXISTS im_group_announcements (
-- 6. Announcement read receipts.
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,
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 (announcement_id, user_id),
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;
-- 7. Current member mute state.
-- Historical changes are stored in im_group_admin_logs.
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,
@@ -160,7 +166,8 @@ CREATE TABLE IF NOT EXISTS im_group_mutes (
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic, user_id),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_mutes_topic_user (topic, user_id),
INDEX idx_im_group_mutes_topic_status (topic, status, expires_at),
INDEX idx_im_group_mutes_user_status (user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+5 -1
View File
@@ -5,6 +5,7 @@
> 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md`
> 多租户依据:`IM-独立库多租户改造方案.md`
> 明确排除:已废弃的 `IM-服务端多租户改造方案.md`
> 数据库更新:2026-08-18 已完成无独立 `id` 表的雪花主键改造和本地库迁移。
## 1. 审核说明
@@ -353,10 +354,13 @@
- 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。
- 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。
- `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。
- MySQL 业务表已统一具备独立 `id` 主键;原先使用自然键或复合主键的 11 张表已改用服务端雪花 `BIGINT id`,原业务键保留唯一索引。
- 应用启动会在当前租户库幂等补列、回填存量雪花 ID 并切换主键;迁移使用数据库级 advisory lock,独立租户库与残留 `tenant_id` 旧结构均可保留原业务唯一性。
- 用户身份字段已结构化:手机号迁入 `im_users.phone`,邮箱迁入 `im_user_profiles.email`;用户 `tags/im_usertags` 在回填后删除,Topic 标签体系继续保留。
### 需要修正
1. 建立唯一 schema 版本和迁移入口,禁止“应用启动建部分表 + 手工 SQL 建另一部分表”长期并存。
1. 继续把其他历史建表/升级逻辑收敛到唯一 schema 版本和迁移入口;本次雪花主键改造已具备统一自动迁移入口。
2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。
3. 新租户建库和存量租户升级必须执行同一套 migration。
4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

@@ -0,0 +1,202 @@
# IM 前后端 1-11 章测试环境接口测试结果(2026-08-20)
## 1. 测试范围
- 对照文档:`docs/IM-前后端对接文档.md`
- 测试时间:`2026-08-20 23:15:31 +0800`
- 测试环境:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels`
- 租户:`default`
- 测试账号 A:`a98656000` / `15598656000` / `usr--xhFVUQ8Qs`
- 测试账号 B:`b98656000` / `15698656000` / `usrkzrIHX2xBXc`
- API Key 与 Token 已脱敏,不在文档中展开。
## 2. 总体结论
本轮按前后端对接文档从第 1 章到第 11 章直接访问测试环境接口,共执行 `98` 项,符合预期 `97` 项,不符合预期 `1` 项。符合预期不等于业务全链路完全验收;部分接口以 404/405/409/403 作为当前文档标注的未实现、无数据或权限分支记录。
| 章 | 符合预期 | 不符合预期 |
| --- | ---: | ---: |
| 1 | 16 | 0 |
| 2 | 4 | 0 |
| 3 | 4 | 0 |
| 4 | 6 | 0 |
| 5 | 12 | 0 |
| 6 | 21 | 0 |
| 7 | 5 | 1 |
| 8 | 2 | 0 |
| 9 | 14 | 0 |
| 10 | 8 | 0 |
| 11 | 5 | 0 |
## 3. 需要关注的问题
- `7.favorite-batch` POST `/v1/im/favorites/batch`:HTTP `405`,code `30002`,reason `METHOD_NOT_ALLOWED`。
## 4. 分章明细
### 第 1 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `1.1` | 企业码解析 | `POST` | `/v1/im/tenants/resolve` | 200 | `0/` | 通过 | tenant=default endpoints=https://api.118im.com/v1/im |
| `1.2A-code` | 注册发码 a98656000 | `POST` | `/v1/im/auth/verification-codes` | 200 | `0/` | 通过 | challenge_id=CDk2...OJRB |
| `1.2A-verify` | 注册验证码校验 a98656000 | `POST` | `/v1/im/auth/verification-codes/CDk2pfsTzVTcHZ4ToFWLOJRB/verify` | 200 | `0/` | 通过 | verification_token=w4q6...t_xs |
| `1.2A-ws-acc` | WebSocket hi+acc 注册 a98656000 | `WS` | `/v0/channels` | 0 | `0/` | 通过 | user=usr--xhFVUQ8Qs token=AwEA...awQ6 |
| `1.2B-code` | 注册发码 b98656000 | `POST` | `/v1/im/auth/verification-codes` | 200 | `0/` | 通过 | challenge_id=DVmh...ZWZ0 |
| `1.2B-verify` | 注册验证码校验 b98656000 | `POST` | `/v1/im/auth/verification-codes/DVmh4897_KsYDoIdof9aZWZ0/verify` | 200 | `0/` | 通过 | verification_token=Vyvk...sGBg |
| `1.2B-ws-acc` | WebSocket hi+acc 注册 b98656000 | `WS` | `/v0/channels` | 0 | `0/` | 通过 | user=usrkzrIHX2xBXc token=AwEA...gsYR |
| `1.6b` | 忘记密码验证码校验 | `POST` | `/v1/im/auth/password-reset/challenges/XOuAkUsIQgDsddmRSmaigs12/verify` | 200 | `0/` | 通过 | reset_token=XOuA...BSvE |
| `1.6a` | 忘记密码发码 | `POST` | `/v1/im/auth/verification-codes` | 200 | `0/` | 通过 | challenge_id=XOuA...gs12 |
| `1.7b` | 二次身份验证码校验 | `POST` | `/v1/im/security/challenges/PapKGWb1YkgkoM2GOISkhWrF/verify` | 200 | `0/` | 通过 | action_token=_udB...ddt4 |
| `1.7a` | 二次身份验证发起 | `POST` | `/v1/im/security/challenges` | 200 | `0/` | 通过 | challenge_id=PapK...hWrF |
| `1.8` | 实名状态查询 | `GET` | `/v1/im/security/realname` | 200 | `0/` | 通过 | |
| `1.8-submit` | 实名提交 | `POST` | `/v1/im/security/realname/submissions` | 200 | `0/` | 通过 | |
| `1.11` | 设置独立密码 | `POST` | `/v1/im/settings/independent-password` | 400 | `30040/PASSWORD_POLICY_VIOLATION` | 通过 | |
| `1.12` | 应用版本查询 | `GET` | `/v1/im/app/releases/latest?platform=ios&current_version=1.0.0&channel=stable` | 200 | `0/` | 通过 | |
| `1.logout` | 退出登录 | `POST` | `/v1/im/auth/logout` | 404 | `0/` | 通过 | |
### 第 2 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `2.1` | Bootstrap | `GET` | `/v1/im/bootstrap` | 200 | `0/` | 通过 | entries=true limits=true |
| `2.search` | 会话搜索 | `GET` | `/v1/im/search/conversations?q=&cursor=` | 200 | `0/` | 通过 | |
| `2.sys` | 系统通知列表 | `GET` | `/v1/im/system-notifications?limit=20` | 200 | `0/` | 通过 | |
| `2.sys-read` | 系统通知已读不存在 ID | `POST` | `/v1/im/system-notifications/nonexistent/read` | 404 | `30044/CHALLENGE_EXPIRED` | 通过 | |
### 第 3 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `3.context` | P2P 会话上下文 | `GET` | `/v1/im/conversations/p2p--xhFVUQ8QuTOsgdfbEFdw/context` | 200 | `0/` | 通过 | |
| `3.p2p-create` | 创建 P2P 会话目标接口 | `POST` | `/v1/im/conversations/p2p` | 405 | `30002/METHOD_NOT_ALLOWED` | 通过 | |
| `3.conv-settings` | 会话设置查询 | `GET` | `/v1/im/conversations/p2p--xhFVUQ8QuTOsgdfbEFdw/settings` | 200 | `0/` | 通过 | |
| `3.conv-messages` | 会话消息列表 | `GET` | `/v1/im/conversations/p2p--xhFVUQ8QuTOsgdfbEFdw/messages?since=1&limit=20` | 200 | `0/` | 通过 | |
### 第 4 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `4.forward-targets` | 转发目标列表 | `GET` | `/v1/im/forward-targets?query=&cursor=` | 200 | `0/` | 通过 | |
| `4.forward` | 消息逐条转发 | `POST` | `/v1/im/messages/forward` | 404 | `0/` | 通过 | |
| `4.bundle` | 合并转发详情 | `GET` | `/v1/im/forward-bundles/fbundle_missing?cursor=` | 404 | `0/` | 通过 | |
| `4.recall` | 消息撤回不存在 seq | `POST` | `/v1/im/topics/p2p--xhFVUQ8QuTOsgdfbEFdw/messages/1/recall` | 404 | `30004/MALFORMED_REQUEST` | 通过 | |
| `4.revision` | 消息编辑不存在 seq | `POST` | `/v1/im/topics/p2p--xhFVUQ8QuTOsgdfbEFdw/messages/1/revisions` | 404 | `30004/MALFORMED_REQUEST` | 通过 | |
| `4.receipts` | 消息回执查询 | `GET` | `/v1/im/topics/p2p--xhFVUQ8QuTOsgdfbEFdw/messages/1/receipts?cursor=` | 200 | `0/` | 通过 | |
### 第 5 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `5.search` | 用户搜索 B | `GET` | `/v1/im/users/search?q=b98656000&type=account&cursor=` | 200 | `0/` | 通过 | |
| `5.profile` | 查看 B 资料 | `GET` | `/v1/im/users/b98656000/profile` | 403 | `30005/PERMISSION_DENIED` | 通过 | |
| `5.user-qr` | 查看 B 加好友二维码 | `POST` | `/v1/im/users/b98656000/qrcodes` | 403 | `30005/PERMISSION_DENIED` | 通过 | |
| `5.friend-create` | 创建好友申请 A->B | `POST` | `/v1/im/friend-requests` | 200 | `0/` | 通过 | request_id=frq_4BCvvh7aJaQ |
| `5.friend-accept` | B 同意好友申请 | `POST` | `/v1/im/friend-requests/frq_4BCvvh7aJaQ/accept` | 200 | `0/` | 通过 | topic=p2p--xhFVUQ8QuTOsgdfbEFdw |
| `5.friends` | 好友列表 | `GET` | `/v1/im/friends?query=&initial=&cursor=` | 200 | `0/` | 通过 | |
| `5.requests` | 好友申请列表 | `GET` | `/v1/im/friend-requests?direction=inbound&status=all&cursor=` | 200 | `0/` | 通过 | |
| `5.blocks` | 黑名单列表 | `GET` | `/v1/im/blocks?cursor=` | 200 | `0/` | 通过 | |
| `5.block-put` | 拉黑 B | `PUT` | `/v1/im/blocks/usrkzrIHX2xBXc` | 200 | `0/` | 通过 | |
| `5.block-del` | 解除拉黑 B | `DELETE` | `/v1/im/blocks/usrkzrIHX2xBXc` | 200 | `0/` | 通过 | |
| `5.me-qr` | 我的二维码 | `POST` | `/v1/im/me/qrcodes` | 200 | `0/` | 通过 | qr_token=eyJ2...J5N8 |
| `5.qr-resolve` | 二维码解析 | `POST` | `/v1/im/qrcodes/resolve` | 200 | `0/` | 通过 | |
### 第 6 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `6.create` | 创建群 | `POST` | `/v1/im/groups` | 200 | `0/` | 通过 | topic=grp4ch145Ku80k version=1 |
| `6.list` | 群列表 | `GET` | `/v1/im/groups?category=owned&query=&cursor=` | 200 | `0/` | 通过 | |
| `6.detail` | 群详情 | `GET` | `/v1/im/groups/grp4ch145Ku80k` | 200 | `0/` | 通过 | |
| `6.patch` | 更新群资料 | `PATCH` | `/v1/im/groups/grp4ch145Ku80k` | 200 | `0/` | 通过 | version=2 |
| `6.settings` | 群设置 | `GET` | `/v1/im/groups/grp4ch145Ku80k/settings` | 200 | `0/` | 通过 | |
| `6.member-settings` | 群个人设置 | `PATCH` | `/v1/im/groups/grp4ch145Ku80k/member-settings` | 200 | `0/` | 通过 | |
| `6.my-profile` | 群内昵称 | `PUT` | `/v1/im/groups/grp4ch145Ku80k/members/me/profile` | 409 | `20009/VERSION_CONFLICT` | 通过 | |
| `6.members` | 群成员 | `GET` | `/v1/im/groups/grp4ch145Ku80k/members?query=&cursor=&mentionable=true` | 200 | `0/` | 通过 | |
| `6.member-profile` | 群成员资料 | `GET` | `/v1/im/groups/grp4ch145Ku80k/members/usrkzrIHX2xBXc/profile` | 200 | `0/` | 通过 | |
| `6.qrcode` | 群二维码 | `POST` | `/v1/im/groups/grp4ch145Ku80k/qrcodes` | 200 | `0/` | 通过 | qr_token=eyJ2...cq2U |
| `6.invite` | 群邀请 | `POST` | `/v1/im/groups/grp4ch145Ku80k/invitations` | 200 | `0/` | 通过 | |
| `6.join-requests` | 入群申请列表 | `GET` | `/v1/im/groups/grp4ch145Ku80k/join-requests?status=pending&cursor=` | 200 | `0/` | 通过 | |
| `6.summary` | 群概要 | `GET` | `/v1/im/groups/grp4ch145Ku80k/summary` | 200 | `0/` | 通过 | |
| `6.ann-latest` | 群公告最新 | `GET` | `/v1/im/groups/grp4ch145Ku80k/announcements/latest` | 200 | `0/` | 通过 | |
| `6.ann-put` | 发布群公告 | `PUT` | `/v1/im/groups/grp4ch145Ku80k/announcements/current` | 200 | `0/` | 通过 | |
| `6.governance` | 群治理配置 | `GET` | `/v1/im/groups/grp4ch145Ku80k/governance` | 200 | `0/` | 通过 | |
| `6.governance-patch` | 更新群治理配置 | `PATCH` | `/v1/im/groups/grp4ch145Ku80k/governance` | 409 | `20009/VERSION_CONFLICT` | 通过 | |
| `6.role-limits` | 管理员数量限制 | `GET` | `/v1/im/groups/grp4ch145Ku80k/role-limits` | 200 | `0/` | 通过 | |
| `6.admins` | 管理员列表 | `GET` | `/v1/im/groups/grp4ch145Ku80k/members?role=admin&cursor=` | 200 | `0/` | 通过 | |
| `6.mutes` | 禁言列表 | `GET` | `/v1/im/groups/grp4ch145Ku80k/mutes?cursor=` | 200 | `0/` | 通过 | |
| `6.files` | 群文件列表 | `GET` | `/v1/im/groups/grp4ch145Ku80k/files?query=&type=&cursor=` | 404 | `30004/MALFORMED_REQUEST` | 通过 | |
### 第 7 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `7.favorites` | 收藏列表 | `GET` | `/v1/im/favorites?type=all&q=&cursor=` | 200 | `0/` | 通过 | |
| `7.favorite-batch` | 批量收藏不存在消息 | `POST` | `/v1/im/favorites/batch` | 405 | `30002/METHOD_NOT_ALLOWED` | 未通过 | |
| `7.message-search` | 聊天记录搜索 | `GET` | `/v1/im/topics/grp4ch145Ku80k/messages/search?q=%E8%81%94%E8%B0%83&type=text&cursor=` | 200 | `0/` | 通过 | |
| `7.media` | 会话媒体列表 | `GET` | `/v1/im/topics/grp4ch145Ku80k/media?type=image&cursor=` | 200 | `0/` | 通过 | |
| `7.msg-context` | 消息上下文 | `GET` | `/v1/im/topics/grp4ch145Ku80k/messages/1/context?before=20&after=20` | 200 | `0/` | 通过 | |
| `7.file-download` | 文件下载不存在文件 | `GET` | `/v0/file/s/nonexistent` | 400 | `0/` | 通过 | |
### 第 8 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `8.calls` | 通话记录列表 | `GET` | `/v1/im/calls?limit=5` | 200 | `0/` | 通过 | |
| `8.topic-calls` | 会话通话记录 | `GET` | `/v1/im/topics/p2p--xhFVUQ8QuTOsgdfbEFdw/calls?limit=5` | 200 | `0/` | 通过 | |
### 第 9 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `9.workbench` | 工作台 | `GET` | `/v1/im/workbench` | 200 | `0/` | 通过 | |
| `9.depts` | 组织部门 | `GET` | `/v1/im/organization/departments?parent_id=&cursor=` | 200 | `0/` | 通过 | |
| `9.org-search-empty` | 组织空关键词搜索 | `GET` | `/v1/im/organization/search?q=` | 400 | `30000/MALFORMED_REQUEST` | 通过 | |
| `9.meeting-create` | 创建会议 | `POST` | `/v1/im/meetings` | 200 | `0/` | 通过 | meeting_id=mtg_FMdvsdTBSc8mAUiB version=1 |
| `9.meeting-list` | 会议列表 | `GET` | `/v1/im/meetings?limit=5` | 405 | `30002/METHOD_NOT_ALLOWED` | 通过 | |
| `9.meeting-get` | 会议详情 | `GET` | `/v1/im/meetings/mtg_FMdvsdTBSc8mAUiB` | 200 | `0/` | 通过 | |
| `9.daily-list` | 日报列表 | `GET` | `/v1/im/reports/daily?limit=5` | 200 | `0/` | 通过 | |
| `9.daily-create` | 创建日报草稿 | `POST` | `/v1/im/reports/daily` | 200 | `0/` | 通过 | report_id=dr_akcU6KDAcH9g9nwJ version=1 |
| `9.daily-stats` | 日报统计 | `GET` | `/v1/im/reports/daily/statistics?date=2026-08-20` | 200 | `0/` | 通过 | |
| `9.monthly-list` | 月报列表 | `GET` | `/v1/im/reports/monthly?limit=5` | 200 | `0/` | 通过 | |
| `9.monthly-create` | 创建月报草稿 | `POST` | `/v1/im/reports/monthly` | 200 | `0/` | 通过 | report_id=mr_iULR69u4Y8_IbXMg version=1 |
| `9.monthly-stats` | 月报统计 | `GET` | `/v1/im/reports/monthly/statistics?month=2026-08` | 200 | `0/` | 通过 | |
| `9.mailbox-config` | 总经理信箱配置 | `GET` | `/v1/im/executive-mailbox/config` | 200 | `0/` | 通过 | notice_version=2026-08-13T15:04:31.004Z |
| `9.mailbox-submit` | 总经理信箱提交 | `POST` | `/v1/im/executive-mailbox/submissions` | 200 | `0/` | 通过 | |
### 第 10 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `10.me` | 我的账号 | `GET` | `/v1/im/me` | 200 | `0/` | 通过 | |
| `10.profile` | 个人资料 | `GET` | `/v1/im/me/profile` | 200 | `0/` | 通过 | version=1 |
| `10.profile-patch` | 更新个人资料 | `PATCH` | `/v1/im/me/profile` | 200 | `0/` | 通过 | |
| `10.privacy` | 隐私设置 | `GET` | `/v1/im/me/privacy` | 200 | `0/` | 通过 | version=1 |
| `10.privacy-patch` | 更新隐私设置 | `PATCH` | `/v1/im/me/privacy` | 200 | `0/` | 通过 | |
| `10.youth` | 青少年模式 | `GET` | `/v1/im/settings/youth-mode` | 200 | `0/` | 通过 | |
| `10.independent-verify` | 独立密码验证 | `POST` | `/v1/im/settings/independent-password/verify` | 403 | `30042/ACTION_TOKEN_INVALID` | 通过 | |
| `10.push` | 设备 Push Token | `PUT` | `/v1/im/devices/audit-device-98656000/push-token` | 200 | `0/` | 通过 | |
### 第 11 章
| ID | 场景 | 方法 | 路径 | HTTP | code/reason | 结果 | 备注 |
| --- | --- | --- | --- | ---: | --- | --- | --- |
| `11.url` | URL 风险检查 | `POST` | `/v1/im/security/urls/check` | 200 | `0/` | 通过 | |
| `11.abuse-reasons` | 举报原因 | `GET` | `/v1/im/reports/abuse/reasons?target_type=user` | 200 | `0/` | 通过 | reason=fraud |
| `11.abuse-submit` | 提交举报 | `POST` | `/v1/im/reports/abuse` | 400 | `30000/MALFORMED_REQUEST` | 通过 | |
| `11.feedback-config` | 反馈配置 | `GET` | `/v1/im/feedback/config` | 200 | `0/` | 通过 | category=bug |
| `11.feedback` | 提交反馈 | `POST` | `/v1/im/feedback` | 200 | `0/` | 通过 | |
## 5. 测试产生的数据
- 用户 A:`usr--xhFVUQ8Qs`,username `a98656000`,phone `15598656000`
- 用户 B:`usrkzrIHX2xBXc`,username `b98656000`,phone `15698656000`
- 好友申请:`frq_4BCvvh7aJaQ`
- P2P Topic:`p2p--xhFVUQ8QuTOsgdfbEFdw`
- 群 Topic:`grp4ch145Ku80k`
- 会议:`mtg_FMdvsdTBSc8mAUiB`
- 日报草稿:`dr_akcU6KDAcH9g9nwJ`
- 月报草稿:`mr_iULR69u4Y8_IbXMg`
这些数据位于测试环境租户 `default`,可按需清理。
@@ -0,0 +1,58 @@
# IM WebSocket 测试环境专项联调结果(2026-08-21)
## 1. 测试范围
- 测试时间:`2026-08-21 00:39:01 +0800`
- HTTP:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels`
- 租户:`default`
- 用户 A:`usr7-zA3UMrw7c` / `wsa459000` / `15794459000`
- 用户 B:`usrnDXQ3A2oQP0` / `wsb459000` / `15894459000`
- API Key 与 Token 已脱敏,不在文档中展开。
## 2. 总体结论
本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `27` 项,未通过 `2` 项。
| ID | 场景 | 方法 | 目标 | 结果 | 备注 |
| --- | --- | --- | --- | --- | --- |
| `WS-01` | 注册用户 A: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usr7-zA3UMrw7c token=AwEA...18Om |
| `WS-02` | 注册用户 B: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usrnDXQ3A2oQP0 token=AwEA...NX6b |
| `WS-03` | HTTP 建立好友关系生成 P2P Topic | `HTTP` | `/v1/im/friend-requests` | 通过 | request_id=frq_KAOkEfarCOA topic=p2p7-zA3UMrw7ecNdDcDahA_Q |
| `WS-04` | A token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usr7-zA3UMrw7c |
| `WS-05` | B token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usrnDXQ3A2oQP0 |
| `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | topic=p2p7-zA3UMrw7ecNdDcDahA_Q |
| `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=1 bob_topic=usr7-zA3UMrw7c |
| `WS-08` | B 发送 recv/read 回执 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | what=read seq=1 |
| `WS-09` | basic login + sub me | `WS` | `/v0/channels` | 通过 | basic login ok |
| `WS-10` | 重复 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=already authenticated |
| `WS-11` | 订阅 fnd 搜索 Topic | `WS` | `fnd` | 通过 | sub fnd ok |
| `WS-12` | get me desc/sub/tags/cred | `WS` | `me` | 通过 | get me ok |
| `WS-13` | get P2P desc/sub/data/del | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | get p2p ok |
| `WS-14` | set P2P private 设置 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | set private ok |
| `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=2 bob_topic=usr7-zA3UMrw7c |
| `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003857.244320000 code=500 text=internal error |
| `WS-17` | note kp 输入中通知 | `WS` | `usr7-zA3UMrw7c` | 通过 | what=kp |
| `WS-18` | del 当前用户消息视图 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | deleted seq=1 |
| `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | leave/resub ok |
| `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpURzKYanXB1Y |
| `WS-21` | A/B 订阅群 Topic | `WS` | `grpURzKYanXB1Y` | 通过 | sub group ok |
| `WS-22` | 群 pub 文本广播 | `WS` | `grpURzKYanXB1Y` | 通过 | seq=1 topic=grpURzKYanXB1Y |
| `WS-23` | get 群 desc/sub/data | `WS` | `grpURzKYanXB1Y` | 通过 | get group ok |
| `WS-24` | 文件上传 /v0/file/u | `HTTP` | `/v0/file/u/` | 通过 | url=/v0/....bin |
| `WS-25` | WS 文件消息下发 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=3 bob_topic=usr7-zA3UMrw7c |
| `WS-26` | WebRTC 通话 invite pub | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003859.867810000 code=501 text=not implemented |
| `WS-27` | 未 hi 直接 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence |
| `WS-28` | hi 后切换 tenant 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence |
| `WS-29` | 非法 API Key 拒绝建连 | `WS` | `/v0/channels` | 通过 | http_status=403 |
## 3. 覆盖说明
- 已覆盖:API Key Upgrade、`hi(tenant)`、`acc`、`basic login`、`token login`、重复登录拒绝、`sub me/fnd/p2p/grp`、`get desc/sub/data/del/tags/cred`、`set sub.private`、P2P/群 `pub`、产品 `x-im-*` 基础头、`note recv/read/kp`、`del msg`、`leave`、文件上传入口、文件消息、WebRTC invite、未 hi 顺序错误、切换租户拒绝、非法 API Key 拒绝。
- 仍需前端/真机补充:断线重连恢复、Long Polling 长轮询持续收包、真实音视频媒体协商质量、离线 Push 到达与点击恢复。
## 4. 测试产生的数据
- P2P Topic:`p2p7-zA3UMrw7ecNdDcDahA_Q`
- Bob 收到的 Topic:`usr7-zA3UMrw7c`
- 消息 Seq:`1`
@@ -0,0 +1,161 @@
# IM 文件安全第三方接入记录
更新时间:2026-08-23
## 1. 接入范围
本次记录三条文件与安全链路:
1. 阿里云 OSS:用于文件对象存储和签名下载,media handler 标识为 `oss`。
2. 阿里云内容安全增强版:用于文件内容审核,当前 provider 标识为 `aliyun_green`,任务类型为 `content`。
3. 阿里云云安全中心恶意文件检测:用于病毒、木马、WebShell、勒索等恶意文件检测,当前 provider 标识为 `aliyun_sas`,任务类型为 `malware`。
密钥、账号、Bucket 等敏感信息不写入本文档,也不写入源码。运行环境应通过配置中心或部署环境注入。
## 2. 阿里云 OSS 接入
新增媒体 handler:`server/media/oss`。
当前支持:
- `Upload`:上传文件到 OSS Bucket,并把 OSS object key 写入 `FileDef.Location`。
- `Headers`:对 GET/HEAD 生成 OSS 签名 URL,并返回 `308 Permanent Redirect`。
- `Delete`:按 object key 批量删除 OSS 对象。
- `GetIdFromUrl`:从 Tinode 文件 URL 中解析文件 ID。
对象路径规则:
```text
tenants/{tenantID}/{yyyy}/{mm}/{fileID}
```
配置字段:
| 字段 | 说明 |
| --- | --- |
| `access_key_id` | 阿里云 AccessKey ID,部署环境注入 |
| `access_key_secret` | 阿里云 AccessKey Secret,部署环境注入 |
| `endpoint` | OSS 普通 Bucket endpoint |
| `bucket` | OSS Bucket 名称 |
| `region_id` | OSS Bucket 所属地域 |
| `serve_url` | Tinode 对外文件 URL 前缀,默认 `/v0/file/s/` |
| `presign_ttl` | 签名 URL 有效秒数,默认 `120` |
| `cache_control` | 下载缓存头 |
| `cors_origins` | CORS 允许来源 |
| `verify_bucket` | 启动时是否联网校验 Bucket 存在 |
| `use_sys_config` | 为 `true` 时从 `hotgo.hg_sys_config` 读取 OSS 配置 |
当前 `server/tinode.conf` 仍默认使用 `fs`。切换 OSS 时,将 `media.use_handler` 改为 `oss`,并在 `media.handlers.oss` 中设置 `use_sys_config=true` 后,运行时会从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取 OSS 配置。
代码入口:
- Handler 实现:`server/media/oss/oss.go`
- 后台配置读取:`store.ThirdPartyConfig.Get("im_third_party", ...)`
- Handler 注册:`server/main.go`
- 单元测试:`server/media/oss/oss_tenant_test.go`
注意事项:
- 不使用 AccessPoint endpoint,当前按普通 Bucket endpoint 接入。
- 不在源码或文档中保存真实 AK/SK。
- 下载链路采用签名 URL 重定向,不由 Tinode 代理大文件内容。
## 3. 数据库状态机
新增租户业务库表:`im_file_review_tasks`。
独立租户库方案下,该表不包含 `tenant_id`。每个租户库保存本租户自己的文件审核任务。
核心字段:
| 字段 | 说明 |
| --- | --- |
| `task_id` | 服务端生成的审核任务 ID |
| `file_id` | 对应 `im_fileuploads.id` |
| `user_id` | 上传用户 |
| `task_type` | `content` 或 `malware` |
| `provider` | `aliyun_green` 或 `aliyun_sas` |
| `object_name` | OSS 对象名快照 |
| `mime_type` | 文件 MIME 快照 |
| `file_size` | 文件大小快照 |
| `provider_task_id` | 第三方返回的任务 ID |
| `status` | `pending`、`running`、`clean`、`risky`、`failed` |
| `risk_level` | 第三方风险等级 |
| `risk_labels` | 命中的风险标签 JSON |
| `result_json` | 第三方原始结果 JSON |
| `attempts` / `next_retry_at` | 重试控制 |
| `completed_at` | 审核完成时间 |
唯一约束:
- `task_id` 全局唯一。
- `file_id + task_type + provider` 唯一,避免同一文件重复创建同类任务。
## 4. 当前代码入口
文件上传完成后,在数据库事务内自动创建审核任务:
- 普通/独立库路径:`server/db/mysql/adapter.go` 的 `FileFinishUpload`
- 共享库兼容路径:`server/db/mysql/tenant_adapter.go` 的 `TenantFileFinishUpload`
- 建表逻辑:`server/db/mysql/file_review_schema.go`
- 任务插入:`server/db/mysql/file_review_tasks.go`
- 状态常量和结构:`server/store/types/safety.go`
当前策略是异步审核:上传成功后先生成 `pending` 任务,不阻塞上传响应。
## 5. 阿里云内容安全接入
新增封装包:`server/filesafety/aliyun`。
当前支持:
- `SubmitOSSFile`:提交同账号 OSS 文件审核任务。
- `PollOSSFile`:轮询文件审核结果。
- 默认服务:`document_detection`。
- 支持参数:`ossBucketName`、`ossObjectName`、`ossRegionId`、`dataId`。
文件安全配置通过 `server/filesafety.LoadConfig` 从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取。内容安全启用后,`server/filesafety.NewGreenClientFromConfig` 可直接构建阿里云内容安全客户端;恶意文件检测当前先落配置和任务状态,具体云安全中心 API worker 后续补充。
结果映射:
| 阿里云风险等级 | 内部状态 |
| --- | --- |
| 空 | `running` |
| `none` / `low` | `clean` |
| 其他风险等级 | `risky` |
后续 worker 需要读取 `provider=aliyun_green,status=pending/running` 的任务,调用提交和轮询接口,并回写 `provider_task_id/status/risk_level/risk_labels/result_json`。
## 6. 阿里云恶意文件检测接入
当前已完成状态机和任务落库,上传成功会创建:
- `task_type=malware`
- `provider=aliyun_sas`
- `status=pending`
后续还需要补云安全中心恶意文件检测 SDK/HTTP client,并实现 worker 提交、轮询和结果映射。
建议映射:
| 三方结果 | 内部状态 |
| --- | --- |
| 未完成 / 排队中 | `running` |
| 无风险 | `clean` |
| 命中恶意类型 | `risky` |
| 调用失败且可重试 | `pending`,更新 `attempts/next_retry_at` |
| 调用失败且不可重试 | `failed` |
## 7. 本地库变更
已在本地 `tinode` 业务库创建并更新 `im_file_review_tasks` 表。
后续新库或已有库启动时,会通过 MySQL adapter 的 ensure 流程自动补表。
## 8. 后续待办
1. 实现文件安全 worker:批量领取 `pending` 任务、提交第三方、轮询结果、回写状态。
2. 接入阿里云云安全中心恶意文件检测 client。
3. 增加审核策略:是否阻断下载、是否只告警、是否对高风险文件自动下架。
4. 在文件下载授权处读取审核结果,按策略限制 `risky/failed/running` 文件访问。
5. 增加后台查询与人工处置接口,用于查看任务、重试、放行、下架。
@@ -0,0 +1,211 @@
-- IM 第三方配置字典与文件安全表
-- 更新时间:2026-08-23
--
-- 执行范围:
-- 1. hotgo 控制库:后台字典项与第三方配置项。
-- 2. tinode 租户业务库:文件安全审核任务表。
--
-- 注意:
-- - 本脚本不写入真实 AccessKey、Secret、密码等敏感值。
-- - 独立租户库方案下,业务表不包含 tenant_id。
-- - 多租户时,im_file_review_tasks 需要在每个租户业务库执行。
USE hotgo;
-- 1. 后台字典类型
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM 存储服务商', 'im_storage_provider', 210, 'IM 文件存储 provider 字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_storage_provider');
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM 文件审核服务商', 'im_file_review_provider', 220, 'IM 文件审核 provider 字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_provider');
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM 文件审核任务类型', 'im_file_review_task_type', 230, 'IM 文件审核任务类型字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_task_type');
INSERT INTO hg_sys_dict_type
(`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT 0, 'IM 文件审核状态', 'im_file_review_status', 240, 'IM 文件审核任务状态字典', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_status');
-- 2. 后台字典数据
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '阿里云 OSS', 'oss', 'string', 'im_storage_provider', 'success', 1, 10, '阿里云对象存储 OSS', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_storage_provider' AND `value` = 'oss');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '阿里云内容安全增强版', 'aliyun_green', 'string', 'im_file_review_provider', 'info', 1, 10, 'FileModeration + document_detection', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_provider' AND `value` = 'aliyun_green');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '阿里云云安全中心', 'aliyun_sas', 'string', 'im_file_review_provider', 'warning', 1, 20, '恶意文件检测', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_provider' AND `value` = 'aliyun_sas');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '内容审核', 'content', 'string', 'im_file_review_task_type', 'info', 1, 10, '文件内容审核任务', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_task_type' AND `value` = 'content');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '恶意文件检测', 'malware', 'string', 'im_file_review_task_type', 'warning', 1, 20, '病毒、木马、WebShell 等恶意文件检测任务', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_task_type' AND `value` = 'malware');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '待处理', 'pending', 'string', 'im_file_review_status', 'default', 1, 10, '等待 worker 提交或重试', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'pending');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '处理中', 'running', 'string', 'im_file_review_status', 'info', 1, 20, '第三方审核任务处理中', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'running');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '无风险', 'clean', 'string', 'im_file_review_status', 'success', 1, 30, '审核通过或无风险', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'clean');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '有风险', 'risky', 'string', 'im_file_review_status', 'error', 1, 40, '命中风险标签或恶意文件', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'risky');
INSERT INTO hg_sys_dict_data
(`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT '失败', 'failed', 'string', 'im_file_review_status', 'warning', 1, 50, '不可重试失败或超过重试次数', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'failed');
-- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 启用状态', 'bool', 'oss_enabled', '0', '0', 10, '是否启用阿里云 OSS 作为 IM 文件存储', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 服务商', 'string', 'oss_provider', 'oss', 'oss', 11, '取值来自字典 im_storage_provider', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_provider');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS Endpoint', 'string', 'oss_endpoint', '', '', 12, '例如 oss-cn-shenzhen.aliyuncs.com,禁止写 AccessPoint endpoint', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_endpoint');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS Bucket', 'string', 'oss_bucket', '', '', 13, 'IM 文件对象存储 Bucket', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_bucket');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS Region', 'string', 'oss_region_id', 'cn-shenzhen', 'cn-shenzhen', 14, 'OSS Bucket 所属地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_region_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS AccessKey ID', 'string', 'oss_access_key_id', '', '', 15, '敏感配置,建议由部署环境或密钥系统注入', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_access_key_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS AccessKey Secret', 'string', 'oss_access_key_secret', '', '', 16, '敏感配置,后台和日志必须脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_access_key_secret');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 签名 URL TTL', 'int', 'oss_presign_ttl', '120', '120', 17, '签名下载 URL 有效秒数', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_presign_ttl');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '文件内容安全启用状态', 'bool', 'file_review_content_enabled', '0', '0', 30, '是否启用阿里云内容安全增强版文件审核', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_content_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '内容安全 Provider', 'string', 'file_review_content_provider', 'aliyun_green', 'aliyun_green', 31, '取值来自字典 im_file_review_provider', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_content_provider');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '内容安全 Region', 'string', 'aliyun_green_region_id', 'cn-shanghai', 'cn-shanghai', 32, '阿里云内容安全增强版地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_green_region_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '内容安全 Endpoint', 'string', 'aliyun_green_endpoint', 'green-cip.cn-shanghai.aliyuncs.com', 'green-cip.cn-shanghai.aliyuncs.com', 33, '阿里云内容安全增强版 endpoint', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_green_endpoint');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '内容安全服务', 'string', 'aliyun_green_document_service', 'document_detection', 'document_detection', 34, 'FileModeration 服务名', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_green_document_service');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '恶意文件检测启用状态', 'bool', 'file_review_malware_enabled', '0', '0', 40, '是否启用阿里云云安全中心恶意文件检测', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_malware_enabled');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '恶意文件检测 Provider', 'string', 'file_review_malware_provider', 'aliyun_sas', 'aliyun_sas', 41, '取值来自字典 im_file_review_provider', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_malware_provider');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '恶意文件检测 Region', 'string', 'aliyun_sas_region_id', 'cn-shanghai', 'cn-shanghai', 42, '阿里云云安全中心恶意文件检测地域', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_sas_region_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '阿里云 AccessKey ID', 'string', 'aliyun_access_key_id', '', '', 90, '内容安全和恶意文件检测共用的敏感配置,建议由部署环境或密钥系统注入', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_access_key_id');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '阿里云 AccessKey Secret', 'string', 'aliyun_access_key_secret', '', '', 91, '内容安全和恶意文件检测共用的敏感配置,后台和日志必须脱敏展示', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_access_key_secret');
USE tinode;
-- 4. 文件安全审核任务表
CREATE TABLE IF NOT EXISTS im_file_review_tasks (
id BIGINT NOT NULL,
task_id VARCHAR(64) NOT NULL,
file_id BIGINT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
task_type VARCHAR(32) NOT NULL,
provider VARCHAR(64) NOT NULL,
storage_provider VARCHAR(32) NOT NULL DEFAULT '',
object_name VARCHAR(2048) NOT NULL DEFAULT '',
mime_type VARCHAR(255) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
provider_task_id VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending',
risk_level VARCHAR(32) NOT NULL DEFAULT '',
risk_labels JSON,
result_json JSON,
error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message VARCHAR(512) NOT NULL DEFAULT '',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
completed_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_file_review_tasks_task(task_id),
UNIQUE INDEX uk_im_file_review_tasks_file_provider(file_id, task_type, provider),
INDEX idx_im_file_review_tasks_pending(status, next_retry_at, id),
INDEX idx_im_file_review_tasks_file(file_id),
INDEX idx_im_file_review_tasks_object(storage_provider, object_name(191)),
INDEX idx_im_file_review_tasks_provider_task(provider, provider_task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+14
View File
@@ -4,6 +4,10 @@ go 1.26.0
require (
firebase.google.com/go v3.13.0+incompatible
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
github.com/alibabacloud-go/tea v1.5.2
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
@@ -42,6 +46,10 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
@@ -55,6 +63,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
@@ -63,11 +72,15 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
@@ -78,6 +91,7 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
require (
+191
View File
@@ -1,5 +1,6 @@
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
@@ -27,6 +28,7 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
@@ -35,6 +37,46 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
@@ -79,8 +121,13 @@ github.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2io
github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -88,12 +135,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -111,19 +161,35 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@@ -134,6 +200,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -151,6 +219,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -168,6 +240,13 @@ github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZyc
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -191,6 +270,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
@@ -204,6 +284,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -223,6 +306,9 @@ github.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE=
github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8=
github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=
github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/twilio/twilio-go v1.30.9 h1:4W4GEV2q0sLQ9xsr1N/97JQlt0c82hZ0ij4qTErstv8=
github.com/twilio/twilio-go v1.30.9/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
@@ -233,6 +319,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
@@ -265,54 +353,138 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -321,16 +493,30 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -344,6 +530,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -353,3 +542,5 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+21 -6
View File
@@ -132,6 +132,7 @@ func main() {
INDEX idx_im_friend_requests_inbox(target_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friendships(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
@@ -139,15 +140,18 @@ func main() {
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id, peer_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_blocks(
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT 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(blocker_id, blocked_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_favorites(
@@ -356,6 +360,7 @@ func main() {
INDEX idx_im_mailbox_submissions_status(status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
@@ -363,13 +368,16 @@ func main() {
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_profiles_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
@@ -378,7 +386,8 @@ func main() {
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_privacy_settings_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -399,6 +408,7 @@ func main() {
UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
@@ -407,11 +417,13 @@ func main() {
platform VARCHAR(32) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(device_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
@@ -423,7 +435,8 @@ func main() {
locked_until 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(user_id, purpose)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges(
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -524,6 +537,8 @@ func main() {
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN policy_version INT NOT NULL DEFAULT 1 AFTER enabled",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN restrictions JSON AFTER policy_version",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions",
"ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent",
"ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region",
} {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
+1 -1
View File
@@ -278,7 +278,7 @@ type AuthHandler interface {
// continue the authentication process to the next step, or return an error code.
// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for
// additional validation. The stock authenticators don't use it.
// store.Users.GetAuthRecord("scheme", "unique")
// The selected handler resolves its account through the tenant-scoped auth store.
// Returns: user auth record, challenge, error.
Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error)
+19 -20
View File
@@ -17,7 +17,7 @@ import (
// Define default constraints on login and password
const (
defaultMinLoginLength = 2
defaultMinLoginLength = 1
defaultMaxLoginLength = 32
defaultMinPasswordLength = 3
@@ -26,7 +26,7 @@ const (
// Token suitable as a login: either an E.164 phone number or a name which starts
// and ends with a Unicode letter (class L) or number (class N), and contains
// Unicode letters, numbers, dot and underscore.
var loginPattern = regexp.MustCompile(`^(\+[\pN]+|[\pL\pN][_.\pL\pN]*[\pL\pN]+)$`)
var loginPattern = regexp.MustCompile(`^(\+[0-9]+|[\pL\pN](?:[_.\pL\pN]*[\pL\pN])?)$`)
// authenticator is the type to map authentication methods to.
type authenticator struct {
@@ -143,7 +143,7 @@ func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []
authLevel = auth.LevelAuth
}
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, passhash, expires)
if err != nil {
return nil, err
}
@@ -165,7 +165,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
return nil, err
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name)
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid)
if err != nil {
return nil, err
}
@@ -179,7 +179,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
uname = login
} else if err = a.checkLoginPolicy(uname); err != nil {
return nil, err
} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil {
} else if uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname); err != nil {
return nil, err
} else if !uid.IsZero() {
// The (new) user name already exists. Report an error.
@@ -198,23 +198,22 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
if rec.Lifetime > 0 {
expires = types.TimeNow().Add(time.Duration(rec.Lifetime))
}
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, uname, passhash, expires)
if err != nil {
return nil, err
}
// Remove old tag from the list of tags
oldTag := a.name + ":" + login
for i, tag := range rec.Tags {
if tag == oldTag {
rec.Tags[i] = rec.Tags[len(rec.Tags)-1]
rec.Tags = rec.Tags[:len(rec.Tags)-1]
break
if a.addToTags {
oldTag := a.name + ":" + login
for i, tag := range rec.Tags {
if tag == oldTag {
rec.Tags[i] = rec.Tags[len(rec.Tags)-1]
rec.Tags = rec.Tags[:len(rec.Tags)-1]
break
}
}
rec.Tags = append(rec.Tags, a.name+":"+uname)
}
// Add new tag
rec.Tags = append(rec.Tags, a.name+":"+uname)
return rec, nil
}
@@ -229,7 +228,7 @@ func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth
return nil, nil, err
}
uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
uid, authLvl, passhash, expires, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
if err != nil {
return nil, nil, err
}
@@ -288,7 +287,7 @@ func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, err
return false, err
}
uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
if err != nil {
return false, err
}
@@ -306,7 +305,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
// DelRecords deletes saved authentication records of the given user.
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
return store.Users.DelAuthRecords(tenantID, uid, a.name)
return store.Users.DeleteAuthRecord(tenantID, uid)
}
// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.
@@ -320,7 +319,7 @@ func (a *authenticator) RestrictedTags() ([]string, error) {
// GetResetParams returns authenticator parameters passed to password reset handler.
func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name)
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -26,9 +26,9 @@ func TestAuthenticateScopesLoginByTenant(t *testing.T) {
if err != nil {
t.Fatal(err)
}
users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice").
users.EXPECT().GetAuthByLogin(types.TenantID(7), "alice").
Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil)
users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice").
users.EXPECT().GetAuthByLogin(types.TenantID(8), "alice").
Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil)
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
@@ -73,7 +73,7 @@ func TestAddRecordAcceptsE164PhoneLogin(t *testing.T) {
})
users.EXPECT().AddAuthRecord(types.TenantID(7), types.Uid(42), auth.LevelAuth,
"basic", "+8615521120255", gomock.Any(), time.Time{}).Return(nil)
gomock.Any(), time.Time{}).Return(nil)
a := &authenticator{name: "basic", addToTags: true, minLoginLength: defaultMinLoginLength,
minPasswordLength: 6}
+8
View File
@@ -31,6 +31,10 @@ func (s testSecurityStore) CreateRealNameSubmission(types.TenantID, *types.RealN
return s.err
}
func (s testSecurityStore) CreateRealNameAppeal(types.TenantID, *types.RealNameAppeal) (*types.RealNameAppeal, error) {
return nil, s.err
}
func (s testSecurityStore) CreateActionToken(types.TenantID, *types.ActionToken) error {
return s.err
}
@@ -67,6 +71,10 @@ func (s testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, st
return nil, "", s.err
}
func (s testSecurityStore) CountUnreadSystemNotifications(types.TenantID, types.Uid) (int, error) {
return 0, s.err
}
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
return s.err
}
+6
View File
@@ -146,6 +146,12 @@ type MsgClientAcc struct {
Scheme string `json:"scheme,omitempty"`
// Shared secret
Secret []byte `json:"secret,omitempty"`
// Registration login name. Ignored when Phone is provided.
Username string `json:"username,omitempty"`
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
Phone string `json:"phone,omitempty"`
// Optional case-sensitive registration invitation code.
InviteCode string `json:"invite_code,omitempty"`
// Authenticate session with the newly created account
Login bool `json:"login,omitempty"`
// Indexable tags for user discovery
+53 -6
View File
@@ -216,6 +216,24 @@ type TenantAdapter interface {
TenantGetByID(id t.TenantID) (*t.Tenant, error)
}
// RegistrationConfigAdapter reads global registration policy from the control database.
type RegistrationConfigAdapter interface {
RegistrationSMSEnabled() (bool, error)
}
// ThirdPartyConfigAdapter reads global third-party integration settings from
// the control database.
type ThirdPartyConfigAdapter interface {
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
}
// TenantEntryAdapter stores public tenant entry data before a user is authenticated.
type TenantEntryAdapter interface {
PublicUploadCreate(*t.PublicUpload) (*t.PublicUpload, error)
TenantApplicationCreate(*t.TenantApplication) (*t.TenantApplication, error)
TenantEntryFeedbackCreate(*t.TenantEntryFeedback) (*t.TenantEntryFeedback, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
@@ -233,15 +251,29 @@ type TenantHTTPInfrastructureAdapter interface {
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
}
// TenantRegistrationInviteAdapter stores invitation definitions in the
// registry database and usage details in the selected tenant database.
type TenantRegistrationInviteAdapter interface {
TenantRegistrationInviteCreate(t.TenantID, *t.RegistrationInviteCode) error
TenantRegistrationInviteCreateBatch(t.TenantID, *t.RegistrationInviteBatch, []t.RegistrationInviteCode) error
TenantRegistrationInviteGetByCode(t.TenantID, string) (*t.RegistrationInviteCode, error)
TenantRegistrationInviteList(t.TenantID, t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error)
TenantRegistrationInviteBatchGet(t.TenantID, int64) (*t.RegistrationInviteBatch, error)
TenantRegistrationInviteBatchGetByIdempotency(t.TenantID, string) (*t.RegistrationInviteBatch, error)
TenantRegistrationInviteRevoke(t.TenantID, int64, time.Time) error
TenantRegistrationInviteBatchRevoke(t.TenantID, int64, time.Time) (int, error)
TenantRegistrationInviteRecordUse(t.TenantID, string, t.Uid, string, time.Time) error
TenantRegistrationInviteSyncUsage(t.TenantID, int64) error
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error)
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error
TenantAuthDelScheme(tenantID t.TenantID, user t.Uid, scheme string) error
TenantAuthUpdRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid, auth.Level, []byte, time.Time, error)
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid) (string, auth.Level, []byte, time.Time, error)
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
TenantAuthDeleteRecord(tenantID t.TenantID, user t.Uid) error
TenantAuthUpdateRecord(tenantID t.TenantID, user t.Uid, username string, authLvl auth.Level,
secret []byte, expires time.Time) error
TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error)
TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error)
@@ -263,6 +295,7 @@ type TenantBusinessAdapter interface {
TenantUserCreate(t.TenantID, *t.User) error
TenantUserGet(t.TenantID, t.Uid) (*t.User, error)
TenantUserGetAll(t.TenantID, ...t.Uid) ([]t.User, error)
TenantUserGetByPhone(t.TenantID, string) (t.Uid, error)
TenantUserDelete(t.TenantID, t.Uid, bool) error
TenantUserUpdate(t.TenantID, t.Uid, map[string]any) error
TenantUserUpdateTags(t.TenantID, t.Uid, []string, []string, []string) ([]string, error)
@@ -301,6 +334,11 @@ type TenantBusinessAdapter interface {
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error)
TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error)
TenantTopicDraftGet(t.TenantID, t.Uid, string) (*t.TopicDraft, error)
TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error)
TenantCallCreate(t.TenantID, *t.CallRecord) error
TenantCallAccept(t.TenantID, string, int, time.Time) error
TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error
@@ -314,6 +352,7 @@ type TenantBusinessAdapter interface {
TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error)
TenantGroupMemberUpdate(t.TenantID, string, t.Uid, t.GroupMemberPatch) (*t.GroupMember, error)
TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
TenantGroupInvitationAccept(t.TenantID, string, t.Uid, any, time.Time) (*t.GroupInvitation, error)
TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error)
TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error)
TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error)
@@ -323,6 +362,9 @@ type TenantBusinessAdapter interface {
TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error)
TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error)
TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error
TenantGroupFileList(t.TenantID, string, string, string, string, int) ([]t.GroupFile, string, error)
TenantGroupFileCreate(t.TenantID, *t.GroupFile) (*t.GroupFile, error)
TenantGroupFileDelete(t.TenantID, string, string, t.Uid, bool, time.Time) (*t.GroupFile, error)
TenantGroupCreate(t.TenantID, *t.Topic, t.Uid, any, *t.GroupSettings, []t.Uid, any, time.Time) error
TenantGroupInviteMember(t.TenantID, string, t.Uid, t.Uid, string, string, any, time.Time) (bool, error)
TenantGroupLeave(t.TenantID, string, t.Uid, time.Time) error
@@ -335,6 +377,7 @@ type TenantBusinessAdapter interface {
TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error)
TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error)
TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error)
TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error)
TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error)
TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error)
TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error)
@@ -354,6 +397,7 @@ type TenantBusinessAdapter interface {
TenantMonthlyReportGet(t.TenantID, t.Uid, string) (*t.MonthlyReport, error)
TenantMonthlyReportSaveDraft(t.TenantID, t.Uid, string, string, string, []string, int) (*t.MonthlyReport, error)
TenantMonthlyReportSubmit(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error)
TenantMonthlyReportReopen(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error)
TenantMonthlyReportStats(t.TenantID, t.Uid, string, string) (*t.MonthlyReportStats, error)
TenantMailboxConfigGet(t.TenantID, t.Uid) (*t.MailboxConfig, error)
TenantMailboxSubmissionCreate(t.TenantID, t.Uid, *t.MailboxSubmission, string) (*t.MailboxSubmission, error)
@@ -402,6 +446,7 @@ type TenantBusinessAdapter interface {
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
TenantRealNameGet(t.TenantID, t.Uid) (*t.RealNameVerification, error)
TenantRealNameCreate(t.TenantID, *t.RealNameVerification) error
TenantRealNameAppealCreate(t.TenantID, *t.RealNameAppeal) (*t.RealNameAppeal, error)
TenantActionTokenCreate(t.TenantID, *t.ActionToken) error
TenantActionTokenConsume(t.TenantID, t.Uid, string, string, string, time.Time) (*t.ActionToken, error)
TenantIndependentPasswordGet(t.TenantID, t.Uid, string) (*t.IndependentPassword, error)
@@ -412,10 +457,12 @@ type TenantBusinessAdapter interface {
TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, error)
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error)
TenantFriendRequestList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendRequestListItem, string, error)
TenantFriendRequestPendingCount(t.TenantID, t.Uid) (int, error)
TenantFriendRequestLatestBetween(t.TenantID, t.Uid, t.Uid) (*t.FriendRequest, error)
TenantFriendRequestAccept(t.TenantID, string, t.Uid, time.Time) (*t.FriendRequest, error)
TenantFriendRequestResolve(t.TenantID, string, t.Uid, string, time.Time) (*t.FriendRequest, error)
+3
View File
@@ -30,6 +30,7 @@ func initUsers(now time.Time) []*types.User {
Id: "3ysxkod5hNM",
},
UserAgent: "SomeAgent v1.2.3",
Username: "alice",
Tags: []string{"alice"},
})
users = append(users, &types.User{
@@ -37,6 +38,7 @@ func initUsers(now time.Time) []*types.User {
Id: "9AVDamaNCRY",
},
UserAgent: "Tinode Web v111.222.333",
Username: "bob",
Tags: []string{"bob"},
})
users = append(users, &types.User{
@@ -44,6 +46,7 @@ func initUsers(now time.Time) []*types.User {
Id: "0QLrX3WPS2o",
},
UserAgent: "Tindroid v1.2.3",
Username: "carol",
Tags: []string{"carol"},
})
for _, user := range users {
+326 -202
View File
@@ -239,6 +239,18 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
return errors.New("mysql adapter failed to open tenant registry database: " + err.Error())
}
}
if a.registryDB != nil || !databaseMissing {
controlDB := a.db
if a.registryDB != nil {
controlDB = a.registryDB
}
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
}
if err = ensureIMTenantEntryControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure tenant entry control schema: " + err.Error())
}
}
if !databaseMissing {
if err = ensureIMGroupBusinessTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure group business schema: " + err.Error())
@@ -246,6 +258,18 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
}
if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
}
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
}
if err = ensureUserIdentitySchema(a.db); err != nil {
return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error())
}
if err = ensureIMRegistrationInviteUseTable(a.db); err != nil {
return errors.New("mysql adapter failed to ensure registration invite usage schema: " + err.Error())
}
}
}
return err
@@ -440,8 +464,29 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMFileReviewTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureUserIdentitySchema(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMRegistrationInviteUseTable(db); err != nil {
db.Close()
return nil, nil, err
}
registryDB := a.registryDB
if registryDB == nil {
registryDB = a.db
}
tenantAdp := &adapter{
db: db,
registryDB: registryDB,
dsn: mysqlDefaultString(dsn, a.dsn),
dbName: resolvedDBName,
maxResults: a.maxResults,
@@ -589,7 +634,13 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil {
return err
}
if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(registryDB)); err != nil {
if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
return err
}
if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant_config"); err != nil {
return err
}
if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant"); err != nil {
return err
}
}
@@ -609,6 +660,7 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec(
`CREATE TABLE im_users(
id BIGINT NOT NULL,
username VARCHAR(32) NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
@@ -616,10 +668,12 @@ func (a *adapter) CreateDb(reset bool) error {
access JSON,
lastseen DATETIME,
useragent VARCHAR(255) DEFAULT '',
phone VARCHAR(32),
public JSON,
trusted JSON,
tags JSON,
PRIMARY KEY(id),
UNIQUE INDEX users_username(username),
UNIQUE INDEX users_phone(phone),
INDEX users_state_stateat(state, stateat),
INDEX users_lastseen_updatedat(lastseen, updatedat)
)`); err != nil {
@@ -628,12 +682,14 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec(
`CREATE TABLE im_user_security(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_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(user_id)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_security_user(user_id)
)`); err != nil {
return err
}
@@ -644,20 +700,14 @@ func (a *adapter) CreateDb(reset bool) error {
if err = createIMNotificationTables(tx); err != nil {
return err
}
// Indexed user tags.
if _, err = tx.Exec(
`CREATE TABLE im_usertags(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
INDEX usertags_tag(tag),
UNIQUE INDEX usertags_userid_tag(userid, tag)
)`); err != nil {
if err = ensureIMRegistrationInviteUseTable(tx); err != nil {
return err
}
if a.registryDB == nil {
if err = ensureIMRegistrationInviteControlTables(tx); err != nil {
return err
}
}
// Indexed devices. Normalized into a separate table.
if _, err = tx.Exec(
@@ -680,16 +730,13 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec(
`CREATE TABLE im_auth(
id INT NOT NULL AUTO_INCREMENT,
uname VARCHAR(32) NOT NULL,
userid BIGINT NOT NULL,
scheme VARCHAR(16) NOT NULL,
authlvl INT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
UNIQUE INDEX auth_uname(uname),
UNIQUE INDEX auth_userid_scheme(userid, scheme)
UNIQUE INDEX auth_userid(userid)
)`); err != nil {
return err
}
@@ -840,6 +887,9 @@ func (a *adapter) CreateDb(reset bool) error {
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
return err
}
if err = ensureIMFileReviewTables(tx); err != nil {
return err
}
// Deletion log
if _, err = tx.Exec(
@@ -919,15 +969,21 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec(
`CREATE TABLE im_kvmeta(` +
"id BIGINT NOT NULL," +
"`key` VARCHAR(64) NOT NULL," +
"createdat DATETIME(3)," +
"`value` TEXT," +
"PRIMARY KEY(`key`)," +
"PRIMARY KEY(id)," +
"UNIQUE INDEX uk_im_kvmeta_key(`key`)," +
"INDEX kvmeta_createdat_key(createdat, `key`)" +
`)`); err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO im_kvmeta(`key`, `value`) VALUES('version',?)", adpVersion); err != nil {
versionID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO im_kvmeta(id, `key`, `value`) VALUES(?,'version',?)", versionID, adpVersion); err != nil {
return err
}
@@ -1225,6 +1281,7 @@ func createIMMailboxTables(tx *sql.Tx) error {
func createIMUserProductTables(tx *sql.Tx) error {
statements := []string{
`CREATE TABLE im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
@@ -1232,13 +1289,16 @@ func createIMUserProductTables(tx *sql.Tx) error {
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_profiles_user(user_id)
)`,
`CREATE TABLE im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
@@ -1247,7 +1307,8 @@ func createIMUserProductTables(tx *sql.Tx) error {
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_privacy_settings_user(user_id)
)`,
`CREATE TABLE im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1268,6 +1329,7 @@ func createIMUserProductTables(tx *sql.Tx) error {
UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel)
)`,
`CREATE TABLE im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
@@ -1276,7 +1338,8 @@ func createIMUserProductTables(tx *sql.Tx) error {
platform VARCHAR(32) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(device_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at)
)`,
@@ -1443,6 +1506,7 @@ func createIMRelationTables(exec sqlExecutor) error {
INDEX idx_im_friend_requests_inbox(target_id, status, created_at)
)`,
`CREATE TABLE im_friendships(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
@@ -1450,15 +1514,18 @@ func createIMRelationTables(exec sqlExecutor) error {
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id, peer_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status)
)`,
`CREATE TABLE im_user_blocks(
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT 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(blocker_id, blocked_id),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id)
)`,
`CREATE TABLE im_favorites(
@@ -1492,6 +1559,32 @@ func createIMRelationTables(exec sqlExecutor) error {
}
func createIMSecurityTables(tx *sql.Tx) error {
if _, err := tx.Exec(
`CREATE TABLE im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
action VARCHAR(64) NOT NULL DEFAULT '',
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
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_verification_challenge(challenge_id),
INDEX idx_im_verification_scene_phone_state(scene, phone_hash, state, expires_at)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1515,6 +1608,24 @@ func createIMSecurityTables(tx *sql.Tx) error {
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_realname_appeals(
id BIGINT NOT NULL,
appeal_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '',
attachments_json JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
review_comment VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_realname_appeals_appeal(appeal_id),
INDEX idx_im_realname_appeals_user(user_id, created_at),
INDEX idx_im_realname_appeals_status(status, created_at)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1536,6 +1647,7 @@ func createIMSecurityTables(tx *sql.Tx) error {
}
_, err := tx.Exec(
`CREATE TABLE im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
@@ -1547,7 +1659,8 @@ func createIMSecurityTables(tx *sql.Tx) error {
locked_until 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(user_id, purpose)
PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose)
)`)
return err
}
@@ -1631,7 +1744,7 @@ func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) e
// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
// false for any other error
func (a *adapter) UserCreate(user *t.User) error {
if user == nil {
if user == nil || strings.TrimSpace(user.Username) == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
@@ -1650,71 +1763,95 @@ func (a *adapter) UserCreate(user *t.User) error {
}()
decoded_uid := store.DecodeUid(user.Uid())
if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)",
securityID, err := nextSnowflakeID()
if err != nil {
return err
}
var phone any
if strings.TrimSpace(user.Phone) != "" {
canonicalPhone, err := normalizeUserPhone(user.Phone)
if err != nil {
return err
}
user.Phone = canonicalPhone
phone = canonicalPhone
}
if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)",
decoded_uid,
user.Username,
user.CreatedAt,
user.UpdatedAt,
user.State,
user.Access,
phone,
common.ToJSON(user.Public),
common.ToJSON(user.Trusted),
user.Tags); err != nil {
common.ToJSON(user.Trusted)); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
if _, err = tx.Exec("INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?)",
decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
// Save user's tags to a separate table to make user findable.
if err = addTags(tx, "im_usertags", "userid", decoded_uid, user.Tags, false); err != nil {
if _, err = tx.Exec("INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)",
securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
user.Tags = nil
return tx.Commit()
}
// TenantAuthAddRecord adds an authentication record scoped to a tenant.
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
// TenantAuthAddRecord adds the user's Basic authentication record.
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
authLvl auth.Level, secret []byte, expires time.Time) error {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
}
return a.AuthAddRecord(uid, scheme, unique, authLvl, secret, 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 == sql.ErrNoRows {
return t.ErrNotFound
}
return err
}
return a.AuthAddRecord(uid, "basic", username, authLvl, secret, expires)
}
// TenantAuthDelScheme deletes an authentication scheme only in the selected tenant.
func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme string) error {
// TenantAuthDeleteRecord deletes the Basic authentication record.
func (a *adapter) TenantAuthDeleteRecord(tenantID t.TenantID, uid t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthDelScheme(uid, scheme)
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
}
return a.AuthDelScheme(uid, scheme)
return a.AuthDelScheme(uid, "basic")
}
// TenantAuthUpdRecord updates an authentication record only in the selected tenant.
func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
// TenantAuthUpdateRecord updates the Basic authentication record and optional username.
func (a *adapter) TenantAuthUpdateRecord(tenantID t.TenantID, uid t.Uid, username string,
authLvl auth.Level, secret []byte, expires time.Time) error {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires)
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
}
return a.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires)
return a.AuthUpdRecord(uid, "basic", username, authLvl, secret, expires)
}
// TenantAuthGetRecord retrieves a user's authentication record in one tenant.
func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme string) (string,
// TenantAuthGetRecord retrieves a user's Basic authentication record.
func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid) (string,
auth.Level, []byte, time.Time, error) {
var expires time.Time
tenantAdp, err := a.routedTenantAdapter(tenantID)
@@ -1722,13 +1859,13 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str
return "", auth.LevelNone, nil, expires, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthGetRecord(uid, scheme)
return tenantAdp.TenantAuthGetRecord(tenantID, uid)
}
return a.AuthGetRecord(uid, scheme)
return a.AuthGetRecord(uid, "basic")
}
// TenantAuthGetUniqueRecord retrieves an authentication record by tenant-local login.
func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid,
// TenantAuthGetByLogin retrieves a Basic authentication record by tenant-local login.
func (a *adapter) TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid,
auth.Level, []byte, time.Time, error) {
var expires time.Time
tenantAdp, err := a.routedTenantAdapter(tenantID)
@@ -1736,9 +1873,9 @@ func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string)
return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthGetUniqueRecord(unique)
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
}
return a.AuthGetUniqueRecord(unique)
return a.AuthGetUniqueRecord(login)
}
// Add user's authentication record
@@ -1754,8 +1891,17 @@ func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.L
defer cancel()
}
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)",
unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {
var storedUsername string
if err := a.db.GetContext(ctx, &storedUsername, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
return t.ErrNotFound
}
return err
} else if unique != storedUsername {
return t.ErrMalformed
}
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(userid,authLvl,secret,expires) VALUES(?,?,?,?)",
store.DecodeUid(uid), authLvl, secret, exp); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
@@ -1770,7 +1916,7 @@ func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme)
_, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user))
return err
}
@@ -1792,14 +1938,8 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {
// Update user's authentication unique, secret, auth level.
func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error {
params := []string{"authLvl=?"}
args := []any{authLvl}
if unique != "" {
params = append(params, "uname=?")
args = append(args, unique)
}
if len(secret) > 0 {
params = append(params, "secret=?")
args = append(args, secret)
@@ -1808,23 +1948,46 @@ func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.L
params = append(params, "expires=?")
args = append(args, expires)
}
args = append(args, store.DecodeUid(uid), scheme)
decodedUID := store.DecodeUid(uid)
args = append(args, decodedUID)
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
sql := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?"
resp, err := a.db.ExecContext(ctx, sql, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if unique != "" {
if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE id=?", unique, t.TimeNow(), decodedUID); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
query := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=?"
resp, err := tx.ExecContext(ctx, query, args...)
if isDupe(err) {
return t.ErrDuplicate
}
if count, _ := resp.RowsAffected(); count <= 0 {
return t.ErrNotFound
if err != nil {
return err
}
return err
if count, _ := resp.RowsAffected(); count <= 0 {
var exists int
if err = tx.GetContext(ctx, &exists, "SELECT COUNT(*) FROM im_auth WHERE userid=?", decodedUID); err != nil {
return err
}
if exists == 0 {
return t.ErrNotFound
}
}
return tx.Commit()
}
// Retrieve user's authentication record
@@ -1832,18 +1995,18 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [
var expires time.Time
var record struct {
Uname string
Authlvl auth.Level
Secret []byte
Expires *time.Time
Username string
Authlvl auth.Level
Secret []byte
Expires *time.Time
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=? AND scheme=?",
store.DecodeUid(uid), scheme); err != nil {
if 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.id=a.userid WHERE a.userid=?`, store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
// Nothing found - use standard error.
err = t.ErrNotFound
@@ -1855,7 +2018,7 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [
expires = *record.Expires
}
return record.Uname, record.Authlvl, record.Secret, expires, nil
return record.Username, record.Authlvl, record.Secret, expires, nil
}
// Retrieve user's authentication record
@@ -1873,7 +2036,14 @@ func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte,
if cancel != nil {
defer cancel()
}
if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=?", unique); err != nil {
lookup := unique
if len(unique) == 11 && asciiDigits(unique) {
lookup = "+86" + unique
}
if 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.userid=u.id
WHERE u.username=? OR (u.phone IS NOT NULL AND u.phone=?) ORDER BY (u.username=?) DESC LIMIT 1`,
unique, lookup, unique); err != nil {
if err == sql.ErrNoRows {
// Nothing found - clear the error
err = nil
@@ -1895,7 +2065,8 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
defer cancel()
}
var user t.User
err := a.db.GetContext(ctx, &user, "SELECT * FROM im_users WHERE id=? AND state!=?", store.DecodeUid(uid), t.StateDeleted)
err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil {
user.SetUid(uid)
user.Public = common.FromJSON(user.Public)
@@ -1925,7 +2096,8 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
if cancel != nil {
defer cancel()
}
q, uids, _ := sqlx.In("SELECT * FROM im_users WHERE id IN (?) AND state!=?", uids, t.StateDeleted)
q, uids, _ := sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
if err != nil {
return nil, err
@@ -2048,10 +2220,6 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
return err
}
if _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_users WHERE id=?", decoded_uid); err != nil {
return err
}
@@ -2150,7 +2318,11 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
}
}()
update = withoutUserTags(update)
cols, args := common.UpdateByMap(update)
if len(cols) == 0 {
return tx.Commit()
}
decoded_uid := store.DecodeUid(uid)
args = append(args, decoded_uid)
_, err = tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...)
@@ -2166,76 +2338,12 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
}
}
// Tags are also stored in a separate table
if tags := common.ExtractTags(update); tags != nil {
// First delete all user tags
_, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid)
if err != nil {
return err
}
// Now insert new tags
err = addTags(tx, "im_usertags", "userid", decoded_uid, tags, false)
if err != nil {
return err
}
}
return tx.Commit()
}
// UserUpdateTags adds, removes, or resets user's tags.
// UserUpdateTags is retained for adapter compatibility; user tags are disabled.
func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded_uid := store.DecodeUid(uid)
if reset != nil {
// Delete all tags first if resetting.
_, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid)
if err != nil {
return nil, err
}
add = reset
remove = nil
}
// Now insert new tags. Ignore duplicates if resetting.
err = addTags(tx, "im_usertags", "userid", decoded_uid, add, reset == nil)
if err != nil {
return nil, err
}
// Delete tags.
err = removeTags(tx, "im_usertags", "userid", decoded_uid, remove)
if err != nil {
return nil, err
}
var allTags []string
err = tx.Select(&allTags, "SELECT tag FROM im_usertags WHERE userid=?", decoded_uid)
if err != nil {
return nil, err
}
_, err = tx.Exec("UPDATE im_users SET tags=? WHERE id=?", t.StringSlice(allTags), decoded_uid)
if err != nil {
return nil, err
}
return allTags, tx.Commit()
return nil, nil
}
// UserGetByCred returns user ID for the given validated credential.
@@ -2245,7 +2353,12 @@ func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
defer cancel()
}
var decoded_uid int64
err := a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value)
var err error
if method == "tel" {
err = a.db.GetContext(ctx, &decoded_uid, "SELECT id FROM im_users WHERE phone=? AND state!=?", value, t.StateDeleted)
} else {
err = a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value)
}
if err == nil {
return store.EncodeUid(decoded_uid), nil
}
@@ -2266,6 +2379,22 @@ func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string)
if tenantAdp != a {
return tenantAdp.UserGetByCred(method, value)
}
if !a.tenantScopedDB && method == "tel" {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var decodedUID int64
err = a.db.GetContext(ctx, &decodedUID,
"SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", tenantID, value, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decodedUID), nil
}
return a.UserGetByCred(method, value)
}
@@ -3329,17 +3458,21 @@ func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error {
return tx.Commit()
}
// Find returns a list of users or group topics who match given tags, such as "email:jdoe@example.com" or "tel:+18003287448".
// Find returns group topics which match the requested discovery tags.
func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
index := make(map[string]struct{})
allReq := t.FlattenDoubleSlice(req)
allTags := append(append([]string{}, allReq...), opt...)
if len(allTags) == 0 {
return nil, nil
}
var args []any
stateConstraint := ""
if activeOnly {
args = append(args, t.StateOK)
stateConstraint = "u.state=? AND "
stateConstraint = "t.state=? AND "
}
index := make(map[string]struct{})
allReq := t.FlattenDoubleSlice(req)
for _, tag := range append(allReq, opt...) {
for _, tag := range allTags {
args = append(args, tag)
index[tag] = struct{}{}
}
@@ -3352,29 +3485,9 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string,
matcher = "COUNT(*)"
}
query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " +
"FROM im_users AS u JOIN im_usertags AS tg ON tg.userid=u.id " +
"WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " +
"GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags "
if len(allReq) > 0 {
q, a := common.DisjunctionSql(req, "tg.tag")
query += q
args = append(args, a...)
}
query += "UNION ALL "
if activeOnly {
args = append(args, t.StateOK)
stateConstraint = "t.state=? AND "
}
for _, tag := range append(allReq, opt...) {
args = append(args, tag)
}
query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " +
"WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " +
"WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
if len(allReq) > 0 {
q, a := common.DisjunctionSql(req, "tg.tag")
@@ -3443,29 +3556,17 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string,
return subs, err
}
// FindOne returns the first topic or user which matches the given tag.
// FindOne returns the first topic which matches the given tag.
func (a *adapter) FindOne(tag string) (string, error) {
var args []any
query := "SELECT t.name AS topic FROM im_topics AS t LEFT JOIN im_topictags AS tt ON t.name=tt.topic " +
"WHERE tt.tag=?"
args = append(args, tag)
query += " UNION ALL "
query += "SELECT u.id AS topic FROM im_users AS u LEFT JOIN im_usertags AS ut ON ut.userid=u.id " +
"WHERE ut.tag=?"
args = append(args, tag)
// LIMIT is applied to all resultant rows.
query += " LIMIT 1"
"WHERE tt.tag=? LIMIT 1"
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
rows, err := a.db.QueryxContext(ctx, query, tag)
if err != nil {
return "", err
}
@@ -3498,10 +3599,11 @@ func (a *adapter) MessageSave(msg *t.Message) error {
}
// store assignes message ID, but we don't use it. Message IDs are not used anywhere.
// Using a sequential ID provided by the database.
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, err := a.db.ExecContext(ctx,
"INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?)",
"INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?)",
msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
store.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content))
store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content))
if err == nil {
id, _ := res.LastInsertId()
// Replacing ID given by store by ID given by the DB.
@@ -3928,6 +4030,15 @@ func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
// 2.4 Insert new credential record.
func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
var err error
if cred == nil {
return false, t.ErrMalformed
}
if cred.Method == "tel" {
cred.Value, err = normalizeUserPhone(cred.Value)
if err != nil {
return false, err
}
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
@@ -3998,6 +4109,11 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
}
return true, err
}
if cred.Done && cred.Method == "tel" {
if _, err = tx.Exec("UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')", cred.Value, userId); err != nil {
return true, err
}
}
return true, tx.Commit()
}
@@ -4362,9 +4478,13 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.
if err != nil {
return nil, err
}
fd.UpdatedAt = now
fd.Size = size
if err = insertFileReviewTasks(ctx, tx, fd); err != nil {
return nil, err
}
fd.Status = t.UploadCompleted
fd.Size = size
} else {
// Deleting the record: there is no value in keeping it in the DB.
_, err = tx.ExecContext(ctx, "DELETE FROM im_fileuploads WHERE id=?", store.DecodeUid(fd.Uid()))
@@ -4635,7 +4755,11 @@ func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) e
action = "REPLACE"
}
_, err := a.db.ExecContext(ctx, action+" INTO im_kvmeta(`key`,createdat,`value`) VALUES(?,?,?)", key, t.TimeNow(), value)
id, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, action+" INTO im_kvmeta(id,`key`,createdat,`value`) VALUES(?,?,?,?)", id, key, t.TimeNow(), value)
if isDupe(err) {
return t.ErrDuplicate
}
+37
View File
@@ -0,0 +1,37 @@
//go:build mysql
// +build mysql
package mysql
// ensureIMFileReviewTables installs async file safety review state in the
// currently selected tenant database. Tenant databases intentionally do not
// carry tenant_id columns.
func ensureIMFileReviewTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_file_review_tasks (
id BIGINT NOT NULL, task_id VARCHAR(64) NOT NULL, file_id BIGINT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0, task_type VARCHAR(32) NOT NULL,
provider VARCHAR(64) NOT NULL, storage_provider VARCHAR(32) NOT NULL DEFAULT '',
object_name VARCHAR(2048) NOT NULL DEFAULT '', mime_type VARCHAR(255) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0, provider_task_id VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', risk_level VARCHAR(32) NOT NULL DEFAULT '',
risk_labels JSON, result_json JSON, error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message VARCHAR(512) NOT NULL DEFAULT '', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), completed_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_file_review_tasks_task(task_id),
UNIQUE INDEX uk_im_file_review_tasks_file_provider(file_id, task_type, provider),
INDEX idx_im_file_review_tasks_pending(status, next_retry_at, id),
INDEX idx_im_file_review_tasks_file(file_id),
INDEX idx_im_file_review_tasks_object(storage_provider, object_name(191)),
INDEX idx_im_file_review_tasks_provider_task(provider, provider_task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,32 @@
//go:build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMFileReviewTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMFileReviewTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 1 {
t.Fatalf("created %d file review tables, want 1", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, expected := range []string{
"im_file_review_tasks",
"unique index uk_im_file_review_tasks_file_provider(file_id, task_type, provider)",
"index idx_im_file_review_tasks_pending(status, next_retry_at, id)",
"index idx_im_file_review_tasks_object(storage_provider, object_name(191))",
} {
if !strings.Contains(joined, expected) {
t.Fatalf("file review schema is missing %q", expected)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("file review schema must stay inside the selected tenant database: %s", joined)
}
}
+47
View File
@@ -0,0 +1,47 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
type fileReviewExecContext interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func insertFileReviewTasks(ctx context.Context, exec fileReviewExecContext, fd *t.FileDef) error {
fileID := decodeUID(fd.Uid())
userID := int64(0)
if fd.User != "" {
userID = decodeUID(t.ParseUid(fd.User))
}
tasks := []struct {
taskType string
provider string
}{
{taskType: t.FileReviewTaskTypeContent, provider: t.FileReviewProviderAliyunGreen},
{taskType: t.FileReviewTaskTypeMalware, provider: t.FileReviewProviderAliyunSAS},
}
for _, task := range tasks {
id, err := nextSnowflakeID()
if err != nil {
return err
}
taskID := store.EncodeUid(id).String()
_, err = exec.ExecContext(ctx, `INSERT IGNORE INTO im_file_review_tasks
(id,task_id,file_id,user_id,task_type,provider,storage_provider,object_name,mime_type,file_size,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, taskID, fileID, userID, task.taskType, task.provider, "", fd.Location, fd.MimeType, fd.Size,
t.FileReviewStatusPending, fd.UpdatedAt, fd.UpdatedAt)
if err != nil {
return err
}
}
return nil
}
+9 -6
View File
@@ -9,7 +9,7 @@ package mysql
func ensureIMGroupBusinessTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_settings (
topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL,
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,
@@ -19,7 +19,8 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
version INT NOT NULL DEFAULT 1, created_by BIGINT NOT NULL, dissolved_by BIGINT,
dissolved_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(topic), INDEX idx_im_group_settings_owner(owner_user_id, group_status),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_settings_topic(topic),
INDEX idx_im_group_settings_owner(owner_user_id, group_status),
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_members (
@@ -77,18 +78,20 @@ 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 (
announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
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(announcement_id, user_id), INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at)
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 (
topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
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),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic, user_id), INDEX idx_im_group_mutes_topic_status(topic, status, expires_at),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_mutes_topic_user(topic, user_id),
INDEX idx_im_group_mutes_topic_status(topic, status, expires_at),
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 (
+10
View File
@@ -30,4 +30,14 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
t.Fatalf("group schema must use the selected tenant database: %s", statement)
}
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
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)",
"primary key(id), unique index uk_im_group_mutes_topic_user(topic, user_id)",
} {
if !strings.Contains(joined, expected) {
t.Fatalf("group schema is missing snowflake primary-key definition %q", expected)
}
}
}
+27
View File
@@ -29,6 +29,33 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target(target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_realname_appeals (
id BIGINT NOT NULL, appeal_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '', attachments_json JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending', review_comment VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_realname_appeals_appeal(appeal_id),
INDEX idx_im_realname_appeals_user(user_id, created_at),
INDEX idx_im_realname_appeals_status(status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_conversation_settings (
id BIGINT NOT NULL, user_id BIGINT NOT NULL, topic VARCHAR(128) NOT NULL,
pinned TINYINT NOT NULL DEFAULT 0, muted TINYINT NOT NULL DEFAULT 0,
remark VARCHAR(128) NOT NULL DEFAULT '', message_retention VARCHAR(32) NOT NULL DEFAULT 'default',
version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_conversation_settings_user_topic(user_id, topic),
INDEX idx_im_conversation_settings_user_pinned(user_id, pinned, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_topic_drafts (
id BIGINT NOT NULL, user_id BIGINT NOT NULL, topic VARCHAR(128) NOT NULL,
content TEXT, entities_json JSON, version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_topic_drafts_user_topic(user_id, topic),
INDEX idx_im_topic_drafts_user_updated(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
@@ -12,11 +12,17 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 2 {
t.Fatalf("created %d infrastructure tables, want 2", len(exec.statements))
if len(exec.statements) != 5 {
t.Fatalf("created %d infrastructure tables, want 5", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_http_idempotency", "im_business_events_outbox"} {
for _, table := range []string{
"im_http_idempotency",
"im_business_events_outbox",
"im_realname_appeals",
"im_conversation_settings",
"im_topic_drafts",
} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
+48
View File
@@ -0,0 +1,48 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"errors"
"strconv"
"strings"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) RegistrationSMSEnabled() (bool, error) {
db := a.registryDB
if db == nil {
db = a.db
}
if db == nil {
return true, t.ErrInternal
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var value string
err := db.GetContext(ctx, &value, `SELECT value FROM hg_sys_config
WHERE `+"`group`"+`='im' AND `+"`key`"+`='register_sms_enabled' AND status=1 LIMIT 1`)
if errors.Is(err, sql.ErrNoRows) {
return true, t.ErrNotFound
}
if err != nil {
return true, err
}
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "1", "true":
return true, nil
case "0", "false":
return false, nil
default:
if parsed, parseErr := strconv.ParseBool(value); parseErr == nil {
return parsed, nil
}
return true, t.ErrMalformed
}
}
@@ -0,0 +1,87 @@
//go:build mysql
// +build mysql
package mysql
import (
"errors"
mysqldriver "github.com/go-sql-driver/mysql"
)
func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_registration_invite_batches(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
quantity INT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency(tenant_id,idempotency_key),
INDEX idx_im_registration_invite_batch_tenant_created(tenant_id,created_at)
)`,
`CREATE TABLE IF NOT EXISTS im_registration_invite_codes(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
batch_id BIGINT,
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
expires_at DATETIME(3) NOT NULL,
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
last_used_at DATETIME(3) NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_registration_invite_code_tenant(tenant_id,code),
INDEX idx_im_registration_invite_tenant_status_expires(tenant_id,status,expires_at),
INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id),
INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at)
)`,
}
for _, stmt := range statements {
if _, err := exec.Exec(stmt); err != nil {
return err
}
}
return nil
}
func ensureIMRegistrationInviteUseTable(exec sqlExecutor) error {
if _, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_registration_invite_uses(
id BIGINT NOT NULL,
invite_code_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
used_at DATETIME(3) NOT NULL,
PRIMARY KEY(id),
INDEX idx_im_registration_invite_use_code(invite_code_id),
UNIQUE INDEX uk_im_registration_invite_use_user(user_id),
INDEX idx_im_registration_invite_use_request(request_id)
)`); err != nil {
return err
}
for _, migration := range []struct {
statement string
ignored uint16
}{
{`ALTER TABLE im_registration_invite_uses DROP INDEX uk_im_registration_invite_use_code`, 1091},
{`ALTER TABLE im_registration_invite_uses ADD INDEX idx_im_registration_invite_use_code(invite_code_id)`, 1061},
} {
if _, err := exec.Exec(migration.statement); err != nil && !isMySQLErrorNumber(err, migration.ignored) {
return err
}
}
return nil
}
func isMySQLErrorNumber(err error, number uint16) bool {
var mysqlErr *mysqldriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == number
}
+457
View File
@@ -0,0 +1,457 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) routedRegistrationInviteAdapter(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
}
func (a *adapter) registrationInviteControlDB() *sqlx.DB {
if a.registryDB != nil {
return a.registryDB
}
return a.db
}
func (a *adapter) TenantRegistrationInviteCreate(id t.TenantID, code *t.RegistrationInviteCode) error {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteCreate(id, code)
}
if code == nil || len(code.Code) != 5 || code.ExpiresAt.IsZero() || code.CreatedBy <= 0 {
return t.ErrMalformed
}
if code.ID == 0 {
code.ID, err = nextSnowflakeID()
if err != nil {
return err
}
}
now := t.TimeNow()
code.TenantID = id
code.Status = t.RegistrationInviteActive
code.CreatedAt, code.UpdatedAt = now, now
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.registrationInviteControlDB().ExecContext(ctx, `INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt,
code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantRegistrationInviteCreateBatch(id t.TenantID, batch *t.RegistrationInviteBatch, codes []t.RegistrationInviteCode) (err error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteCreateBatch(id, batch, codes)
}
if batch == nil || batch.Quantity != len(codes) || len(codes) == 0 || strings.TrimSpace(batch.IdempotencyKey) == "" ||
batch.ExpiresAt.IsZero() || batch.CreatedBy <= 0 {
return t.ErrMalformed
}
if batch.ID == 0 {
batch.ID, err = nextSnowflakeID()
if err != nil {
return err
}
}
now := t.TimeNow()
batch.TenantID = id
batch.CreatedAt, batch.UpdatedAt = now, now
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.registrationInviteControlDB().BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_batches
(id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, batch.ID, id, batch.Quantity, batch.IdempotencyKey, batch.ExpiresAt,
batch.CreatedBy, batch.Remark, batch.CreatedAt, batch.UpdatedAt); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
for i := range codes {
if len(codes[i].Code) != 5 {
return t.ErrMalformed
}
codes[i].ID, err = nextSnowflakeID()
if err != nil {
return err
}
codes[i].BatchID = &batch.ID
codes[i].TenantID = id
codes[i].Status = t.RegistrationInviteActive
codes[i].ExpiresAt = batch.ExpiresAt
codes[i].CreatedBy = batch.CreatedBy
codes[i].Remark = batch.Remark
codes[i].CreatedAt, codes[i].UpdatedAt = now, now
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status,
codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, now, now); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantRegistrationInviteGetByCode(id t.TenantID, value string) (*t.RegistrationInviteCode, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteGetByCode(id, value)
}
if len(value) != 5 {
return nil, t.ErrInviteInvalid
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var code t.RegistrationInviteCode
err = a.registrationInviteControlDB().GetContext(ctx, &code, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,
c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at
FROM im_registration_invite_codes AS c
WHERE c.tenant_id=? AND c.code=? LIMIT 1`, id, value)
if err == sql.ErrNoRows {
return nil, t.ErrInviteInvalid
}
if err != nil {
return nil, err
}
if code.Status == t.RegistrationInviteActive && !t.TimeNow().Before(code.ExpiresAt) {
code.Status = t.RegistrationInviteExpired
}
return &code, nil
}
func (a *adapter) TenantRegistrationInviteList(id t.TenantID, opts t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteList(id, opts)
}
if opts.Limit <= 0 || opts.Limit > 100 {
opts.Limit = 20
}
if opts.Offset < 0 {
opts.Offset = 0
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
controlDB := a.registrationInviteControlDB()
_, _ = controlDB.ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
WHERE tenant_id=? AND status=? AND expires_at<=?`, t.RegistrationInviteExpired, now, id, t.RegistrationInviteActive, now)
where := []string{"c.tenant_id=?"}
args := []any{id}
if opts.Code != "" {
where, args = append(where, "c.code=?"), append(args, opts.Code)
}
if opts.Status != "" {
where, args = append(where, "c.status=?"), append(args, opts.Status)
}
if opts.BatchID > 0 {
where, args = append(where, "c.batch_id=?"), append(args, opts.BatchID)
}
if opts.CreatedBy > 0 {
where, args = append(where, "c.created_by=?"), append(args, opts.CreatedBy)
}
if opts.CreatedAtStart != nil {
where, args = append(where, "c.created_at>=?"), append(args, *opts.CreatedAtStart)
}
if opts.CreatedAtEnd != nil {
where, args = append(where, "c.created_at<=?"), append(args, *opts.CreatedAtEnd)
}
if opts.ExpiresAtStart != nil {
where, args = append(where, "c.expires_at>=?"), append(args, *opts.ExpiresAtStart)
}
if opts.ExpiresAtEnd != nil {
where, args = append(where, "c.expires_at<=?"), append(args, *opts.ExpiresAtEnd)
}
filter := strings.Join(where, " AND ")
var total int
if err = controlDB.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_registration_invite_codes AS c WHERE "+filter, args...); err != nil {
return nil, err
}
queryArgs := append(append([]any{}, args...), opts.Limit, opts.Offset)
var items []t.RegistrationInviteCode
err = controlDB.SelectContext(ctx, &items, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,
c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at
FROM im_registration_invite_codes AS c
WHERE `+filter+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, err
}
return &t.RegistrationInvitePage{Items: items, Total: total}, nil
}
func (a *adapter) registrationInviteBatchGet(id t.TenantID, where string, arg any) (*t.RegistrationInviteBatch, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
if where == "b.id=?" {
return tenantAdp.TenantRegistrationInviteBatchGet(id, arg.(int64))
}
return tenantAdp.TenantRegistrationInviteBatchGetByIdempotency(id, arg.(string))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var batch t.RegistrationInviteBatch
err = a.registrationInviteControlDB().GetContext(ctx, &batch, `SELECT b.id,b.tenant_id,b.quantity,b.idempotency_key,b.expires_at,b.created_by,b.remark,b.created_at,b.updated_at,
COALESCE(SUM(CASE WHEN c.status='active' AND c.expires_at>? THEN 1 ELSE 0 END),0) AS active_count,
COALESCE(SUM(c.usage_count),0) AS usage_count,
COALESCE(SUM(CASE WHEN (c.status='active' AND c.expires_at<=?) OR c.status='expired' THEN 1 ELSE 0 END),0) AS expired_count,
COALESCE(SUM(CASE WHEN c.status='revoked' THEN 1 ELSE 0 END),0) AS revoked_count
FROM im_registration_invite_batches AS b
LEFT JOIN im_registration_invite_codes AS c ON c.tenant_id=b.tenant_id AND c.batch_id=b.id
WHERE b.tenant_id=? AND `+where+` GROUP BY b.id`, t.TimeNow(), t.TimeNow(), id, arg)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return &batch, err
}
func (a *adapter) TenantRegistrationInviteBatchGet(id t.TenantID, batchID int64) (*t.RegistrationInviteBatch, error) {
if batchID <= 0 {
return nil, t.ErrMalformed
}
return a.registrationInviteBatchGet(id, "b.id=?", batchID)
}
func (a *adapter) TenantRegistrationInviteBatchGetByIdempotency(id t.TenantID, key string) (*t.RegistrationInviteBatch, error) {
if strings.TrimSpace(key) == "" {
return nil, t.ErrMalformed
}
return a.registrationInviteBatchGet(id, "b.idempotency_key=?", key)
}
func (a *adapter) TenantRegistrationInviteRevoke(id t.TenantID, codeID int64, now time.Time) error {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteRevoke(id, codeID, now)
}
if codeID <= 0 || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
WHERE tenant_id=? AND id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked, now,
id, codeID, t.RegistrationInviteActive, now)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantRegistrationInviteBatchRevoke(id t.TenantID, batchID int64, now time.Time) (int, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteBatchRevoke(id, batchID, now)
}
if batchID <= 0 || now.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
WHERE tenant_id=? AND batch_id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked,
now, id, batchID, t.RegistrationInviteActive, now)
if err != nil {
return 0, err
}
count, _ := res.RowsAffected()
return int(count), nil
}
func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, uid t.Uid, requestID string, now time.Time) (err error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteRecordUse(id, value, uid, requestID, now)
}
if len(value) != 5 || uid.IsZero() || strings.TrimSpace(requestID) == "" || now.IsZero() {
return t.ErrMalformed
}
var row struct {
ID int64 `db:"id"`
Status t.RegistrationInviteStatus `db:"status"`
ExpiresAt time.Time `db:"expires_at"`
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
err = a.registrationInviteControlDB().GetContext(ctx, &row, `SELECT id,status,expires_at
FROM im_registration_invite_codes WHERE tenant_id=? AND code=? LIMIT 1`, id, value)
if err == sql.ErrNoRows {
return t.ErrInviteInvalid
}
if err != nil {
return err
}
if row.Status == t.RegistrationInviteRevoked {
return t.ErrInviteRevoked
}
if row.Status == t.RegistrationInviteExpired || !now.Before(row.ExpiresAt) {
return t.ErrInviteExpired
}
if row.Status != t.RegistrationInviteActive {
return t.ErrInviteInvalid
}
txCtx, txCancel := a.getContextForTx()
if txCancel != nil {
defer txCancel()
}
tx, err := a.db.BeginTxx(txCtx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
useID, err := nextSnowflakeID()
if err != nil {
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,
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 {
err = nil
} else {
return t.ErrDuplicate
}
} else {
return err
}
}
eventID := t.StableEventID("registration-invite-use", id, row.ID, store.DecodeUid(uid))
if err = insertBusinessEventTx(tx, eventID, "user", uid.UserId(), uid, "registration.invite_used", map[string]any{
"tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid),
"request_id": requestID, "used_at": now,
}); err != nil {
return err
}
return tx.Commit()
}
// TenantRegistrationInviteSyncUsage projects the tenant usage aggregate to the
// control database. The overwrite is idempotent, so outbox retries are safe.
func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteSyncUsage(id, codeID)
}
if codeID <= 0 {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var aggregate struct {
UsageCount int `db:"usage_count"`
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 {
return err
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes
SET usage_count=?,last_used_at=? WHERE tenant_id=? AND id=?`, aggregate.UsageCount, aggregate.LastUsedAt, id, codeID)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
var exists int
if lookupErr := a.registrationInviteControlDB().GetContext(ctx, &exists,
"SELECT 1 FROM im_registration_invite_codes WHERE tenant_id=? AND id=?", id, codeID); lookupErr != nil {
if lookupErr == sql.ErrNoRows {
return t.ErrNotFound
}
return lookupErr
}
}
return nil
}
+3
View File
@@ -8,10 +8,13 @@
# - Control database: hotgo
# - im_tenant
# - im_tenant_config
# - im_public_uploads, im_tenant_applications and im_tenant_entry_feedbacks
# for unauthenticated enterprise registration and tenant-entry feedback
#
# - Default tenant business database: tinode
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
# - im_http_idempotency and im_business_events_outbox for reliable HTTP writes
# - im_file_review_tasks for asynchronous content safety and malware checks
# - business tables do not store tenant_id in independent-database mode
#
# The previous shared-table tenant_id draft has been removed from this file to
+178
View File
@@ -0,0 +1,178 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
)
type snowflakePrimaryKeySpec struct {
table string
uniqueIndex string
businessKey []string
}
var snowflakePrimaryKeySpecs = []snowflakePrimaryKeySpec{
{table: "im_user_security", uniqueIndex: "uk_im_user_security_user", businessKey: []string{"user_id"}},
{table: "im_kvmeta", uniqueIndex: "uk_im_kvmeta_key", businessKey: []string{"key"}},
{table: "im_user_profiles", uniqueIndex: "uk_im_user_profiles_user", businessKey: []string{"user_id"}},
{table: "im_user_privacy_settings", uniqueIndex: "uk_im_user_privacy_settings_user", businessKey: []string{"user_id"}},
{table: "im_device_push_tokens", uniqueIndex: "uk_im_device_push_tokens_device", businessKey: []string{"device_id"}},
{table: "im_friendships", uniqueIndex: "uk_im_friendships_pair", businessKey: []string{"user_id", "peer_id"}},
{table: "im_user_blocks", uniqueIndex: "uk_im_user_blocks_pair", businessKey: []string{"blocker_id", "blocked_id"}},
{table: "im_youth_mode_settings", uniqueIndex: "uk_im_youth_mode_settings_user_purpose", businessKey: []string{"user_id", "purpose"}},
{table: "im_group_settings", uniqueIndex: "uk_im_group_settings_topic", businessKey: []string{"topic"}},
{table: "im_group_announcement_reads", uniqueIndex: "uk_im_group_announcement_reads_announcement_user", businessKey: []string{"announcement_id", "user_id"}},
{table: "im_group_mutes", uniqueIndex: "uk_im_group_mutes_topic_user", businessKey: []string{"topic", "user_id"}},
}
const snowflakeMigrationLock = "CONCAT('im_snowflake_pk:', LEFT(SHA2(DATABASE(), 256), 40))"
func nextSnowflakeID() (int64, error) {
uid := store.Store.GetUid()
if uid.IsZero() {
return 0, errors.New("mysql: snowflake generator is not initialized")
}
id := store.DecodeUid(uid)
if id <= 0 {
return 0, errors.New("mysql: snowflake generator returned an invalid ID")
}
return id, nil
}
// ensureSnowflakePrimaryKeys upgrades legacy business-key primary keys in the
// selected tenant database. The former primary-key columns remain unique.
func ensureSnowflakePrimaryKeys(db *sqlx.DB) error {
ctx := context.Background()
conn, err := db.Connx(ctx)
if err != nil {
return err
}
defer conn.Close()
var locked sql.NullInt64
if err = conn.QueryRowContext(ctx,
"SELECT GET_LOCK("+snowflakeMigrationLock+", 60)").Scan(&locked); err != nil {
return err
}
if !locked.Valid || locked.Int64 != 1 {
return errors.New("mysql: timed out waiting for snowflake primary-key migration lock")
}
defer conn.ExecContext(ctx, "SELECT RELEASE_LOCK("+snowflakeMigrationLock+")")
for _, spec := range snowflakePrimaryKeySpecs {
if err = migrateSnowflakePrimaryKey(ctx, conn, spec); err != nil {
return fmt.Errorf("mysql: migrate %s primary key: %w", spec.table, err)
}
}
return nil
}
func migrateSnowflakePrimaryKey(ctx context.Context, conn *sqlx.Conn, spec snowflakePrimaryKeySpec) error {
var exists int
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?)`, spec.table).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return nil
}
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name='id')`, spec.table).Scan(&exists); err != nil {
return err
}
if exists == 0 {
if _, err := conn.ExecContext(ctx, "ALTER TABLE "+quoteIdent(spec.table)+" ADD COLUMN id BIGINT NULL FIRST"); err != nil {
return err
}
}
for {
if err := conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM "+quoteIdent(spec.table)+" WHERE id IS NULL OR id=0 LIMIT 1)").Scan(&exists); err != nil {
return err
}
if exists == 0 {
break
}
id, err := nextSnowflakeID()
if err != nil {
return err
}
result, err := conn.ExecContext(ctx, "UPDATE "+quoteIdent(spec.table)+" SET id=? WHERE id IS NULL OR id=0 LIMIT 1", id)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return err
}
return errors.New("could not backfill exactly one row")
}
}
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`,
spec.table, spec.uniqueIndex).Scan(&exists); err != nil {
return err
}
if exists == 0 {
businessKey := spec.businessKey
var hasTenantID int
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name='tenant_id')`,
spec.table).Scan(&hasTenantID); err != nil {
return err
}
if hasTenantID == 1 {
businessKey = append([]string{"tenant_id"}, businessKey...)
}
columns := make([]string, len(businessKey))
for i, column := range businessKey {
columns[i] = quoteIdent(column)
}
statement := "CREATE UNIQUE INDEX " + quoteIdent(spec.uniqueIndex) + " ON " +
quoteIdent(spec.table) + "(" + strings.Join(columns, ",") + ")"
if _, err := conn.ExecContext(ctx, statement); err != nil {
return err
}
}
var primaryColumns sql.NullString
if err := conn.QueryRowContext(ctx, `SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',')
FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name=? AND index_name='PRIMARY'`, spec.table).Scan(&primaryColumns); err != nil {
return err
}
if primaryColumns.Valid && primaryColumns.String == "id" {
var dataType, nullable, extra string
if err := conn.QueryRowContext(ctx, `SELECT data_type,is_nullable,extra
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name=? AND column_name='id'`, spec.table).
Scan(&dataType, &nullable, &extra); err != nil {
return err
}
if strings.EqualFold(dataType, "bigint") && strings.EqualFold(nullable, "NO") &&
!strings.Contains(strings.ToLower(extra), "auto_increment") {
return nil
}
_, err := conn.ExecContext(ctx, "ALTER TABLE "+quoteIdent(spec.table)+" MODIFY COLUMN id BIGINT NOT NULL")
return err
}
statement := "ALTER TABLE " + quoteIdent(spec.table) + " MODIFY COLUMN id BIGINT NOT NULL"
if primaryColumns.Valid && primaryColumns.String != "" {
statement += ", DROP PRIMARY KEY"
}
statement += ", ADD PRIMARY KEY(id)"
_, err := conn.ExecContext(ctx, statement)
return err
}
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
//go:build mysql
// +build mysql
package mysql
import (
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) PublicUploadCreate(upload *t.PublicUpload) (*t.PublicUpload, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if upload.CreatedAt.IsZero() {
upload.CreatedAt = now
}
if upload.UploadedAt.IsZero() {
upload.UploadedAt = now
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_public_uploads
(file_id,purpose,mime_type,size,etag,location,expires_at,created_at,uploaded_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
upload.FileID, upload.Purpose, upload.MimeType, upload.Size, upload.ETag, upload.Location,
nullableTenantEntryTime(upload.ExpiresAt), upload.CreatedAt, upload.UploadedAt)
if err != nil {
return nil, err
}
return upload, nil
}
func (a *adapter) TenantApplicationCreate(application *t.TenantApplication) (*t.TenantApplication, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if application.Status == "" {
application.Status = t.TenantApplicationStatusReceived
}
if application.EstimatedReviewHour <= 0 {
application.EstimatedReviewHour = 24
}
if application.SubmittedAt.IsZero() {
application.SubmittedAt = now
}
application.CreatedAt = now
application.UpdatedAt = now
clientContext, _ := json.Marshal(application.ClientContext)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_applications
(application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
application.ApplicationID, application.CreditCode, application.EnterpriseEmail, application.LicenseFileID,
application.TenantCodeHint, string(clientContext), application.IdempotencyKey, application.Status,
application.EstimatedReviewHour, application.SubmittedAt, now, now)
if err != nil {
return a.tenantApplicationByIdempotency(application.IdempotencyKey)
}
return a.tenantApplicationByIdempotency(application.IdempotencyKey)
}
func (a *adapter) TenantEntryFeedbackCreate(feedback *t.TenantEntryFeedback) (*t.TenantEntryFeedback, error) {
db := a.controlDB()
if db == nil {
return nil, t.ErrInternal
}
now := t.TimeNow()
if feedback.Status == "" {
feedback.Status = t.TenantEntryFeedbackReceived
}
if feedback.SubmittedAt.IsZero() {
feedback.SubmittedAt = now
}
feedback.CreatedAt = now
feedback.UpdatedAt = now
screenshots, _ := json.Marshal(feedback.ScreenshotFileIDs)
clientContext, _ := json.Marshal(feedback.ClientContext)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := db.ExecContext(ctx, `INSERT INTO im_tenant_entry_feedbacks
(feedback_id,description,contact,tenant_code_hint,screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
feedback.FeedbackID, feedback.Description, feedback.Contact, feedback.TenantCodeHint,
string(screenshots), string(clientContext), feedback.IdempotencyKey, feedback.Status,
feedback.SubmittedAt, now, now)
if err != nil {
return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
}
return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
}
func (a *adapter) tenantApplicationByIdempotency(idempotencyKey string) (*t.TenantApplication, error) {
db := a.controlDB()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
ApplicationID string `db:"application_id"`
CreditCode string `db:"credit_code"`
EnterpriseEmail string `db:"enterprise_email"`
LicenseFileID string `db:"license_file_id"`
TenantCodeHint string `db:"tenant_code_hint"`
ClientContext []byte `db:"client_context"`
IdempotencyKey string `db:"idempotency_key"`
Status string `db:"status"`
EstimatedReviewHour int `db:"estimated_review_hour"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := db.GetContext(ctx, &row, `SELECT application_id,credit_code,enterprise_email,license_file_id,
tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at
FROM im_tenant_applications WHERE idempotency_key=?`, idempotencyKey); err != nil {
return nil, err
}
app := &t.TenantApplication{
ApplicationID: row.ApplicationID,
CreditCode: row.CreditCode,
EnterpriseEmail: row.EnterpriseEmail,
LicenseFileID: row.LicenseFileID,
TenantCodeHint: row.TenantCodeHint,
IdempotencyKey: row.IdempotencyKey,
Status: row.Status,
EstimatedReviewHour: row.EstimatedReviewHour,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.ClientContext, &app.ClientContext)
return app, nil
}
func (a *adapter) tenantEntryFeedbackByIdempotency(idempotencyKey string) (*t.TenantEntryFeedback, error) {
db := a.controlDB()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
FeedbackID string `db:"feedback_id"`
Description string `db:"description"`
Contact string `db:"contact"`
TenantCodeHint string `db:"tenant_code_hint"`
ScreenshotFileIDs []byte `db:"screenshot_file_ids"`
ClientContext []byte `db:"client_context"`
IdempotencyKey string `db:"idempotency_key"`
Status string `db:"status"`
SubmittedAt time.Time `db:"submitted_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := db.GetContext(ctx, &row, `SELECT feedback_id,description,contact,tenant_code_hint,
screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at
FROM im_tenant_entry_feedbacks WHERE idempotency_key=?`, idempotencyKey); err != nil {
return nil, err
}
feedback := &t.TenantEntryFeedback{
FeedbackID: row.FeedbackID,
Description: row.Description,
Contact: row.Contact,
TenantCodeHint: row.TenantCodeHint,
IdempotencyKey: row.IdempotencyKey,
Status: row.Status,
SubmittedAt: row.SubmittedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.ScreenshotFileIDs, &feedback.ScreenshotFileIDs)
_ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
return feedback, nil
}
func (a *adapter) controlDB() *sqlx.DB {
if a.registryDB != nil {
return a.registryDB
}
return a.db
}
func nullableTenantEntryTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
+72
View File
@@ -0,0 +1,72 @@
//go:build mysql
// +build mysql
package mysql
import "github.com/jmoiron/sqlx"
func ensureIMTenantEntryControlTables(db *sqlx.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_public_uploads(
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
mime_type VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
etag VARCHAR(128) NOT NULL DEFAULT '',
location VARCHAR(2048) NOT NULL DEFAULT '',
expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id),
INDEX idx_im_public_uploads_purpose(purpose, created_at),
INDEX idx_im_public_uploads_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_tenant_applications(
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
license_file_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
estimated_review_hour INT NOT NULL DEFAULT 24,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_applications_app(application_id),
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
INDEX idx_im_tenant_applications_credit(credit_code),
INDEX idx_im_tenant_applications_status(status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
contact VARCHAR(255) NOT NULL DEFAULT '',
tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
screenshot_file_ids JSON,
client_context JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_entry_feedbacks_feedback(feedback_id),
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
INDEX idx_im_tenant_entry_feedbacks_status(status, id),
INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, stmt := range statements {
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,40 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureTenantEntryControlTablesStatements(t *testing.T) {
stmts := []string{
`CREATE TABLE IF NOT EXISTS im_public_uploads(
id BIGINT NOT NULL AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
purpose VARCHAR(64) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_public_uploads_file(file_id)
)`,
`CREATE TABLE IF NOT EXISTS im_tenant_applications(
id BIGINT NOT NULL AUTO_INCREMENT,
application_id VARCHAR(64) NOT NULL,
credit_code VARCHAR(18) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key)
)`,
`CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key)
)`,
}
joined := strings.ToLower(strings.Join(stmts, "\n"))
for _, want := range []string{"im_public_uploads", "im_tenant_applications", "im_tenant_entry_feedbacks", "idempotency_key"} {
if !strings.Contains(joined, want) {
t.Fatalf("schema statement missing %s", want)
}
}
}
+282 -16
View File
@@ -16,6 +16,14 @@ import (
t "github.com/tinode/chat/server/store/types"
)
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`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
)
func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
@@ -45,11 +53,15 @@ func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSett
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_settings
(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,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
settingsID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_group_settings
(id,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,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE name=VALUES(name),avatar_file_id=VALUES(avatar_file_id),description=VALUES(description),updated_at=VALUES(updated_at)`,
settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus,
settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus,
settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite,
settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
if err != nil {
@@ -386,6 +398,97 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
return inv, nil
}
func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string, uid t.Uid, memberPrivate any, now time.Time) (*t.GroupInvitation, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupInvitationAccept(id, invitationID, uid, memberPrivate, now)
}
if strings.TrimSpace(invitationID) == "" || uid.IsZero() || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var inv t.GroupInvitation
var handledAt sql.NullTime
err = tx.QueryRowxContext(ctx, `SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,
join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at
FROM im_group_invitations WHERE invitation_id=? FOR UPDATE`, strings.TrimSpace(invitationID)).
Scan(&inv.ID, &inv.InvitationID, &inv.Topic, &inv.InviterUserRaw, &inv.TargetUserRaw, &inv.Message, &inv.Status,
&inv.Result, &inv.JoinRequestID, &inv.IdempotencyKey, &inv.ExpiresAt, &handledAt, &inv.CreatedAt, &inv.UpdatedAt)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
inv.InviterUserID = store.EncodeUid(inv.InviterUserRaw)
inv.TargetUserID = store.EncodeUid(inv.TargetUserRaw)
if handledAt.Valid {
inv.HandledAt = &handledAt.Time
}
if inv.TargetUserID != uid {
return nil, t.ErrPermissionDenied
}
if inv.ExpiresAt.Before(now) {
return nil, t.ErrExpired
}
if inv.Status == t.GroupInvitationStatusJoined || inv.Result == "joined" {
return &inv, nil
}
var deletedAt sql.NullTime
err = tx.QueryRowxContext(ctx, "SELECT DeletedAt FROM subscriptions WHERE Topic=? AND User=?", inv.Topic, uid.String()).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err == sql.ErrNoRows || deletedAt.Valid {
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 {
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 {
return nil, err
}
if err = recountGroupMembersTx(tx, inv.Topic); err != nil {
return nil, err
}
if err = updateGroupTopicSubCountTx(tx, inv.Topic); err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations
SET status=?,result=?,handled_at=?,updated_at=? WHERE invitation_id=?`,
t.GroupInvitationStatusJoined, "joined", now, now, inv.InvitationID); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, inv.Topic, uid, "group.member.joined", map[string]any{
"user_id": uid.UserId(), "source": "invitation", "invitation_id": inv.InvitationID,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
inv.Status = t.GroupInvitationStatusJoined
inv.Result = "joined"
inv.HandledAt = &now
inv.UpdatedAt = now
return &inv, nil
}
func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRequest) (*t.GroupJoinRequest, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
@@ -557,7 +660,7 @@ func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t
if cancel != nil {
defer cancel()
}
return a.groupAnnouncementByQuery(ctx, "SELECT * 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+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
}
func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) {
@@ -626,8 +729,12 @@ func (a *adapter) TenantGroupAnnouncementRead(id t.TenantID, topic, announcement
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(announcement_id,topic,user_id,read_at)
VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, announcementID, topic, decodeUID(uid), readAt)
readID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(id,announcement_id,topic,user_id,read_at)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, readID, announcementID, topic, decodeUID(uid), readAt)
return err
}
@@ -652,7 +759,7 @@ func (a *adapter) TenantGroupMuteList(id t.TenantID, topic, cursor string, limit
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_group_mutes WHERE "+strings.Join(where, " AND ")+" ORDER BY user_id LIMIT ?", args...)
rows, err := a.db.QueryxContext(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE "+strings.Join(where, " AND ")+" ORDER BY user_id LIMIT ?", args...)
if err != nil {
return nil, "", err
}
@@ -696,10 +803,14 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
VALUES(?,?,?,?,?,?,?,1)
muteID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
VALUES(?,?,?,?,?,?,?,?,1)
ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`,
mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status)
muteID, mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status)
if err != nil {
return nil, err
}
@@ -711,7 +822,7 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupMuteByQuery(ctx, "SELECT * FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID))
return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID))
}
func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor t.Uid, now time.Time) error {
@@ -745,6 +856,130 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
return tx.Commit()
}
func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cursor string, limit int) ([]t.GroupFile, string, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, "", err
} else if tenantAdp != a {
return tenantAdp.TenantGroupFileList(id, topic, fileType, query, cursor, limit)
}
if strings.TrimSpace(topic) == "" {
return nil, "", t.ErrMalformed
}
where := []string{"topic=?", "status='active'"}
args := []any{strings.TrimSpace(topic)}
if fileType != "" {
where = append(where, "file_type=?")
args = append(args, strings.TrimSpace(fileType))
}
if query != "" {
where = append(where, "file_name LIKE ?")
args = append(args, "%"+strings.TrimSpace(query)+"%")
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,
file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,idempotency_key,created_at,updated_at
FROM im_group_files WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.GroupFile, 0, limit)
next := ""
for rows.Next() {
file, err := scanGroupFile(rows)
if err != nil {
return nil, "", err
}
if len(items) == limit {
next = strconv.FormatInt(file.ID, 10)
continue
}
items = append(items, *file)
}
return items, next, rows.Err()
}
func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.GroupFile, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupFileCreate(id, file)
}
if file == nil || strings.TrimSpace(file.Topic) == "" || strings.TrimSpace(file.FileID) == "" || file.UploaderID.IsZero() {
return nil, t.ErrMalformed
}
if file.GroupFileID == "" {
file.GroupFileID = "gfile_" + store.Store.GetUidString()
}
if file.FileType == "" {
file.FileType = "file"
}
if file.Status == "" {
file.Status = "active"
}
if file.IdempotencyKey == "" {
file.IdempotencyKey = file.GroupFileID
}
now := t.TimeNow()
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_files
(group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
file.GroupFileID, file.Topic, file.FileID, file.SourceTopic, file.SourceSeq, decodeUID(file.UploaderID),
file.FileName, file.MIMEType, file.FileType, file.FileSize, file.Status, file.IdempotencyKey, now, now)
if err != nil && !isDupe(err) {
return nil, err
}
return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID)
}
func (a *adapter) TenantGroupFileDelete(id t.TenantID, topic, groupFileID string, actor t.Uid, canManage bool, now time.Time) (*t.GroupFile, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupFileDelete(id, topic, groupFileID, actor, canManage, now)
}
if strings.TrimSpace(topic) == "" || strings.TrimSpace(groupFileID) == "" || actor.IsZero() || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
query := `UPDATE im_group_files
SET status='deleted',deleted_by=?,deleted_at=?,updated_at=?
WHERE topic=? AND group_file_id=? AND status='active'`
args := []any{decodeUID(actor), now, now, strings.TrimSpace(topic), strings.TrimSpace(groupFileID)}
if !canManage {
query += " AND uploader_id=?"
args = append(args, decodeUID(actor))
}
res, err := a.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrNotFound
}
return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND group_file_id=?", strings.TrimSpace(topic), strings.TrimSpace(groupFileID))
}
func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any,
settings *t.GroupSettings, members []t.Uid, memberPrivate any, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
@@ -1090,9 +1325,13 @@ func (a *adapter) TenantGroupEventMarkDelivered(id t.TenantID, eventID string, n
}
func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error {
_, err := tx.Exec(`INSERT INTO im_group_settings
(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,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name,
settingsID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO im_group_settings
(id,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,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name,
settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll,
settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount,
settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
@@ -1143,7 +1382,7 @@ func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) {
func (a *adapter) groupSettingsByTopic(ctx context.Context, topic string) (*t.GroupSettings, error) {
var item t.GroupSettings
err := a.db.QueryRowxContext(ctx, "SELECT * FROM im_group_settings WHERE topic=?", topic).StructScan(&item)
err := a.db.QueryRowxContext(ctx, "SELECT "+groupSettingsSelectColumns+" FROM im_group_settings WHERE topic=?", topic).StructScan(&item)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
@@ -1206,6 +1445,21 @@ func (a *adapter) groupMuteByQuery(ctx context.Context, query string, args ...an
return &item, nil
}
func (a *adapter) groupFileByQuery(ctx context.Context, query string, args ...any) (*t.GroupFile, error) {
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
if rows.Next() {
return scanGroupFile(rows)
}
if err = rows.Err(); err != nil {
return nil, err
}
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)
return err
@@ -1246,3 +1500,15 @@ func scanGroupMute(item *t.GroupMute) {
item.RevokedBy = store.EncodeUid(*item.RevokedRaw)
}
}
func scanGroupFile(rows *sqlx.Rows) (*t.GroupFile, error) {
var item t.GroupFile
if err := rows.StructScan(&item); err != nil {
return nil, err
}
item.UploaderID = store.EncodeUid(item.UploaderRaw)
if item.DeletedByRaw != nil {
item.DeletedBy = store.EncodeUid(*item.DeletedByRaw)
}
return &item, nil
}
+308 -67
View File
@@ -117,6 +117,103 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) {
}
}
func TestUserIdentitySchema(t *testing.T) {
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='phone'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("im_users.phone is missing")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='username' AND is_nullable='NO'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("im_users.username is missing or nullable")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_auth' AND column_name IN ('uname','scheme')`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("legacy im_auth.uname or im_auth.scheme still exists")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_user_profiles' AND column_name='email'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("im_user_profiles.email is missing")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='tags'`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("legacy im_users.tags still exists")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='im_usertags'`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("legacy im_usertags still exists")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='im_topictags'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("topic tag table must be retained")
}
}
func TestAllIMTablesUseIDPrimaryKey(t *testing.T) {
var tables []string
if err := db.Select(&tables, `SELECT t.table_name
FROM information_schema.tables AS t
WHERE t.table_schema=DATABASE() AND t.table_type='BASE TABLE' AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns AS c
WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name AND c.column_name='id'
)
ORDER BY t.table_name`); err != nil {
t.Fatal(err)
}
if len(tables) != 0 {
t.Fatalf("IM tables without id column: %v", tables)
}
tables = nil
if err := db.Select(&tables, `SELECT s.table_name
FROM information_schema.statistics AS s
WHERE s.table_schema=DATABASE() AND s.index_name='PRIMARY'
GROUP BY s.table_name
HAVING GROUP_CONCAT(s.column_name ORDER BY s.seq_in_index) <> 'id'
ORDER BY s.table_name`); err != nil {
t.Fatal(err)
}
if len(tables) != 0 {
t.Fatalf("IM tables whose primary key is not id: %v", tables)
}
targets := make([]string, 0, 11)
if err := db.Select(&targets, `SELECT table_name
FROM information_schema.columns
WHERE table_schema=DATABASE() AND column_name='id' AND extra LIKE '%auto_increment%'
AND table_name IN ('im_user_security','im_kvmeta','im_user_profiles','im_user_privacy_settings',
'im_device_push_tokens','im_friendships','im_user_blocks','im_youth_mode_settings',
'im_group_settings','im_group_announcement_reads','im_group_mutes')
ORDER BY table_name`); err != nil {
t.Fatal(err)
}
if len(targets) != 0 {
t.Fatalf("snowflake primary keys unexpectedly use AUTO_INCREMENT: %v", targets)
}
}
func TestTenantAuthIsolation(t *testing.T) {
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
if !ok {
@@ -192,12 +289,12 @@ func TestTenantAuthIsolation(t *testing.T) {
userBID := userAID + 1
for index, userID := range []int64{userAID, userBID} {
tenantDB := quoteIdent(dbNames[index])
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,public,trusted,tags) "+
"VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, nil, nil, nil); err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+
"VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)",
"basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)",
userID, auth.LevelAuth, []byte("hash")); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
@@ -206,11 +303,11 @@ func TestTenantAuthIsolation(t *testing.T) {
}
}
uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
uidA, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantA, "alice")
if err != nil || uidA != store.EncodeUid(userAID) {
t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err)
}
uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
uidB, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantB, "alice")
if err != nil || uidB != store.EncodeUid(userBID) {
t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err)
}
@@ -223,12 +320,85 @@ func TestTenantAuthIsolation(t *testing.T) {
t.Fatalf("tenant B credential lookup: uid=%v err=%v", credB, err)
}
if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
if err = tenantAdp.TenantAuthUpdateRecord(tenantA, uidB, "changed",
auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound)
}
}
func TestTenantUserSearchMatchesUsernamePhoneAndPublicAccount(t *testing.T) {
businessAdp, 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)
}
now := types.TimeNow()
viewerID := now.UnixNano() & 0x3fffffffffffffff
targetID := viewerID + 1
viewer := store.EncodeUid(viewerID)
target := store.EncodeUid(targetID)
t.Cleanup(func() {
db.Exec("DELETE FROM im_friendships WHERE user_id IN (?,?) OR peer_id IN (?,?)", viewerID, targetID, viewerID, targetID)
db.Exec("DELETE FROM im_user_privacy_settings WHERE user_id IN (?,?)", viewerID, targetID)
db.Exec("DELETE FROM im_user_profiles WHERE user_id IN (?,?)", viewerID, targetID)
db.Exec("DELETE FROM im_credentials WHERE userid IN (?,?)", viewerID, targetID)
db.Exec("DELETE FROM im_auth WHERE userid IN (?,?)", viewerID, targetID)
db.Exec("DELETE FROM im_users WHERE id IN (?,?)", viewerID, targetID)
})
for _, user := range []struct {
id int64
username string
phone any
public string
}{
{viewerID, "viewerx", nil, `{"fn":"Viewer"}`},
{targetID, "potter", "+8615500000002", `{"fn":"Potter"}`},
} {
if _, err := db.Exec(
`INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted)
VALUES(?,?,?,?,?,?,?,?,?)`,
user.id, user.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, user.phone, user.public, `{}`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(
`INSERT INTO im_user_privacy_settings(id,user_id,add_me_methods,created_at,updated_at)
VALUES(?,?,?,?,?)`,
user.id+1000, user.id, `{"account":true,"phone":true,"qr":true,"group":true}`, now, now); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec(
`INSERT INTO im_user_profiles(id,user_id,nickname,public_account,avatar_file_id,signature,gender,region,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
targetID+1000, targetID, "后端-potter", "15500000002", "avatar-1", "hi", "other", "CN", now, now); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
query string
searchType string
}{
{"username", "potter", "account"},
{"public_account", "15500000002", "account"},
{"phone_raw", "15500000002", "account"},
{"phone_e164", "+8615500000002", "phone"},
} {
users, _, err := businessAdp.TenantUserSearch(tenantID, viewer, tc.query, tc.searchType, "", 20)
if err != nil {
t.Fatalf("%s search failed: %v", tc.name, err)
}
if len(users) != 1 || users[0].Uid() != target {
t.Fatalf("%s search returned %+v, want %s", tc.name, users, target.UserId())
}
}
}
// ================== Create tests ================================
func TestUserCreate(t *testing.T) {
for _, user := range testData.Users {
@@ -299,7 +469,7 @@ func TestCredUpsert(t *testing.T) {
func TestAuthAddRecord(t *testing.T) {
for _, rec := range testData.Recs {
err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"),
rec.AuthLvl, rec.Secret, rec.Expires)
if err != nil {
t.Fatal(err)
@@ -307,12 +477,123 @@ func TestAuthAddRecord(t *testing.T) {
}
//Test duplicate
err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
strings.TrimPrefix(testData.Recs[0].Unique, "basic:"), testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
if err != types.ErrDuplicate {
t.Fatal("Should be duplicate error but got", err)
}
}
func TestRegistrationInviteLifecycle(t *testing.T) {
inviteAdapter, ok := adp.(adapter.TenantRegistrationInviteAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantRegistrationInviteAdapter")
}
var tenantID types.TenantID
if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil {
t.Fatal(err)
}
now := types.TimeNow()
invite := &types.RegistrationInviteCode{
Code: "AbCdE",
ExpiresAt: now.Add(time.Hour),
CreatedBy: 10001,
Remark: "integration test",
}
if err := inviteAdapter.TenantRegistrationInviteCreate(tenantID, invite); err != nil {
t.Fatal(err)
}
stored, err := inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE")
if err != nil || stored.Code != "AbCdE" || stored.Status != types.RegistrationInviteActive {
t.Fatalf("stored invite=%#v err=%v", stored, err)
}
if _, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "abcde"); err != types.ErrInviteInvalid {
t.Fatalf("case-insensitive invite lookup was accepted: %v", err)
}
firstUID := types.ParseUserId("usr" + testData.Users[0].Id)
if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", firstUID, "req-invite-test", now); err != nil {
t.Fatal(err)
}
secondUID := types.ParseUserId("usr" + testData.Users[1].Id)
if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", secondUID, "req-invite-second-user", now); err != nil {
t.Fatalf("second user could not reuse invite: %v", err)
}
if err = inviteAdapter.TenantRegistrationInviteSyncUsage(tenantID, invite.ID); err != nil {
t.Fatalf("invite usage projection failed: %v", err)
}
stored, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE")
if err != nil || stored.Status != types.RegistrationInviteActive || stored.UsageCount != 2 {
t.Fatalf("reusable invite=%#v err=%v", stored, err)
}
}
func TestRegistrationInviteReusableSchema(t *testing.T) {
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_registration_invite_codes' AND column_name IN ('used_by','used_at')`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("single-use columns still exist: %d", count)
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='im_registration_invite_uses'
AND index_name='idx_im_registration_invite_use_code' AND non_unique=1`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("reusable invite use index count: got %d, want 1", count)
}
}
func TestVerificationTargetUserRoundTrip(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)
}
uid := types.ParseUserId("usr" + testData.Users[0].Id)
now := types.TimeNow()
challengeID := "challenge-roundtrip-target-user"
phoneHash := "verification-target-user-phone-hash"
_, _ = db.Exec("DELETE FROM im_verification_codes WHERE challenge_id=? OR phone_hash=?", challengeID, phoneHash)
vc := &types.VerificationCode{
TenantID: tenantID,
ChallengeID: challengeID,
Scene: "reset_password",
PhoneHash: phoneHash,
PhoneMask: "+86******0000",
TargetUser: uid,
CodeHash: strings.Repeat("a", 64),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: now.Add(time.Hour),
ResendAfter: now.Add(time.Minute),
}
if err := tenantAdapter.TenantVerificationCreate(tenantID, vc); err != nil {
t.Fatal(err)
}
stored, err := tenantAdapter.TenantVerificationGet(tenantID, challengeID)
if err != nil {
t.Fatal(err)
}
if stored.TargetUser != uid {
t.Fatalf("Get target user: got %s, want %s", stored.TargetUser.UserId(), uid.UserId())
}
active, err := tenantAdapter.TenantVerificationGetActive(tenantID, "reset_password", phoneHash, now)
if err != nil {
t.Fatal(err)
}
if active.TargetUser != uid {
t.Fatalf("GetActive target user: got %s, want %s", active.TargetUser.UserId(), uid.UserId())
}
}
func TestTopicCreate(t *testing.T) {
err := adp.TopicCreate(testData.Topics[0])
if err != nil {
@@ -527,7 +808,7 @@ func TestCredGetAll(t *testing.T) {
}
func TestAuthGetUniqueRecord(t *testing.T) {
uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("alice")
if err != nil {
t.Fatal(err)
}
@@ -553,13 +834,14 @@ func TestAuthGetRecord(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if recId != testData.Recs[0].Unique ||
wantLogin := strings.TrimPrefix(testData.Recs[0].Unique, "basic:")
if recId != wantLogin ||
authLvl != testData.Recs[0].AuthLvl ||
!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
expires != testData.Recs[0].Expires {
got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
want := fmt.Sprintf("%v %v %v %v", wantLogin, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
t.Error(mismatchErrorString("Auth record", got, want))
}
@@ -740,8 +1022,8 @@ func TestFind(t *testing.T) {
if err != nil {
t.Error(err)
}
if len(got) != 3 {
t.Error(mismatchErrorString("result length", len(got), 3))
if len(got) != 1 {
t.Error(mismatchErrorString("topic-only result length", len(got), 1))
}
}
@@ -811,54 +1093,13 @@ func TestUserUpdate(t *testing.T) {
}
func TestUserUpdateTags(t *testing.T) {
addTags := testData.Tags[0]
removeTags := testData.Tags[1]
resetTags := testData.Tags[2]
uid := types.ParseUserId("usr" + testData.Users[0].Id)
got, err := adp.UserUpdateTags(uid, addTags, nil, nil)
got, err := adp.UserUpdateTags(uid, []string{"legacy"}, nil, nil)
if err != nil {
t.Fatal(err)
}
want := []string{"alice", "tag1"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, nil, removeTags, nil)
if err != nil {
t.Fatal(err)
}
want = nil
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, nil, nil, resetTags)
if err != nil {
t.Fatal(err)
}
want = []string{"alice", "tag111", "tag333"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
if err != nil {
t.Fatal(err)
}
want = []string{"tag111", "tag333"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
if err != nil {
t.Fatal(err)
}
want = []string{"tag111", "tag333"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
if got != nil {
t.Fatalf("user tags must stay disabled, got %v", got)
}
}
@@ -915,13 +1156,13 @@ func TestCredConfirm(t *testing.T) {
func TestAuthUpdRecord(t *testing.T) {
rec := testData.Recs[1]
newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"),
rec.AuthLvl, newSecret, rec.Expires)
if err != nil {
t.Fatal(err)
}
var got []byte
err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got)
err = db.QueryRow("SELECT secret FROM im_auth WHERE userid=?", decodeUid(rec.UserId)).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -930,7 +1171,7 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test with auth ID (unique) change
newId := "basic:bob12345"
newId := "bob12345"
err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
rec.AuthLvl, newSecret, rec.Expires)
if err != nil {
@@ -938,7 +1179,7 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=? AND username=?", decodeUid(rec.UserId), strings.TrimPrefix(rec.Unique, "basic:")).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1505,6 +1746,11 @@ func init() {
if adp.IsOpen() {
log.Print("Connection is already opened")
}
testData = test_data.InitTestData()
if testData == nil {
log.Fatal("Failed to initialize test data")
}
store.SetTestUidGenerator(*testData.UGen)
err := adp.Open(config.Adapters[adp.GetName()])
if err != nil {
@@ -1512,9 +1758,4 @@ func init() {
}
db = adp.GetTestDB().(*sqlx.DB)
testData = test_data.InitTestData()
if testData == nil {
log.Fatal("Failed to initialize test data")
}
store.SetTestUidGenerator(*testData.UGen)
}
+5 -3
View File
@@ -1,10 +1,12 @@
{
"reset_db_data": true,
"adapters": {
"mysql": {
"dsn": "root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
"mysql": {
"dsn": "root:root@tcp(127.0.0.1:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
// Name of the main database.
"database": "tinode_test"
"database": "tinode_test",
"tenant_registry_db": "tinode_test",
"tenant_db_routing": false
}
}
}
+70
View File
@@ -0,0 +1,70 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"strings"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) {
db := a.registryDB
if db == nil {
db = a.db
}
if db == nil {
return nil, t.ErrInternal
}
group = strings.TrimSpace(group)
if group == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows := []struct {
Key string `db:"key"`
Value string `db:"value"`
}{}
var err error
if len(keys) == 0 {
err = db.SelectContext(ctx, &rows, `SELECT `+"`key`"+`, value FROM hg_sys_config
WHERE `+"`group`"+`=? AND status=1`, group)
} else {
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if key = strings.TrimSpace(key); key != "" {
normalized = append(normalized, key)
}
}
if len(normalized) == 0 {
return map[string]string{}, nil
}
query, args, inErr := sqlx.In(`SELECT `+"`key`"+`, value FROM hg_sys_config
WHERE `+"`group`"+`=? AND status=1 AND `+"`key`"+` IN (?)`, group, normalized)
if inErr != nil {
return nil, inErr
}
query = db.Rebind(query)
err = db.SelectContext(ctx, &rows, query, args...)
}
if err != nil {
if err == sql.ErrNoRows {
return map[string]string{}, nil
}
return nil, err
}
result := make(map[string]string, len(rows))
for _, row := range rows {
result[row.Key] = row.Value
}
return result, nil
}
+40
View File
@@ -0,0 +1,40 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"github.com/nyaruka/phonenumbers"
t "github.com/tinode/chat/server/store/types"
)
func normalizeUserPhone(value string) (string, error) {
value = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(value, "tel:"), "basic:"))
if value == "" {
return "", t.ErrMalformed
}
number, err := phonenumbers.Parse(value, "CN")
if err != nil || !phonenumbers.IsValidNumber(number) {
return "", t.ErrMalformed
}
numberType := phonenumbers.GetNumberType(number)
if numberType != phonenumbers.MOBILE && numberType != phonenumbers.FIXED_LINE_OR_MOBILE {
return "", t.ErrMalformed
}
return phonenumbers.Format(number, phonenumbers.E164), nil
}
func withoutUserTags(update map[string]any) map[string]any {
if update == nil {
return nil
}
clean := make(map[string]any, len(update))
for key, value := range update {
if key != "Tags" && key != "tags" {
clean[key] = value
}
}
return clean
}
+514
View File
@@ -0,0 +1,514 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
)
const userIdentityMigrationLock = "CONCAT('im_user_identity:', LEFT(SHA2(DATABASE(), 256), 40))"
type identityValue struct {
TenantID int64 `db:"tenant_id"`
UserID int64 `db:"user_id"`
Value string `db:"value"`
}
// ensureUserIdentitySchema moves user phone/email out of the legacy tag store.
// Topic tags are intentionally unaffected.
func ensureUserIdentitySchema(db *sqlx.DB) error {
ctx := context.Background()
conn, err := db.Connx(ctx)
if err != nil {
return err
}
defer conn.Close()
var locked sql.NullInt64
if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK("+userIdentityMigrationLock+", 60)").Scan(&locked); err != nil {
return err
}
if !locked.Valid || locked.Int64 != 1 {
return errors.New("mysql: timed out waiting for user identity migration lock")
}
defer conn.ExecContext(ctx, "SELECT RELEASE_LOCK("+userIdentityMigrationLock+")")
usersExist, err := identityTableExists(ctx, conn, "im_users")
if err != nil || !usersExist {
return err
}
usersScoped, err := identityColumnExists(ctx, conn, "im_users", "tenant_id")
if err != nil {
return err
}
profilesExist, err := identityTableExists(ctx, conn, "im_user_profiles")
if err != nil {
return err
}
if !profilesExist {
profileScope := ""
profileUniqueKey := "user_id"
if usersScoped {
profileScope = "tenant_id BIGINT NOT NULL,"
profileUniqueKey = "tenant_id,user_id"
}
if _, err = conn.ExecContext(ctx, `CREATE TABLE im_user_profiles(
id BIGINT NOT NULL,`+profileScope+`user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',nickname VARCHAR(64) NOT NULL DEFAULT '',
birth_date DATE,signature VARCHAR(256) NOT NULL DEFAULT '',gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),UNIQUE INDEX uk_im_user_profiles_user(`+profileUniqueKey+`))`); err != nil {
return err
}
profilesExist = true
}
if exists, err := identityColumnExists(ctx, conn, "im_users", "phone"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent"); err != nil {
return err
}
}
if exists, err := identityColumnExists(ctx, conn, "im_users", "username"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN username VARCHAR(32) NULL AFTER id"); err != nil {
return err
}
}
if profilesExist {
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region"); err != nil {
return err
}
}
}
if err = backfillUserPhones(ctx, conn, usersScoped); err != nil {
return err
}
if err = backfillUsernames(ctx, conn, usersScoped); err != nil {
return err
}
if profilesExist {
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
return err
}
}
indexName := "users_phone"
if usersScoped {
indexName = "uk_im_users_tenant_phone"
}
if exists, err := identityIndexExists(ctx, conn, "im_users", indexName); err != nil {
return err
} else if !exists {
columns := "phone"
if usersScoped {
columns = "tenant_id,phone"
}
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_users("+columns+")"); err != nil {
return fmt.Errorf("create unique phone index: %w", err)
}
}
usernameIndex := "users_username"
usernameColumns := "username"
if usersScoped {
usernameIndex = "uk_im_users_tenant_username"
usernameColumns = "tenant_id,username"
}
if exists, err := identityIndexExists(ctx, conn, "im_users", usernameIndex); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(usernameIndex)+" ON im_users("+usernameColumns+")"); err != nil {
return fmt.Errorf("create unique username index: %w", err)
}
}
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil {
return err
}
if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil {
return err
}
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "DROP TABLE im_usertags"); err != nil {
return err
}
}
if exists, err := identityColumnExists(ctx, conn, "im_users", "tags"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP COLUMN tags"); err != nil {
return err
}
}
return nil
}
func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
prefix := "0 AS tenant_id,u.id AS user_id,"
join := "a.userid=u.id"
orderBy := "u.id"
if scoped {
prefix = "u.tenant_id AS tenant_id,u.id AS user_id,"
join = "a.tenant_id=u.tenant_id AND a.userid=u.id"
orderBy = "u.tenant_id,u.id"
}
authExists, err := identityTableExists(ctx, conn, "im_auth")
if err != nil {
return err
}
selectAuth := "NULL"
authJoin := ""
if authExists {
hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname")
if err != nil {
return err
}
hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme")
if err != nil {
return err
}
if hasUname {
selectAuth = "CASE WHEN a.uname LIKE 'basic:%' THEN SUBSTRING(a.uname,7) ELSE a.uname END"
authJoin = " LEFT JOIN im_auth AS a ON " + join
if hasScheme {
authJoin += " AND a.scheme='basic'"
}
}
}
var rows []identityValue
query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " ORDER BY " + orderBy
if err = conn.SelectContext(ctx, &rows, query); err != nil {
return err
}
seen := make(map[string]bool, len(rows))
for _, row := range rows {
value := strings.TrimSpace(row.Value)
if strings.HasPrefix(value, "+") || (len(value) == 11 && asciiDigits(value)) {
if canonical, normalizeErr := normalizeUserPhone(value); normalizeErr == nil {
value = canonical
}
}
if value == "" || len(value) > 32 || seen[identityUniqueKey(row.TenantID, value, scoped)] {
value = fmt.Sprintf("reserved_%d", row.UserID)
}
seen[identityUniqueKey(row.TenantID, value, scoped)] = true
update := "UPDATE im_users SET username=? WHERE id=?"
args := []any{value, row.UserID}
if scoped {
update = "UPDATE im_users SET username=? WHERE tenant_id=? AND id=?"
args = []any{value, row.TenantID, row.UserID}
}
if _, err = conn.ExecContext(ctx, update, args...); err != nil {
return err
}
}
return nil
}
func migrateBasicAuthSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
exists, err := identityTableExists(ctx, conn, "im_auth")
if err != nil || !exists {
return err
}
if hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme"); err != nil {
return err
} else if hasScheme {
var unsupported int
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM im_auth WHERE scheme<>'basic'").Scan(&unsupported); err != nil {
return err
}
if unsupported > 0 {
return fmt.Errorf("mysql: im_auth contains %d non-basic records", unsupported)
}
}
legacyComposite, err := identityIndexExists(ctx, conn, "im_auth", "auth_userid_scheme")
if err != nil {
return err
}
if legacyComposite {
// The legacy composite index may currently support the userid foreign key.
// Give the FK a replacement before dropping that index.
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE INDEX auth_userid_fk ON im_auth(userid)"); err != nil {
return err
}
}
}
for _, index := range []string{"auth_uname", "auth_userid_scheme"} {
if exists, err = identityIndexExists(ctx, conn, "im_auth", index); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX "+quoteIdent(index)); err != nil {
return err
}
}
}
for _, column := range []string{"uname", "scheme"} {
if exists, err = identityColumnExists(ctx, conn, "im_auth", column); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP COLUMN "+quoteIdent(column)); err != nil {
return err
}
}
}
indexName := "auth_userid"
columns := "userid"
if scoped {
indexName = "auth_tenant_userid"
columns = "tenant_id,userid"
}
if exists, err = identityIndexExists(ctx, conn, "im_auth", indexName); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_auth("+columns+")"); err != nil {
return err
}
}
if !scoped {
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid_fk"); err != nil {
return err
}
}
}
return nil
}
func asciiDigits(value string) bool {
if value == "" {
return false
}
for i := range value {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
candidates, err := identityCandidates(ctx, conn, scoped, "tel")
if err != nil {
return err
}
seen := make(map[string]bool)
var existing []identityValue
prefix := "0 AS tenant_id,"
if scoped {
prefix = "tenant_id,"
}
orderBy := "id"
if scoped {
orderBy = "tenant_id,id"
}
if err = conn.SelectContext(ctx, &existing, "SELECT "+prefix+"id AS user_id,phone AS value FROM im_users WHERE phone IS NOT NULL AND phone<>'' ORDER BY "+orderBy); err != nil {
return err
}
for _, item := range existing {
canonical, err := normalizeUserPhone(item.Value)
if err != nil {
return fmt.Errorf("normalize existing phone for user %d: %w", item.UserID, err)
}
if canonical != item.Value {
query := "UPDATE im_users SET phone=? WHERE id=?"
args := []any{canonical, item.UserID}
if scoped {
query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=?"
args = []any{canonical, item.TenantID, item.UserID}
}
if _, err = conn.ExecContext(ctx, query, args...); err != nil {
return err
}
}
seen[identityUniqueKey(item.TenantID, canonical, scoped)] = true
}
updatedUsers := make(map[string]bool)
for _, item := range candidates {
item.Value, err = normalizeUserPhone(item.Value)
if err != nil {
return fmt.Errorf("normalize legacy phone for user %d: %w", item.UserID, err)
}
userKey := identityUserKey(item.TenantID, item.UserID, scoped)
valueKey := identityUniqueKey(item.TenantID, item.Value, scoped)
if item.Value == "" || updatedUsers[userKey] || seen[valueKey] {
continue
}
query := "UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')"
args := []any{item.Value, item.UserID}
if scoped {
query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=? AND (phone IS NULL OR phone='')"
args = []any{item.Value, item.TenantID, item.UserID}
}
result, err := conn.ExecContext(ctx, query, args...)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected > 0 {
updatedUsers[userKey] = true
seen[valueKey] = true
}
}
return nil
}
func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error {
candidates, err := identityCandidates(ctx, conn, usersScoped, "email")
if err != nil {
return err
}
profilesScoped, err := identityColumnExists(ctx, conn, "im_user_profiles", "tenant_id")
if err != nil {
return err
}
if profilesScoped != usersScoped {
return errors.New("mysql: im_users and im_user_profiles tenant scope mismatch")
}
processed := make(map[string]bool)
for _, item := range candidates {
item.Value = strings.TrimSpace(strings.TrimPrefix(item.Value, "email:"))
key := identityUserKey(item.TenantID, item.UserID, usersScoped)
if item.Value == "" || processed[key] {
continue
}
processed[key] = true
id, err := nextSnowflakeID()
if err != nil {
return err
}
account := store.EncodeUid(item.UserID).UserId()
if usersScoped {
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,tenant_id,user_id,email,public_account)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`,
id, item.TenantID, item.UserID, item.Value, account)
} else {
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,user_id,email,public_account)
VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`,
id, item.UserID, item.Value, account)
}
if err != nil {
return err
}
}
return nil
}
func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
var out []identityValue
appendQuery := func(query string, args ...any) error {
var rows []identityValue
if err := conn.SelectContext(ctx, &rows, query, args...); err != nil {
return err
}
out = append(out, rows...)
return nil
}
prefix := "0 AS tenant_id,u.id AS user_id,"
credentialJoin := "c.userid=u.id"
tagJoin := "ut.userid=u.id"
if scoped {
prefix = "u.tenant_id AS tenant_id,u.id AS user_id,"
credentialJoin = "c.tenant_id=u.tenant_id AND c.userid=u.id"
tagJoin = "ut.tenant_id=u.tenant_id AND ut.userid=u.id"
}
if exists, err := identityTableExists(ctx, conn, "im_credentials"); err != nil {
return nil, err
} else if exists {
if err = appendQuery("SELECT "+prefix+"c.value AS value FROM im_users AS u JOIN im_credentials AS c ON "+credentialJoin+
" WHERE c.method=? AND c.done=1 AND c.deletedat IS NULL ORDER BY u.id,c.updatedat DESC", method); err != nil {
return nil, err
}
}
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
return nil, err
} else if exists {
if err = appendQuery("SELECT "+prefix+"ut.tag AS value FROM im_users AS u JOIN im_usertags AS ut ON "+tagJoin+
" WHERE ut.tag LIKE ? ORDER BY u.id,ut.id", method+":%"); err != nil {
return nil, err
}
}
if method == "tel" {
if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil {
return nil, err
} else if exists {
hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname")
if err != nil {
return nil, err
}
hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme")
if err != nil {
return nil, err
}
if !hasUname || !hasScheme {
return out, nil
}
authJoin := "a.userid=u.id"
if scoped {
authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id"
}
if err = appendQuery("SELECT " + prefix + "a.uname AS value FROM im_users AS u JOIN im_auth AS a ON " + authJoin +
" WHERE a.scheme='basic' AND (a.uname LIKE '+%' OR a.uname LIKE 'basic:+%') ORDER BY u.id"); err != nil {
return nil, err
}
}
}
return out, nil
}
func identityTableExists(ctx context.Context, conn *sqlx.Conn, table string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name=?)`, table).Scan(&exists)
return exists == 1, err
}
func identityColumnExists(ctx context.Context, conn *sqlx.Conn, table, column string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name=? AND column_name=?)`, table, column).Scan(&exists)
return exists == 1, err
}
func identityIndexExists(ctx context.Context, conn *sqlx.Conn, table, index string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`, table, index).Scan(&exists)
return exists == 1, err
}
func identityUserKey(tenantID, userID int64, scoped bool) string {
if scoped {
return fmt.Sprintf("%d:%d", tenantID, userID)
}
return fmt.Sprintf("%d", userID)
}
func identityUniqueKey(tenantID int64, value string, scoped bool) string {
if scoped {
return fmt.Sprintf("%d:%s", tenantID, value)
}
return value
}
+25
View File
@@ -0,0 +1,25 @@
//go:build mysql
// +build mysql
package mysql
import "testing"
func TestNormalizeUserPhone(t *testing.T) {
for input, want := range map[string]string{
"13800138000": "+8613800138000",
"+8613800138000": "+8613800138000",
"tel:+17025550001": "+17025550001",
} {
got, err := normalizeUserPhone(input)
if err != nil {
t.Fatalf("normalize %q: %v", input, err)
}
if got != want {
t.Fatalf("normalize %q: got %q, want %q", input, got, want)
}
}
if _, err := normalizeUserPhone("12345"); err == nil {
t.Fatal("invalid phone unexpectedly accepted")
}
}
+243
View File
@@ -0,0 +1,243 @@
// Package aliyun wraps Alibaba Cloud Green file moderation APIs.
package aliyun
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
green "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/tinode/chat/server/store/types"
)
const defaultDocumentService = "document_detection"
type GreenConfig struct {
AccessKeyID string
AccessKeySecret string
RegionID string
Endpoint string
DocumentService string
}
type OSSObject struct {
BucketName string
ObjectName string
RegionID string
DataID string
}
type SubmitResult struct {
ProviderTaskID string
RequestID string
}
type PollResult struct {
Status string
RiskLevel string
RiskLabels []string
Raw map[string]any
RequestID string
}
type GreenClient struct {
service string
client *green.Client
}
func NewGreenClient(conf GreenConfig) (*GreenClient, error) {
if strings.TrimSpace(conf.AccessKeyID) == "" {
return nil, errors.New("missing Access Key ID")
}
if strings.TrimSpace(conf.AccessKeySecret) == "" {
return nil, errors.New("missing Access Key Secret")
}
if strings.TrimSpace(conf.RegionID) == "" {
return nil, errors.New("missing Region ID")
}
service := strings.TrimSpace(conf.DocumentService)
if service == "" {
service = defaultDocumentService
}
clientConf := &openapi.Config{
AccessKeyId: dara.String(conf.AccessKeyID),
AccessKeySecret: dara.String(conf.AccessKeySecret),
RegionId: dara.String(conf.RegionID),
Protocol: dara.String("https"),
}
if endpoint := strings.TrimSpace(conf.Endpoint); endpoint != "" {
clientConf.Endpoint = dara.String(endpoint)
}
client, err := green.NewClient(clientConf)
if err != nil {
return nil, err
}
return &GreenClient{service: service, client: client}, nil
}
func (c *GreenClient) SubmitOSSFile(ctx context.Context, object OSSObject) (*SubmitResult, error) {
params, err := buildOSSServiceParameters(object)
if err != nil {
return nil, err
}
resp, err := c.client.FileModerationWithContext(ctx, &green.FileModerationRequest{
Service: dara.String(c.service),
ServiceParameters: dara.String(params),
}, &dara.RuntimeOptions{})
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil || resp.Body.Code == nil || *resp.Body.Code != 200 || resp.Body.Data == nil || resp.Body.Data.TaskId == nil {
return nil, responseError("file moderation submit failed", responseCode(resp), responseMessage(resp))
}
return &SubmitResult{ProviderTaskID: *resp.Body.Data.TaskId, RequestID: responseRequestID(resp)}, nil
}
func (c *GreenClient) PollOSSFile(ctx context.Context, providerTaskID string) (*PollResult, error) {
params, err := json.Marshal(map[string]string{"taskId": strings.TrimSpace(providerTaskID)})
if err != nil {
return nil, err
}
resp, err := c.client.DescribeFileModerationResultWithContext(ctx, &green.DescribeFileModerationResultRequest{
Service: dara.String(c.service),
ServiceParameters: dara.String(string(params)),
}, &dara.RuntimeOptions{})
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil || resp.Body.Code == nil || *resp.Body.Code != 200 {
return nil, responseError("file moderation poll failed", describeResponseCode(resp), describeResponseMessage(resp))
}
raw := map[string]any{}
if data := resp.Body.Data; data != nil {
encoded, _ := json.Marshal(data)
json.Unmarshal(encoded, &raw)
riskLevel := strings.TrimSpace(dara.StringValue(data.RiskLevel))
return &PollResult{
Status: statusFromRiskLevel(riskLevel),
RiskLevel: riskLevel,
RiskLabels: labelsFromFileResult(data),
Raw: raw,
RequestID: describeResponseRequestID(resp),
}, nil
}
return &PollResult{Status: types.FileReviewStatusRunning, Raw: raw, RequestID: describeResponseRequestID(resp)}, nil
}
func buildOSSServiceParameters(object OSSObject) (string, error) {
if strings.TrimSpace(object.BucketName) == "" || strings.TrimSpace(object.ObjectName) == "" || strings.TrimSpace(object.RegionID) == "" {
return "", types.ErrMalformed
}
params := map[string]string{
"ossBucketName": strings.TrimSpace(object.BucketName),
"ossObjectName": strings.TrimSpace(object.ObjectName),
"ossRegionId": strings.TrimSpace(object.RegionID),
}
if dataID := strings.TrimSpace(object.DataID); dataID != "" {
params["dataId"] = dataID
}
encoded, err := json.Marshal(params)
return string(encoded), err
}
func statusFromRiskLevel(riskLevel string) string {
switch strings.ToLower(strings.TrimSpace(riskLevel)) {
case "":
return types.FileReviewStatusRunning
case "none", "low":
return types.FileReviewStatusClean
default:
return types.FileReviewStatusRisky
}
}
func labelsFromFileResult(data *green.DescribeFileModerationResultResponseBodyData) []string {
seen := map[string]bool{}
var labels []string
for _, page := range data.PageResult {
if page == nil {
continue
}
for _, image := range page.ImageResult {
if image == nil {
continue
}
for _, result := range image.LabelResult {
if result == nil {
continue
}
if label := strings.TrimSpace(dara.StringValue(result.Label)); label != "" && !seen[label] {
seen[label] = true
labels = append(labels, label)
}
}
}
for _, text := range page.TextResult {
if text == nil {
continue
}
for _, label := range strings.Split(dara.StringValue(text.Labels), ",") {
label = strings.TrimSpace(label)
if label != "" && !seen[label] {
seen[label] = true
labels = append(labels, label)
}
}
}
}
return labels
}
func responseError(prefix string, code int32, msg string) error {
if msg == "" {
return errors.New(prefix)
}
return errors.New(prefix + ": " + msg + " (" + strconv.FormatInt(int64(code), 10) + ")")
}
func responseCode(resp *green.FileModerationResponse) int32 {
if resp != nil && resp.Body != nil && resp.Body.Code != nil {
return *resp.Body.Code
}
return 0
}
func responseMessage(resp *green.FileModerationResponse) string {
if resp != nil && resp.Body != nil && resp.Body.Message != nil {
return *resp.Body.Message
}
return ""
}
func responseRequestID(resp *green.FileModerationResponse) string {
if resp != nil && resp.Body != nil && resp.Body.RequestId != nil {
return *resp.Body.RequestId
}
return ""
}
func describeResponseCode(resp *green.DescribeFileModerationResultResponse) int32 {
if resp != nil && resp.Body != nil && resp.Body.Code != nil {
return *resp.Body.Code
}
return 0
}
func describeResponseMessage(resp *green.DescribeFileModerationResultResponse) string {
if resp != nil && resp.Body != nil && resp.Body.Message != nil {
return *resp.Body.Message
}
return ""
}
func describeResponseRequestID(resp *green.DescribeFileModerationResultResponse) string {
if resp != nil && resp.Body != nil && resp.Body.RequestId != nil {
return *resp.Body.RequestId
}
return ""
}
+70
View File
@@ -0,0 +1,70 @@
package aliyun
import (
"encoding/json"
"testing"
green "github.com/alibabacloud-go/green-20220302/v3/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/tinode/chat/server/store/types"
)
func TestBuildOSSServiceParameters(t *testing.T) {
got, err := buildOSSServiceParameters(OSSObject{
BucketName: "im-oss-t1",
ObjectName: "tenants/1/2026/08/file",
RegionID: "cn-shenzhen",
DataID: "task-1",
})
if err != nil {
t.Fatal(err)
}
var params map[string]string
if err := json.Unmarshal([]byte(got), &params); err != nil {
t.Fatal(err)
}
for key, want := range map[string]string{
"ossBucketName": "im-oss-t1",
"ossObjectName": "tenants/1/2026/08/file",
"ossRegionId": "cn-shenzhen",
"dataId": "task-1",
} {
if params[key] != want {
t.Fatalf("params[%s] = %q, want %q", key, params[key], want)
}
}
}
func TestStatusFromRiskLevel(t *testing.T) {
cases := map[string]string{
"": types.FileReviewStatusRunning,
"none": types.FileReviewStatusClean,
"low": types.FileReviewStatusClean,
"medium": types.FileReviewStatusRisky,
"high": types.FileReviewStatusRisky,
}
for input, want := range cases {
if got := statusFromRiskLevel(input); got != want {
t.Fatalf("statusFromRiskLevel(%q) = %q, want %q", input, got, want)
}
}
}
func TestLabelsFromFileResult(t *testing.T) {
got := labelsFromFileResult(&green.DescribeFileModerationResultResponseBodyData{
PageResult: []*green.DescribeFileModerationResultResponseBodyDataPageResult{
{ImageResult: []*green.DescribeFileModerationResultResponseBodyDataPageResultImageResult{
{LabelResult: []*green.DescribeFileModerationResultResponseBodyDataPageResultImageResultLabelResult{
{Label: dara.String("porn")},
{Label: dara.String("porn")},
}},
}},
{TextResult: []*green.DescribeFileModerationResultResponseBodyDataPageResultTextResult{
{Labels: dara.String("violence,ad")},
}},
},
})
if len(got) != 3 || got[0] != "porn" || got[1] != "violence" || got[2] != "ad" {
t.Fatalf("labelsFromFileResult = %#v", got)
}
}
+104
View File
@@ -0,0 +1,104 @@
package filesafety
import (
"errors"
"strconv"
"strings"
"github.com/tinode/chat/server/filesafety/aliyun"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const thirdPartyConfigGroup = "im_third_party"
type ContentReviewConfig struct {
Enabled bool
Provider string
Green aliyun.GreenConfig
}
type MalwareReviewConfig struct {
Enabled bool
Provider string
RegionID string
}
type Config struct {
Content ContentReviewConfig
Malware MalwareReviewConfig
}
func LoadConfig() (*Config, error) {
values, err := store.ThirdPartyConfig.Get(thirdPartyConfigGroup,
"file_review_content_enabled",
"file_review_content_provider",
"aliyun_green_region_id",
"aliyun_green_endpoint",
"aliyun_green_document_service",
"file_review_malware_enabled",
"file_review_malware_provider",
"aliyun_sas_region_id",
"aliyun_access_key_id",
"aliyun_access_key_secret",
)
if err != nil {
return nil, err
}
config := &Config{
Content: ContentReviewConfig{
Enabled: boolValue(values["file_review_content_enabled"]),
Provider: stringDefault(values["file_review_content_provider"], types.FileReviewProviderAliyunGreen),
Green: aliyun.GreenConfig{
AccessKeyID: strings.TrimSpace(values["aliyun_access_key_id"]),
AccessKeySecret: strings.TrimSpace(values["aliyun_access_key_secret"]),
RegionID: strings.TrimSpace(values["aliyun_green_region_id"]),
Endpoint: strings.TrimSpace(values["aliyun_green_endpoint"]),
DocumentService: strings.TrimSpace(values["aliyun_green_document_service"]),
},
},
Malware: MalwareReviewConfig{
Enabled: boolValue(values["file_review_malware_enabled"]),
Provider: stringDefault(values["file_review_malware_provider"], types.FileReviewProviderAliyunSAS),
RegionID: strings.TrimSpace(values["aliyun_sas_region_id"]),
},
}
if config.Content.Enabled && config.Content.Provider != types.FileReviewProviderAliyunGreen {
return nil, errors.New("unsupported file content review provider: " + config.Content.Provider)
}
if config.Malware.Enabled && config.Malware.Provider != types.FileReviewProviderAliyunSAS {
return nil, errors.New("unsupported malware review provider: " + config.Malware.Provider)
}
return config, nil
}
func NewGreenClientFromConfig() (*aliyun.GreenClient, error) {
config, err := LoadConfig()
if err != nil {
return nil, err
}
if !config.Content.Enabled {
return nil, types.ErrUnsupported
}
return aliyun.NewGreenClient(config.Content.Green)
}
func boolValue(value string) bool {
if parsed, err := strconv.ParseBool(strings.TrimSpace(value)); err == nil {
return parsed
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "yes", "on":
return true
default:
return false
}
}
func stringDefault(value, fallback string) string {
if value = strings.TrimSpace(value); value != "" {
return value
}
return fallback
}
+51
View File
@@ -0,0 +1,51 @@
package filesafety
import (
"testing"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type fakeThirdPartyConfig map[string]string
func (f fakeThirdPartyConfig) Get(group string, keys ...string) (map[string]string, error) {
result := map[string]string{}
for _, key := range keys {
if value, ok := f[key]; ok {
result[key] = value
}
}
return result, nil
}
func TestLoadConfig(t *testing.T) {
original := store.ThirdPartyConfig
store.ThirdPartyConfig = fakeThirdPartyConfig{
"file_review_content_enabled": "1",
"file_review_content_provider": types.FileReviewProviderAliyunGreen,
"aliyun_green_region_id": "cn-shanghai",
"aliyun_green_endpoint": "green-cip.cn-shanghai.aliyuncs.com",
"aliyun_green_document_service": "document_detection",
"file_review_malware_enabled": "true",
"file_review_malware_provider": types.FileReviewProviderAliyunSAS,
"aliyun_sas_region_id": "cn-shanghai",
"aliyun_access_key_id": "ak",
"aliyun_access_key_secret": "sk",
}
defer func() { store.ThirdPartyConfig = original }()
config, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if !config.Content.Enabled || config.Content.Provider != types.FileReviewProviderAliyunGreen {
t.Fatalf("unexpected content config: %+v", config.Content)
}
if !config.Malware.Enabled || config.Malware.Provider != types.FileReviewProviderAliyunSAS {
t.Fatalf("unexpected malware config: %+v", config.Malware)
}
if config.Content.Green.AccessKeyID != "ak" || config.Content.Green.RegionID != "cn-shanghai" {
t.Fatalf("unexpected green config: %+v", config.Content.Green)
}
}
+74
View File
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
@@ -10,6 +11,10 @@ import (
"github.com/tinode/chat/server/store/types"
)
const imExternalBusinessEventPollInterval = time.Second
var imBusinessEventDispatchLocks sync.Map
func stableIMEventID(parts ...any) string {
return types.StableEventID(parts...)
}
@@ -87,6 +92,11 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
return
}
lockValue, _ := imBusinessEventDispatchLocks.LoadOrStore(tenantID, &sync.Mutex{})
lock := lockValue.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
if err != nil {
@@ -94,6 +104,39 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
return
}
for _, event := range events {
if event.EventType == "registration.invite_used" {
var payload struct {
InviteCodeID int64 `json:"invite_code_id"`
}
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.InviteCodeID <= 0 {
logs.Warn.Printf("business outbox: malformed invite usage event=%s: %v", event.EventID, err)
return
}
if err = store.RegistrationInvites.SyncUsage(tenantID, payload.InviteCodeID); err != nil {
logs.Warn.Printf("business outbox: sync invite usage event=%s: %v", event.EventID, err)
return
}
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
continue
}
if event.EventType == "security.password_reset" {
var payload struct {
UserID int64 `json:"user_id"`
}
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.UserID <= 0 {
logs.Warn.Printf("business outbox: malformed password reset event=%s: %v", event.EventID, err)
} else if globals.sessionStore != nil {
globals.sessionStore.EvictUser(store.EncodeUid(payload.UserID), "")
}
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
continue
}
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
@@ -118,3 +161,34 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
}
}
}
// startIMExternalBusinessEventPoller bridges direct database writers until the
// same event contract is transported by Kafka.
func startIMExternalBusinessEventPoller() func() {
stop := make(chan struct{})
go func() {
ticker := time.NewTicker(imExternalBusinessEventPollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if globals.sessionStore == nil {
continue
}
tenantIDs := make(map[types.TenantID]struct{})
globals.sessionStore.Range(func(_ string, session *Session) bool {
if session != nil && !session.tenantID.IsZero() {
tenantIDs[session.tenantID] = struct{}{}
}
return true
})
for tenantID := range tenantIDs {
dispatchIMBusinessEvents(tenantID)
}
case <-stop:
return
}
}
}()
return func() { close(stop) }
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type passwordResetEventStore struct {
testHTTPInfrastructureStore
delivered []string
}
func (s *passwordResetEventStore) MarkEventDelivered(_ types.TenantID, eventID string, _ time.Time) error {
s.delivered = append(s.delivered, eventID)
return nil
}
func TestDispatchPasswordResetEvent(t *testing.T) {
oldInfrastructure := store.HTTPInfrastructure
oldHub, oldSessions := globals.hub, globals.sessionStore
defer func() {
store.HTTPInfrastructure = oldInfrastructure
globals.hub, globals.sessionStore = oldHub, oldSessions
}()
infrastructure := &passwordResetEventStore{}
infrastructure.events = []types.BusinessEvent{{
EventID: "password-reset-1", EventType: "security.password_reset",
TargetType: "user", Target: "42", Payload: []byte(`{"user_id":42}`),
}}
store.HTTPInfrastructure = infrastructure
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
globals.sessionStore = nil
dispatchIMBusinessEvents(types.TenantID(1))
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "password-reset-1" {
t.Fatalf("password reset event was not marked delivered: %v", infrastructure.delivered)
}
if len(globals.hub.routeSrv) != 0 {
t.Fatal("password reset event was routed as a regular client event")
}
}
func TestDispatchRegistrationInviteUsageEvent(t *testing.T) {
oldInfrastructure, oldInvites := store.HTTPInfrastructure, store.RegistrationInvites
oldHub := globals.hub
defer func() {
store.HTTPInfrastructure, store.RegistrationInvites = oldInfrastructure, oldInvites
globals.hub = oldHub
}()
infrastructure := &passwordResetEventStore{}
infrastructure.events = []types.BusinessEvent{{
EventID: "invite-use-1", EventType: "registration.invite_used",
TargetType: "user", Target: "42", Payload: []byte(`{"invite_code_id":99}`),
}}
invites := &testRegistrationInviteStore{}
store.HTTPInfrastructure = infrastructure
store.RegistrationInvites = invites
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
dispatchIMBusinessEvents(types.TenantID(1))
if invites.syncedCode != 99 {
t.Fatalf("invite usage projection code=%d, want 99", invites.syncedCode)
}
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "invite-use-1" {
t.Fatalf("invite usage event was not marked delivered: %v", infrastructure.delivered)
}
if len(globals.hub.routeSrv) != 0 {
t.Fatal("invite usage event was routed as a regular client event")
}
}
+1374 -50
View File
File diff suppressed because it is too large Load Diff
+952 -13
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -57,6 +57,7 @@ import (
// File upload handlers
_ "github.com/tinode/chat/server/media/fs"
_ "github.com/tinode/chat/server/media/oss"
_ "github.com/tinode/chat/server/media/s3"
)
@@ -450,7 +451,6 @@ func main() {
// API key signing secret
globals.apiKeySalt = config.APIKeySalt
err = store.InitAuthLogicalNames(config.Auth["logical_names"])
if err != nil {
logs.Err.Fatal(err)
@@ -492,8 +492,10 @@ func main() {
globals.immutableTagNS[name] = true
}
if len(vconf.Required) == 0 {
// Skip disabled validator.
if len(vconf.Required) == 0 && name != "tel" {
// Optional product phone registration uses the tel validator even
// when it is not required for an auth level. Other validators stay
// disabled when they are not explicitly required.
continue
}
@@ -674,6 +676,8 @@ func main() {
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
// The hub (the main message router)
globals.hub = newHub()
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents()
// Start accepting cluster traffic.
if globals.cluster != nil {
@@ -778,6 +782,9 @@ func main() {
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
// Product HTTP API.
mux.HandleFunc(config.ApiPath+"v1/im/tenants/resolve", serveIMTenantResolve)
mux.HandleFunc(config.ApiPath+"v1/im/public/uploads", serveIMPublicUploads)
mux.HandleFunc(config.ApiPath+"v1/im/tenant-applications", serveIMTenantApplications)
mux.HandleFunc(config.ApiPath+"v1/im/tenant-entry-feedback", serveIMTenantEntryFeedback)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes", serveIMVerificationCodes)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
@@ -787,11 +794,13 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges)
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname/appeals", serveIMRealNameAppeals)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions)
mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap)
mux.HandleFunc(config.ApiPath+"v1/im/workbench", serveIMWorkbench)
mux.HandleFunc(config.ApiPath+"v1/im/organization/departments", serveIMOrganizationDepartments)
mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource)
mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers)
mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
@@ -819,6 +828,8 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify)
mux.HandleFunc(config.ApiPath+"v1/im/devices/", serveIMDevicePushToken)
mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/p2p", serveIMP2PConversation)
mux.HandleFunc(config.ApiPath+"v1/im/forward-targets", serveIMForwardTargets)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
@@ -826,6 +837,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
mux.HandleFunc(config.ApiPath+"v1/im/group-invitations/", serveIMGroupInvitationResource)
mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups)
mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource)
mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes)
+45
View File
@@ -153,6 +153,29 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
return fh.ServeURL + fname, size, nil
}
func (fh *fshandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
location, err := publicFileLocation(fh.FileUploadDirectory, fdef.Purpose, fdef.ID, fdef.CreatedAt)
if err != nil {
return "", 0, err
}
if err = os.MkdirAll(filepath.Dir(location), 0777); err != nil {
return "", 0, err
}
outfile, err := os.Create(location)
if err != nil {
return "", 0, err
}
size, err := io.Copy(outfile, file)
outfile.Close()
if err != nil {
os.Remove(location)
return "", 0, err
}
fdef.Location = location
fdef.ETag = etagFromPath(location)
return location, size, nil
}
// Download processes request for file download.
// The returned ReadSeekCloser must be closed after use.
func (fh *fshandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
@@ -226,6 +249,28 @@ func tenantFileLocation(root string, tenantID types.TenantID, fileID string, cre
createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
func publicFileLocation(root, purpose, fileID string, createdAt time.Time) (string, error) {
purpose = cleanPublicPurpose(purpose)
fileID = strings.Trim(fileID, `/\`)
if root == "" || purpose == "" || fileID == "" {
return "", types.ErrMalformed
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return filepath.Join(root, "public", purpose, createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
func cleanPublicPurpose(purpose string) string {
purpose = strings.Trim(strings.ToLower(strings.TrimSpace(purpose)), `/\`)
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return -1
}, purpose)
}
func init() {
store.RegisterMediaHandler(handlerName, &fshandler{})
}
+14
View File
@@ -9,6 +9,7 @@ import (
"path"
"regexp"
"strings"
"time"
"slices"
@@ -45,6 +46,19 @@ type Handler interface {
GetIdFromUrl(url string) types.Uid
}
type PublicFileDef struct {
ID string
Purpose string
MimeType string
CreatedAt time.Time
Location string
ETag string
}
type PublicUploader interface {
UploadPublic(fdef *PublicFileDef, file io.Reader) (string, int64, error)
}
type AllowedOrigin struct {
Origin string
URL url.URL
+364
View File
@@ -0,0 +1,364 @@
// Package oss implements media interface by storing media objects in Alibaba Cloud OSS.
package oss
import (
"encoding/base32"
"encoding/json"
"errors"
"hash/fnv"
"io"
"mime"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync/atomic"
"time"
aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
defaultServeURL = "/v0/file/s/"
defaultCacheControl = "no-cache, must-revalidate"
handlerName = "oss"
// Presign GET URLs for this number of seconds.
defaultPresignDuration = 120
)
type ossConfig struct {
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
Endpoint string `json:"endpoint"`
BucketName string `json:"bucket"`
RegionID string `json:"region_id"`
CorsOrigins []string `json:"cors_origins"`
ServeURL string `json:"serve_url"`
PresignTTL int `json:"presign_ttl"`
CacheControl string `json:"cache_control"`
VerifyBucket bool `json:"verify_bucket"`
UseSysConfig bool `json:"use_sys_config"`
}
type ossHandler struct {
client *aliyunoss.Client
bucket *aliyunoss.Bucket
conf ossConfig
corsOrigins []media.AllowedOrigin
}
type readerCounter struct {
count int64
reader io.Reader
}
func (rc *readerCounter) Read(buf []byte) (int, error) {
n, err := rc.reader.Read(buf)
atomic.AddInt64(&rc.count, int64(n))
return n, err
}
// Init initializes the media handler.
func (oh *ossHandler) Init(jsconf string) error {
var err error
if err = json.Unmarshal([]byte(jsconf), &oh.conf); err != nil {
return errors.New("failed to parse config: " + err.Error())
}
if err = oh.loadSysConfig(); err != nil {
return err
}
if oh.conf.AccessKeyID == "" {
return errors.New("missing Access Key ID")
}
if oh.conf.AccessKeySecret == "" {
return errors.New("missing Access Key Secret")
}
if oh.conf.Endpoint == "" {
return errors.New("missing Endpoint")
}
if oh.conf.BucketName == "" {
return errors.New("missing Bucket")
}
if oh.conf.PresignTTL <= 0 {
oh.conf.PresignTTL = defaultPresignDuration
}
if oh.conf.CacheControl == "" {
oh.conf.CacheControl = defaultCacheControl
}
if oh.conf.ServeURL == "" {
oh.conf.ServeURL = defaultServeURL
}
oh.corsOrigins, err = media.ParseCORSAllow(oh.conf.CorsOrigins)
if err != nil {
return errors.New("failed to parse CORS allowed origins: " + err.Error())
}
oh.client, err = aliyunoss.New(oh.conf.Endpoint, oh.conf.AccessKeyID, oh.conf.AccessKeySecret)
if err != nil {
return err
}
if oh.conf.VerifyBucket {
exists, err := oh.client.IsBucketExist(oh.conf.BucketName)
if err != nil {
return err
}
if !exists {
return errors.New("bucket does not exist")
}
}
oh.bucket, err = oh.client.Bucket(oh.conf.BucketName)
return err
}
func (oh *ossHandler) loadSysConfig() error {
if !oh.conf.UseSysConfig {
return nil
}
values, err := store.ThirdPartyConfig.Get("im_third_party",
"oss_enabled",
"oss_provider",
"oss_endpoint",
"oss_bucket",
"oss_region_id",
"oss_access_key_id",
"oss_access_key_secret",
"oss_presign_ttl",
)
if err != nil {
return errors.New("failed to load OSS config from sys config: " + err.Error())
}
if !truthy(values["oss_enabled"]) {
return errors.New("OSS is disabled in sys config")
}
if provider := strings.TrimSpace(values["oss_provider"]); provider != "" && provider != handlerName {
return errors.New("unsupported storage provider in sys config: " + provider)
}
applyString(&oh.conf.Endpoint, values["oss_endpoint"])
applyString(&oh.conf.BucketName, values["oss_bucket"])
applyString(&oh.conf.RegionID, values["oss_region_id"])
applyString(&oh.conf.AccessKeyID, values["oss_access_key_id"])
applyString(&oh.conf.AccessKeySecret, values["oss_access_key_secret"])
if ttl := strings.TrimSpace(values["oss_presign_ttl"]); ttl != "" {
parsed, err := strconv.Atoi(ttl)
if err != nil {
return errors.New("invalid OSS presign TTL in sys config: " + err.Error())
}
oh.conf.PresignTTL = parsed
}
return nil
}
func applyString(target *string, value string) {
if value = strings.TrimSpace(value); value != "" {
*target = value
}
}
func truthy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// Headers adds CORS headers and redirects GET and HEAD requests to OSS.
func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) {
headers, status := media.CORSHandler(method, headers, oh.corsOrigins, serve)
if status != 0 || method == http.MethodPost || method == http.MethodPut {
return headers, status, nil
}
fid := oh.GetIdFromUrl(url.String())
if fid.IsZero() {
return nil, 0, types.ErrNotFound
}
fdef, err := oh.getFileRecord(tenantID, fid)
if err != nil {
return nil, 0, err
}
if fdef.ETag != "" && strings.Trim(headers.Get("If-None-Match"), "\"") == fdef.ETag {
return http.Header{
"ETag": {`"` + fdef.ETag + `"`},
"Cache-Control": {oh.conf.CacheControl},
}, http.StatusNotModified, nil
}
var redirURL string
switch method {
case http.MethodGet:
options := []aliyunoss.Option{
aliyunoss.ResponseCacheControl(oh.conf.CacheControl),
aliyunoss.ResponseContentType(fdef.MimeType),
}
if isAttachment, _ := strconv.ParseBool(url.Query().Get("asatt")); isAttachment {
options = append(options, aliyunoss.ResponseContentDisposition("attachment"))
}
redirURL, err = oh.bucket.SignURL(fdef.Location, aliyunoss.HTTPGet, int64(oh.conf.PresignTTL), options...)
case http.MethodHead:
redirURL, err = oh.bucket.SignURL(fdef.Location, aliyunoss.HTTPHead, int64(oh.conf.PresignTTL))
}
if err != nil {
return nil, 0, err
}
if redirURL != "" {
return http.Header{
"Location": {redirURL},
"ETag": {`"` + fdef.ETag + `"`},
"Content-Type": {"application/json; charset=utf-8"},
"Cache-Control": {oh.conf.CacheControl},
}, http.StatusPermanentRedirect, nil
}
return nil, 0, nil
}
// Upload processes request for a file upload.
func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {
key, err := tenantObjectKey(fdef.TenantID, fdef.Uid().String32(), fdef.CreatedAt)
if err != nil {
return "", 0, err
}
if err = store.Files.StartUpload(fdef.TenantID, fdef); err != nil {
logs.Warn.Println("failed to create file record", fdef.Id, err)
return "", 0, err
}
rc := readerCounter{reader: file}
options := []aliyunoss.Option{aliyunoss.CacheControl(oh.conf.CacheControl)}
if fdef.MimeType != "" {
options = append(options, aliyunoss.ContentType(fdef.MimeType))
}
if err = oh.bucket.PutObject(key, &rc, options...); err != nil {
return "", 0, err
}
fname := fdef.Id
ext, _ := mime.ExtensionsByType(fdef.MimeType)
if len(ext) > 0 {
fname += ext[0]
}
fdef.Location = key
fdef.ETag = etagFromLocation(key)
if meta, err := oh.bucket.GetObjectDetailedMeta(key); err == nil {
if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag
}
}
return oh.conf.ServeURL + fname, rc.count, nil
}
func (oh *ossHandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
key, err := publicObjectKey(fdef.Purpose, fdef.ID, fdef.CreatedAt)
if err != nil {
return "", 0, err
}
rc := readerCounter{reader: file}
options := []aliyunoss.Option{aliyunoss.CacheControl(oh.conf.CacheControl)}
if fdef.MimeType != "" {
options = append(options, aliyunoss.ContentType(fdef.MimeType))
}
if err = oh.bucket.PutObject(key, &rc, options...); err != nil {
return "", 0, err
}
fdef.Location = key
fdef.ETag = etagFromLocation(key)
if meta, err := oh.bucket.GetObjectDetailedMeta(key); err == nil {
if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag
}
}
return key, rc.count, nil
}
// Download is unsupported because OSS objects are served through signed redirects.
func (oh *ossHandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
return nil, nil, types.ErrUnsupported
}
// Delete deletes files from OSS by provided object keys.
func (oh *ossHandler) Delete(locations []string) error {
for i := 0; i < len(locations); i += 1000 {
end := i + 1000
if end > len(locations) {
end = len(locations)
}
if _, err := oh.bucket.DeleteObjects(locations[i:end], aliyunoss.DeleteObjectsQuiet(true)); err != nil {
return err
}
}
return nil
}
// GetIdFromUrl converts an attachment URL to a file UID.
func (oh *ossHandler) GetIdFromUrl(url string) types.Uid {
return media.GetIdFromUrl(url, oh.conf.ServeURL)
}
func (oh *ossHandler) getFileRecord(tenantID types.TenantID, fid types.Uid) (*types.FileDef, error) {
fd, err := store.Files.Get(tenantID, fid.String())
if err != nil {
return nil, err
}
if fd == nil {
return nil, types.ErrNotFound
}
return fd, nil
}
func tenantObjectKey(tenantID types.TenantID, fileID string, createdAt time.Time) (string, error) {
if !tenantID.IsValid() || fileID == "" {
return "", types.ErrMalformed
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return path.Join("tenants", strconv.FormatInt(int64(tenantID), 10),
createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
func publicObjectKey(purpose, fileID string, createdAt time.Time) (string, error) {
purpose = cleanPublicPurpose(purpose)
if purpose == "" || fileID == "" {
return "", types.ErrMalformed
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return path.Join("public", purpose, createdAt.Format("2006"), createdAt.Format("01"), strings.Trim(fileID, "/")), nil
}
func cleanPublicPurpose(purpose string) string {
purpose = strings.Trim(strings.ToLower(strings.TrimSpace(purpose)), "/")
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return -1
}, purpose)
}
func etagFromLocation(location string) string {
hasher := fnv.New128()
hasher.Write([]byte(location))
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).
EncodeToString(hasher.Sum(make([]byte, 0, hasher.Size()))))
}
func init() {
store.RegisterMediaHandler(handlerName, &ossHandler{})
}
+73
View File
@@ -0,0 +1,73 @@
package oss
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type fakeThirdPartyConfig map[string]string
func (f fakeThirdPartyConfig) Get(group string, keys ...string) (map[string]string, error) {
result := map[string]string{}
for _, key := range keys {
if value, ok := f[key]; ok {
result[key] = value
}
}
return result, nil
}
func TestTenantObjectKey(t *testing.T) {
got, err := tenantObjectKey(types.TenantID(42), "abc123", time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("tenantObjectKey failed: %v", err)
}
if want := "tenants/42/2026/08/abc123"; got != want {
t.Fatalf("tenantObjectKey = %q, want %q", got, want)
}
}
func TestTenantObjectKeyRejectsZeroTenant(t *testing.T) {
if _, err := tenantObjectKey(types.ZeroTenantID, "abc123", time.Time{}); err != types.ErrMalformed {
t.Fatalf("tenantObjectKey err = %v, want malformed", err)
}
}
func TestGetIdFromUrl(t *testing.T) {
handler := ossHandler{conf: ossConfig{ServeURL: "/v0/file/s/"}}
fid := types.Uid(123456)
got := handler.GetIdFromUrl("/v0/file/s/" + fid.String() + ".jpg")
if got != fid {
t.Fatalf("GetIdFromUrl = %v, want %v", got, fid)
}
}
func TestLoadSysConfig(t *testing.T) {
original := store.ThirdPartyConfig
store.ThirdPartyConfig = fakeThirdPartyConfig{
"oss_enabled": "1",
"oss_provider": "oss",
"oss_endpoint": "oss-cn-shenzhen.aliyuncs.com",
"oss_bucket": "im-files",
"oss_region_id": "cn-shenzhen",
"oss_access_key_id": "ak",
"oss_access_key_secret": "sk",
"oss_presign_ttl": "300",
}
defer func() { store.ThirdPartyConfig = original }()
handler := ossHandler{conf: ossConfig{UseSysConfig: true}}
if err := handler.loadSysConfig(); err != nil {
t.Fatalf("loadSysConfig failed: %v", err)
}
if handler.conf.Endpoint != "oss-cn-shenzhen.aliyuncs.com" || handler.conf.BucketName != "im-files" {
t.Fatalf("unexpected OSS config: %+v", handler.conf)
}
if handler.conf.RegionID != "cn-shenzhen" || handler.conf.PresignTTL != 300 {
t.Fatalf("unexpected OSS region or ttl: %+v", handler.conf)
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"fmt"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"github.com/tinode/chat/server/validate"
)
const registrationSMSConfigCacheTTL = 30 * time.Second
var registrationSMSConfigCache struct {
sync.Mutex
value bool
loaded bool
loadedAt time.Time
loadValue func() (bool, error)
}
func registrationSMSEnabled(now time.Time) bool {
registrationSMSConfigCache.Lock()
defer registrationSMSConfigCache.Unlock()
if registrationSMSConfigCache.loaded && now.Sub(registrationSMSConfigCache.loadedAt) < registrationSMSConfigCacheTTL {
return registrationSMSConfigCache.value
}
loader := registrationSMSConfigCache.loadValue
if loader == nil {
loader = func() (bool, error) {
if store.RegistrationConfig == nil {
return true, types.ErrUnsupported
}
return store.RegistrationConfig.SMSEnabled()
}
}
value, err := loader()
if err != nil {
if registrationSMSConfigCache.loaded {
logs.Warn.Println("registration SMS config read failed; using cached value:", err)
return registrationSMSConfigCache.value
}
logs.Warn.Println("registration SMS config read failed; defaulting to enabled:", err)
return true
}
registrationSMSConfigCache.value = value
registrationSMSConfigCache.loaded = true
registrationSMSConfigCache.loadedAt = now
return value
}
func isValidIMInviteCode(value string) bool {
if len(value) != 5 {
return false
}
for i := range value {
if (value[i] < 'A' || value[i] > 'Z') && (value[i] < 'a' || value[i] > 'z') {
return false
}
}
return true
}
var sendIMVerificationCode = func(tenantID types.TenantID, recipient, lang, scene, code string) error {
if store.Store == nil {
return types.ErrInternal
}
validatorImpl := store.Store.GetValidator("tel")
sender, ok := validatorImpl.(validate.CodeSender)
if !ok || !validatorImpl.IsInitialized() {
return types.ErrUnsupported
}
if err := sender.SendCode(tenantID, recipient, lang, scene, code); err != nil {
return fmt.Errorf("send verification code: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"time"
"github.com/tinode/chat/server/store/types"
)
type testRegistrationInviteStore struct {
invite *types.RegistrationInviteCode
created *types.RegistrationInviteCode
page *types.RegistrationInvitePage
batch *types.RegistrationInviteBatch
err error
useRecorded bool
syncedCode int64
}
func (s *testRegistrationInviteStore) Create(_ types.TenantID, code *types.RegistrationInviteCode) error {
s.created = code
return s.err
}
func (s *testRegistrationInviteStore) CreateBatch(types.TenantID, *types.RegistrationInviteBatch, []types.RegistrationInviteCode) error {
return s.err
}
func (s *testRegistrationInviteStore) GetByCode(types.TenantID, string) (*types.RegistrationInviteCode, error) {
return s.invite, s.err
}
func (s *testRegistrationInviteStore) List(types.TenantID, types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
return s.page, s.err
}
func (s *testRegistrationInviteStore) GetBatch(types.TenantID, int64) (*types.RegistrationInviteBatch, error) {
return s.batch, s.err
}
func (s *testRegistrationInviteStore) GetBatchByIdempotency(types.TenantID, string) (*types.RegistrationInviteBatch, error) {
return s.batch, s.err
}
func (s *testRegistrationInviteStore) Revoke(types.TenantID, int64, time.Time) error { return s.err }
func (s *testRegistrationInviteStore) RevokeBatch(types.TenantID, int64, time.Time) (int, error) {
return 0, s.err
}
func (s *testRegistrationInviteStore) RecordUse(types.TenantID, string, types.Uid, string, time.Time) error {
s.useRecorded = true
return s.err
}
func (s *testRegistrationInviteStore) SyncUsage(_ types.TenantID, codeID int64) error {
s.syncedCode = codeID
return s.err
}
func setRegistrationSMSConfigForTest(enabled bool) {
registrationSMSConfigCache.Lock()
defer registrationSMSConfigCache.Unlock()
registrationSMSConfigCache.value = false
registrationSMSConfigCache.loaded = false
registrationSMSConfigCache.loadedAt = time.Time{}
registrationSMSConfigCache.loadValue = func() (bool, error) { return enabled, nil }
}
func resetRegistrationSMSConfigForTest() {
registrationSMSConfigCache.Lock()
defer registrationSMSConfigCache.Unlock()
registrationSMSConfigCache.value = false
registrationSMSConfigCache.loaded = false
registrationSMSConfigCache.loadedAt = time.Time{}
registrationSMSConfigCache.loadValue = nil
}
+25 -12
View File
@@ -1236,24 +1236,31 @@ func TestDispatchAccNew(t *testing.T) {
uid := types.Uid(1)
oldSecurity := store.Security
oldVerifications := store.Verifications
oldInvites := store.RegistrationInvites
store.Store = ss
store.Users = uu
store.Security = &testSecurityStore{version: 1}
store.Verifications = nil
invites := &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
}}
store.RegistrationInvites = invites
defer func() {
store.Store = nil
store.Users = nil
store.Security = oldSecurity
store.Verifications = oldVerifications
store.RegistrationInvites = oldInvites
ctrl.Finish()
}()
remoteAddr := "192.168.0.1"
secret := "<==auth-secret==>"
tags := []string{"tag1", "tag2"}
secret := "alice:password"
authRec := &auth.Rec{
TenantID: 1,
Uid: uid,
AuthLevel: auth.LevelAuth,
Tags: tags,
State: types.StateOK,
}
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
@@ -1271,7 +1278,6 @@ func TestDispatchAccNew(t *testing.T) {
ss.EXPECT().GetLogicalAuthHandler("token").Return(aa)
token := "<==auth-token==>"
aa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil)
uu.EXPECT().UpdateTags(types.TenantID(1), uid, tags, nil, nil).Return(tags, nil)
s := &Session{
send: make(chan any, 10),
@@ -1289,12 +1295,13 @@ func TestDispatchAccNew(t *testing.T) {
public := "public name"
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "123",
User: "newXYZ",
Scheme: "basic",
Secret: []byte(secret),
Tags: []string{"abc", "123"},
Desc: &MsgSetDesc{Public: public},
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
InviteCode: "AbCdE",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: public},
},
}
@@ -1305,6 +1312,9 @@ func TestDispatchAccNew(t *testing.T) {
if len(r.messages) != 1 {
t.Errorf("responses: expected 1, received %d.", len(r.messages))
}
if !invites.useRecorded {
t.Error("registration invite use was not recorded")
}
resp := r.messages[0].(*ServerComMessage)
if resp == nil {
t.Fatal("Response must be ServerComMessage")
@@ -1326,8 +1336,11 @@ func TestDispatchAccNew(t *testing.T) {
if lvl := p["authlvl"].(string); lvl != auth.LevelAuth.String() {
t.Errorf("Auth level: expected '%s', found '%s'.", auth.LevelAuth.String(), lvl)
}
if desc := p["desc"].(*MsgTopicDesc); desc.Public.(string) != public {
t.Errorf("Public: expected '%s', found '%s'.", public, desc.Public.(string))
if username := p["username"].(string); username != "alice" {
t.Errorf("Username: expected 'alice', found '%s'.", username)
}
if desc := p["desc"].(*MsgTopicDesc); desc.Public.(map[string]any)["fn"] != "alice" {
t.Errorf("Public fn: expected 'alice', found '%v'.", desc.Public)
}
} else {
t.Error("Response must contain a ctrl message.")
+39 -24
View File
@@ -354,17 +354,17 @@ func (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceM
}
// AddAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, secret, expires)
ret0, _ := ret[0].(error)
return ret0
}
// AddAuthRecord indicates an expected call of AddAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, secret, expires interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, secret, expires)
}
// ConfirmCred mocks base method.
@@ -396,18 +396,18 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Create(tenantID, user, priv
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), tenantID, user, private)
}
// DelAuthRecords mocks base method.
func (m *MockUsersPersistenceInterface) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
// DeleteAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DelAuthRecords", tenantID, uid, scheme)
ret := m.ctrl.Call(m, "DeleteAuthRecord", tenantID, uid)
ret0, _ := ret[0].(error)
return ret0
}
// DelAuthRecords indicates an expected call of DelAuthRecords.
func (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(tenantID, uid, scheme interface{}) *gomock.Call {
// DeleteAuthRecord indicates an expected call of DeleteAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) DeleteAuthRecord(tenantID, uid interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelAuthRecords", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), tenantID, uid, scheme)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DeleteAuthRecord), tenantID, uid)
}
// DelCred mocks base method.
@@ -532,6 +532,21 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(tenantID interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), varargs...)
}
// GetByPhone mocks base method.
func (m *MockUsersPersistenceInterface) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByPhone", tenantID, phone)
ret0, _ := ret[0].(types.Uid)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByPhone indicates an expected call of GetByPhone.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetByPhone(tenantID, phone interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByPhone", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByPhone), tenantID, phone)
}
// GetAllCreds mocks base method.
func (m *MockUsersPersistenceInterface) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {
m.ctrl.T.Helper()
@@ -548,9 +563,9 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(tenantID, id, m
}
// GetAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user, scheme)
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(auth.Level)
ret2, _ := ret[2].([]byte)
@@ -560,15 +575,15 @@ func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, u
}
// GetAuthRecord indicates an expected call of GetAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user, scheme interface{}) *gomock.Call {
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user, scheme)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user)
}
// GetAuthUniqueRecord mocks base method.
func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
// GetAuthByLogin mocks base method.
func (m *MockUsersPersistenceInterface) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthUniqueRecord", tenantID, scheme, unique)
ret := m.ctrl.Call(m, "GetAuthByLogin", tenantID, login)
ret0, _ := ret[0].(types.Uid)
ret1, _ := ret[1].(auth.Level)
ret2, _ := ret[2].([]byte)
@@ -577,10 +592,10 @@ func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.Tenan
return ret0, ret1, ret2, ret3, ret4
}
// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(tenantID, scheme, unique interface{}) *gomock.Call {
// GetAuthByLogin indicates an expected call of GetAuthByLogin.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthByLogin(tenantID, login interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthUniqueRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), tenantID, scheme, unique)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthByLogin", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthByLogin), tenantID, login)
}
// GetByCred mocks base method.
@@ -723,17 +738,17 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Update(tenantID, uid, updat
}
// UpdateAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, username, secret, expires)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateAuthRecord indicates an expected call of UpdateAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, username, secret, expires interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, username, secret, expires)
}
// UpdateLastSeen mocks base method.
+464 -29
View File
@@ -137,6 +137,88 @@ type tenantsMapper struct{}
// Tenants is the tenant registry access object.
var Tenants TenantsPersistenceInterface
// RegistrationConfigPersistenceInterface exposes global registration policy.
type RegistrationConfigPersistenceInterface interface {
SMSEnabled() (bool, error)
}
type registrationConfigMapper struct{}
// RegistrationConfig reads registration policy from the control database.
var RegistrationConfig RegistrationConfigPersistenceInterface
func (registrationConfigMapper) SMSEnabled() (bool, error) {
configAdp, ok := adp.(adapter.RegistrationConfigAdapter)
if !ok {
return true, types.ErrUnsupported
}
return configAdp.RegistrationSMSEnabled()
}
// ThirdPartyConfigPersistenceInterface exposes global third-party integration settings.
type ThirdPartyConfigPersistenceInterface interface {
Get(group string, keys ...string) (map[string]string, error)
}
type thirdPartyConfigMapper struct{}
// ThirdPartyConfig reads third-party settings from the control database.
var ThirdPartyConfig ThirdPartyConfigPersistenceInterface = thirdPartyConfigMapper{}
func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]string, error) {
configAdp, ok := adp.(adapter.ThirdPartyConfigAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return configAdp.ThirdPartyConfig(group, keys...)
}
type TenantEntryPersistenceInterface interface {
CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error)
CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error)
CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error)
}
type tenantEntryMapper struct{}
var TenantEntry TenantEntryPersistenceInterface
func (tenantEntryMapper) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) {
if upload == nil || strings.TrimSpace(upload.FileID) == "" || strings.TrimSpace(upload.Purpose) == "" ||
strings.TrimSpace(upload.MimeType) == "" || strings.TrimSpace(upload.Location) == "" || upload.Size <= 0 {
return nil, types.ErrMalformed
}
entryAdp, ok := adp.(adapter.TenantEntryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return entryAdp.PublicUploadCreate(upload)
}
func (tenantEntryMapper) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) {
if application == nil || strings.TrimSpace(application.ApplicationID) == "" ||
strings.TrimSpace(application.CreditCode) == "" || strings.TrimSpace(application.IdempotencyKey) == "" {
return nil, types.ErrMalformed
}
entryAdp, ok := adp.(adapter.TenantEntryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return entryAdp.TenantApplicationCreate(application)
}
func (tenantEntryMapper) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) {
if feedback == nil || strings.TrimSpace(feedback.FeedbackID) == "" ||
strings.TrimSpace(feedback.Description) == "" || strings.TrimSpace(feedback.IdempotencyKey) == "" {
return nil, types.ErrMalformed
}
entryAdp, ok := adp.(adapter.TenantEntryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return entryAdp.TenantEntryFeedbackCreate(feedback)
}
// GetByCode resolves a tenant by its public enterprise code.
func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
tenantAdp, ok := adp.(adapter.TenantAdapter)
@@ -368,13 +450,14 @@ func (s storeObj) DbStats() func() any {
// UsersPersistenceInterface is an interface which defines methods for persistent storage of user records.
type UsersPersistenceInterface interface {
Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error)
GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error)
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error
GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error)
GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error)
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error
DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error
Get(tenantID types.TenantID, uid types.Uid) (*types.User, error)
GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error)
GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error)
GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error)
Delete(tenantID types.TenantID, id types.Uid, hard bool) error
UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error
@@ -455,61 +538,50 @@ func (usersMapper) Create(tenantID types.TenantID, user *types.User, private any
// GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and
// authentication secret.
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return "", auth.LevelNone, nil, time.Time{}, err
}
unique, authLvl, secret, expires, err := tenantAdp.TenantAuthGetRecord(tenantID, user, scheme)
if err == nil {
parts := strings.Split(unique, ":")
if len(parts) > 1 {
unique = parts[1]
} else {
err = types.ErrInternal
}
}
return unique, authLvl, secret, expires, err
return tenantAdp.TenantAuthGetRecord(tenantID, user)
}
// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and
// authentication secret.
func (usersMapper) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
// GetAuthByLogin fetches a Basic authentication record by username or phone.
func (usersMapper) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return types.ZeroUid, auth.LevelNone, nil, time.Time{}, err
}
return tenantAdp.TenantAuthGetUniqueRecord(tenantID, scheme+":"+unique)
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
}
// AddAuthRecord creates a new authentication record for the given user.
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte,
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte,
expires time.Time) error {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantAuthAddRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
}
// UpdateAuthRecord updates authentication record with a new secret and expiration time.
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string,
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string,
secret []byte, expires time.Time) error {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantAuthUpdRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
}
// DelAuthRecords deletes user's auth records of the given scheme.
func (usersMapper) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
// DeleteAuthRecord deletes the user's Basic authentication record.
func (usersMapper) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantAuthDelScheme(tenantID, uid, scheme)
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
}
// Get returns a user object for the given user ID or nil if the user is not found.
@@ -552,6 +624,16 @@ func (usersMapper) GetByCred(tenantID types.TenantID, method, value string) (typ
return tenantAdp.TenantUserGetByCred(tenantID, method, value)
}
// GetByPhone resolves an account by the phone identity stored on im_users.
// It intentionally does not require a completed tel credential.
func (usersMapper) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return types.ZeroUid, err
}
return tenantAdp.TenantUserGetByPhone(tenantID, phone)
}
// Delete deletes user records.
func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1027,6 +1109,114 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor
return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
type ConversationsPersistenceInterface interface {
EnsureP2P(tenantID types.TenantID, uid, peer types.Uid) (string, error)
GetSettings(tenantID types.TenantID, uid types.Uid, topic string) (*types.ConversationSettings, error)
UpdateSettings(tenantID types.TenantID, uid types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error)
GetDraft(tenantID types.TenantID, uid types.Uid, topic string) (*types.TopicDraft, error)
PutDraft(tenantID types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error)
}
type conversationsMapper struct{}
var Conversations ConversationsPersistenceInterface
func (conversationsMapper) EnsureP2P(tenantID types.TenantID, uid, peer types.Uid) (string, error) {
if uid.IsZero() || peer.IsZero() || uid == peer {
return "", types.ErrMalformed
}
canSend, _, err := Relations.CanSendP2P(tenantID, uid, peer)
if err != nil {
return "", err
}
if !canSend {
return "", types.ErrPermissionDenied
}
topic := uid.P2PName(peer)
if topic == "" {
return "", types.ErrMalformed
}
now := types.TimeNow()
initiator := &types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
User: uid.String(),
Topic: topic,
ModeWant: types.ModeCP2P,
ModeGiven: types.ModeCP2P,
Private: map[string]any{"peer": peer.UserId(), "created_by": "http"},
}
invited := &types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
User: peer.String(),
Topic: topic,
ModeWant: types.ModeCP2P,
ModeGiven: types.ModeCP2P,
Private: map[string]any{"peer": uid.UserId(), "created_by": "http"},
}
if err = Topics.CreateP2P(tenantID, initiator, invited); err != nil {
return "", err
}
return topic, nil
}
func (conversationsMapper) GetSettings(tenantID types.TenantID, uid types.Uid, topic string) (*types.ConversationSettings, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantConversationSettingsGet(tenantID, uid, strings.TrimSpace(topic))
}
func (conversationsMapper) UpdateSettings(tenantID types.TenantID, uid types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantConversationSettingsUpdate(tenantID, uid, strings.TrimSpace(topic), patch)
}
func (conversationsMapper) GetDraft(tenantID types.TenantID, uid types.Uid, topic string) (*types.TopicDraft, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantTopicDraftGet(tenantID, uid, strings.TrimSpace(topic))
}
func (conversationsMapper) PutDraft(tenantID types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) {
if uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 {
return nil, types.ErrMalformed
}
if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil {
return nil, err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantTopicDraftPut(tenantID, uid, strings.TrimSpace(topic), content, entities, version)
}
type CallsPersistenceInterface interface {
Create(tenantID types.TenantID, record *types.CallRecord) error
Accept(tenantID types.TenantID, callID string, seq int, acceptedAt time.Time) error
@@ -1102,6 +1292,7 @@ type GroupBusinessPersistenceInterface interface {
ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error)
UpdateMember(types.TenantID, string, types.Uid, types.GroupMemberPatch) (*types.GroupMember, error)
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
AcceptInvitation(types.TenantID, string, types.Uid, any, time.Time) (*types.GroupInvitation, error)
CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error)
ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error)
@@ -1111,6 +1302,9 @@ type GroupBusinessPersistenceInterface interface {
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
ListFiles(types.TenantID, string, string, string, string, int) ([]types.GroupFile, string, error)
CreateFile(types.TenantID, *types.GroupFile) (*types.GroupFile, error)
DeleteFile(types.TenantID, string, string, types.Uid, bool, time.Time) (*types.GroupFile, error)
InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error)
Leave(types.TenantID, string, types.Uid, time.Time) error
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
@@ -1230,6 +1424,17 @@ func (groupBusinessMapper) CreateInvitation(tenantID types.TenantID, invitation
return tenantAdp.TenantGroupInvitationCreate(tenantID, invitation)
}
func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationID string, uid types.Uid, memberPrivate any, now time.Time) (*types.GroupInvitation, error) {
if strings.TrimSpace(invitationID) == "" || uid.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
}
func (groupBusinessMapper) CreateJoinRequest(tenantID types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
if req == nil || req.Topic == "" || req.ApplicantUserID.IsZero() || req.RequestID == "" {
return nil, types.ErrMalformed
@@ -1335,6 +1540,42 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid
return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now)
}
func (groupBusinessMapper) ListFiles(tenantID types.TenantID, topic, fileType, query, cursor string, limit int) ([]types.GroupFile, string, error) {
if strings.TrimSpace(topic) == "" {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantGroupFileList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(fileType), strings.TrimSpace(query), strings.TrimSpace(cursor), limit)
}
func (groupBusinessMapper) CreateFile(tenantID types.TenantID, file *types.GroupFile) (*types.GroupFile, error) {
if file == nil || strings.TrimSpace(file.Topic) == "" || strings.TrimSpace(file.FileID) == "" || file.UploaderID.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupFileCreate(tenantID, file)
}
func (groupBusinessMapper) DeleteFile(tenantID types.TenantID, topic, groupFileID string, actor types.Uid, canManage bool, now time.Time) (*types.GroupFile, error) {
if strings.TrimSpace(topic) == "" || strings.TrimSpace(groupFileID) == "" || actor.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupFileDelete(tenantID, strings.TrimSpace(topic), strings.TrimSpace(groupFileID), actor, canManage, now)
}
func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() {
@@ -1438,6 +1679,7 @@ func (workbenchMapper) Get(tenantID types.TenantID, uid types.Uid) ([]types.Work
type OrganizationPersistenceInterface interface {
ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error)
ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error)
ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error)
Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error)
}
@@ -1473,6 +1715,20 @@ func (organizationMapper) ListMembers(tenantID types.TenantID, uid types.Uid, de
return tenantAdp.TenantOrganizationMembers(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(cursor), limit)
}
func (organizationMapper) ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantOrganizationMembersAll(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(queryText), strings.TrimSpace(cursor), limit)
}
func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) {
if uid.IsZero() || strings.TrimSpace(queryText) == "" {
return nil, "", types.ErrMalformed
@@ -1696,6 +1952,7 @@ type MonthlyReportsPersistenceInterface interface {
Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error)
SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error)
Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error)
Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error)
Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error)
}
@@ -1761,6 +2018,17 @@ func (monthlyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, repor
return tenantAdp.TenantMonthlyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version)
}
func (monthlyReportsMapper) Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) {
if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantMonthlyReportReopen(tenantID, uid, strings.TrimSpace(reportID), version)
}
func (monthlyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) {
if uid.IsZero() || strings.TrimSpace(month) == "" {
return nil, types.ErrMalformed
@@ -2034,7 +2302,7 @@ func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool {
}
func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil {
if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil && patch.Email == nil {
return false
}
if patch.Nickname != nil {
@@ -2046,12 +2314,16 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.Signature != nil && len([]rune(*patch.Signature)) > 120 {
return false
}
if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 {
return false
}
return true
}
type RelationsPersistenceInterface interface {
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error)
ListFriends(tenantID types.TenantID, uid types.Uid, query, initial, cursor string, limit int) ([]types.FriendListItem, string, error)
GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error)
@@ -2161,6 +2433,17 @@ func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid
return tenantAdp.TenantFriendRequestList(tenantID, uid, direction, status, cursor, limit)
}
func (relationsMapper) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) {
if uid.IsZero() {
return 0, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantFriendRequestPendingCount(tenantID, uid)
}
func (relationsMapper) ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) {
if strings.TrimSpace(requestID) == "" || actor.IsZero() || (status != types.FriendRequestAccepted && status != types.FriendRequestRejected) {
return nil, types.ErrMalformed
@@ -3059,6 +3342,25 @@ type verificationMapper struct{}
// Verifications is a singleton for product verification challenges.
var Verifications VerificationPersistenceInterface
// RegistrationInvitePersistenceInterface manages tenant-scoped registration invitations.
type RegistrationInvitePersistenceInterface interface {
Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error
CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error
GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error)
List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error)
GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error)
GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error)
Revoke(tenantID types.TenantID, codeID int64, now time.Time) error
RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error)
RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error
SyncUsage(tenantID types.TenantID, codeID int64) error
}
type registrationInviteMapper struct{}
// RegistrationInvites is the tenant-scoped registration invitation store.
var RegistrationInvites RegistrationInvitePersistenceInterface
// UserSecurityPersistenceInterface defines user security state used to
// invalidate previously issued login tokens.
type UserSecurityPersistenceInterface interface {
@@ -3066,6 +3368,7 @@ type UserSecurityPersistenceInterface interface {
BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error)
GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error)
CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error
CreateRealNameAppeal(tenantID types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error)
CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error
ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error)
GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error)
@@ -3075,6 +3378,7 @@ type UserSecurityPersistenceInterface interface {
SetYouthMode(tenantID types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error)
CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error)
CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error)
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error
}
@@ -3118,6 +3422,17 @@ func (userSecurityMapper) CreateRealNameSubmission(tenantID types.TenantID, rv *
return tenantAdp.TenantRealNameCreate(tenantID, rv)
}
func (userSecurityMapper) CreateRealNameAppeal(tenantID types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) {
if appeal == nil || appeal.TenantID != tenantID || appeal.UserID.IsZero() || strings.TrimSpace(appeal.AppealID) == "" {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantRealNameAppealCreate(tenantID, appeal)
}
func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error {
if token == nil || token.TenantID != tenantID {
return types.ErrMalformed
@@ -3205,6 +3520,17 @@ func (userSecurityMapper) ListSystemNotifications(tenantID types.TenantID, uid t
return tenantAdp.TenantSystemNotificationList(tenantID, uid, cursor, limit)
}
func (userSecurityMapper) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) {
if uid.IsZero() {
return 0, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantSystemNotificationUnreadCount(tenantID, uid)
}
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
@@ -3264,6 +3590,111 @@ func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHas
return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt)
}
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
if router, ok := adp.(adapter.TenantRoutedAdapter); ok {
business, _, err := router.TenantAdapterFor(tenantID)
if err != nil {
return nil, err
}
if inviteAdp, ok := business.(adapter.TenantRegistrationInviteAdapter); ok {
return inviteAdp, nil
}
}
if inviteAdp, ok := adp.(adapter.TenantRegistrationInviteAdapter); ok {
return inviteAdp, nil
}
return nil, types.ErrUnsupported
}
func (registrationInviteMapper) Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error {
if code == nil {
return types.ErrMalformed
}
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteCreate(tenantID, code)
}
func (registrationInviteMapper) CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error {
if batch == nil || len(codes) == 0 {
return types.ErrMalformed
}
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteCreateBatch(tenantID, batch, codes)
}
func (registrationInviteMapper) GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteGetByCode(tenantID, code)
}
func (registrationInviteMapper) List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteList(tenantID, opts)
}
func (registrationInviteMapper) GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteBatchGet(tenantID, batchID)
}
func (registrationInviteMapper) GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteBatchGetByIdempotency(tenantID, key)
}
func (registrationInviteMapper) Revoke(tenantID types.TenantID, codeID int64, now time.Time) error {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteRevoke(tenantID, codeID, now)
}
func (registrationInviteMapper) RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return 0, err
}
return inviteAdp.TenantRegistrationInviteBatchRevoke(tenantID, batchID, now)
}
func (registrationInviteMapper) RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteRecordUse(tenantID, code, uid, requestID, now)
}
func (registrationInviteMapper) SyncUsage(tenantID types.TenantID, codeID int64) error {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteSyncUsage(tenantID, codeID)
}
// Get reads a persistent cache entry.
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -3307,10 +3738,13 @@ func SetTestUidGenerator(g types.UidGenerator) {
func init() {
Store = storeObj{}
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
TenantEntry = tenantEntryMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
Messages = messagesMapper{}
Conversations = conversationsMapper{}
Calls = callsMapper{}
GroupBusiness = groupBusinessMapper{}
Workbench = workbenchMapper{}
@@ -3327,6 +3761,7 @@ func init() {
Files = fileMapper{}
PCache = pcacheMapper{}
Verifications = verificationMapper{}
RegistrationInvites = registrationInviteMapper{}
Security = userSecurityMapper{}
Relations = relationsMapper{}
Favorites = favoritesMapper{}
+37
View File
@@ -0,0 +1,37 @@
package types
import "time"
type ConversationSettings struct {
ID int64 `db:"id"`
UserID Uid `db:"-"`
Topic string `db:"topic"`
Pinned bool `db:"pinned"`
Muted bool `db:"muted"`
Remark string `db:"remark"`
MessageRetention string `db:"message_retention"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserIDRaw int64 `db:"user_id"`
}
type ConversationSettingsPatch struct {
Pinned *bool
Muted *bool
Remark *string
MessageRetention *string
Version int
}
type TopicDraft struct {
ID int64 `db:"id"`
UserID Uid `db:"-"`
Topic string `db:"topic"`
Content string `db:"content"`
EntitiesJSON []byte `db:"entities_json"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserIDRaw int64 `db:"user_id"`
}
+22
View File
@@ -181,6 +181,28 @@ type GroupMute struct {
RevokedRaw *int64 `db:"revoked_by"`
}
type GroupFile struct {
ID int64 `db:"id"`
GroupFileID string `db:"group_file_id"`
Topic string `db:"topic"`
FileID string `db:"file_id"`
SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"`
UploaderID Uid `db:"-"`
FileName string `db:"file_name"`
MIMEType string `db:"mime_type"`
FileType string `db:"file_type"`
FileSize int64 `db:"file_size"`
Status string `db:"status"`
DeletedBy Uid `db:"-"`
DeletedAt *time.Time `db:"deleted_at"`
IdempotencyKey string `db:"idempotency_key"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UploaderRaw int64 `db:"uploader_id"`
DeletedByRaw *int64 `db:"deleted_by"`
}
type GroupEvent struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
+2
View File
@@ -10,6 +10,7 @@ type UserProfile struct {
Signature string
Gender string
Region string
Email string
PublicAccount string
Version int
CreatedAt time.Time
@@ -23,6 +24,7 @@ type UserProfilePatch struct {
Signature *string
Gender *string
Region *string
Email *string
Version int
}
+70
View File
@@ -0,0 +1,70 @@
package types
import (
"errors"
"time"
)
var (
ErrInviteInvalid = errors.New("registration invite invalid")
ErrInviteExpired = errors.New("registration invite expired")
ErrInviteRevoked = errors.New("registration invite revoked")
ErrInviteIdempotencyConflict = errors.New("registration invite idempotency conflict")
)
type RegistrationInviteStatus string
const (
RegistrationInviteActive RegistrationInviteStatus = "active"
RegistrationInviteExpired RegistrationInviteStatus = "expired"
RegistrationInviteRevoked RegistrationInviteStatus = "revoked"
)
type RegistrationInviteBatch struct {
ID int64 `db:"id" json:"batch_id"`
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
Quantity int `db:"quantity" json:"quantity"`
IdempotencyKey string `db:"idempotency_key" json:"-"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedBy int64 `db:"created_by" json:"created_by"`
Remark string `db:"remark" json:"remark,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ActiveCount int `db:"active_count" json:"active_count"`
UsageCount int `db:"usage_count" json:"usage_count"`
ExpiredCount int `db:"expired_count" json:"expired_count"`
RevokedCount int `db:"revoked_count" json:"revoked_count"`
}
type RegistrationInviteCode struct {
ID int64 `db:"id" json:"id"`
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"`
Code string `db:"code" json:"code"`
Status RegistrationInviteStatus `db:"status" json:"status"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
UsageCount int `db:"usage_count" json:"usage_count"`
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
CreatedBy int64 `db:"created_by" json:"created_by"`
Remark string `db:"remark" json:"remark,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type RegistrationInviteListOptions struct {
Code string
Status RegistrationInviteStatus
BatchID int64
CreatedBy int64
CreatedAtStart *time.Time
CreatedAtEnd *time.Time
ExpiresAtStart *time.Time
ExpiresAtEnd *time.Time
Offset int
Limit int
}
type RegistrationInvitePage struct {
Items []RegistrationInviteCode
Total int
}
+43 -6
View File
@@ -10,6 +10,18 @@ const (
AbuseReportStatusReceived = "received"
FeedbackStatusReceived = "received"
FileReviewTaskTypeContent = "content"
FileReviewTaskTypeMalware = "malware"
FileReviewProviderAliyunGreen = "aliyun_green"
FileReviewProviderAliyunSAS = "aliyun_sas"
FileReviewStatusPending = "pending"
FileReviewStatusRunning = "running"
FileReviewStatusClean = "clean"
FileReviewStatusRisky = "risky"
FileReviewStatusFailed = "failed"
)
type RiskChallenge struct {
@@ -29,12 +41,12 @@ type RiskChallenge struct {
}
type AbuseReason struct {
TargetType string
ReasonCode string
Label string
RequiresDescription bool
AllowsEvidence bool
SortOrder int
TargetType string `db:"target_type"`
ReasonCode string `db:"reason_code"`
Label string `db:"label"`
RequiresDescription bool `db:"requires_description"`
AllowsEvidence bool `db:"allows_evidence"`
SortOrder int `db:"sort_order"`
}
type AbuseTarget struct {
@@ -84,3 +96,28 @@ type FeedbackSubmission struct {
CreatedAt time.Time
UpdatedAt time.Time
}
type FileReviewTask struct {
ID int64
TaskID string
FileID Uid
UserID Uid
TaskType string
Provider string
StorageProvider string
ObjectName string
MimeType string
FileSize int64
ProviderTaskID string
Status string
RiskLevel string
RiskLabels []string
ResultJSON map[string]any
ErrorCode string
ErrorMessage string
Attempts int
NextRetryAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt *time.Time
}
+15
View File
@@ -21,6 +21,21 @@ type RealNameVerification struct {
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
// RealNameAppeal is a user-submitted request to correct a real-name review.
type RealNameAppeal struct {
ID int64 `db:"id" json:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
UserID Uid `db:"user_id" json:"-"`
AppealID string `db:"appeal_id" json:"appeal_id"`
Reason string `db:"reason" json:"reason"`
AttachmentsJSON []byte `db:"attachments_json" json:"-"`
Attachments []string `db:"-" json:"attachments,omitempty"`
Status string `db:"status" json:"status"`
ReviewComment string `db:"review_comment" json:"review_comment,omitempty"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
// ActionToken is a short-lived one-time token for sensitive actions.
type ActionToken struct {
ID int64 `db:"id" json:"-"`
+52
View File
@@ -0,0 +1,52 @@
package types
import "time"
const (
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
TenantApplicationStatusReceived = "received"
TenantEntryFeedbackReceived = "received"
)
type PublicUpload struct {
FileID string
Purpose string
MimeType string
Size int64
ETag string
Location string
ExpiresAt time.Time
CreatedAt time.Time
UploadedAt time.Time
}
type TenantApplication struct {
ApplicationID string
CreditCode string
EnterpriseEmail string
LicenseFileID string
TenantCodeHint string
ClientContext map[string]any
IdempotencyKey string
Status string
EstimatedReviewHour int
SubmittedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type TenantEntryFeedback struct {
FeedbackID string
Description string
Contact string
TenantCodeHint string
ScreenshotFileIDs []string
ClientContext map[string]any
IdempotencyKey string
Status string
SubmittedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
+4
View File
@@ -519,6 +519,10 @@ type User struct {
LastSeen *time.Time
// User agent provided when accessing the topic last time
UserAgent string
// Canonical mobile number used for registration and account recovery.
Phone string
// Primary login account. Ordinary accounts are lowercase ASCII names; phone accounts use E.164.
Username string
Public any
Trusted any
+12 -11
View File
@@ -3,15 +3,16 @@ package types
import "time"
type WorkbenchModule struct {
ID int64 `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
IconFileID string `db:"icon_file_id"`
Route string `db:"route"`
SortOrder int `db:"sort_order"`
UnreadKey string `db:"unread_key"`
Enabled bool `db:"enabled"`
Permission string `db:"permission"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID int64 `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
IconFileID string `db:"icon_file_id"`
Route string `db:"route"`
SortOrder int `db:"sort_order"`
UnreadKey string `db:"unread_key"`
UnreadCount int `db:"unread_count"`
Enabled bool `db:"enabled"`
Permission string `db:"permission"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
+18 -4
View File
@@ -36,6 +36,16 @@
}
},
"webrtc": {
"enabled": true,
"call_establishment_timeout": 30,
"ice_servers": [
{
"urls": ["stun:stun.l.google.com:19302"]
}
]
},
"tls": {
"enabled": false,
"http_redirect": "",
@@ -45,8 +55,8 @@
"auth_config": {
"logical_names": [],
"basic": {
"add_to_tags": true,
"min_login_length": 4,
"add_to_tags": false,
"min_login_length": 1,
"min_password_length": 6
},
"token": {
@@ -80,20 +90,24 @@
"acc_validation": {
"email": {
"add_to_tags": true,
"add_to_tags": false,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "Tinode <noreply@118im.com>",
"validation_templ": "server/templ/email-validation-en.templ",
"reset_secret_templ": "server/templ/email-password-reset-en.templ",
"debug_response": "123456",
"max_retries": 3
}
},
"tel": {
"add_to_tags": true,
"add_to_tags": false,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "Tinode",
"universal_templ": "server/templ/sms-universal-en.templ",
"debug_response": "123456",
"max_retries": 3
}
+12 -1
View File
@@ -3071,6 +3071,10 @@ func (t *Topic) replySetTags(sess *Session, asUid types.Uid, msg *ClientComMessa
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("invalid topic category to assign tags")
}
if t.cat == types.TopicCatMe {
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("user tags are no longer supported")
}
if t.cat == types.TopicCatGrp && t.owner != asUid {
sess.queueOut(ErrPermissionDeniedReply(msg, now))
@@ -3168,7 +3172,6 @@ func (t *Topic) replyGetCreds(sess *Session, asUid types.Uid, msg *ClientComMess
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("invalid topic category for getting credentials")
}
screds, err := store.Users.GetAllCreds(sess.tenantID, asUid, "", false)
if err != nil {
sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, msg.Timestamp, nil))
@@ -3206,6 +3209,10 @@ func (t *Topic) replySetCred(sess *Session, asUid types.Uid, authLevel auth.Leve
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("invalid topic category for updating credentials")
}
if set.Cred.Method == "tel" || set.Cred.Method == "email" {
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("phone and email are managed by product profile APIs")
}
var err error
var tags []string
@@ -3484,6 +3491,10 @@ func (t *Topic) replyDelCred(sess *Session, asUid types.Uid, authLvl auth.Level,
sess.queueOut(ErrMalformedReply(msg, now))
return errors.New("del.cred: missing method")
}
if del.Cred.Method == "tel" || del.Cred.Method == "email" {
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("phone and email are managed by product profile APIs")
}
tags, err := deleteCred(sess.tenantID, asUid, authLvl, del.Cred)
if tags != nil {
+271 -92
View File
@@ -2,6 +2,7 @@ package main
import (
"container/heap"
crand "crypto/rand"
"math/rand"
"strings"
"time"
@@ -34,24 +35,57 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Find authenticator for the requested scheme.
authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)
if authhdl == nil {
if authhdl == nil || msg.Acc.Scheme != "basic" {
// New accounts must have an authentication scheme
s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
logs.Warn.Println("create user: unknown auth handler, sid=", s.sid)
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)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"}))
return
}
authSecret := msg.Acc.Secret
if account != secretAccount {
authSecret = registrationSecretForAccount(msg.Acc.Secret, account)
}
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
return
}
// Check if login is unique and compliance with the policy (not too long or too short).
authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}
if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok {
logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
map[string]any{"what": "auth"}))
return
for attempts := 0; ; attempts++ {
if ok, err := authhdl.IsUnique(authCtx, authSecret); ok {
break
} else if account != secretAccount && err == types.ErrDuplicate && attempts < 8 {
account, err = randomRegistrationUsername()
if err != nil {
logs.Warn.Println("create user: failed to generate username", err, "sid=", s.sid)
s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp))
return
}
authSecret = registrationSecretForAccount(msg.Acc.Secret, account)
continue
} else {
logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
map[string]any{"what": "auth"}))
return
}
}
var user types.User
user.TenantID = s.tenantID
user.Username = account
user.Phone = phone
var private any
// If account state is being assigned, make sure the sender is a root user.
@@ -73,30 +107,77 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
user.State = state
}
// Ensure tags are unique and not restricted.
if tags := normalizeTags(msg.Acc.Tags, globals.maxTagCount); tags != nil {
if !restrictedTagsEqual(tags, nil, globals.immutableTagNS) {
logs.Warn.Println("create user: attempt to directly assign restricted tags, sid=", s.sid)
msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp)
msg.Ctrl.Params = map[string]any{"what": "tags"}
s.queueOut(msg)
return
}
user.Tags = tags
if msg.Acc.Tags != nil {
logs.Warn.Println("create user: user tags are no longer supported, sid=", s.sid)
msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp)
msg.Ctrl.Params = map[string]any{"what": "tags"}
s.queueOut(msg)
return
}
// Pre-check credentials for validity. We don't know user's access level
// consequently cannot check presence of required credentials. Must do that later.
creds := normalizeCredentials(msg.Acc.Cred, true)
var registrationPhone string
var registrationTelCredential bool
for i := range creds {
cr := &creds[i]
vld := store.Store.GetValidator(cr.Method)
if _, err := vld.PreCheck(cr.Value, cr.Params); err != nil {
if vld == nil || !vld.IsInitialized() {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": cr.Method}))
return
}
normalized, err := vld.PreCheck(cr.Value, cr.Params)
if err != nil {
logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
map[string]any{"what": cr.Method}))
return
}
if cr.Method == "tel" {
phone := strings.TrimSpace(strings.TrimPrefix(normalized, "tel:"))
challengeID, _ := cr.Params["challenge_id"].(string)
if phone == "" || 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"}}))
return
}
registrationPhone = phone
registrationTelCredential = true
}
}
if !phoneRegistration && registrationTelCredential {
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
return
}
if phoneRegistration && registrationTelCredential && registrationPhone != phone {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
return
}
smsEnabled := registrationSMSEnabled(time.Now().UTC())
if phoneRegistration && smsEnabled && !registrationTelCredential {
logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
return
}
inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential)
if inviteRequired && inviteCode == "" {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"}))
return
}
if inviteCode != "" {
if err := validateRegistrationInvite(s.tenantID, inviteCode, time.Now().UTC()); err != nil {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
return
}
}
// Assign default access values in case the acc creator has not provided them
@@ -130,6 +211,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
private = msg.Acc.Desc.Private
}
}
user.Public = registrationPublicWithUsername(user.Public, user.Username)
// Create user record in the database.
if _, err := store.Users.Create(s.tenantID, &user, private); err != nil {
@@ -138,9 +220,9 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
// Add authentication record. The authhdl.AddRecord may change tags.
rec, err := authhdl.AddRecord(authCtx,
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret)
// Add authentication record.
rec, err = authhdl.AddRecord(authCtx,
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, authSecret)
if err != nil {
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
@@ -167,14 +249,14 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
// Save credentials, update tags if necessary.
// Save and validate credentials.
tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{
TenantID: s.tenantID,
Uid: user.Uid(),
AuthLevel: auth.LevelAuth,
Lifetime: auth.Duration(time.Hour * 24),
})
validated, _, err := addCreds(s.tenantID, user.Uid(), creds, rec.Tags, s.lang, tmpToken)
validated, _, err := addCreds(s.tenantID, user.Uid(), creds, nil, s.lang, tmpToken)
if err != nil {
logs.Warn.Println("create user: failed to save or validate credential", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
@@ -185,6 +267,34 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
return
}
if registrationTelCredential && !slices.Contains(validated, "tel") {
logs.Warn.Println("create user: phone credential was not validated, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil {
logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid)
}
return
}
if inviteCode != "" {
requestID := strings.TrimSpace(msg.Id)
if requestID == "" {
requestID = "acc:" + s.sid
}
if len(requestID) > 64 {
requestID = requestID[:64]
}
if err = store.RegistrationInvites.RecordUse(s.tenantID, inviteCode, user.Uid(), requestID, time.Now().UTC()); err != nil {
logs.Warn.Println("create user: record invite use failed", err, "sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
if deleteErr := store.Users.Delete(s.tenantID, user.Uid(), true); deleteErr != nil {
logs.Warn.Println("create user: failed to delete incomplete user record", deleteErr, "sid=", s.sid)
}
return
}
dispatchIMBusinessEvents(s.tenantID)
}
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil {
@@ -208,6 +318,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
params := reply.Ctrl.Params.(map[string]any)
params["username"] = user.Username
params["desc"] = &MsgTopicDesc{
CreatedAt: &user.CreatedAt,
UpdatedAt: &user.UpdatedAt,
@@ -224,6 +335,135 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
pluginAccount(&user, plgActCreate)
}
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone, secretAccount string, phoneRegistration bool, err error) {
if phone != "" {
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
return "", "", "", false, types.ErrPolicy
}
canonicalPhone, err = normalizeIMPhone(phone, "CN")
if err != nil {
return "", "", "", false, err
}
if username == "" {
account, err = randomRegistrationUsername()
if err != nil {
return "", "", "", false, err
}
return account, canonicalPhone, canonicalPhone, true, nil
}
if err = validateRegistrationUsername(username); err != nil {
return "", "", "", false, err
}
return username, canonicalPhone, username, true, nil
}
if err = validateRegistrationUsername(username); err != nil {
return "", "", "", false, err
}
return username, "", username, false, nil
}
func validateRegistrationUsername(username string) error {
if username == "" || len(username) > 10 {
return types.ErrPolicy
}
for i := range username {
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
return types.ErrPolicy
}
}
return nil
}
func asciiRegistrationDigits(value string) bool {
for i := range value {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return value != ""
}
func registrationSecretMatches(secret []byte, account string) bool {
separator := strings.IndexByte(string(secret), ':')
return separator > 0 && string(secret[:separator]) == account
}
func registrationSecretForAccount(secret []byte, account string) []byte {
separator := strings.IndexByte(string(secret), ':')
if separator < 0 {
return secret
}
return []byte(account + string(secret[separator:]))
}
func randomRegistrationUsername() (string, error) {
const letters = "abcdefghijklmnopqrstuvwxyz"
const digits = "0123456789"
var raw [9]byte
if _, err := crand.Read(raw[:]); err != nil {
return "", err
}
out := make([]byte, 9)
for i := 0; i < 3; i++ {
out[i] = letters[int(raw[i])%len(letters)]
}
for i := 3; i < 9; i++ {
out[i] = digits[int(raw[i])%len(digits)]
}
return string(out), nil
}
func registrationPublicWithUsername(public any, username string) any {
if username == "" {
return public
}
if public == nil {
return map[string]any{"fn": username}
}
if values, ok := public.(map[string]any); ok {
values["fn"] = username
return values
}
return map[string]any{"fn": username}
}
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
if store.RegistrationInvites == nil {
return types.ErrInternal
}
invite, err := store.RegistrationInvites.GetByCode(tenantID, code)
if err != nil {
return err
}
if invite == nil {
return types.ErrInviteInvalid
}
switch invite.Status {
case types.RegistrationInviteRevoked:
return types.ErrInviteRevoked
case types.RegistrationInviteExpired:
return types.ErrInviteExpired
case types.RegistrationInviteActive:
if !now.Before(invite.ExpiresAt) {
return types.ErrInviteExpired
}
return nil
default:
return types.ErrInviteInvalid
}
}
func registrationInviteReason(err error) string {
switch err {
case types.ErrInviteExpired:
return "INVITE_CODE_EXPIRED"
case types.ErrInviteRevoked:
return "INVITE_CODE_REVOKED"
default:
return "INVITE_CODE_INVALID"
}
}
// Process update to an account:
// * Authentication update, i.e. login/password change
// * Credentials update
@@ -351,17 +591,11 @@ func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types.
// TODO(gene): support adding new auth schemes
rec, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr},
&auth.Rec{TenantID: tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret)
_, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr},
&auth.Rec{TenantID: tenantID, Uid: user.Uid()}, msg.Acc.Secret)
if err != nil {
return err
}
// Tags may have been changed by authhdl.UpdateRecord, reset them.
// Can't do much with the error here, logging it but not returning.
if _, err = store.Users.UpdateTags(tenantID, user.Uid(), nil, nil, rec.Tags); err != nil {
logs.Warn.Println("updateUserAuth tags update failed:", err)
}
return nil
}
@@ -369,11 +603,8 @@ func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types.
return types.ErrMalformed
}
// addCreds adds new credentials and re-send validation request for existing ones.
// It also adds credential-defined tags if necessary.
// Returns methods validated in this call only. Returns either a full set of tags
// or nil for tags when tags are unchanged.
func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, extraTags []string,
// addCreds adds credentials and re-sends validation requests for existing ones.
func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, _ []string,
lang string, tmpToken []byte) ([]string, []string, error) {
var validated []string
for i := range creds {
@@ -385,14 +616,11 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
}
if cr.Response != "" {
if value, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil {
if _, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil {
if err != nil {
return nil, nil, err
}
validated = append(validated, cr.Method)
if globals.validators[cr.Method].addToTags {
extraTags = append(extraTags, cr.Method+":"+value)
}
continue
}
}
@@ -407,24 +635,9 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
// successfully validated.
validated = append(validated, cr.Method)
// Generate tags for these confirmed credentials.
if globals.validators[cr.Method].addToTags {
extraTags = append(extraTags, cr.Method+":"+cr.Value)
}
}
}
// Save tags potentially changed by the validator.
if len(extraTags) > 0 {
if utags, err := store.Users.UpdateTags(tenantID, uid, extraTags, nil, nil); err == nil {
extraTags = utags
} else {
logs.Warn.Println("add cred tags update failed:", err)
}
} else {
extraTags = nil
}
return validated, extraTags, nil
return validated, nil, nil
}
func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient,
@@ -475,7 +688,6 @@ func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *
// validatedCreds returns the list of validated credentials including those validated in this call.
// Returns all validated methods including those validated earlier and now.
// Returns either a full set of tags or nil for tags if tags are unchanged.
func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, creds []MsgCredClient,
errorOnFail bool) ([]string, []string, error) {
// Check if credential validation is required.
@@ -497,7 +709,6 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
// Add credentials which are validated in this call.
// Unknown validators are removed.
creds = normalizeCredentials(creds, false)
var tagsToAdd []string
for i := range creds {
cr := &creds[i]
if cr.Response == "" {
@@ -506,7 +717,7 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
}
vld := store.Store.GetValidator(cr.Method) // No need to check for nil, unknown methods are removed earlier.
value, err := vld.Check(tenantID, uid, cr.Response)
_, err := vld.Check(tenantID, uid, cr.Response)
if err != nil {
// Check failed.
@@ -525,23 +736,6 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
// Check did not return an error: the request was successfully validated.
methods[cr.Method] = struct{}{}
// Add validated credential to user's tags.
if globals.validators[cr.Method].addToTags {
tagsToAdd = append(tagsToAdd, cr.Method+":"+value)
}
}
var tags []string
if len(tagsToAdd) > 0 {
// Save update to tags
if utags, err := store.Users.UpdateTags(tenantID, uid, tagsToAdd, nil, nil); err == nil {
tags = utags
} else {
logs.Warn.Println("validated creds tags update failed:", err)
tags = nil
}
} else {
tags = nil
}
validated := make([]string, 0, len(methods))
@@ -549,11 +743,10 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
validated = append(validated, method)
}
return validated, tags, nil
return validated, nil, nil
}
// deleteCred deletes user's credential.
// Returns full set of remaining tags or nil if tags are unchanged.
func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) {
vld := store.Store.GetValidator(cred.Method)
if vld == nil || cred.Value == "" {
@@ -601,21 +794,7 @@ func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred
return nil, err
}
// Remove generated tags for the deleted credential.
var tags []string
if globals.validators[cred.Method].addToTags {
// This error should not be returned to user.
if utags, err := store.Users.UpdateTags(tenantID, uid, nil, []string{cred.Method + ":" + cred.Value}, nil); err == nil {
tags = utags
} else {
logs.Warn.Println("delete cred: failed to update tags:", err)
tags = nil
}
} else {
tags = nil
}
return tags, nil
return nil, nil
}
// Change user state: suspended/normal (ok).
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"regexp"
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func TestResolveRegistrationAccount(t *testing.T) {
tests := []struct {
name string
username string
phone string
wantAccount string
wantPhone string
wantSecret string
wantPhoneFlow bool
wantGenerated bool
wantErr bool
}{
{name: "username", username: "alice1", wantAccount: "alice1", wantSecret: "alice1"},
{name: "numeric username", username: "12345", wantAccount: "12345", wantSecret: "12345"},
{name: "username and phone", username: "alice1", phone: "13800138000", wantAccount: "alice1", wantPhone: "+8613800138000", wantSecret: "alice1", wantPhoneFlow: true},
{name: "phone only", phone: "13800138000", wantPhone: "+8613800138000", wantSecret: "+8613800138000", wantPhoneFlow: true, wantGenerated: true},
{name: "uppercase with phone rejected", username: "Alice", phone: "13800138000", wantErr: true},
{name: "uppercase rejected", username: "Alice", wantErr: true},
{name: "long username rejected", username: "abcdefghijk", wantErr: true},
{name: "malformed phone does not fall back", username: "alice", phone: "1380013800", wantErr: true},
{name: "phone whitespace rejected", username: "alice", phone: " 13800138000", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
account, phone, secretAccount, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone)
if (err != nil) != tc.wantErr || phone != tc.wantPhone || secretAccount != tc.wantSecret || phoneFlow != tc.wantPhoneFlow {
t.Fatalf("got account=%q phone=%q secret=%q phoneFlow=%v err=%v", account, phone, secretAccount, phoneFlow, err)
}
if tc.wantGenerated {
if !regexp.MustCompile(`^[a-z]{3}[0-9]{6}$`).MatchString(account) {
t.Fatalf("generated account %q does not match expected format", account)
}
} else if account != tc.wantAccount {
t.Fatalf("got account=%q want %q", account, tc.wantAccount)
}
})
}
}
func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) {
if !registrationSecretMatches([]byte("alice:password:with-colon"), "alice") {
t.Fatal("matching account was rejected")
}
if registrationSecretMatches([]byte("bob:password"), "alice") || registrationSecretMatches([]byte("password"), "alice") {
t.Fatal("mismatched or malformed secret was accepted")
}
}
func TestRegistrationSecretForAccount(t *testing.T) {
if got := string(registrationSecretForAccount([]byte("+8613800138000:secret"), "abcxyz123456")); got != "abcxyz123456:secret" {
t.Fatalf("got %q", got)
}
}
func TestRegistrationPublicWithUsername(t *testing.T) {
if got := registrationPublicWithUsername(nil, "alice").(map[string]any)["fn"]; got != "alice" {
t.Fatalf("got fn=%v", got)
}
public := map[string]any{"fn": "old", "photo": "file1"}
got := registrationPublicWithUsername(public, "bob").(map[string]any)
if got["fn"] != "bob" || got["photo"] != "file1" {
t.Fatalf("got public=%v", got)
}
}
func TestValidateRegistrationInviteStates(t *testing.T) {
oldInvites := store.RegistrationInvites
defer func() { store.RegistrationInvites = oldInvites }()
now := time.Now().UTC()
tests := []struct {
status types.RegistrationInviteStatus
expires time.Time
want error
}{
{types.RegistrationInviteActive, now.Add(time.Minute), nil},
{types.RegistrationInviteActive, now, types.ErrInviteExpired},
{types.RegistrationInviteRevoked, now.Add(time.Minute), types.ErrInviteRevoked},
{types.RegistrationInviteExpired, now.Add(time.Minute), types.ErrInviteExpired},
}
for _, tc := range tests {
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{Status: tc.status, ExpiresAt: tc.expires}}
if got := validateRegistrationInvite(7, "AbCdE", now); got != tc.want {
t.Fatalf("status=%q: got %v want %v", tc.status, got, tc.want)
}
}
}
+40 -1
View File
@@ -173,8 +173,17 @@ func TestServeIMPasswordResetConfirm(t *testing.T) {
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic").
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42)).
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).
Return(&types.User{Phone: "+8613800138000"}, nil)
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
if cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
t.Fatalf("unexpected reset credential: %#v", cred)
}
return true, nil
})
store.Users = users
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
basicAuth.EXPECT().IsInitialized().Return(true)
@@ -212,12 +221,14 @@ type testSecurityStore struct {
bumpedUser types.Uid
realName *types.RealNameVerification
realNameAppeal *types.RealNameAppeal
actionToken *types.ActionToken
independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings
attempts int
lockedUntil *time.Time
notifications []types.SystemNotification
unreadNotifications int
readNotificationID string
realNameTenant types.TenantID
realNameUser types.Uid
@@ -226,6 +237,8 @@ type testSecurityStore struct {
listCursor string
listLimit int
nextCursor string
unreadTenant types.TenantID
unreadUser types.Uid
readTenant types.TenantID
readUser types.Uid
}
@@ -264,6 +277,17 @@ func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types
return s.err
}
func (s *testSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) {
s.realNameAppeal = appeal
if s.err != nil {
return nil, s.err
}
now := time.Now().UTC().Round(time.Millisecond)
appeal.CreatedAt = now
appeal.UpdatedAt = now
return appeal, nil
}
func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error {
s.actionToken = token
return s.err
@@ -359,6 +383,21 @@ func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid
return s.notifications, s.nextCursor, s.err
}
func (s *testSecurityStore) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) {
s.unreadTenant = tenantID
s.unreadUser = uid
if s.unreadNotifications != 0 {
return s.unreadNotifications, s.err
}
count := 0
for _, notification := range s.notifications {
if notification.ReadAt == nil {
count++
}
}
return count, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid
+26 -1
View File
@@ -129,7 +129,7 @@ func (*validator) PreCheck(cred string, params map[string]any) (string, error) {
}
countryCode, ok := params["countryCode"].(string)
if !ok {
countryCode = "US"
countryCode = "CN"
}
number, err := phonenumbers.Parse(cred, countryCode)
if err != nil {
@@ -214,6 +214,31 @@ func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string,
return nil
}
// SendCode synchronously delivers a product verification code. Unlike the
// legacy credential flow, it fails when no real SMS provider is configured.
func (v *validator) SendCode(_ t.TenantID, phone, lang, scene, code string) error {
if v.Twilio == nil {
return t.ErrUnsupported
}
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.universalTempl[idx]
} else if len(v.universalTempl) > 0 {
template = v.universalTempl[0]
}
if template == nil {
return t.ErrInternal
}
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
"Code": code, "HostUrl": v.HostUrl, "Scene": scene,
})
if err != nil {
return err
}
return twilioSend(v.Sender, phone, content[""])
}
// Check checks validity of user's response.
func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) {
cred, err := store.Users.GetActiveCred(tenantID, user, validatorName)
+13
View File
@@ -0,0 +1,13 @@
package tel
import "testing"
func TestPreCheckDefaultsToChina(t *testing.T) {
got, err := (&validator{}).PreCheck("13800138000", map[string]any{})
if err != nil {
t.Fatal(err)
}
if got != "tel:+8613800138000" {
t.Fatalf("got %q, want tel:+8613800138000", got)
}
}
+6
View File
@@ -58,6 +58,12 @@ type Validator interface {
TempAuthScheme() (string, error)
}
// CodeSender is an optional synchronous delivery capability used by product
// verification challenges which exist before a user account is created.
type CodeSender interface {
SendCode(tenantID t.TenantID, recipient, lang, scene, code string) error
}
func ValidateHostURL(origUrl string) (string, error) {
hostUrl, err := url.Parse(origUrl)
if err != nil {
+3 -10
View File
@@ -27,7 +27,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
// Add authentication record
authHandler := store.Store.GetAuthHandler("basic")
authHandler.Init([]byte(`{"add_to_tags": true}`), "basic")
authHandler.Init([]byte(`{"add_to_tags": false}`), "basic")
nameIndex := make(map[string]string, len(data.Users))
@@ -40,12 +40,13 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
}
user := types.User{
TenantID: tenantID,
Username: uu.Username,
Phone: uu.Tel,
State: state,
Access: types.DefaultAccess{
Auth: types.ModeCAuth,
Anon: types.ModeNone,
},
Tags: uu.Tags,
Public: parsePublic(&uu.Public, data.datapath),
}
if !uu.Trusted.IsZero() {
@@ -53,14 +54,6 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
}
user.CreatedAt = getCreatedTime(uu.CreatedAt)
user.Tags = append(user.Tags, "basic:"+uu.Username)
if uu.Email != "" {
user.Tags = append(user.Tags, "email:"+uu.Email)
}
if uu.Tel != "" {
user.Tags = append(user.Tags, "tel:"+uu.Tel)
}
// store.Users.Create will subscribe user to !me topic but won't create a !me topic
if _, err := store.Users.Create(tenantID, &user, uu.Private); err != nil {
log.Fatal(err)
+3 -3
View File
@@ -321,17 +321,17 @@ func main() {
if userId.IsZero() {
log.Fatalf("Must specify a valid user ID '%s' to promote to ROOT", *makeRoot)
}
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId, "basic")
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId)
if err != nil {
log.Fatalln("Failed to read user authentication record", err)
}
tenantAdapter, ok := adapter.(interface {
TenantAuthUpdRecord(types.TenantID, types.Uid, string, string, auth.Level, []byte, time.Time) error
TenantAuthUpdateRecord(types.TenantID, types.Uid, string, auth.Level, []byte, time.Time) error
})
if !ok {
log.Fatalln("Configured adapter does not support tenant authentication")
}
if err = tenantAdapter.TenantAuthUpdRecord(tenantID, userId, "basic", "basic:"+login,
if err = tenantAdapter.TenantAuthUpdateRecord(tenantID, userId, login,
auth.LevelRoot, nil, time.Time{}); err != nil {
log.Fatalln("Failed to promote user to ROOT", err)
}