Compare commits

...
29 Commits
Author SHA1 Message Date
richjohnrambo d10c05b030 fix: decode bearer tokens for IM HTTP 2026-08-17 15:08:32 +08:00
richjohnrambo b7a5d5cb17 fix: align message revision schema with tenant databases 2026-08-17 14:54:50 +08:00
richjohnrambo e14dec9af7 fix: keep routed tenants on scoped mysql adapter 2026-08-17 14:49:23 +08:00
richjohnrambo 47968e39fa fix: use tenant database auth schema 2026-08-17 14:43:17 +08:00
richjohnrambo 4ec84f961b fix: allow phone number as basic login 2026-08-17 14:28:24 +08:00
richjohnrambo cf590a1c78 chore: point tinode db config to remote mysql 2026-08-17 13:58:09 +08:00
richjohnrambo f2c229135b feat: complete IM P0 and P1 reliability work 2026-08-17 13:43:27 +08:00
richjohnrambo ddf0f7a55e feat: persist group business state in tables 2026-08-17 09:50:31 +08:00
richjohnrambo d8d1639d5b fix mysql tenant database schema cleanup 2026-08-15 15:48:17 +08:00
richjohnrambo 672fc68cfa Merge remote-tracking branch 'origin/dev' into dev 2026-08-15 15:07:04 +08:00
richjohnrambo 7888c87634 im 服务端 2026-08-15 15:06:06 +08:00
Your Name a7c5bc9bbd 修复登录500: auth/credentials表缺少tenant_id列
登录时报 Error 1054: Unknown column 'tenant_id' in 'where clause'。

根因:CreateDb里auth/credentials的建表语句从未定义过tenant_id列——这不是
版本跳过导致的迁移遗漏,而是即便全新安装也会触发的建表语句本身的缺陷,
因为查询代码(AuthAddRecord/AuthGetUniqueRecord等)一直在无条件地按
tenant_id过滤/写入这两张表。

同时migration脚本里的dropTenantIDColumns此前会把auth/credentials的
tenant_id也当成"新增多租户特性表的遗留列"一并删掉,跟前面加的列直接打架。

修复:
- adapter.go: CreateDb的auth/credentials建表语句补上tenant_id列,
  修正未来全新安装的根源问题
- apply_hotgo_tenant_db.go: 补上ALTER TABLE ADD COLUMN(现有数据回填到
  默认租户id=1);dropTenantIDColumns排除auth/credentials,不再误删
2026-08-15 14:35:02 +08:00
Your Name 76ca6147ec 修复{hi}握手拒绝未携带tenant字段的客户端
多租户改造给{hi}握手消息加了必填的tenant字段,但目前的im_webapp(以及
现有iOS/Android客户端)都是标准Tinode SDK,不知道要发这个新字段,导致
每次连接握手都被判定为malformed(400),前端表现为反复断线重连。

改为tenant为空时回退到默认租户"default"(对应迁移脚本里seed的那条记录),
不再直接拒绝;显式携带tenant code的客户端行为不受影响,仅长度校验保留。
2026-08-15 14:25:59 +08:00
Your Name b01ed29751 修复迁移脚本: 补全CreateDb版本跳过导致缺失的表
CreateDb的多个建表函数(createTenantRegistry/createIMSecurityTables/
createIMNotificationTables/createIMRelationTables)只在全新库的建表路径里
调用;UpgradeDb对已存在且版本号已达标的库直接判定"无需升级"直接跳过,
不会补建任何表。我们这个tinode库版本号刚好已经等于目标版本,所以这几个
函数从未真正执行过。

补上迁移脚本里缺失的表(与adapter.go里的建表语句保持一致):
tinode.im_tenant / im_tenant_config (原脚本假设已存在,直接ALTER导致报错)
tinode.im_realname_verifications / im_action_tokens
tinode.im_system_notifications
tinode.im_friend_requests / im_friendships / im_user_blocks / im_favorites
2026-08-15 14:17:53 +08:00
Your Name 57274778b5 接入Gitea Actions蓝绿部署:本地源码构建替代release下载,接入租户注册表迁移
- 新增docker/tinode/Dockerfile.build:从本仓库源码编译server/tinode-db/
  apply_hotgo_tenant_db,替代原Dockerfile下载GitHub release的方式(否则
  workbench/组织架构/会议等改造代码永远不会真正跑起来)
- config.template补上连接池设置(max_open_conns等,此前im_server踩过注册卡死
  的坑),并启用tenant_db_routing+tenant_registry_db=hotgo,对应本仓库自带
  tinode.conf里的默认约定,单租户场景下会透明落回同一个tinode库
- entrypoint.sh在init-db之后追加执行apply-hotgo-tenant-db,幂等地建好/
  升级hotgo租户注册表及tinode库里的workbench/组织架构/会议/日报/信箱等新表
- 新增.gitea/workflows/deploy.yml,复用服务器上已有的蓝绿部署脚本
2026-08-15 14:08:07 +08:00
richjohnrambo 5af396c5f5 im 服务端 2026-08-15 01:33:23 +08:00
richjohnrambo 7c360167b3 单元测试 2026-08-14 23:46:28 +08:00
richjohnrambo 04c7a148e1 单元测试 2026-08-14 23:37:31 +08:00
richjohnrambo 170bbdca76 feat: add safety reporting APIs 2026-08-14 00:01:54 +08:00
richjohnrambo fa75c0f0f9 feat: add workbench and client settings APIs 2026-08-13 23:43:24 +08:00
richjohnrambo dfcccbb761 feat: add chapter eight call records 2026-08-13 16:14:57 +08:00
richjohnrambo 92d0066a02 feat: support tenant database routing 2026-08-13 15:07:06 +08:00
richjohnrambo 31f82d9c41 feat: add im tenant db routing and chapter seven flows 2026-08-13 11:30:48 +08:00
richjohnrambo 774a448c12 feat: add im group mention flows 2026-08-12 15:33:56 +08:00
richjohnrambo 943ebe1397 feat: complete im p2p relationship onboarding 2026-08-12 15:20:15 +08:00
richjohnrambo d02041659e feat: add im relationship flows 2026-08-12 11:31:16 +08:00
richjohnrambo 5bd35f97a6 feat: add im home and message action flows 2026-08-12 10:36:41 +08:00
richjohnrambo d239e37f56 feat: add im auth verification flows 2026-08-12 00:20:27 +08:00
richjohnramboandCursor 1ffcb85bee docs: move IM-WS-API-CN to obsolete, remove IM-WS-TECH-DESIGN-CN
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:08:07 +08:00
87 changed files with 30510 additions and 3136 deletions
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Backend (Blue-Green)
on:
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,留空则使用commit短哈希'
required: false
jobs:
deploy:
runs-on: deploy-host
steps:
- uses: actions/checkout@v4
- name: 确定镜像tag
id: vars
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 构建镜像(从本仓库源码编译,非下载官方release)
run: docker build -t im-server:${{ steps.vars.outputs.tag }} -f docker/tinode/Dockerfile.build .
- name: 蓝绿部署
env:
MYSQL_DSN: ${{ secrets.MYSQL_DSN }}
REDIS_ADDR: ${{ secrets.REDIS_ADDR }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
UID_ENCRYPTION_KEY: ${{ secrets.UID_ENCRYPTION_KEY }}
run: |
IMAGE=im-server:${{ steps.vars.outputs.tag }} /opt/apps/im-server/deploy.sh
+12
View File
@@ -1,3 +1,15 @@
/.github/
/templ/
/templ/
# macOS metadata.
.DS_Store
# IDE workspace files.
.idea/
# Local Go build output.
/server/server
/.codex-gocache/
/.github/
/.github/
@@ -0,0 +1,183 @@
# IM 前后端对接文档 1-11 章单元测试用例
本文基于 `IM-前后端对接文档.md` 与当前实现代码整理,默认按 `httptest` + mock store + fake auth/media/push store 的方式落单元测试。
## 0. 验收准备
### 0.1 基础环境
| [ ] 项目 | 准备项 | 说明 |
|---|---|---|
| [ ] ENV-01 | 测试租户 A/B | A、B 都是 `active`,且准备一个 `suspended` 租户用于负向验证 |
| [ ] ENV-02 | 统一 API Key | 使用测试 API Key,覆盖 WS/HTTP 两类入口 |
| [ ] ENV-03 | 固定时间 | 对 Token、验证码、重置票据、过期时间使用可冻结时钟 |
| [ ] ENV-04 | Mock 存储 | `store.Users`、`store.Tenants`、`store.Verifications`、`store.Security`、`store.Files`、`store.Store` 可注入 mock/fake |
| [ ] ENV-05 | 文件沙箱 | 文件下载/上传路径使用临时目录,避免污染真实对象存储 |
| [ ] ENV-06 | 多设备上下文 | 至少准备同一用户的两个 session,验证退出/踢出/读回执聚合 |
| [ ] ENV-07 | 负向数据 | 同名 `alice`、同手机号、同群名、同 tag、同 file id 的 A/B 对照数据 |
### 0.2 章节映射
| [ ] 章 | 主要入口/能力 | 代码证据 | 现有测试证据 |
|---|---|---|---|
| [ ] MAP-01 | 第1章 账号注册、登录与身份安全 | [server/main.go](/Users/superysh/im/im_server/server/main.go:780), [server/session.go](/Users/superysh/im/im_server/server/session.go:755), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19) | [server/session_test.go](/Users/superysh/im/im_server/server/session_test.go:38), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19), [server/auth/token/auth_token_test.go](/Users/superysh/im/im_server/server/auth/token/auth_token_test.go:1) |
| [ ] MAP-02 | 第2章 消息首页、全局导航与系统通知 | [server/main.go](/Users/superysh/im/im_server/server/main.go:791), [server/http.go](/Users/superysh/im/im_server/server/http.go:1) | 以 bootstrap / system notification handler 单测补齐 |
| [ ] MAP-03 | 第3章 单聊会话、会话建立与消息状态 | [server/main.go](/Users/superysh/im/im_server/server/main.go:821), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10), [server/topic_test.go](/Users/superysh/im/im_server/server/topic_test.go:1) |
| [ ] MAP-04 | 第4章 消息输入、媒体内容与消息操作 | [server/main.go](/Users/superysh/im/im_server/server/main.go:827), [server/datamodel.go](/Users/superysh/im/im_server/server/datamodel.go:1) | 需补 `pub/get/set/del/note` 行为单测 |
| [ ] MAP-05 | 第5章 通讯录、好友关系与黑名单 | [server/main.go](/Users/superysh/im/im_server/server/main.go:833), [server/main.go](/Users/superysh/im/im_server/server/main.go:835) | 需补 friend/block/qrcode 单测 |
| [ ] MAP-06 | 第6章 群聊创建、资料维护与成员治理 | [server/main.go](/Users/superysh/im/im_server/server/main.go:829), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10) |
| [ ] MAP-07 | 第7章 文件下载、收藏管理与聊天记录检索 | [server/main.go](/Users/superysh/im/im_server/server/main.go:824), [server/hdl_files.go](/Users/superysh/im/im_server/server/hdl_files.go:38) | [server/hdl_files_test.go](/Users/superysh/im/im_server/server/hdl_files_test.go:11), [server/media/fs/filesys_tenant_test.go](/Users/superysh/im/im_server/server/media/fs/filesys_tenant_test.go:11) |
| [ ] MAP-08 | 第8章 实时语音与视频通话 | [server/main.go](/Users/superysh/im/im_server/server/main.go:826), [server/calls.go](/Users/superysh/im/im_server/server/calls.go:1) | 需补 call 信令状态机单测 |
| [ ] MAP-09 | 第9章 企业工作台与办公协同 | [server/main.go](/Users/superysh/im/im_server/server/main.go:792), [server/main.go](/Users/superysh/im/im_server/server/main.go:796) | 需补 workbench/org/meeting/report/mailbox 单测 |
| [ ] MAP-10 | 第10章 个人资料、通用设置、隐私与青少年模式 | [server/main.go](/Users/superysh/im/im_server/server/main.go:812), [server/main.go](/Users/superysh/im/im_server/server/main.go:815) | [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:104) |
| [ ] MAP-11 | 第11章 反诈安全、举报与意见反馈 | [server/main.go](/Users/superysh/im/im_server/server/main.go:785), [server/main.go](/Users/superysh/im/im_server/server/main.go:798) | 需补 risk/abuse/feedback 单测 |
### 0.3 现有护栏证据
| [ ] 证据 | 已覆盖的护栏 | 说明 |
|---|---|---|
| [ ] EVD-01 | Tenant 0 直接拒绝 | `store.ForTenant(0)` 已有测试 |
| [ ] EVD-02 | 同名 topic 按 tenant 隔离 | Hub tenant 测试已覆盖同名 `sys` 分离 |
| [ ] EVD-03 | 文件路径按 tenant 命名空间化 | tenant 文件路径测试已覆盖 |
| [ ] EVD-04 | Push channel 按 tenant 命名空间化 | tenant channel 测试已覆盖 |
| [ ] EVD-05 | 文件 HTTP 认证返回 Principal | 文件认证测试已覆盖 tenant/uid 绑定 |
| [ ] EVD-06 | Verification / reset 流程按 tenant 处理 | `user_verification_test.go` 已覆盖验证与重置主流程 |
## 1. 第1章 账号注册、登录与身份安全
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T01-01 | 企业码握手成功 | `hi.tenant=acme`,租户为 `active` | Session 只绑定一次 tenant;返回公开 tenant 信息,不暴露内部 `tenant_id` |
| [ ] T01-02 | 企业码缺失或非法 | `hi` 不带 tenant / 空白 / 超长 | 返回 400 类错误,Session 不绑定 tenant |
| [ ] T01-03 | 已绑定 Session 再发另一个 tenant | 先绑定 A,再发 B | 返回冲突类错误,tenant 不变化 |
| [ ] T01-04 | basic 登录同名跨租户 | A/B 都有 `alice`,分别用各自密码登录 | 只命中当前 tenant 的 auth 记录,token 携带当前 tenant |
| [ ] T01-05 | token 登录 tenant 不匹配 | A token 放进 B session | 认证失败,不写入 uid/authLvl |
| [ ] T01-06 | 注册验证码 tenant 隔离 | A、B 同手机号注册 | 只消费当前 tenant 的验证码记录,不串租户 |
| [ ] T01-07 | 找回密码 tenant 隔离 | A、B 同手机号触发重置 | 仅更新当前 tenant 用户,auth version 按当前 tenant 递增 |
| [ ] T01-08 | 实名/青少年/独立密码入口 | 无 tenant 或 tenant 不活跃 | 统一拒绝,不进入后续敏感流程 |
## 2. 第2章 消息首页、全局导航与系统通知
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C02-01 | bootstrap 只返回本租户菜单 | 调用 `bootstrap` | 功能开关、未读数、模块列表只来自当前 tenant |
| [ ] C02-02 | system notification 列表 | 查询/翻页已读未读 | 只返回当前 tenant 通知,分页游标稳定 |
| [ ] C02-03 | system notification read | 标记单条已读 | 只改当前 tenant 当前用户的 read state |
| [ ] C02-04 | conversation search | A 搜索 B 的联系人/会话 | 返回空或通用 NotFound,不泄露 B 的存在性 |
| [ ] C02-05 | 无网络/失效 token 恢复 | 模拟 token 失效后重连 | 进入统一错误分支,不误切 tenant |
## 3. 第3章 单聊会话、会话建立与消息状态
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T03-01 | P2P context 正常返回 | 查询同租户双方会话上下文 | `relationship`、`can_send`、`topic` 与当前 tenant 一致 |
| [ ] T03-02 | 非好友/黑名单发消息 | 在 `can_send=false` 下调用 pub | 服务端拒绝,不产生消息 seq |
| [ ] T03-03 | read/recv 回执推进 | 连续提交 `note read/recv` | 只允许单调推进,不回退读游标 |
| [ ] T03-04 | 删除好友后历史保留 | 删除好友后再查历史消息 | 历史还在,但后续发送被拒绝 |
| [ ] T03-05 | 跨租户 topic 访问 | 已知 B topic 订阅/读/写 | 统一 NotFound/Denied,不进入 Hub 缓存 |
| [ ] T03-06 | 主题状态切换 | attach/leave/reattach | session/topic 状态机不被错误 tenant 影响 |
## 4. 第4章 消息输入、媒体内容与消息操作
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C04-01 | 文本消息 schema 校验 | `x-im-schema=1`、`x-im-type=text`、`x-im-client-mid` | 通过并保持 client-mid 幂等 |
| [ ] C04-02 | 媒体消息字段校验 | image/video/voice/file 的 `content` | 必填字段缺失或超长时拒绝 |
| [ ] C04-03 | @成员与 @all | 指定群成员 / `mention_all` | 成员过滤正确,越权 `@all` 被拒绝 |
| [ ] C04-04 | 编辑/撤回/删除 | 对同一消息进行修改或撤回 | 只允许符合时限和角色的操作 |
| [ ] C04-05 | 合并转发结构 | bundle/逐条转发 | 快照字段稳定,源消息与目标消息分离 |
| [ ] C04-06 | 媒体消息重复提交 | 重试同一 `client_mid` | 返回首次写入结果,不重复落库 |
## 5. 第5章 通讯录、好友关系与黑名单
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C05-01 | 用户搜索 tenant 隔离 | A 搜索 B 仅有的账号/tag | 返回空结果,不提示跨租户命中 |
| [ ] C05-02 | 二维码解析 | 用 add_friend token 解析 | 只返回当前 tenant 可见的公开资料 |
| [ ] C05-03 | 好友申请幂等 | 同一请求重复提交 | 返回原申请记录,不重复创建 |
| [ ] C05-04 | 好友同意/拒绝 | 审批 inbound request | 双方关系和 P2P topic 同步更新 |
| [ ] C05-05 | 拉黑/解除拉黑 | `PUT/DELETE /blocks/{user_id}` | 只影响当前 tenant 当前用户关系 |
| [ ] C05-06 | 删除好友 | `DELETE /friends/{user_id}` | 历史消息不删,后续会话发送被拒绝 |
## 6. 第6章 群聊创建、资料维护与成员治理
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C06-01 | 创建群 | 创建新 group topic | topic、group settings、owner、subscription 同 tenant 落库 |
| [ ] C06-02 | 邀请/审批入群 | 邀请 B tenant 用户进 A 群 | 统一拒绝,不产生部分写入 |
| [ ] C06-03 | 群管理员变更 | 提升/降级成员角色 | 仅允许本群本 tenant 成员,ACL 一致 |
| [ ] C06-04 | 群主转让 | 指定新 owner | 事务内同步业务真源和 Topic Owner |
| [ ] C06-05 | 群禁言/全员禁言 | muted member 发消息 | 服务端拒绝,其他成员不可见 |
| [ ] C06-06 | 群文件/成员列表 | 查询群成员、群文件 | 只返回当前 tenant 当前群成员 |
## 7. 第7章 文件下载、收藏管理与聊天记录检索
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C07-01 | 文件上传 principal | HTTP/gRPC 文件上传 | `RequestPrincipal` 必须带 tenant/uid,落库 tenant 正确 |
| [ ] C07-02 | 文件下载授权 | 用 A 用户访问 B file id | 返回 403/404,不暴露 location |
| [ ] C07-03 | 收藏去重 | 同一 `topic+seq` 重复收藏 | 只新增一条,返回 existing 计数 |
| [ ] C07-04 | 收藏转发 | 对已收藏消息做转发 | 保持 source snapshot,不越权扩散 |
| [ ] C07-05 | 会话/消息搜索 | 搜索关键字、类型、文件 | 只返回当前 tenant 且有读权限的结果 |
| [ ] C07-06 | 垃圾清理 | 过期未使用文件清理 | 只清当前 tenant,不误删其他 tenant 文件 |
## 8. 第8章 实时语音与视频通话
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T08-01 | 发起通话 | `call` invite / note started | 生成稳定 `callId`,同 tenant 双方可见 |
| [ ] T08-02 | 接听/拒绝/挂断 | accept / reject / hang-up | 状态转换正确,结束原因稳定 |
| [ ] T08-03 | 超时结束 | 不接听直到超时 | 服务端替换为 missed/timeout 结果 |
| [ ] T08-04 | 多设备回放 | 同用户多个 session | 只向合法 session 广播,不串 tenant |
| [ ] T08-05 | 跨租户呼叫 | A 呼叫 B tenant 用户 | 统一拒绝,不创建有效通话记录 |
## 9. 第9章 企业工作台与办公协同
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B09-01 | workbench 模块列表 | 查询工作台 | 模块、权限、未读数只取当前 tenant |
| [ ] B09-02 | 组织树查询 | 查部门根节点/子节点/成员 | 只返回当前 tenant 组织结构 |
| [ ] B09-03 | 会议创建/变更/取消 | 创建会议并更新参会人 | 幂等、通知、审计都在当前 tenant |
| [ ] B09-04 | 日报/月报草稿与统计 | 创建、提交、统计、提醒 | 统计范围受 tenant 与管理权限约束 |
| [ ] B09-05 | 总经理信箱/工作台入口 | 查询配置与提交 | 匿名/必填字段/附件策略按 tenant 配置 |
## 10. 第10章 个人资料、通用设置、隐私与青少年模式
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T10-01 | me/profile 读取与更新 | 查询并 patch 个人资料 | 只允许当前用户当前 tenant 数据更新 |
| [ ] T10-02 | privacy 更新 | 修改隐私项 | 版本控制正确,其他设备能收到变更 |
| [ ] T10-03 | youth mode 开关 | 启用/关闭青少年模式 | 需要 action token / 独立密码,tenant 一致 |
| [ ] T10-04 | independent password | 设置/验证独立密码 | 明文不落库,不跨租户复用 |
| [ ] T10-05 | push token 绑定 | 更新设备 push token | `(tenant_id, uid, device_id)` 维度唯一 |
| [ ] T10-06 | app release / settings menu | 查询版本与设置菜单 | 返回当前 tenant 配置,不混入别的企业开关 |
## 11. 第11章 反诈安全、举报与意见反馈
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B11-01 | URL 风险检查 | 检查外链 | 返回 allow/warn/block,带租户上下文 |
| [ ] B11-02 | 风险挑战确认 | `continue/cancel` | 只消费当前租户当前 challenge |
| [ ] B11-03 | 举报原因与提交 | 读取原因并提交举报 | reason、证据、目标对象按 tenant 存档 |
| [ ] B11-04 | 反馈配置与提交 | 查询反馈配置并提交反馈 | 附件、联系方式、诊断信息按 tenant 策略处理 |
| [ ] B11-05 | 系统通知联动 | 风险/举报结果产生通知 | 只推当前 tenant 相关用户,不跨租户扩散 |
## 12. 高风险迁移确认点
| [ ] ID | 确认点 | 为什么要测 | 预期 |
|---|---|---|---|
| [ ] RISK-01 | Token / Session tenant 一致性 | 这是全链路身份根 | 任意篡改都拒绝 |
| [ ] RISK-02 | 文件认证 Principal | 文件链路最容易只剩 UID | 没 tenant 直接失败 |
| [ ] RISK-03 | 同名 `sys` / Topic 隔离 | 最容易发生缓存串租户 | 不同 tenant 命中不同对象 |
| [ ] RISK-04 | 事务内幂等 | 验证码、好友、群审批、会议、举报都依赖 | 重试不重复写 |
| [ ] RISK-05 | 搜索二次 ACL | 索引命中不代表可见 | 返回前再做权限过滤 |
| [ ] RISK-06 | Push / 回调 / 外部服务 | 外部 side-effect 最易丢 tenant | 出站上下文保持 tenant |
| [ ] RISK-07 | 旧请求重放 | 防止用旧参数跨 tenant 复用 | 统一按当前 tenant 拒绝 |
| [ ] RISK-08 | 失败回滚 | 群主转让、入群、文件关联等 | 只要一处失败就全回滚 |
## 13. 建议补测顺序
1. 先补第 1 章和第 7 章,因为它们是登录态与文件安全的根。
2. 再补第 3、5、6 章,把联系人、群和消息主链路闭环。
3. 然后补第 8、9、10、11 章,覆盖通话、工作台、设置和安全合规模块。
4. 最后做跨章回归:同名数据双租户、同 token 重放、同 file id 访问、同 topic 搜索。
+33
View File
@@ -0,0 +1,33 @@
这些图片中的内容是关于IM即时通讯的软件,你识别图片中的内容,整理为需求文档,包含图片,功能说明
上面是一张图片,下面就是说明,功能的标题就是图片的名称,说明的时候将功能相关的放一起,我希望是这样的
1.相关功能图片的总体功能(需要你总结一个标题)
1.1 图片名称
图片
功能说明:
图片内容的描述
写成一个md文档,要在md文档中可以看到图片
密码aes 加解密 密码能够加解密
聊天内容加密
聊天内容的监控
用户的类别
后台生成的有标识
登录设备做标识 移动
登基日志 连续N天设备登录
多通过配置的方式处理 前端不要写死
一定要考虑好群聊的推送问题
离线和在线状态存到redis里面
离线的不推
离线的我们的服务不推 走极光的推送
多用开关
admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
记录先放到Kafka、 再异步落库。用考虑重复落库的可能
1. 有 IM 即时通讯项目开发经验优先;
2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理;
3. 熟悉群消息扩散策略,如读扩散、写扩散;
4. 理解多端同步、消息漫游、消息推送等 IM 核心机制;
5. 有 OpenIM 或类似 IM 项目经验优先。
+83
View File
@@ -0,0 +1,83 @@
# Builds the Tinode server + tinode-db + tenant-db-migration tools from THIS
# repo's local source, instead of downloading a pre-built release from GitHub
# (the upstream docker/tinode/Dockerfile does that, so it never picks up the
# tenant/workbench/etc. customizations that live only in this repo's source).
# ---- Stage 1: compile from local source ----
FROM golang:1.26-alpine AS build
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags mysql -o /out/tinode ./server
RUN CGO_ENABLED=0 go build -tags mysql -o /out/init-db ./tinode-db
# scripts/apply_hotgo_tenant_db.go is `//go:build ignore`-tagged (a standalone
# migration script, not part of any package) - build it by exact file path.
RUN CGO_ENABLED=0 go build -o /out/apply-hotgo-tenant-db ./scripts/apply_hotgo_tenant_db.go
# ---- Stage 2: runtime image ----
FROM alpine:3.22
LABEL maintainer="118im"
LABEL name="ImServer"
# Runtime options (same names/semantics as upstream docker/tinode/Dockerfile).
ENV WAIT_FOR=
ENV RESET_DB=false
ENV UPGRADE_DB=false
ENV NO_DB_INIT=false
# Empty by default: do not load Tinode's canned demo users into a real deployment.
# Override explicitly if sample data is ever wanted.
ENV SAMPLE_DATA=
ENV DEFAULT_COUNTRY_CODE=US
# DB connection is provided at deploy time via secret-backed env var. No default value.
ENV MYSQL_DSN=
ENV STORE_USE_ADAPTER=mysql
# Tenant registry lives in the hotgo database (shared with im_manager); tenant
# DB routing resolves to the same tinode DB for the seeded "default" tenant.
ENV TENANT_REGISTRY_DB=hotgo
ENV MEDIA_HANDLER=fs
ENV FS_CORS_ORIGINS='["*"]'
ENV AWS_CORS_ORIGINS='["*"]'
ENV SMTP_HOST_URL='http://localhost:6060'
ENV TLS_ENABLED=false
ENV FCM_PUSH_ENABLED=false
ENV FCM_INCLUDE_ANDROID_NOTIFICATION=true
ENV TNPG_PUSH_ENABLED=false
ENV WEBRTC_ENABLED=false
ENV SERVER_STATUS_PATH=''
ENV ACC_GC_ENABLED=false
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
# Secrets: intentionally NO default values. Must be injected at deploy time.
# An empty/missing value fails loudly at startup rather than silently falling
# back to Tinode's publicly-known demo keys.
ENV API_KEY_SALT=
ENV AUTH_TOKEN_KEY=
ENV UID_ENCRYPTION_KEY=
RUN apk add --no-cache ca-certificates bash grep
WORKDIR /opt/tinode
COPY docker/tinode/config.template .
COPY docker/tinode/entrypoint.sh .
COPY tinode-db/credentials.sh .
COPY --from=build /out/tinode .
COPY --from=build /out/init-db .
COPY --from=build /out/apply-hotgo-tenant-db .
RUN mkdir -p /botdata static \
&& chmod +x entrypoint.sh credentials.sh tinode init-db apply-hotgo-tenant-db
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD nc -z localhost 6060 || exit 1
ENTRYPOINT ["./entrypoint.sh"]
EXPOSE 6060 16060
+7 -1
View File
@@ -75,7 +75,13 @@
"adapters": {
"mysql": {
"database": "tinode",
"dsn": "$MYSQL_DSN"
"dsn": "$MYSQL_DSN",
"max_open_conns": 64,
"max_idle_conns": 64,
"conn_max_lifetime": 60,
"sql_timeout": 10,
"tenant_registry_db": "$TENANT_REGISTRY_DB",
"tenant_db_routing": true
},
"postgres": {
"database": "tinode",
+9
View File
@@ -127,6 +127,15 @@ if [ $? -ne 0 ]; then
exit 1
fi
# Create/upgrade the hotgo tenant registry and the workbench/org/meetings/
# reports/mailbox/etc. tables that init-db above does not manage. Idempotent
# (IF NOT EXISTS / ON DUPLICATE KEY UPDATE throughout), safe to run every start.
IM_MYSQL_DSN="${MYSQL_DSN}" ./apply-hotgo-tenant-db
if [ $? -ne 0 ]; then
echo "./apply-hotgo-tenant-db failed. Quitting."
exit 1
fi
# If sample data was provided, try to find Tino password.
if [ ! -z "$SAMPLE_DATA" ] ; then
grep "usr;tino;" $init_stdout > /botdata/tino-password
-672
View File
@@ -1,672 +0,0 @@
# IM WebSocket 后端技术方案
> 本文档基于 [IM WebSocket 前后端接口文档](/Users/superysh/im/chat-0.25.3/docs/IM-WS-API-CN.md:1)、Tinode 原生 [API 文档](/Users/superysh/im/chat-0.25.3/docs/API.md:1)、[代码通读指南](/Users/superysh/im/chat-0.25.3/docs/CODE-WALKTHROUGH.md:1) 与当前 Go 代码整理,作为后续后端开发依据。目标是保持 Tinode 现有协议和代码风格,默认全部前后端业务通信走 WebSocket,尽量复用现有 Topic、Session、Store、ACL、Drafty、文件上传和 JSON 扩展字段,减少侵入式改动。
## 一、目标与原则
### 1.1 建设目标
| 目标 | 说明 |
|---|---|
| 保持 Tinode 协议兼容 | 继续使用 `{hi}`、`{login}`、`{sub}`、`{pub}`、`{get}`、`{set}`、`{del}`、`{note}` |
| 默认 WebSocket 通信 | 客户端统一连接 `/v0/channels`,服务端通过现有 `Session -> Hub -> Topic` 消息链路处理 |
| 小步扩展业务能力 | 好友、群聊、文件、收藏、通话优先映射到 Tinode 原生能力 |
| 小改动 | 第一阶段不新增数据库表,不改核心消息协议字段语义 |
| 可测试 | 所有新增逻辑必须补充 Go 单元测试,MySQL 持久化改动必须补 adapter 集成测试 |
### 1.2 设计原则
1. 原生字段优先:用户资料使用 `users.public`,用户私有设置使用 `subscriptions.private` 或 `users.private`,群业务配置使用 `topics.aux`。
2. 扩展字段隔离:所有产品扩展字段统一使用 `x-im-*`,避免污染 Tinode 原生字段。
3. 权限使用 ACL:管理员、禁言、踢人、群主转让优先通过 Tinode `modewant/modegiven` 权限实现。
4. 持久消息走 `{pub}`:文本、图片、文件、语音、名片、转发、收藏、系统通知都以持久化消息建模。
5. 临时状态走 `{note}`:输入中、录音中、已收、已读、通话信令使用临时通知,不落库。
6. 数据库变更后置:只有搜索、积分、签到这类确实需要索引或事务的能力才新增业务表。
## 二、系统设计
### 2.1 总体架构
```text
Client
|
| WebSocket JSON frames
v
server/hdl_websock.go
|
v
Session.dispatchRaw / Session.dispatch
|
+-- hi/login/acc: Session 内处理
|
+-- sub/get/set/del/pub/note
|
v
Hub
|
v
Topic.runLocal
|
+-- handleSubscription
+-- handleClientMsg
+-- handleMeta
+-- handleServerMsg
|
v
store.* interfaces
|
v
server/db/mysql adapter
```
本方案不新增独立 HTTP 业务 API。除 Tinode 已有大文件上传 `/v0/file/u/` 与下载 `/v0/file/s/` 外,业务请求全部复用 WebSocket 消息包。
### 2.2 功能映射
| 原型能力 | 后端实现 |
|---|---|
| 登录、注册、忘记密码 | 复用 `{hi}`、`{login}`、`{acc}`、`basic/token/code/rest` auth |
| 私聊 | `{sub topic="usrXXX"}` 创建或进入 P2P topic |
| 群聊 | `{sub topic="new"}` 创建群,`grpXXX` 进入群 |
| 消息列表 | 订阅 `me`,`{get what="sub"}` 获取会话列表 |
| 文本、表情、@ | `{pub}` + Drafty,`head.mentions` |
| 图片、文件、语音 | 上传文件后 `{pub}` + Drafty `IM/EX/AU` entity + `extra.attachments` |
| 已收、已读、输入中 | `{note what="recv/read/kp/kpa/kpv"}` |
| 撤回、删除 | `{del what="msg"}` |
| 好友申请 | P2P `{sub}` + ACL 状态,申请备注写 `private.x-im-applyText` |
| 备注、置顶、免打扰、拉黑 | `{set desc.private}` |
| 管理员、禁言、踢人 | `{set sub}`、`{del what="sub"}` + `aux.x-im-group` |
| 群公告、进群验证 | `{set aux}` + 可选系统样式 `{pub}` |
| 收藏 | 使用 `slf` topic 存储 `head.x-im-type="favorite"` 消息 |
| 通话 | `{note what="call"}` 信令,结束后 `{pub}` 持久化通话记录;产品通话邀请和控制事件校验 `callId` |
| 话题 | 使用 `nch/chn` 频道建模,话题元数据写 `public.x-im-topic` |
| 举报、反馈 | 向 `sys` topic 发送 `x-im-type="report/feedback"` 消息 |
| 系统消息 | 系统账号或 `sys` topic 投递 `x-im-type="system-notice"` |
### 2.3 分阶段实现
| 阶段 | 范围 | 数据库 |
|---|---|---|
| Phase 1 | 登录、私聊、群聊、消息、附件、回执、基础好友、群管理、收藏、通话信令、举报反馈 | 不新增表 |
| Phase 2 | 聊天记录关键词搜索、按文件类型搜索、话题积分、签到幂等、运营后台检索 | 新增少量 `im_*` 业务表 |
| Phase 3 | 反诈风控、内容审核、推荐排序、全文搜索优化 MVP | 复用 `messages/topics/topictags` 与 `im_message_index`,MySQL schema `119` 新增全文索引 |
当前 Phase 1、Phase 2 与 Phase 3 MVP 已按小改动路线接入。更复杂的风控模型、人工审核工作台、离线推荐服务和独立搜索引擎仍作为后续演进,不影响现有 WebSocket 对接。
## 三、代码结构设计
### 3.1 现有代码复用点
| 文件/模块 | 复用方式 |
|---|---|
| [server/hdl_websock.go](/Users/superysh/im/chat-0.25.3/server/hdl_websock.go:117) | 保持 WebSocket 入口不变 |
| [server/session.go](/Users/superysh/im/chat-0.25.3/server/session.go:431) | 复用消息分发、鉴权、topic 展开逻辑 |
| [server/hub.go](/Users/superysh/im/chat-0.25.3/server/hub.go:93) | 复用 topic 创建、加载、路由 |
| [server/topic.go](/Users/superysh/im/chat-0.25.3/server/topic.go:568) | 复用订阅、发布、meta、presence、call 处理 |
| [server/datamodel.go](/Users/superysh/im/chat-0.25.3/server/datamodel.go:336) | 复用 C2S/S2C 消息结构 |
| [server/store/store.go](/Users/superysh/im/chat-0.25.3/server/store/store.go:505) | 复用 Topic/Sub/Message/File/PCache 抽象 |
| [server/db/mysql/adapter.go](/Users/superysh/im/chat-0.25.3/server/db/mysql/adapter.go:1) | 复用 MySQL adapter、迁移机制、业务索引与推荐查询 |
| [server/drafty](/Users/superysh/im/chat-0.25.3/server/drafty/drafty.go:1) | 复用富文本纯文本提取与预览能力 |
| [server/store/mock_store](/Users/superysh/im/chat-0.25.3/server/store/mock_store/mock_store.go:1) | 新增测试时继续使用 gomock |
### 3.2 建议新增文件
第一阶段新增文件控制在少量 helper 与测试文件:
| 文件 | 作用 |
|---|---|
| `server/imext.go` | 定义 `x-im-*` 常量、消息类型枚举、Drafty/JSON 解析辅助方法 |
| `server/imext_test.go` | 测试扩展字段解析、消息类型判断、搜索文本提取 |
| `server/im_policy.go` | 封装业务策略:群禁言、进群验证、拉黑、反诈标记判断 |
| `server/im_policy_test.go` | 业务策略单元测试 |
第二阶段如需数据库索引,再新增:
| 文件 | 作用 |
|---|---|
| `server/store/imindex.go` | 定义业务索引查询接口,避免把搜索逻辑塞进 Topic |
| `server/db/mysql/imindex.go` | MySQL 业务索引表读写实现 |
| `server/db/mysql/imindex_test.go` | MySQL 业务索引集成测试 |
### 3.3 最小代码变更点
#### 3.3.1 扩展字段解析
新增 `server/imext.go`,只做轻量解析,不改变原有消息结构语义。
```go
const (
imHeadType = "x-im-type"
imHeadClientMID = "x-im-client-mid"
imHeadAntiFraud = "x-im-anti-fraud"
)
type imMessageType string
const (
imMsgText imMessageType = "text"
imMsgImage imMessageType = "image"
imMsgAudio imMessageType = "audio"
imMsgVideo imMessageType = "video"
imMsgFile imMessageType = "file"
imMsgCard imMessageType = "card"
imMsgFavorite imMessageType = "favorite"
imMsgForwardBundle imMessageType = "forward-bundle"
imMsgGroupJoinApply imMessageType = "group-join-apply"
imMsgGroupAnnouncement imMessageType = "group-announcement"
imMsgCall imMessageType = "call"
imMsgTopicShare imMessageType = "topic-share"
imMsgSystemNotice imMessageType = "system-notice"
imMsgReport imMessageType = "report"
imMsgFeedback imMessageType = "feedback"
imMsgCheckin imMessageType = "checkin"
)
```
解析 helper 建议只返回类型与布尔值,不在业务层直接类型断言散落代码:
```go
func imGetMessageType(head map[string]any) imMessageType
func imIsAttachmentType(tp imMessageType) bool
func imExtractSearchText(head map[string]any, content any) string
func imValidateCallPayload(payload json.RawMessage) error
```
#### 3.3.2 查询扩展字段
当前 `MsgGetQuery` 没有 `x-im-search` 或 `x-im-filter` 字段,Go 反序列化会忽略未知字段。若第一阶段需要服务端处理聊天记录搜索,需要在 [server/datamodel.go](/Users/superysh/im/chat-0.25.3/server/datamodel.go:40) 显式增加字段:
```go
type MsgGetQuery struct {
What string `json:"what"`
Desc *MsgGetOpts `json:"desc,omitempty"`
Sub *MsgGetOpts `json:"sub,omitempty"`
Data *MsgGetOpts `json:"data,omitempty"`
Del *MsgGetOpts `json:"del,omitempty"`
XIMSearch *MsgSearchOpts `json:"x-im-search,omitempty"`
XIMFilter *MsgFilterOpts `json:"x-im-filter,omitempty"`
}
type MsgSearchOpts struct {
Keyword string `json:"keyword,omitempty"`
Types []string `json:"types,omitempty"`
}
type MsgFilterOpts struct {
Types []string `json:"types,omitempty"`
Keyword string `json:"keyword,omitempty"`
}
```
第一阶段可以先不实现数据库索引,`Topic.replyGetData` 仍按 `data.limit` 拉取消息,再在内存里用 `imGetMessageType` 和 `imExtractSearchText` 做轻量过滤。第二阶段再替换为索引表查询。
#### 3.3.3 群禁言与拉黑策略
Tinode ACL 已支持写权限控制。业务层只需约定:
| 场景 | 实现 |
|---|---|
| 群禁言成员 | 将目标用户 `mode` 设置为不含 `W` 的完整权限,如 `JRP`;同时维护 `aux.x-im-group.mutedUsers` |
| 解除群禁言 | 将目标用户 `mode` 设置为含 `W` 的完整权限,如 `JRWP` 或 `JRWPS`;同时清理 `aux.x-im-group.mutedUsers` |
| 拉黑好友 | 当前用户 `desc.private.x-im-blocked=true`,同时可调整对方 P2P 权限 |
| 群全员禁言 | `aux.x-im-group.muteAll=true`,发送消息时后端策略拒绝普通成员 |
| 单成员业务禁言 | `aux.x-im-group.mutedUsers`,发送消息时后端策略拒绝列表中的普通成员 |
| 群文件上传限制 | `aux.x-im-group.allowMemberFileUpload=false`,普通成员不能发送附件类消息 |
| 仅管理员邀请 | `aux.x-im-group.onlyAdminInvite=true`,非管理员不能邀请或重新邀请成员 |
群级业务策略是 Tinode 原生 ACL 不能一次表达的约束,在 `Topic.handlePubBroadcast` 与 `Topic.anotherUserSub` 进入持久化前做小的策略检查:
```go
if err := imCanPublish(t, asUid, msg); err != nil {
msg.sess.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
return
}
```
`imCanPublish` 与 `imCanInvite` 只读取 `t.aux` 与 `t.perUser`,不访问数据库,避免扩大影响面。
#### 3.3.4 收藏
收藏不新增接口,不新增表。客户端向 `slf` 发送 `x-im-type="favorite"` 消息即可。
后端只需保证:
1. `slf` topic 可正常创建和订阅。
2. 收藏消息里的附件 URL 被写入 `extra.attachments`,复用文件 GC 引用计数。
3. 收藏列表通过 `{sub topic="slf" get.what="data"}` 返回。
#### 3.3.5 话题
话题用频道建模:
1. 发布话题:`{sub topic="nch"}` 创建 channel topic。
2. 话题详情:读取 `desc.public.x-im-topic`。
3. 参与话题:订阅 `chnXXX`。
4. 评论:根据业务授予 `W` 权限后使用 `{pub}`。
第一阶段不做积分扣减时,不需要新增表。第二阶段接入积分时增加 `im_user_points`。
### 3.4 不建议改动的地方
| 模块 | 原因 |
|---|---|
| WebSocket handler | 入口稳定,不应新增业务分支 |
| Hub 路由模型 | 现有 channel/goroutine 模型清晰,业务应落在 Topic/Store |
| 原生消息字段命名 | 客户端 SDK 与文档依赖现有字段 |
| Adapter 核心接口 | 第一阶段避免给所有数据库后端增加方法 |
| gRPC converter | 本项目默认 WS,不做 gRPC 扩展 |
## 四、数据库设计与变更
### 4.1 当前可复用字段
现有 MySQL schema 已经覆盖多数业务扩展需求:
| 表 | 字段 | 用途 |
|---|---|---|
| `users` | `public` | 昵称、头像、性别、生日、地区、个性签名 |
| `users` | `tags` + `usertags` | 用户查找、手机号/用户名发现 |
| `topics` | `public` | 群资料、话题资料、频道封面 |
| `topics` | `aux` | 群公告、进群验证、群设置、全员禁言 |
| `subscriptions` | `private` | 备注、置顶、免打扰、拉黑、草稿 |
| `messages` | `head` | `x-im-type`、附件类型、引用、转发、@ |
| `messages` | `content` | Drafty 或业务 JSON 内容 |
| `fileuploads` | 全表 | 上传文件记录 |
| `filemsglinks` | 全表 | 文件与消息/topic/user 绑定,防止 GC |
| `kvmeta` | 全表 | 小型持久缓存,已有 `PCache` 封装 |
### 4.2 Phase 1 数据库变更
Phase 1 不新增表、不新增字段。
原因:
1. 用户资料、群资料、群设置、收藏、举报、反馈都可用 JSON 字段或消息承载。
2. 图片、文件、语音已经有 `fileuploads` 与 `filemsglinks`。
3. 消息查询先复用 `messages(topic, seqid)` 分页。
4. 小改动优先,先保证 WebSocket 主流程稳定。
### 4.3 Phase 2 数据库变更
聊天记录搜索、话题积分、签到等功能进入后端强一致实现时新增业务表,表名统一 `im_` 前缀,避免与 Tinode 原生表混淆。当前 MySQL adapter 已实现 `im_message_index`、`im_user_points`、`im_user_daily_events`,数据库版本为 `118`。
#### 4.3.1 消息业务索引表
用途:支持按消息类型、关键词、附件维度查询,避免频繁扫描 `messages.content` JSON。
```sql
CREATE TABLE im_message_index(
id INT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
fromuid BIGINT NOT NULL,
msgtype VARCHAR(32) NOT NULL,
search_text TEXT,
attachment_count INT NOT NULL DEFAULT 0,
deletedat DATETIME(3),
PRIMARY KEY(id),
FOREIGN KEY(id) REFERENCES messages(id) ON DELETE CASCADE,
UNIQUE INDEX im_message_index_topic_seqid(topic, seqid),
INDEX im_message_index_topic_type_seq(topic, msgtype, seqid),
INDEX im_message_index_topic_createdat(topic, createdat),
FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE
);
```
说明:`id` 与 Tinode 现有 `messages.id` 保持一致使用 `INT`。中文全文检索初期使用 `LIKE`,MySQL `FULLTEXT` 是否启用 `ngram` parser 作为后续部署决策,不作为第一版强依赖。
#### 4.3.2 用户积分表
用途:话题发布积分扣减、签到积分发放。
```sql
CREATE TABLE im_user_points(
userid BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
frozen BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(userid),
FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
);
```
#### 4.3.3 用户每日事件表
用途:签到幂等、每日任务、青少年模式时长限制等按日事件。
```sql
CREATE TABLE im_user_daily_events(
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
userid BIGINT NOT NULL,
event_date DATE NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload JSON,
PRIMARY KEY(id),
UNIQUE INDEX im_user_daily_events_user_date_type(userid, event_date, event_type),
FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
);
```
### 4.4 MySQL 迁移方式
Phase 2/3 按现有 MySQL adapter 风格升级。当前 MySQL adapter 已从 `116` 升级到 `119`,其中 `117` 新增 `im_message_index`,`118` 新增 `im_user_points` 与 `im_user_daily_events`,`119` 为 `im_message_index.search_text` 增加全文索引:
1. [server/db/mysql/adapter.go](/Users/superysh/im/chat-0.25.3/server/db/mysql/adapter.go:44) 中 `adpVersion` 已更新为 `119`。
2. `CreateDb` 的建表流程已加入三张 `im_*` 表,并在 `im_message_index` 上创建 `FULLTEXT INDEX im_message_index_search_text(search_text)`。
3. `UpgradeDb` 已增加 `116 -> 117`、`117 -> 118` 与 `118 -> 119` 迁移块。
4. 升级旧库到 `117` 时会扫描历史 `messages` 与 `filemsglinks` 回填索引,避免历史消息搜索缺失。
5. 升级旧库到 `118` 时创建积分表和每日事件表。
6. 升级旧库到 `119` 时通过 `ALTER TABLE im_message_index ADD FULLTEXT INDEX im_message_index_search_text(search_text)` 优化全文搜索。
7. 迁移成功后通过 `bumpVersion(a, 119)` 更新 `kvmeta.version`。
8. schema 文档 [server/db/mysql/schema.sql](/Users/superysh/im/chat-0.25.3/server/db/mysql/schema.sql:1) 已同步。
9. MySQL adapter 集成测试已覆盖索引保存、搜索、全文索引存在性、推荐排序、软删除过滤、积分增扣和每日事件幂等。
不建议在第一阶段修改 `adapter.Adapter` 总接口,因为这会要求 PostgreSQL、MongoDB、RethinkDB 同步实现,改动面会变大。业务索引可以在第二阶段单独设计 `imstore` 接口,再按实际支持的数据库接入。
## 五、核心业务设计
### 5.1 登录注册与资料
使用现有认证模块:
| 功能 | 实现 |
|---|---|
| 手机号密码登录 | `basic` 或 `rest` 认证 |
| Token 登录 | `token` 认证 |
| 验证码 | `code` 临时认证或 credential validator |
| 忘记密码 | `{login scheme="reset"}` |
| 实名认证 | `me.desc.private.x-im-realname` 保存申请,审核通过后 root 写 `trusted.verified=true` |
代码改动:无核心改动。若对接外部账号系统,优先配置 `auth/rest`,避免改 `auth/basic`。
### 5.2 好友关系
P2P topic 即好友/联系人关系。添加好友通过 `{sub topic="usrXXX"}`,是否需要审批由 ACL 决定。
建议规则:
| 状态 | ACL/字段 |
|---|---|
| 申请中 | `private.x-im-applyStatus="pending"`,ACL 仍记录当前 `want/given` |
| 已同意 | `private.x-im-applyStatus="accepted"`,双方 P2P 权限满足可聊条件 |
| 已拒绝/过期 | `private.x-im-applyStatus="rejected/expired"`,前端从 `me.sub.private` 过滤 |
| 已好友 | 双方 P2P 权限满足 `JRWPA` |
| 已拉黑 | 当前用户 `private.x-im-blocked=true`,必要时移除对方 `W` |
| 已删除 | `{leave unsub=true}` |
### 5.3 群聊与群权限
复用 Tinode group topic:
| 群角色 | Tinode 权限 |
|---|---|
| 群主 | `O` |
| 管理员 | `A/S/D` |
| 普通成员 | `J/R/W/P` |
| 禁言成员 | 移除 `W` |
| 被踢成员 | 删除订阅 |
群设置统一写 `topics.aux.x-im-group`。前端展示用 `get what="desc sub aux"` 获取。
进群审批使用已有 subscriptions 表表达状态,不新增表:开启 `aux.x-im-group.joinApproval=true` 且群默认 `defacs.auth` 不含 `J` 时,申请者 `{sub set.sub.mode="JRWPS"}` 会创建 `want=JRWPS/given=N` 的待审批订阅,并保存 `private.x-im-joinApplyText` 与 `private.x-im-joinStatus="pending"`。管理员 `{get what="sub"}` 可看到待审批用户及其 private;通过时 `{set sub user=... mode="JRWPS"}` 自动把状态更新为 `accepted`,拒绝时 `{set sub user=... mode="N"}` 自动更新为 `rejected`。
### 5.4 消息与附件
所有消息仍由 `Topic.handlePubBroadcast` 保存为 `messages`:
1. 文本、表情、@ 使用 Drafty。
2. 图片、文件、语音、视频先上传文件,再发送 Drafty entity。
3. `extra.attachments` 必填,复用 `store.Messages.Save` 中的附件绑定逻辑。
4. `head.x-im-client-mid` 用于前端本地发送状态关联。
5. `head.x-im-type` 用于客户端渲染和后续索引。
### 5.5 收藏
收藏使用 `slf` topic,不新增接口:
| 操作 | 实现 |
|---|---|
| 收藏消息 | `{pub topic="slf" head.x-im-type="favorite"}` |
| 查看收藏 | `{sub topic="slf" get.what="data"}` |
| 删除收藏 | `{del topic="slf" what="msg"}` |
| 分享收藏 | 普通转发 `{pub}` |
### 5.6 通话
通话信令复用 `{note what="call"}`:
| 事件 | `note.event` |
|---|---|
| 已振铃 | `ringing` |
| 接受通话 | `accept` |
| SDP offer | `offer` |
| SDP answer | `answer` |
| ICE 候选 | `ice-candidate` |
| 拒绝/挂断 | `hang-up` |
通话结束后发送持久化 `{pub}`,`head.webrtc` 与 `head.webrtc-duration` 使用 Tinode 原生字段。
### 5.7 话题
话题使用 channel topic:
| 操作 | 实现 |
|---|---|
| 发布话题 | `{sub topic="nch"}` |
| 话题列表 | `fnd` 搜索 `topic` tags |
| 话题详情 | `{sub topic="chnXXX" get.what="desc data aux"}` |
| 参与话题 | 订阅 `chnXXX` |
| 转发帖子 | `{pub head.x-im-type="topic-share"}` |
积分能力进入 Phase 2 后,通过 `im_user_points` 做事务扣减;当前签到已通过 `{pub topic="slf" head.x-im-type="checkin"}` 接入 `im_user_daily_events` 与 `im_user_points`,首次签到固定发放 `5` 积分,重复签到返回 `304 no action`。话题发布已通过 `{sub topic="nch"}` 接入 `public.x-im-topic.pointsCost`,MySQL 后端在同一事务中创建频道 topic、作者订阅并扣减积分,余额不足返回 `422 policy violation`。
### 5.8 举报、反馈与安全
举报和反馈向 `sys` topic 发送持久化消息。root/admin 后台订阅 `sys` 处理。
反诈与内容审核 Phase 3 MVP 已在发布链路内置轻量规则:`Topic.handlePubBroadcast` 保存消息前调用 `imReviewPubMessage`,命中疑似转账、验证码、外链、违禁词等关键词时追加 `head.x-im-anti-fraud=true`、`head.x-im-risk-level` 与 `head.x-im-moderation`。当前策略只标记不拦截,避免误伤聊天主流程;复杂风控评分、人工审核队列、模型服务可在后续通过相同 `head` 字段向前端兼容扩展。
### 5.9 推荐排序与搜索优化
话题推荐仍复用 Tinode `fnd` topic,避免新增 HTTP 接口或修改通用 adapter 接口。前端发送 `{get topic="fnd" what="sub" x-im-recommend={...}}`,`topic.go` 在 plugin find 未返回结果时检测 MySQL adapter 是否实现 `FindRecommendedTopics`,再按关键词、类型标签、业务标签、订阅数和活跃时间排序返回。
聊天记录全文搜索继续走 `im_message_index`。MySQL schema `119` 在 `search_text` 上增加 `FULLTEXT` 索引,查询条件使用 `MATCH ... AGAINST` 与 `LIKE ESCAPE` 组合:优先利用全文索引,仍保留中文短词和简单子串查询的兼容性。
## 六、单元测试与集成测试方案
### 6.1 已有测试继续保留
后续开发前后建议固定跑:
```bash
go test -count=1 -tags mysql $(go list ./... | grep -v '/server/db/mongodb/tests$' | grep -v '/server/db/postgres/tests$' | grep -v '/server/db/rethinkdb/tests$')
```
MySQL adapter 变更补跑:
```bash
go test -count=1 -tags mysql ./server/db/mysql/tests
```
构建检查:
```bash
go build -tags mysql ./server ./tinode-db
```
### 6.2 新增单元测试用例
#### `server/imext_test.go`
| 用例 | 目的 |
|---|---|
| `TestIMGetMessageType` | 从 `head.x-im-type` 正确读取消息类型 |
| `TestIMGetMessageTypeMissing` | 缺失类型时回退为普通文本或空类型 |
| `TestIMIsAttachmentType` | 判断 image/audio/video/file 是否为附件类型 |
| `TestIMExtractSearchTextPlain` | 从普通字符串内容提取搜索文本 |
| `TestIMExtractSearchTextDrafty` | 从 Drafty `txt` 提取搜索文本 |
| `TestIMExtractSearchTextJSON` | 从业务 JSON 的 `title/summary/text` 提取搜索文本 |
| `TestIMValidateCallPayload` | 校验 call payload 必填字段 |
| `TestIMValidateCallPayloadMalformed` | 非法 JSON 或缺失 callId 返回错误 |
| `TestIMReviewPubMessageFlagsFraudRisk` | 风控规则命中后追加反诈、风险等级和审核标记 |
#### `server/session_test.go`
| 用例 | 目的 |
|---|---|
| `TestDispatchGetWithXIMSearch` | 确认 `{get}` 中 `x-im-search` 不会被丢弃 |
| `TestDispatchPublishWithXIMClientMID` | 确认 `head.x-im-client-mid` 原样进入 topic |
| `TestDispatchReportToSys` | 普通用户可向 `sys` 发布举报消息 |
| `TestDispatchFavoriteToSlf` | 用户可向 `slf` 发布收藏消息 |
#### `server/topic_test.go`
| 用例 | 目的 |
|---|---|
| `TestHandleBroadcastDataWithXIMType` | 消息保存后 `head.x-im-type` 原样广播 |
| `TestHandleBroadcastDataWithAttachments` | 已有附件测试继续覆盖 `extra.attachments` |
| `TestHandleBroadcastDataGroupMuteAll` | 群全员禁言时普通成员发送被拒绝 |
| `TestHandleBroadcastDataGroupMuteAllAdminAllowed` | 管理员在全员禁言时可发送 |
| `TestHandleBroadcastCardMessage` | 名片消息 JSON 内容原样投递 |
| `TestReplyGetDataWithXIMTypeFilter` | 第一阶段内存过滤按类型返回 |
| `TestReplyGetDataWithXIMKeywordSearch` | 第一阶段内存过滤按关键词返回 |
| `TestHandleCallNoteMediaEvent` | 通话媒体状态 `{note}` 正确转发为 `{info}` |
| `TestGroupJoinApplyMessage` | 加群申请消息可持久化并广播给管理员 |
| `TestGroupAnnouncementAuxUpdate` | 群公告写入 `aux` 后产生 `pres what="aux"` |
#### `server/store/types` 或 `server/db/common`
| 用例 | 目的 |
|---|---|
| `TestMergeMapsWithXIMNestedValues` | 嵌套 `x-im-*` 设置更新不破坏已有值 |
| `TestNullValueClearsXIMSetting` | 使用 `types.NullValue` 清理扩展字段 |
### 6.3 Phase 2 MySQL 集成测试
`im_*` 表需要补充 MySQL 集成测试:
| 用例 | 目的 |
|---|---|
| `TestIMMessageIndexCreateDb` | `CreateDb(reset=true)` 创建索引表 |
| `TestIMMessageIndexUpgradeDb` | 旧版本升级到新版本后表存在 |
| `TestIMMessageIndexUpsert` | 消息索引写入/更新成功 |
| `TestIMMessageIndexSearchByType` | 按消息类型查询 |
| `TestIMMessageIndexSearchByKeyword` | 按关键词查询 |
| `TestIMUserPointsAddAndConsume` | 积分增加与扣减 |
| `TestIMUserPointsInsufficient` | 积分不足时事务回滚 |
| `TestIMDailyEventIdempotent` | 同一用户同一天同类事件只能记录一次 |
| `TestIMDailyEventCreateWithPoints` | 每日事件与积分发放在同一事务中完成,重复事件不重复加分 |
| `TestIMMessageIndexFullTextExists` | MySQL schema `119` 已创建全文索引 |
| `TestFindRecommendedTopics` | 按关键词、类型和标签返回推荐话题 |
### 6.4 手工 WebSocket 联调用例
后端联调时至少覆盖:
1. 登录后订阅 `me/fnd`。
2. 创建 P2P 并发送文本、图片、文件、语音。
3. 发送 `recv/read/kp/kpa`,对端收到 `{info}`。
4. 创建群、邀请成员、设置管理员、禁言、解除禁言。
5. 设置群公告,客户端收到 `pres what="aux"`。
6. 收藏消息到 `slf` 并读取收藏列表。
7. 发起音视频通话,完成 offer/answer/hangup。
8. 发布话题频道并转发到私聊。
9. 举报消息发送到 `sys`。
10. 发送疑似风险文本,接收方收到 `x-im-anti-fraud` 与 `x-im-moderation`。
11. 通过 `fnd + x-im-recommend` 获取推荐话题,频道 topic 以 `chnXXX` 返回。
## 七、开发顺序建议
### 7.1 第一迭代
1. 新增 `server/imext.go` 与测试。
2. 明确 `x-im-*` 常量和消息类型解析。
3. 增加 `MsgGetQuery.XIMSearch/XIMFilter` 字段。
4. 在 `Topic.replyGetData` 中实现第一阶段内存过滤。
5. 增加群全员禁言、单成员禁言、成员文件上传限制策略 `imCanPublish`。
6. 增加 `x-im-type`、`private.x-im-*`、`aux.x-im-group` 轻量校验。
7. 支持创建群时保存 `set.aux.x-im-group` 到 `topics.aux`。
8. 增加仅管理员邀请策略 `imCanInvite`。
9. 补齐 session/topic 单元测试。
### 7.2 第二迭代
1. 完成好友申请状态、备注、拉黑、黑名单联调。
2. 完成群公告、群设置、管理员、禁言、踢人联调。
3. 完成收藏、文件助手、聊天记录分类筛选联调。
4. 完成通话信令和通话记录落库。
### 7.3 第三迭代
1. 已实施 `im_message_index` 与 MySQL 全文索引。
2. 已接入积分和签到表。
3. 已增加后端内置反诈/内容审核标记 MVP。
4. 已增加话题推荐轻量排序 MVP。
5. 后续如需要更高召回率或运营可控性,再引入独立搜索服务、风控模型、人工审核台和离线推荐任务。
## 八、风险与决策点
| 风险/决策 | 建议 |
|---|---|
| 直接改 adapter 接口会牵连多数据库 | 第一阶段避免,第二阶段单独设计业务 store |
| JSON 字段缺少强校验 | 用 `imext.go` 做轻量校验,关键状态由 ACL 保证 |
| 聊天记录关键词搜索性能 | 第一阶段内存过滤,第二阶段索引表或搜索服务 |
| 群全员禁言不是原生 ACL | 通过 `aux` + `imCanPublish` 策略补齐 |
| 积分扣减需要事务 | 不要写 JSON 字段,Phase 2 使用 `im_user_points` |
| 规则风控存在误判 | 当前只标记不拦截,客户端提示可降级,后续再接审核工作台 |
| 推荐排序召回有限 | 当前使用 MySQL 轻量排序,后续可替换为异步索引或推荐服务 |
| `go test ./...` 未指定 tag 会失败 | 使用测试报告中的 MySQL 标准命令 |
| 前端发送未知扩展字段被忽略 | 需要在 datamodel 显式声明查询扩展字段 |
## 九、验收标准
1. 所有新增 Go 代码通过 `gofmt`。
2. 不修改 Tinode 原生协议字段含义。
3. 新增业务字段统一使用 `x-im-*`。
4. 第一阶段不新增数据库表。
5. 核心测试与 MySQL 测试通过。
6. WebSocket 联调用例覆盖登录、私聊、群聊、文件、收藏、通话、举报、风控标记、推荐话题。
7. 文档中的接口示例与实际后端行为一致。
## 十、推荐测试命令
```bash
go test -count=1 ./server ./server/db/common ./server/drafty ./server/media ./server/ringhash ./server/store/types
```
```bash
go test -count=1 -tags mysql ./server/db/mysql/tests
```
```bash
go test -count=1 -tags mysql $(go list ./... | grep -v '/server/db/mongodb/tests$' | grep -v '/server/db/postgres/tests$' | grep -v '/server/db/rethinkdb/tests$')
```
```bash
go build -tags mysql ./server ./tinode-db
```
本地 WebSocket 联调:
```bash
go run ./keygen -salt 'T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4='
```
```bash
go run -tags mysql ./server -config=server/tinode.conf -static_data=-
```
```bash
go run ./tools/wsmock -apikey '<generated>' -scenario all -timeout 12s
```
`tools/wsmock` 当前覆盖:登录注册、`slf` 自存消息、双端 P2P 文本/文件消息、好友申请备注 `x-im-applyText`、好友审批状态 `x-im-applyStatus`、联系人 `private.comment/x-im-muted/x-im-pinned/x-im-blocked` 读写、黑名单视图、删除好友、个人 `x-im-settings`、收藏列表、文件助手、拉黑 ACL 阻断与取消拉黑、真实 `/v0/file/u/` 上传、Drafty 附件消息、`extra.attachments` 防 GC 绑定、`recv/read` 回执、聊天记录 `x-im-search`、文件类型 `x-im-filter`、群创建/邀请/全员禁言/文件权限、进群审批 `x-im-joinApplyText/x-im-joinStatus`、群二维码公开资料、群主转让、群管理 `aux` 读取、仅管理员邀请、管理员授权、单成员禁言/解禁、管理员踢人、签到幂等、频道发布积分扣减、通话邀请参数校验、完整通话信令 `started/ringing/accept/offer/answer/ice-candidate/hang-up/finished`、举报到 `sys`。其中完整通话信令专项场景为 `call-flow`,需要服务端配置 `webrtc.enabled=true`;完整审批专项场景为 `approvals`;完整群管理专项场景为 `group-admin`;联系人状态专项场景为 `contacts`;页面型复用能力专项场景为 `pages`;群主转让专项场景为 `group-owner`;真实文件上传专项场景为 `upload`,会写入真实文件和数据库附件引用。
+845
View File
@@ -0,0 +1,845 @@
# IM 后台需求文档
> 编写日期:2026-08-12
> 依据文档:`IM-产品需求服务端改造分析报告.md`、`IM-服务端多租户改造方案.md`、`IM-多租户第5至12章代码改造与测试方案.md`、`IM-前后端对接文档.md`、`IM-系统设计文档.md`
> 目标读者:后台产品、后端、管理端前端、测试
## 1. 文档目的
本文从现有 IM 产品与服务端设计文档中提取“后台管理端”需求,用于后续后台系统开发。本文只描述后台能力,不重复普通移动端聊天、通讯录、消息收发等客户端功能。
后台系统分为两类:
| 后台 | 使用对象 | 数据范围 |
| --- | --- | --- |
| 平台运营后台 | 平台管理员、运维、安全负责人 | 可管理全部租户,但跨租户操作必须二次验证和审计 |
| 企业管理后台 | 租户负责人、企业管理员、安全审计员、组织管理员 | 只能管理当前租户内的数据 |
所有后台能力必须建立在多租户隔离之上:后台 Token 必须包含可信 `tenant_id`、`uid`、角色、权限版本;平台后台的跨租户操作必须走独立后台 API,禁止通过普通 WebSocket `AsUser` 任意代操作用户。
## 2. 后台角色与权限
### 2.1 角色
| 角色 | 作用域 | 说明 |
| --- | --- | --- |
| `platform_admin` | 平台级 | 租户生命周期、平台配置、运维诊断、跨租户审计 |
| `tenant_owner` | 租户级 | 企业最高管理员,拥有本租户全部管理权限 |
| `tenant_admin` | 租户级 | 组织、用户、群、安全策略和工作台配置 |
| `security_auditor` | 租户级 | 实名、举报、反馈、审计读取和安全处置 |
| `org_admin` | 租户级或部门级 | 部门、成员、岗位、组织数据范围 |
| `report_admin` | 部门级 | 日报、月报、会议统计与提醒 |
| `group_admin` | 群级 | 群成员、公告、禁言、群文件等群治理 |
### 2.2 权限原则
1. 平台管理员可以选择租户上下文进行运维,但所有跨租户读取、导出、代操作必须记录审计。
2. 企业管理员只能访问自己租户的数据,不能通过 URL、Header、Body 指定其他 `tenant_id`。
3. 后台菜单和按钮由服务端返回权限裁剪结果,前端隐藏只是体验优化,服务端仍必须二次鉴权。
4. 敏感操作必须支持二次认证、幂等键、版本号或 `If-Match`,并写入审计。
5. 普通租户管理员不能修改或删除审计日志。
## 3. 后台功能总览
| 模块 | 平台后台 | 企业后台 | 优先级 |
| --- | --- | --- | --- |
| 租户管理 | 是 | 否 | P0 |
| 租户配置与配额 | 是 | 部分只读或申请变更 | P0 |
| 管理员与角色权限 | 是 | 是 | P0 |
| 用户与账号安全 | 支持跨租户运维 | 是 | P0 |
| 组织架构 | 否 | 是 | P1 |
| 群治理 | 运维只读/处置 | 是 | P1 |
| 工作台应用配置 | 平台模板 | 租户配置 | P2 |
| 会议、日报、月报 | 否 | 是 | P2 |
| 总经理信箱 | 否 | 是 | P2 |
| 实名认证审核 | 平台可审计 | 是 | P2 |
| 反诈与内容审核 | 平台策略/插件 | 租户策略/处置 | P2 |
| 举报与反馈工单 | 平台可审计 | 是 | P2 |
| 文件与存储治理 | 平台运维 | 租户范围 | P1 |
| Push、短信、TURN、对象存储配置 | 是 | 租户专属配置 | P1 |
| 审计日志 | 是 | 只读本租户 | P0 |
| Outbox 与后台任务 | 是 | 租户任务可见 | P1 |
## 4. 平台后台需求
### 4.1 租户管理
后台需要支持租户的创建、查看、编辑、停用、恢复、删除前置检查和删除归档。
租户状态:
| 状态 | 登录 | 读消息 | 写业务 | 管理操作 |
| --- | --- | --- | --- | --- |
| `provisioning` | 禁止 | 禁止 | 禁止 | 平台管理员可配置 |
| `active` | 允许 | 允许 | 允许 | 按角色允许 |
| `suspended` | 禁止新登录 | 可配置只读 | 禁止 | 平台管理员可恢复或导出 |
| `deleting` | 禁止 | 禁止 | 禁止 | 只允许删除任务 |
| `deleted` | 禁止 | 禁止 | 禁止 | 只保留审计 |
核心字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 内部租户 ID,普通客户端不返回 |
| `code` | 企业码,全局唯一,最长 64 字符 |
| `name` | 企业名称 |
| `tenant_desc` | 企业说明 |
| `state` | 租户状态 |
| `created_by/updated_by` | 操作人 |
| `created_at/updated_at` | 时间 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenants` | 租户列表,支持状态、企业码、名称过滤 |
| `POST` | `/admin/v1/tenants` | 创建租户 |
| `GET` | `/admin/v1/tenants/{tenant_id}` | 租户详情 |
| `PATCH` | `/admin/v1/tenants/{tenant_id}` | 修改名称、说明等基础资料 |
| `POST` | `/admin/v1/tenants/{tenant_id}/suspend` | 停用租户 |
| `POST` | `/admin/v1/tenants/{tenant_id}/restore` | 恢复租户 |
| `POST` | `/admin/v1/tenants/{tenant_id}/deletion-check` | 删除前检查 |
| `DELETE` | `/admin/v1/tenants/{tenant_id}` | 进入删除流程 |
验收点:
1. 租户 `code` 全局唯一。
2. 停用后旧 Token、WS 登录、HTTP 产品接口均拒绝。
3. 删除必须进入异步任务,不允许直接硬删业务表。
4. 所有状态变更写平台审计。
### 4.2 租户配置与配额
首期配置真源是 `im_tenant_config`。
| 配置 | 说明 | 默认 |
| --- | --- | --- |
| `max_users` | 有效用户数上限 | `0` 表示不限 |
| `max_groups` | 有效群数量上限 | `0` 表示不限 |
| `max_group_members` | 单群有效成员上限 | `0` 表示不限 |
后续可扩展配置:
| 配置域 | 示例 |
| --- | --- |
| 功能开关 | 加好友、建群、用户搜索、音视频、群文件、日报月报 |
| 安全策略 | 实名要求、青少年限制、风控模式、数据保留期 |
| 消息策略 | 文本长度、媒体类型、消息频控、撤回时限、编辑时限 |
| 文件策略 | 单文件大小、总存储配额、允许 MIME、病毒扫描 |
| Push 配置 | APNS/FCM 凭证、模板、锁屏隐私 |
| 外部服务 | 短信、REST 认证、TURN、对象存储、审核插件 |
| 企业品牌 | Logo、主题色、企业描述、客户端下载地址 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenants/{tenant_id}/config` | 查询租户配置 |
| `PATCH` | `/admin/v1/tenants/{tenant_id}/config` | 修改配额和策略 |
| `GET` | `/admin/v1/tenants/{tenant_id}/usage` | 查询用户数、群数、文件容量、消息量 |
| `GET` | `/admin/v1/tenants/{tenant_id}/features` | 查询功能开关 |
| `PATCH` | `/admin/v1/tenants/{tenant_id}/features` | 修改功能开关 |
验收点:
1. 注册、创建群、入群审批必须在事务内校验配额。
2. 超限返回稳定错误码,并记录当前用量、限制值、租户和操作。
3. 修改配置只影响当前租户,不能污染全平台缓存。
### 4.3 平台管理员管理
需求:
1. 创建、停用、恢复平台管理员。
2. 分配平台角色和权限。
3. 平台管理员登录、二次认证、Token 撤销。
4. 查看平台管理员操作审计。
敏感操作:
| 操作 | 要求 |
| --- | --- |
| 创建平台管理员 | 二次确认和审计 |
| 授权 `platform_admin` | 二次认证 |
| 跨租户代操作 | 必须填写原因,记录目标租户和目标对象 |
| 导出数据 | 审批或二次认证,记录导出范围 |
### 4.4 平台运维与任务
需求:
1. 查看运行节点、数据库版本、Adapter 类型和多租户能力。
2. 非 MySQL 或未完成租户能力的 Adapter 在产品模式下必须拒绝启动。
3. 查看 Outbox 事件积压、失败、死信和重试。
4. 查看文件垃圾回收、搜索索引任务、Push 投递、外部审核调用状态。
5. 支持按租户维度查看任务,但避免 Prometheus 高基数标签。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/system/health` | 系统健康 |
| `GET` | `/admin/v1/system/nodes` | 节点列表 |
| `GET` | `/admin/v1/system/outbox-events` | Outbox 查询 |
| `POST` | `/admin/v1/system/outbox-events/{id}/retry` | 重试事件 |
| `POST` | `/admin/v1/system/outbox-events/{id}/dead-letter` | 标记死信 |
| `GET` | `/admin/v1/system/audit-logs` | 平台审计 |
## 5. 企业后台需求
### 5.1 企业管理员与角色权限
企业后台需要支持本租户内管理员管理。
角色:
| 角色 | 权限 |
| --- | --- |
| `tenant_owner` | 全部租户管理权限 |
| `tenant_admin` | 用户、组织、群、安全、工作台配置 |
| `security_auditor` | 实名、举报、反馈、审计读取 |
| `org_admin` | 组织架构和成员维护 |
| `report_admin` | 日报、月报、会议统计 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/admins` | 企业管理员列表 |
| `POST` | `/admin/v1/tenant/admins` | 添加管理员 |
| `PATCH` | `/admin/v1/tenant/admins/{user_id}` | 修改角色 |
| `DELETE` | `/admin/v1/tenant/admins/{user_id}` | 移除管理员 |
| `GET` | `/admin/v1/tenant/roles` | 可用角色和权限点 |
验收点:
1. Tenant Admin 只能管理当前租户。
2. 授权、移除、代操作必须审计。
3. 不允许最后一个 `tenant_owner` 被删除或停用。
### 5.2 用户与账号安全管理
需求:
1. 用户列表、搜索、详情。
2. 创建、启用、停用、离职、恢复用户。
3. 重置密码、撤销 Token、踢下线。
4. 查看登录设备、登录 Session、安全事件。
5. 查看用户实名状态、青少年状态、隐私设置摘要。
6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/users` | 用户列表 |
| `POST` | `/admin/v1/tenant/users` | 创建用户 |
| `GET` | `/admin/v1/tenant/users/{user_id}` | 用户详情 |
| `PATCH` | `/admin/v1/tenant/users/{user_id}` | 修改资料或状态 |
| `POST` | `/admin/v1/tenant/users/{user_id}/reset-password` | 重置密码 |
| `POST` | `/admin/v1/tenant/users/{user_id}/revoke-sessions` | 撤销会话和 Token |
| `GET` | `/admin/v1/tenant/users/{user_id}/devices` | 登录设备 |
| `GET` | `/admin/v1/tenant/users/{user_id}/security-events` | 安全事件 |
验收点:
1. 停用用户不影响其他租户相同手机号或相同设备用户。
2. 重置密码必须递增 `auth_version` 并撤销旧 Token。
3. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。
### 5.3 组织架构管理
需求:
1. 部门树管理:新增、编辑、移动、删除部门。
2. 部门成员管理:添加、移除、调岗、设置负责人。
3. 组织搜索:按部门、用户、岗位搜索。
4. 离职用户处理:不可联系或从结果移除。
5. 组织数据权限:管理员只能管理权限范围内部门。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_org_units` | 部门树 |
| `im_org_members` | 部门成员、岗位、负责人 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/org/departments` | 部门树 |
| `POST` | `/admin/v1/tenant/org/departments` | 创建部门 |
| `PATCH` | `/admin/v1/tenant/org/departments/{id}` | 修改或移动部门 |
| `DELETE` | `/admin/v1/tenant/org/departments/{id}` | 删除部门 |
| `GET` | `/admin/v1/tenant/org/departments/{id}/members` | 部门成员 |
| `POST` | `/admin/v1/tenant/org/departments/{id}/members` | 添加成员 |
| `DELETE` | `/admin/v1/tenant/org/departments/{id}/members/{user_id}` | 移除成员 |
| `GET` | `/admin/v1/tenant/org/search` | 组织搜索 |
验收点:
1. 部门 `parent_id`、负责人、成员必须同租户。
2. 移动部门不能形成环。
3. 删除部门需要校验子部门和成员处理策略。
### 5.4 群治理管理
企业后台和群主/群管理员都需要群治理能力。后台侧侧重全租户范围查询、策略配置、违规处置和审计。
需求:
1. 群列表、群详情、群成员列表。
2. 群资料编辑:名称、头像、简介。
3. 群治理配置:入群审批、全员禁言、成员上传文件、成员私聊、成员邀请。
4. 群管理员管理:添加、删除管理员,管理员数量限制。
5. 禁言管理:添加、解除、过期自动失效。
6. 群主转让、解散群聊。
7. 群二维码管理:生成、撤销、过期。
8. 群文件管理:列表、搜索、删除、下载权限。
9. 群操作日志:角色变化、禁言、公告、解散。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_group_settings` | 群治理配置 |
| `im_group_members` | 群成员和角色真源 |
| `im_group_join_requests` | 入群申请 |
| `im_group_announcements` | 群公告 |
| `im_group_mutes` | 禁言记录 |
| `im_group_admin_logs` | 群管理日志 |
| `im_group_files` | 群文件索引 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/groups` | 群列表 |
| `GET` | `/admin/v1/tenant/groups/{topic}` | 群详情 |
| `PATCH` | `/admin/v1/tenant/groups/{topic}` | 修改群资料 |
| `GET/PATCH` | `/admin/v1/tenant/groups/{topic}/governance` | 群治理配置 |
| `GET` | `/admin/v1/tenant/groups/{topic}/members` | 成员列表 |
| `POST` | `/admin/v1/tenant/groups/{topic}/admins/batch` | 添加管理员 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}/admins/{user_id}` | 删除管理员 |
| `POST` | `/admin/v1/tenant/groups/{topic}/mutes/batch` | 添加禁言 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}/mutes/{user_id}` | 解除禁言 |
| `POST` | `/admin/v1/tenant/groups/{topic}/owner-transfer` | 群主转让 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}` | 解散群 |
| `GET` | `/admin/v1/tenant/groups/{topic}/files` | 群文件 |
| `DELETE` | `/admin/v1/tenant/groups/{topic}/files/{group_file_id}` | 删除群文件 |
验收点:
1. 群业务真源与 Topic/Subscription/ACL 投影必须在同一事务内更新。
2. 管理员不能越级操作群主,普通管理员之间操作规则由治理策略决定。
3. 解散群必须撤销 Topic、二维码、邀请和文件新增权限。
### 5.5 工作台应用配置
需求:
1. 工作台模块配置:启用、禁用、排序、图标、名称、路由。
2. 根据组织角色、权限和功能开关裁剪用户可见模块。
3. 维护会议、日报、月报、总经理信箱等入口。
4. 工作台未读角标由系统事件更新。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_workbench_apps` | 租户工作台应用配置 |
| `im_workbench_user_apps` | 用户或角色级工作台配置 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/workbench/apps` | 应用列表 |
| `POST` | `/admin/v1/tenant/workbench/apps` | 新增应用 |
| `PATCH` | `/admin/v1/tenant/workbench/apps/{code}` | 修改应用 |
| `DELETE` | `/admin/v1/tenant/workbench/apps/{code}` | 删除或禁用应用 |
| `POST` | `/admin/v1/tenant/workbench/apps/reorder` | 排序 |
验收点:
1. 客户端只接受白名单内部 `route`。
2. 禁用模块后普通用户不可通过直接 URL 访问。
### 5.6 会议管理
需求:
1. 创建、编辑、取消会议。
2. 管理参会人、提醒未响应者。
3. 查看参会响应:`pending`、`accepted`、`declined`、`tentative`。
4. 会议过期后只读展示。
5. 生成系统通知和 Push。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_meetings` | 会议主表 |
| `im_meeting_participants` | 参会人响应 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/meetings` | 会议列表 |
| `POST` | `/admin/v1/tenant/meetings` | 创建会议 |
| `GET` | `/admin/v1/tenant/meetings/{id}` | 会议详情 |
| `PATCH` | `/admin/v1/tenant/meetings/{id}` | 更新会议 |
| `POST` | `/admin/v1/tenant/meetings/{id}/cancel` | 取消会议 |
| `POST` | `/admin/v1/tenant/meetings/{id}/reminders` | 提醒未响应者 |
### 5.7 日报与月报管理
需求:
1. 日报/月报模板和校验规则配置。
2. 普通用户草稿、提交、修订。
3. 管理员按部门查看统计和明细。
4. 提醒未提交人员。
5. 查看详情时记录已读状态。
6. 附件使用文件 ACL,不能通过旧 URL 越权下载。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_reports` | 日报/月报统一表 |
| `im_report_read_states` | 阅读状态 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/reports/daily/statistics` | 日报统计 |
| `GET` | `/admin/v1/tenant/reports/daily` | 日报列表 |
| `GET` | `/admin/v1/tenant/reports/daily/{id}` | 日报详情 |
| `POST` | `/admin/v1/tenant/reports/daily/reminders` | 日报提醒 |
| `GET` | `/admin/v1/tenant/reports/monthly/statistics` | 月报统计 |
| `GET` | `/admin/v1/tenant/reports/monthly` | 月报列表 |
| `GET` | `/admin/v1/tenant/reports/monthly/{id}` | 月报详情 |
| `POST` | `/admin/v1/tenant/reports/monthly/reminders` | 月报提醒 |
验收点:
1. 管理员只能看组织权限范围内成员。
2. 并发保存必须使用版本号,冲突返回当前最新版本。
3. 提醒接口需要限流并返回逐项结果。
### 5.8 总经理信箱
需求:
1. 配置是否启用、是否允许匿名、必填字段、附件策略、提示文案。
2. 用户提交意见或建议。
3. 后台查看、分派、处理、回复和关闭。
4. 匿名模式下业务接收方不可见提交人;安全审计是否保留内部追踪由企业政策决定。
5. 文案变更后,客户端提交时可要求重新确认。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_manager_mailbox` | 信箱提交和处理记录 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/executive-mailbox/config` | 查询配置 |
| `PATCH` | `/admin/v1/tenant/executive-mailbox/config` | 修改配置 |
| `GET` | `/admin/v1/tenant/executive-mailbox/submissions` | 提交列表 |
| `GET` | `/admin/v1/tenant/executive-mailbox/submissions/{id}` | 提交详情 |
| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/assign` | 分派 |
| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/reply` | 回复 |
| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/close` | 关闭 |
## 6. 安全、审核与工单后台
### 6.1 实名认证审核
需求:
1. 查看实名认证列表:未认证、待审核、已通过、已拒绝、申诉中。
2. 查看脱敏信息,具备权限时按流程查看敏感原文。
3. 审核通过、拒绝、要求补充材料。
4. 实名策略配置:是否强制实名、允许跳过、提醒周期、重复身份策略。
5. 审核结果通过系统事件通知用户。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_realname_verifications` | 实名信息、审核状态、脱敏展示 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/security/realname/submissions` | 实名审核列表 |
| `GET` | `/admin/v1/tenant/security/realname/submissions/{id}` | 审核详情 |
| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/approve` | 通过 |
| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/reject` | 拒绝 |
| `GET/PATCH` | `/admin/v1/tenant/security/realname/policy` | 实名策略 |
验收点:
1. 姓名、证件号加密存储,普通日志不出现原文。
2. 审核操作必须审计。
3. 普通管理员无权读取敏感原文。
### 6.2 青少年模式策略
需求:
1. 配置青少年模式默认策略和限制范围。
2. 查看用户青少年模式状态。
3. 处理用户忘记独立密码后的重置流程。
限制范围首期建议包括:
1. 添加好友。
2. 加群和创建群。
3. 文件传输。
4. 音视频通话。
5. 工作台外部链接。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/security/youth-mode/policy` | 青少年策略 |
| `GET` | `/admin/v1/tenant/security/youth-mode/users` | 用户状态列表 |
| `POST` | `/admin/v1/tenant/security/youth-mode/users/{user_id}/reset-pin` | 重置独立密码 |
### 6.3 反诈与内容审核
需求:
1. 风控策略配置:高风险动作、提示文案、二次确认、阻断策略。
2. 审核插件配置:平台统一审核服务或租户专属审核服务。
3. 审核模式配置:`sync_block`、`async_hold`、`async_post_audit`。
4. 查看风险事件、确认、取消、过期、阻断记录。
5. 对已发布违规消息执行系统撤回。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/security/risk-policy` | 风控策略 |
| `GET/PATCH` | `/admin/v1/tenant/security/moderation-policy` | 内容审核策略 |
| `GET` | `/admin/v1/tenant/security/risk-events` | 风险事件 |
| `GET` | `/admin/v1/tenant/security/moderation-tasks` | 审核任务 |
| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/approve` | 审核通过 |
| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/reject` | 审核拒绝 |
| `POST` | `/admin/v1/tenant/messages/{message_id}/recall-by-review` | 审核撤回 |
验收点:
1. 客户端提交的审核状态一律忽略。
2. 插件返回 `REPLACE` 或 `RESPOND` 时不能修改 TenantID。
3. 风控确认 Token 单次、短期、绑定原动作。
### 6.4 举报工单
需求:
1. 配置举报原因,按目标类型区分用户、群、消息。
2. 举报列表、详情、证据、目标快照。
3. 工单状态:`received`、`processing`、`resolved`、`rejected`、`closed`。
4. 处置动作:警告、禁言、封禁用户、解散群、撤回消息、标记无效。
5. 重复举报合并策略。
6. 处理结果通知举报人,必要时通知被处置对象。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_abuse_reports` | 举报工单 |
| `im_evidence_files` | 证据文件 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/reports-abuse/reasons` | 举报原因配置 |
| `GET` | `/admin/v1/tenant/reports-abuse` | 举报列表 |
| `GET` | `/admin/v1/tenant/reports-abuse/{id}` | 举报详情 |
| `POST` | `/admin/v1/tenant/reports-abuse/{id}/assign` | 分派 |
| `POST` | `/admin/v1/tenant/reports-abuse/{id}/resolve` | 处置 |
| `POST` | `/admin/v1/tenant/reports-abuse/{id}/close` | 关闭 |
验收点:
1. 举报人必须能看到被举报目标,后台详情使用提交时固化快照。
2. 证据文件使用独立 ACL 和保留期。
3. 原消息删除或撤回后,举报证据仍可处理。
### 6.5 意见反馈工单
需求:
1. 配置反馈类别、最大正文、附件规则、是否允许联系方式、日志授权说明。
2. 反馈列表、详情、附件、设备和版本信息。
3. 分派、回复、关闭、标记重复。
4. 处理结果通过系统通知或客户端查询展示。
建议数据表:
| 表 | 说明 |
| --- | --- |
| `im_feedbacks` | 反馈工单 |
| `im_evidence_files` | 反馈附件 |
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET/PATCH` | `/admin/v1/tenant/feedback/config` | 反馈配置 |
| `GET` | `/admin/v1/tenant/feedbacks` | 反馈列表 |
| `GET` | `/admin/v1/tenant/feedbacks/{id}` | 反馈详情 |
| `POST` | `/admin/v1/tenant/feedbacks/{id}/assign` | 分派 |
| `POST` | `/admin/v1/tenant/feedbacks/{id}/reply` | 回复 |
| `POST` | `/admin/v1/tenant/feedbacks/{id}/close` | 关闭 |
## 7. 文件、搜索与数据治理后台
### 7.1 文件治理
需求:
1. 按租户查看文件用量、文件列表、状态和业务用途。
2. 支持群文件、收藏文件、举报证据、反馈附件、日报/月报附件分类管理。
3. 文件下载审计。
4. 清理未关联文件、过期文件和违规文件。
5. 配置文件大小、MIME、总存储配额、保留期。
文件状态建议:
```text
pending -> uploaded -> ready
pending/uploaded -> failed
uploaded(超时且无引用) -> orphaned -> deleted
```
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/files` | 文件列表 |
| `GET` | `/admin/v1/tenant/files/{file_id}` | 文件详情 |
| `POST` | `/admin/v1/tenant/files/{file_id}/quarantine` | 隔离文件 |
| `DELETE` | `/admin/v1/tenant/files/{file_id}` | 删除文件 |
| `GET/PATCH` | `/admin/v1/tenant/files/policy` | 文件策略 |
验收点:
1. 文件下载不能只靠 FileID,必须校验上传者或关联消息/Topic 读取权限。
2. 后台删除文件必须审计,并处理消息、收藏、举报等引用。
### 7.2 搜索治理
需求:
1. 消息索引状态查看。
2. 重建指定租户、Topic 或时间段索引。
3. 搜索敏感内容审计。
4. 查看搜索性能和失败任务。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/search/index-status` | 索引状态 |
| `POST` | `/admin/v1/tenant/search/reindex` | 重建索引 |
| `GET` | `/admin/v1/tenant/search/audit-logs` | 搜索审计 |
验收点:
1. 搜索索引不是授权来源,返回前仍需 Topic Read ACL 二次校验。
2. 用户退群或权限变化后必须立即阻断搜索结果。
## 8. 通知、Push 与外部服务后台
### 8.1 系统消息模板
需求:
1. 管理系统事件类型和模板版本。
2. 配置账号安全、好友、群、文件、通话、工作台、安全等通知模板。
3. Outbox 事件驱动系统消息、Push、索引和定时任务。
首期事件:
| 业务 | 事件 |
| --- | --- |
| 账号安全 | 新设备登录、密码修改、实名提醒 |
| 好友 | 新申请、通过、拒绝、删除 |
| 群 | 邀请、申请、审批、公告、禁言、管理员变更、群主转让、解散 |
| 文件 | 群文件上传、文件失效 |
| 通话 | 未接、拒绝、取消、忙线、通话结束 |
| 工作台 | 会议通知、会议变更、日报提醒、月报提醒、信箱处理 |
| 安全 | 反诈提示、举报受理、反馈受理 |
### 8.2 Push、短信、TURN、对象存储
需求:
1. 租户级 Push 模板、App 包名、APNS/FCM 凭证引用。
2. 短信服务配置和模板。
3. TURN 配置。
4. 对象存储 Bucket、Prefix、签名策略。
5. 外部 REST 认证服务配置。
6. 外部审核和风控插件配置。
验收点:
1. 租户没有配置时按明确策略失败,不得静默使用其他租户配置。
2. 密钥只保存引用,不把明文 Secret 放入普通业务 JSON。
3. 外部回调必须校验服务端签名的 TenantContext 和 nonce。
## 9. 审计与日志后台
### 9.1 审计范围
以下操作必须写审计:
1. 租户创建、停用、恢复、删除。
2. 平台管理员和租户管理员授权、移除、代操作。
3. 用户停用、离职、密码重置、数据导出。
4. 群主转让、管理员变更、禁言、解散群。
5. 实名审核、举报处置、反馈处理、匿名信箱读取。
6. 文件下载、删除、批量导出、搜索敏感内容。
7. 反诈二次确认、内容审核处置。
8. 外部服务配置修改。
### 9.2 审计字段
| 字段 | 说明 |
| --- | --- |
| `tenant_id` | 租户 |
| `actor_uid` | 操作人 |
| `actor_role` | 操作角色 |
| `action` | 操作类型 |
| `target_type` | 目标类型 |
| `target_id` | 目标 ID |
| `before_snapshot` | 变更前摘要 |
| `after_snapshot` | 变更后摘要 |
| `reason` | 操作原因 |
| `request_id` | 请求 ID |
| `ip` | 操作 IP,必要时脱敏 |
| `user_agent` | 操作客户端 |
| `created_at` | 操作时间 |
审计表只允许追加,不允许普通管理员修改或删除。
建议接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/v1/tenant/audit-logs` | 租户审计 |
| `GET` | `/admin/v1/audit-logs` | 平台审计 |
| `POST` | `/admin/v1/audit-logs/export` | 审计导出 |
## 10. 后台公共能力
### 10.1 认证与会话
1. 后台登录必须使用独立后台入口,不与普通 App 登录混用权限。
2. 后台 Token 包含 `tenant_id`、`uid`、角色、权限版本、`auth_version`。
3. 支持刷新、撤销、踢下线、登录设备管理。
4. 高危操作需要二次认证或 `X-Action-Token`。
### 10.2 API 规范
后台 API 建议统一使用 `/admin/v1/*`。
成功响应:
```json
{
"code": 0,
"data": {},
"request_id": "01K...",
"server_time": "2026-08-12T05:30:00.000Z"
}
```
失败响应:
```json
{
"code": 30001,
"reason": "FORBIDDEN",
"message": "permission denied",
"details": {},
"request_id": "01K...",
"server_time": "2026-08-12T05:30:00.000Z"
}
```
公共要求:
1. 写操作必须带 `Idempotency-Key`。
2. 更新操作必须带版本号或 `If-Match`。
3. 列表统一使用 `limit + cursor`。
4. 请求必须带 `X-Request-ID`。
5. 不能从请求 Body、URL Query 或普通 Header 信任客户端自报 `tenant_id`。
6. 手机号、证件号、验证码、Token、设备 Token、文件签名不写日志。
### 10.3 数据表公共规则
1. 新业务表统一使用 `im_` 前缀。
2. 租户业务表必须包含 `tenant_id BIGINT NOT NULL`。
3. 唯一索引优先使用 `(tenant_id, business_key)`。
4. 子表外键优先使用 `(tenant_id, parent_id)`。
5. 更新、删除、聚合、清理任务必须带 `tenant_id`。
6. 平台级表和租户根表例外,但必须在 DDL 评审中说明作用域。
## 11. 开发优先级建议
### P0:后台地基
1. `/admin/v1` 认证中间件、RBAC、租户 Principal。
2. 平台租户管理:创建、停用、恢复、配置配额。
3. 企业管理员与角色权限。
4. 用户管理:停用、重置密码、撤销 Session。
5. 审计日志基础表和查询。
6. Outbox 查询与重试基础能力。
### P1:企业治理
1. 组织架构管理。
2. 群治理后台:群列表、群成员、管理员、禁言、解散、群文件。
3. 文件策略、文件列表、文件审计。
4. 外部服务配置:Push、短信、对象存储、TURN。
5. 功能开关和配额用量面板。
### P2:办公与安全业务
1. 工作台配置。
2. 会议管理。
3. 日报、月报统计和提醒。
4. 总经理信箱。
5. 实名认证审核。
6. 青少年策略。
7. 反诈、内容审核、举报、反馈工单。
## 12. 验收清单
- [ ] 平台后台与企业后台权限边界清晰,跨租户操作必须审计。
- [ ] 所有后台列表、详情、更新、删除都按租户隔离。
- [ ] 租户停用后普通用户登录和 HTTP 产品接口拒绝。
- [ ] 用户停用、重置密码、撤销 Token 不影响其他租户同手机号用户。
- [ ] 创建用户、建群、入群审批配额在事务内强制执行。
- [ ] 群主转让、禁言、管理员变更、解散群写审计并同步系统消息。
- [ ] 实名、举报、反馈、信箱等敏感数据脱敏展示并按权限读取。
- [ ] 文件下载、删除、导出均有资源级授权和审计。
- [ ] 外部服务密钥只保存引用,不写普通日志。
- [ ] Outbox 事件可查询、重试、死信处理,重复执行不产生重复副作用。
- [ ] 审计表只允许追加,普通管理员不可修改或删除。
+773
View File
@@ -0,0 +1,773 @@
-- IM product database changes summary, chapters 1-11.
-- Target:
-- public backend database: hotgo
-- default tenant business database: tinode
-- Notes:
-- 1. Public tenant registry tables live in hotgo.
-- 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.
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- ============================================================
-- Public database: tenant registry.
-- ============================================================
CREATE TABLE IF NOT EXISTS hotgo.im_tenant (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_tenant_code (code),
INDEX idx_im_tenant_db_name (db_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_tenant_config_tenant_id (tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY (tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 1, tenant-aware login, verification, security.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_security (
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_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_realname_submission (submission_id),
INDEX idx_im_realname_user_latest (user_id, created_at),
INDEX idx_im_realname_id_hash (id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_action_tokens (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_action_tokens_token (token_id),
INDEX idx_im_action_tokens_user_action (user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
policy_version INT NOT NULL DEFAULT 1,
restrictions JSON,
enabled_at DATETIME(3),
failed_attempts INT NOT NULL DEFAULT 0,
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapters 2-7, notifications, messages, friends, files, favorites.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_system_notifications (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_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_system_notifications_event (user_id, event_id),
INDEX idx_im_system_notifications_inbox (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing',
response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB,
expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_http_idempotency_scope (user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_business_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
target_type VARCHAR(16) NOT NULL,
target VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0,
event_type VARCHAR(64) NOT NULL,
payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_business_events_outbox_event (event_id),
INDEX idx_im_business_events_outbox_pending (status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target (target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE INDEX uk_im_message_revisions_version (topic, seqid, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_friend_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_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_friend_requests_request (request_id),
UNIQUE INDEX uk_im_friend_requests_idempotency (applicant_id, idempotency_key),
INDEX idx_im_friend_requests_pair (applicant_id, target_id, status, created_at),
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 (
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
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),
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 (
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),
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 (
id BIGINT NOT NULL AUTO_INCREMENT,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_favorites_favorite (favorite_id),
UNIQUE INDEX uk_im_favorites_source (user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency (user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox (user_id, id DESC),
INDEX idx_im_favorites_type (user_id, item_type, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing Tinode tables must match the current schema for chapters 2-7:
-- im_topics.aux, im_fileuploads.etag, and im_filemsglinks.topic/userid are required by current code.
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'client_msg_id') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'version') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'recalled_at') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'recalled_by') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_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.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0,
'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.im_messages(`from`, client_msg_id)',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
-- Independent-DB tenant isolation: tenant business tables must not keep tenant_id.
DROP TABLE IF EXISTS tinode.im_tenant_config;
DROP TABLE IF EXISTS tinode.im_tenant;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_auth' AND COLUMN_NAME = 'tenant_id') > 0,
'ALTER TABLE tinode.im_auth DROP COLUMN tenant_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_credentials' AND COLUMN_NAME = 'tenant_id') > 0,
'ALTER TABLE tinode.im_credentials DROP COLUMN tenant_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
-- ============================================================
-- Tenant database: chapter 8, realtime call records.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_call_records (
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id INT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
duration_ms BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(3) NOT NULL,
accepted_at DATETIME(3),
finished_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_call_records_call (call_id),
UNIQUE INDEX uk_im_call_records_topic_seq (topic, seq_id),
INDEX idx_im_call_records_caller (caller_id, id DESC),
INDEX idx_im_call_records_callee (callee_id, id DESC),
INDEX idx_im_call_records_status (status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 9, workbench and office collaboration.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
icon_file_id VARCHAR(64) NOT NULL DEFAULT '',
route VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
unread_key VARCHAR(64) NOT NULL DEFAULT '',
enabled TINYINT NOT NULL DEFAULT 1,
permission VARCHAR(64) NOT NULL DEFAULT 'enabled',
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_workbench_apps_code (code),
INDEX idx_im_workbench_apps_enabled_order (enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_org_departments (
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
parent_id VARCHAR(64) NOT NULL DEFAULT 'root',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_org_departments_department (department_id),
INDEX idx_im_org_departments_parent (parent_id, state, sort_order, id),
INDEX idx_im_org_departments_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_org_members (
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
position VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
employment_state VARCHAR(16) NOT NULL DEFAULT 'active',
contactable TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
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_org_members_department_user (department_id, user_id),
INDEX idx_im_org_members_department (department_id, employment_state, sort_order, id),
INDEX idx_im_org_members_user (user_id),
INDEX idx_im_org_members_display_name (display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_meetings (
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
location VARCHAR(256) NOT NULL DEFAULT '',
state VARCHAR(16) NOT NULL DEFAULT 'scheduled',
reminder_minutes JSON,
idempotency_key VARCHAR(128),
version INT NOT NULL DEFAULT 1,
canceled_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_meetings_meeting (meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer (organizer_id, start_at),
INDEX idx_im_meetings_state_time (state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants (
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
response VARCHAR(16) NOT NULL DEFAULT 'pending',
response_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_meeting_participants_user (meeting_id, user_id),
INDEX idx_im_meeting_participants_user_time (user_id, meeting_id),
INDEX idx_im_meeting_participants_response (meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY (meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_daily_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_date DATE NOT NULL,
content TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_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_daily_reports_report (report_id),
UNIQUE INDEX uk_im_daily_reports_author_date (author_id, report_date),
INDEX idx_im_daily_reports_author_id (author_id, id DESC),
INDEX idx_im_daily_reports_date_status (report_date, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_month CHAR(7) NOT NULL,
work_summary TEXT NOT NULL,
next_plan TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_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_monthly_reports_report (report_id),
UNIQUE INDEX uk_im_monthly_reports_author_month (author_id, report_month),
INDEX idx_im_monthly_reports_author_id (author_id, id DESC),
INDEX idx_im_monthly_reports_month_status (report_month, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config (
id BIGINT NOT NULL AUTO_INCREMENT,
enabled TINYINT NOT NULL DEFAULT 1,
allow_anonymous TINYINT NOT NULL DEFAULT 1,
required_fields JSON,
max_length INT NOT NULL DEFAULT 5000,
attachment_policy JSON,
notice_title VARCHAR(128) NOT NULL DEFAULT '',
notice_content TEXT NOT NULL,
notice_updated_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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions (
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
sender_id BIGINT NOT NULL,
subject VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
anonymous TINYINT NOT NULL DEFAULT 0,
attachment_file_ids JSON,
notice_version VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_mailbox_submissions_submission (submission_id),
UNIQUE INDEX uk_im_mailbox_submissions_idempotency (sender_id, idempotency_key),
INDEX idx_im_mailbox_submissions_sender (sender_id, id DESC),
INDEX idx_im_mailbox_submissions_status (status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 10, profile, privacy, app release, push, youth mode.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
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 '',
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
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)
) 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,
platform VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
latest_version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64) NOT NULL,
update_type VARCHAR(16) NOT NULL DEFAULT 'none',
release_notes TEXT NOT NULL,
store_url VARCHAR(512) NOT NULL DEFAULT '',
package_url VARCHAR(512) NOT NULL DEFAULT '',
checksum VARCHAR(128) NOT NULL DEFAULT '',
remind_after_seconds INT NOT NULL DEFAULT 0,
published_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_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 (
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
token VARCHAR(512) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
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),
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;
-- ============================================================
-- Tenant database: chapter 11, risk challenge, abuse report, feedback.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges (
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL,
notice_code VARCHAR(64) NOT NULL,
notice_params JSON,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
decision VARCHAR(16) NOT NULL DEFAULT '',
action_token_id VARCHAR(64) 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_risk_challenges_challenge (challenge_id),
INDEX idx_im_risk_challenges_user_state (user_id, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons (
id BIGINT NOT NULL AUTO_INCREMENT,
target_type VARCHAR(16) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
requires_description TINYINT NOT NULL DEFAULT 0,
allows_evidence TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT 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_abuse_reasons_type_code (target_type, reason_code),
INDEX idx_im_abuse_reasons_type_order (target_type, enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
snapshot JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
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_abuse_reports_report (report_id),
UNIQUE INDEX uk_im_abuse_reports_idempotency (reporter_id, idempotency_key),
INDEX idx_im_abuse_reports_target (target_type, target_topic, target_seq, target_user_id),
INDEX idx_im_abuse_reports_reporter (reporter_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_feedback_config (
id BIGINT NOT NULL AUTO_INCREMENT,
categories JSON,
max_length INT NOT NULL DEFAULT 1000,
attachment_policy JSON,
allow_contact TINYINT NOT NULL DEFAULT 1,
diagnostics_notice TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions (
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
category VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(128) NOT NULL DEFAULT '',
attachment_file_ids JSON,
client_context JSON,
include_diagnostics TINYINT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
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_feedback_submissions_feedback (feedback_id),
UNIQUE INDEX uk_im_feedback_submissions_idempotency (user_id, idempotency_key),
INDEX idx_im_feedback_submissions_user (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Seed data.
-- ============================================================
INSERT INTO hotgo.im_tenant(id, code, name, tenant_desc, db_name, db_dsn, state, created_by, updated_by)
VALUES (1, 'default', 'Default Tenant', NULL, 'tinode', '', 1, 0, 0)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
db_name = VALUES(db_name),
state = VALUES(state),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO hotgo.im_tenant_config(tenant_id, max_users, max_groups, max_group_members, created_by, updated_by)
SELECT id, 0, 0, 0, 0, 0 FROM hotgo.im_tenant WHERE code = 'default'
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_workbench_apps(code, name, route, sort_order, unread_key, enabled, permission)
VALUES
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 20, 'meetings', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 40, 'monthly_reports', 1, 'enabled'),
('executive_mailbox', '总经理信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
route = VALUES(route),
sort_order = VALUES(sort_order),
unread_key = VALUES(unread_key),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content)
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at = updated_at;
INSERT INTO tinode.im_app_releases(platform, channel, latest_version, min_supported_version, update_type, release_notes, remind_after_seconds)
VALUES
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0)
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_abuse_reasons(target_type, reason_code, label, requires_description, allows_evidence, sort_order)
VALUES
('user', 'fraud', '诈骗欺诈', 0, 1, 10),
('user', 'harassment', '骚扰辱骂', 0, 1, 20),
('user', 'illegal', '违法违规', 1, 1, 30),
('group', 'fraud', '诈骗欺诈', 0, 1, 10),
('group', 'spam', '垃圾广告', 0, 1, 20),
('group', 'illegal', '违法违规', 1, 1, 30),
('message', 'fraud', '诈骗欺诈', 0, 1, 10),
('message', 'spam', '垃圾广告', 0, 1, 20),
('message', 'harassment', '骚扰辱骂', 0, 1, 30),
('message', 'illegal', '违法违规', 1, 1, 40)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
requires_description = VALUES(requires_description),
allows_evidence = VALUES(allows_evidence),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_feedback_config(id, categories, max_length, attachment_policy, allow_contact, diagnostics_notice)
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at = updated_at;
-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go.
UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version';
+306
View File
@@ -0,0 +1,306 @@
# IM 第一至十一章后续增强项
> 更新日期:2026-08-17
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
> 状态说明:2026-08-17 已完成的 P0/P1 已移入各章“当前已完成基线”,不再作为后续待办。完整修复记录见 `IM-需求实现情况审核报告.md`。
## 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. 风险确认已生成可重试安全事件和用户系统通知。
### 1.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 |
| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入统一事件和系统通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件且重试不重复入箱。 |
| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 |
| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 |
| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
| 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. 好友、群、会议和安全模块已可信生产系统通知。
### 2.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
## 3. 第三章:单聊关系、权限与会话上下文
### 3.1 当前已完成基线
1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。
2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。
3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。
6. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
7. 好友申请/审批关键路径已在业务事务内写入通知和 Outbox,其他可重复关系写由全局 HTTP 幂等保证可补写。
8. 非好友资料查询已执行 `profile_visibility`。
### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 |
| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 资料策略继续扩展 | 好友/非好友可见性已执行,企业字段级可见性和青少年模式仍需纳入同一策略。 | 企业受限字段与青少年用户无法从其他入口绕过策略。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 |
## 4. 第四章:群消息、群成员与 @ 能力
### 4.1 当前已完成基线
1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。
2. 返回成员角色、公开资料、ACL 和是否可被 @。
3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。
6. 群昵称、角色、禁言、成员和公告等群业务变更已写入群 Outbox,并生成在线事件与系统通知。
### 4.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 |
| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 |
| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 |
## 5. 第五章:通讯录、好友关系与黑名单
### 5.1 当前已完成基线
1. 好友列表支持查询、首字母、分页,返回备注、头像、显示名来源和会话 Topic。
2. 新朋友列表、好友申请、通过、拒绝链路已具备。
3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。
4. 添加好友二维码支持短期签名 Token 生成与解析。
5. 好友个人主页返回关系、资料和操作能力。
6. 群内非好友个人主页返回群角色、关系、资料和操作能力。
7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。
8. 黑名单列表和批量移出黑名单已具备。
9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。
10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。
11. 好友个人主页已执行 `profile_visibility`。
### 5.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P1 | 用户搜索租户策略扩展 | 个人添加方式已执行,后续还需增加租户级 `allow_user_search`、通讯录授权和存在性防探测策略。 | 租户关闭搜索或未授权通讯录时,客户端无法绕过且不泄露用户存在性。 |
| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 基础 `everyone/friends/nobody` 可见性已执行,后续需按群内关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 |
| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 |
| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 |
| P1 | 关系操作审计 | 关键关系系统通知已接入,添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 |
| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 |
## 6. 第六章:群聊创建、资料维护与成员治理
### 6.1 当前已完成基线
1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。
2. 群设置、个人群设置、退出群、群昵称接口已具备。
3. 群二维码生成与扫码解析支持 `join_group` 场景。
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. 群公告已使用独立表并记录成员已读时间。
### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 |
## 7. 第七章:文件下载、收藏管理与聊天记录检索
### 7.1 当前已完成基线
1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。
2. 收藏批量创建和基础分享前的数据结构已具备。
3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。
4. 消息上下文定位接口可围绕指定 seq 返回前后消息。
5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。
6. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。
### 7.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P1 | 收藏业务去重策略 | 同一 HTTP 请求的超时重试已由全局幂等保证;使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 |
| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 |
| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 |
| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 |
| P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 |
| P1 | 聊天记录稳定游标 | 消息搜索和媒体列表需要稳定游标,避免新消息插入导致翻页重复或漏项。 | 搜索期间新增消息不影响当前翻页结果。 |
| P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 |
| P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 |
| P2 | 聊天记录高亮与命中片段 | 搜索结果可返回命中摘要、高亮范围和消息类型摘要。 | 客户端无需二次解析即可展示命中片段。 |
| P2 | 收藏导出与批量管理 | 后续可支持收藏批量删除、批量转发、导出和云端容量统计。 | 批量操作有部分失败语义和可重试结果。 |
## 8. 第八章:实时语音与视频通话
### 8.1 当前已完成基线
1. 通话 invite、accept、offer、answer、ice-candidate、hang-up 等事件沿用 Tinode call event 链路。
2. 通话记录表 `im_call_records` 已具备,记录 `call_id`、Topic、seq、主叫/被叫、类型、状态、时长和结束原因。
3. 通话记录创建、接听和结束状态更新已接入 store/adapter。
4. 通话记录查询接口已具备:全局 `GET /v1/im/calls` 与 Topic 内 `GET /v1/im/topics/{topic}/calls`。
5. 查询结果返回对端用户、方向、通话类型、状态、时长和关键时间点,客户端可先完成通话记录页对接。
### 8.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 |
| P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 |
| P0 | 通话事件 Outbox 与通知 | 来电、未接、挂断、忙线等需要可信事件和 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。
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
### 9.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 |
| P0 | 工作台未读角标事件 | 文档要求 `workbench.badge.updated`,后续需要模块未读聚合和实时增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 |
| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 |
| P1 | 会议离线 Push 联调 | 会议在线事件、系统通知和 Outbox 已完成,后续需使用真实 Firebase 凭据和移动端验证离线通知。 | 参与人离线时收到一次提醒,点击可跳转至正确会议。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
| P2 | 办公模块统一附件策略 | 会议、日报、月报、信箱附件需要复用第七章文件 ACL、用途和病毒扫描。 | 附件上传、查看、下载权限与业务状态一致。 |
## 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` 和独立密码校验链路。
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。
7. 用户搜索/加好友、个人资料、消息回执及 Tinode P2P 最后在线输出已执行对应隐私设置。
### 10.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 |
| P0 | 精准登出与会话撤销 | 现有 token 主要靠 `auth_version` 批量失效,后续需要 access/refresh/session 表支持当前设备登出和指定 session 删除。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 |
| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 |
| P1 | 隐私策略入口扩展 | 产品主要搜索、资料、回执和最后在线入口已执行隐私设置,组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 |
| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 |
## 11. 反诈安全、举报与意见反馈
### 11.1 当前已完成基线
1. URL 安全检查 `POST /v1/im/security/urls/check` 已接入风控 challenge 创建。
2. 风控确认 `POST /v1/im/security/risk-challenges/{id}/confirm` 已具备,`continue` 返回短时 `risk_action_token`。
3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。
4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。
5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。
6. 风险确认结果已生成稳定 `security.risk.*` 事件和用户可见系统通知。
### 11.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 风控策略引擎 | 当前 URL 检查仅基础规则,后续需要按动作、关系、内容、设备、频率和租户策略综合判定。 | 高风险发消息、加好友、打开链接等动作统一返回风控 challenge。 |
| P0 | 风控 token 消费接入业务接口 | 当前确认可签发 `risk_action_token`,后续需要原业务接口校验并单次消费该 token。 | 二次确认后只允许重试原动作一次,重复使用返回 Token 已消费。 |
| P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 |
| P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 |
| P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 |
| P1 | 主动反诈风险提示 | 风险确认通知已由服务端可信生产;低风险主动提示、通知文案策略和 Push 优先级仍需扩展。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 |
| P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 |
| P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 |
## 12. 统一处理建议
1. 优先剩余 P0:验证码真实发送与多维限频、消息撤回历史占位、P2P 文件/通话策略统一、@通知与频控、文件安全与短时授权、通话强状态机、工作台/组织权限和风控 token 业务消费。
2. 再做剩余 P1:租户策略配置、实名审核、设备/会话管理、字段级隐私矩阵、关系审计、群文件与解散状态机、通话质量指标、报表/信箱后台流转和真实 FCM 离线联调。
3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。
4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和 `IM-需求实现情况审核报告.md`,避免已完成项继续出现在待办清单。
+313
View File
@@ -0,0 +1,313 @@
# IM 独立库多租户改造方案
> 编写日期:2026-08-13
> 适用项目:`im_server`
> 方案状态:替代原“共享库共享表 + tenant_id”方案,后续多租户开发以本文为准。
## 1. 方案结论
多租户调整为:
> 共享服务集群 + 控制库保存租户元数据 + 每个租户独立业务库
业务库中的 `users`、`auth`、`credentials`、`topics`、`subscriptions`、`messages`、`fileuploads` 以及后续产品业务表都不再增加 `tenant_id` 字段。租户隔离通过运行时选库实现:客户端先用企业码解析租户,服务端得到 `tenant_id` 后,根据控制库中的 `db_name` 或 `db_dsn` 选择对应租户业务库。
`TenantID` 仍然要保留,但它的职责从“业务表行级隔离字段”调整为“运行时租户上下文”:
1. 登录前企业码解析;
2. Token 签名和校验;
3. Session 当前租户固定;
4. Hub、缓存、集群路由、Push、文件路径和插件上下文隔离;
5. 选择当前请求使用哪个租户业务库;
6. 后台平台侧管理租户、配置配额、停启租户。
## 2. 为什么独立库仍需要 TenantID
独立库解决的是数据库物理隔离,不代表服务端运行时没有租户。服务进程仍然同时服务多个企业,如果内存 Topic、用户缓存、系统 Topic、Push、文件下载授权、Token 校验不带租户上下文,仍可能出现:
1. 两个租户都使用逻辑 Topic `sys` 时共用同一个内存 Topic;
2. HTTP 文件下载只拿到 `file_id`,无法判断该去哪个租户库查授权;
3. Token 只包含 UID 时,无法知道该 UID 属于哪个租户库;
4. 定时任务、插件调用、Push 回调无法定位租户库;
5. 后台接口可能把平台租户 ID 和业务租户数据混在一起。
所以新方案的边界是:
```text
TenantID 负责运行时路由和授权上下文
tenant_id 字段不进入租户业务库表结构
```
## 3. 数据库拆分
### 3.1 控制库
控制库可以使用现有 `hotgo`,只保存平台级租户元数据和配额配置。控制库表需要保留 `tenant_id`,因为这些表本身是在管理所有租户;这里的 `tenant_id` 不是业务库行级隔离字段。
```sql
CREATE TABLE im_tenant (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_tenant_code (code),
KEY idx_im_tenant_db_name (db_name)
);
CREATE TABLE im_tenant_config (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id),
CONSTRAINT fk_im_tenant_config_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id)
ON DELETE CASCADE
);
```
`db_name` 适合所有租户共用同一个 MySQL 实例时使用,例如 `im_tenant_1001`。`db_dsn` 适合某些租户需要独立 MySQL 实例时使用。优先级建议为:`db_dsn` 非空时使用完整 DSN,否则使用主 DSN + `db_name`。
如果要求 `db_name` 不能重复,建议在租户创建服务里校验“非空 db_name 唯一”。MySQL 普通唯一索引不能表达“只对非空字符串唯一”,直接给 `db_name` 建唯一索引会导致多个使用独立 `db_dsn` 的租户因为空 `db_name` 冲突。
### 3.2 租户业务库
每个租户一个业务库,例如:
```text
hotgo
├─ im_tenant
└─ im_tenant_config
im_tenant_1001
├─ users
├─ auth
├─ credentials
├─ topics
├─ subscriptions
├─ messages
├─ fileuploads
└─ 后续产品业务表
```
租户业务库表结构恢复为租户内唯一约束,不加 `tenant_id`。同一个手机号、用户名可以在两个租户库中各自存在,不会冲突。
## 4. 当前代码如何调整
当前代码已经有一部分独立库路由雏形,可以继续利用:
1. `server/store/store.go` 已经预留 `TenantRoutedAdapter` 分支;
2. `server/db/adapter.go` 已定义 `TenantAdapterFor(tenantID)`;
3. `server/db/mysql/adapter.go` 已支持 `tenant_registry_db`、`tenant_db_routing`、`db_name`、`db_dsn`;
4. Token、Session、Hub TopicKey、UserKey、插件上下文已经带 `TenantID`。
需要调整的重点是:
| 模块 | 旧共享表方案 | 新独立库方案 |
| --- | --- | --- |
| 控制库 | 与业务表混在一个库 | 单独保存 `im_tenant`、`im_tenant_config` |
| 业务库 schema | 所有表加 `tenant_id` | 所有业务表不加 `tenant_id` |
| MySQL Adapter | SQL 使用 `WHERE tenant_id=?` | 先根据 TenantID 选库,再执行租户库内 SQL |
| Store 接口 | TenantID 作为 SQL 条件 | TenantID 作为选库参数 |
| 唯一索引 | `(tenant_id, business_key)` | 租户库内 `business_key` 唯一 |
| 外键 | 复合外键带 `tenant_id` | 普通外键 |
| 数据迁移 | 共享库内补列回填 | 新租户直接建独立库,无历史迁移 |
## 5. 代码落地步骤
### 5.1 拆分 schema
把 MySQL schema 拆成两个层次:
1. `registry_schema.sql`:只创建控制库表 `im_tenant`、`im_tenant_config`;
2. `tenant_schema.sql`:创建 Tinode 核心表和产品业务表,不包含 `tenant_id`;
3. `schema.sql` 可以作为文档索引,说明不能直接混用两套 schema。
`CreateDb(reset)` 也要拆分:
1. 初始化控制库;
2. 初始化默认租户业务库;
3. 根据 `im_tenant.db_name/db_dsn` 初始化指定租户库;
4. 禁止在同一个事务中同时创建控制库和多个租户业务库,避免失败后状态难恢复。
### 5.2 改 MySQL Adapter 路由
保留 `TenantAdapterFor(tenantID)`,但租户 Adapter 内执行的 SQL 应该是无 `tenant_id` 版本。
推荐结构:
```text
mysql.adapter
├─ registryDB: 连接控制库 hotgo
├─ defaultDB: 默认租户库
├─ tenantAdapters: map[TenantID]*adapter
└─ TenantAdapterFor(tenantID): 从 registryDB 读取 db_name/db_dsn 并返回租户库 adapter
```
租户业务方法仍保留 `TenantUserGet(tenantID, uid)` 这类签名,但实现改为“先选库,后执行租户库内原 SQL”。实际写代码时要避免递归,可以把“无租户 SQL”的方法放在租户库 adapter 上,把“路由包装方法”放在根 adapter 上。
### 5.3 删除业务表 tenant_id 依赖
需要回退或重写这些位置:
1. `server/db/mysql/schema.sql` 中核心业务表的 `tenant_id` 字段;
2. `server/db/mysql/tenant_adapter.go` 中所有 `WHERE tenant_id=?`、`INSERT tenant_id`、复合 JOIN;
3. `server/db/mysql/tests/mysql_test.go` 中依赖共享表隔离的测试;
4. `docs/sql/mysql-multitenant-tenant-id-review.sql` 这份共享表迁移脚本应标记为废弃;
5. 后续产品业务表设计也不再带 `tenant_id`。
不建议删除 Store、Session、Token、Hub、Push 和插件里的 `TenantID`,这些属于运行时隔离边界。
### 5.4 租户创建流程
后台创建租户时建议按以下顺序:
1. 在控制库 `im_tenant` 创建记录,状态为 `provisioning`;
2. 生成业务库名,例如 `im_tenant_{id}`;
3. 创建业务库并执行 `tenant_schema.sql`;
4. 写入默认系统数据,例如 `kvmeta.version`、系统账号、`sys` Topic;
5. 回写 `im_tenant.db_name`,状态改为 `active`;
6. 创建失败时租户保持不可登录状态,并记录错误供后台重试。
第一阶段可以不做自动建库接口,先由运维执行建库 SQL,再在控制库写入 `im_tenant`。
### 5.5 认证与登录
登录入口仍然是:
1. 客户端携带 `X-Tinode-APIKey` 请求准入;
2. `hi` 或登录前 HTTP 接口提交企业码;
3. 服务端在控制库查 `im_tenant.code`;
4. 租户状态必须是 `active`;
5. Session 固定 `TenantID`;
6. 登录认证根据 `TenantID` 选择租户业务库,然后查该库内 `auth`、`credentials`、`users`;
7. Token 签名内容继续包含 `TenantID`,HTTP 后续请求可从 Token 反推出该使用哪个租户库。
客户端不能通过 URL、Header、Body 直接指定业务库名或 `tenant_id`。
## 6. 产品业务表如何设计
后续根据《IM 产品原型需求文档》补充的业务表也按“租户库内表”设计,不加 `tenant_id`。所有新业务表仍统一要求:
```sql
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)
```
例如好友申请:
```sql
CREATE TABLE im_friend_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id CHAR(26) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(256),
status SMALLINT NOT NULL,
handled_by BIGINT,
handled_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 KEY uk_im_friend_requests_request_id (request_id),
KEY idx_im_friend_requests_target_status (target_id, status, created_at),
KEY idx_im_friend_requests_applicant_status (applicant_id, status, created_at)
);
```
## 7. 文件和对象存储
数据库独立库后,文件元数据在租户库内隔离,但对象存储路径仍建议带租户目录:
```text
tenants/{tenant_id}/yyyy/mm/{file_id}
```
对象存储通常仍是共享 Bucket。带租户目录便于权限策略、容量统计、清理和迁移。
文件下载流程:
1. HTTP Token 解析得到 `tenant_id` 和 `uid`;
2. 根据 `tenant_id` 选择租户业务库;
3. 在该库查 `fileuploads` 和 `filemsglinks`;
4. 校验当前用户是否有 Topic 或消息访问权;
5. 返回文件或预签名 URL。
## 8. 测试策略
需要把旧共享表测试改成独立库测试:
| 用例 | 准备 | 预期 |
| --- | --- | --- |
| 企业码解析 | 控制库有 A、B 两个租户 | A、B 返回不同 `tenant_id` 和不同 `db_name` |
| 同手机号注册 | A、B 租户库分别注册同手机号 | 两边都成功 |
| 登录选库 | A 用户用 B 企业码登录 | B 库查不到该用户,登录失败 |
| Token 选库 | A Token 请求文件接口 | 只访问 A 租户库 |
| 内存 Topic 隔离 | A、B 都使用 `sys` | Hub 中是两个 TopicKey |
| 租户停用 | 控制库把 A 设为 suspended | 新登录失败 |
| 租户库不可用 | A 的 DSN 错误或库不存在 | A 请求失败,B 不受影响 |
| 连接池复用 | 多次访问 A | 只创建一个 A 租户 adapter,后续复用 |
| 配额检查 | A 配置 max_users=1 | A 超额注册失败,B 不受影响 |
## 9. 对旧方案的处理
以下内容停止继续开发:
1. 给核心业务表增加 `tenant_id`;
2. 把唯一索引改成 `(tenant_id, xxx)`;
3. 所有业务 SQL 加 `WHERE tenant_id=?`;
4. 共享库内的数据迁移、回填、复合外键改造;
5. 产品业务表中的 `tenant_id` 字段设计。
以下内容继续保留:
1. 企业码识别;
2. 控制库 `im_tenant`、`im_tenant_config`;
3. `TenantID` 类型;
4. Session TenantID;
5. Token TenantID;
6. Hub、缓存、集群、Push、插件上下文中的 TenantID;
7. `TenantAdapterFor` 选库机制。
## 10. 推荐下一步
下一步代码改造建议从 MySQL 层开始:
1. 把 `server/db/mysql/schema.sql` 拆为控制库 schema 和租户业务库 schema;
2. 把 `tenant_adapter.go` 改成路由包装,不再写 `tenant_id` SQL;
3. 恢复或复用原 `adapter.go` 中无租户 SQL 作为租户库真实执行逻辑;
4. 修改 MySQL 测试,创建两个独立业务库验证隔离;
5. 再继续补产品业务表,所有业务表按租户库内表设计。
## 11. 当前代码落地状态
2026-08-13 已开始按独立库方案改造 MySQL 底座:
1. 默认业务库为 `tinode`;
2. 控制库为 `hotgo`;
3. `CreateDb` 会在 `hotgo` 创建 `im_tenant`、`im_tenant_config`,默认租户 `id=1/code=default/db_name=tinode`;
4. `CreateDb` 会在 `tinode` 创建核心业务表,核心表不再包含 `tenant_id`;
5. `TenantAdapterFor` 开启路由后默认租户也会从控制库解析,不再直接退回根连接;
6. 租户库 adapter 禁用二次路由,避免递归;
7. 用户、认证、凭证、Topic、Subscription、消息、设备、文件等核心 Tenant 方法已改为“先选库,再调用租户库内无 tenant_id 方法”;
8. 旧共享表 `tenant_id` 迁移 SQL 已标记废弃。
剩余项:
1. 产品扩展表和 CRUD,例如好友、收藏、实名、青少年、系统通知,还需要继续从 `tenant_id` SQL 改成租户库内无 `tenant_id` SQL;
2. `server/db/mysql/schema.sql` 仍是参考文件,已标注正在从旧共享表草稿拆分,后续应拆成 `registry_schema.sql` 和 `tenant_schema.sql`;
3. 旧版本 `UpgradeDb` 迁移链仍包含共享表历史步骤。因为当前项目不考虑历史数据,优先使用新建库 `CreateDb`;如后续需要升级老库,应单独重写迁移链。
@@ -0,0 +1,316 @@
-- IM 第一至七章数据库增量审核稿
-- 仅供审核,不要直接在生产库盲执行。
-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。
-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。
-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。
SET NAMES utf8mb4;
-- hotgo 公共后台库需要先存在;如果你审核通过,可先执行:
-- CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- USE hotgo;
--
-- 租户业务库 tinode 也需要存在并完成 Tinode 基础表初始化:
-- CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- ============================================================
-- 1. 租户、认证、安全基础表
-- ============================================================
CREATE TABLE IF NOT EXISTS im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'tenant business database name, e.g. tinode_acme',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'optional full tenant DB DSN, empty means reuse base MySQL config',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0)
ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3);
CREATE TABLE IF NOT EXISTS im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
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_tenant_challenge(tenant_id, challenge_id),
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
CONSTRAINT fk_im_verification_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_security(
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),
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;
CREATE TABLE IF NOT EXISTS im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_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_realname_submission(tenant_id, submission_id),
INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at),
INDEX idx_im_realname_id_hash(tenant_id, id_number_hash),
CONSTRAINT fk_im_realname_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_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id),
INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at),
CONSTRAINT fk_im_action_tokens_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_youth_mode_settings(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
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),
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;
CREATE TABLE IF NOT EXISTS im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_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_system_notifications_event(tenant_id, user_id, event_id),
INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC),
CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 2. 好友、黑名单
-- ============================================================
CREATE TABLE IF NOT EXISTS im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_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_friend_requests_request(tenant_id, request_id),
INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at),
CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
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(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
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),
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(
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),
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;
-- ============================================================
-- 3. 收藏、转发、消息增强
-- ============================================================
CREATE TABLE IF NOT EXISTS im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE UNIQUE INDEX uk_im_favorites_idempotency
ON im_favorites(tenant_id, user_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_message_revisions(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY(id),
UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version),
CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 4. Tinode 基础表需补齐的字段/索引
-- 如果你的库是用当前 schema.sql 初始化,这些已经存在。
-- 如果是旧库,需要逐项核对后再 ALTER。
-- ============================================================
-- im_users:租户隔离字段和索引。
-- ALTER TABLE im_users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_users_tenant_id_id ON im_users(tenant_id, id);
-- im_topics:租户隔离、群治理 Aux、租户维度唯一索引。
-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 im_topics.aux JSON。
-- 后续若要强状态机和审计,应拆 im_group_join_requests、im_group_announcements、im_group_mutes 等表。
-- ALTER TABLE im_topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_topics ADD COLUMN aux JSON AFTER tags;
-- CREATE UNIQUE INDEX im_topics_tenant_name ON im_topics(tenant_id, name);
-- CREATE INDEX im_topics_tenant_owner ON im_topics(tenant_id, owner);
-- im_subscriptions:租户字段和按租户查成员索引。
-- ALTER TABLE im_subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_subscriptions_tenant_userid ON im_subscriptions(tenant_id, userid);
-- CREATE INDEX im_subscriptions_tenant_topic ON im_subscriptions(tenant_id, topic);
-- CREATE INDEX im_subscriptions_userid_topic_deletedat ON im_subscriptions(userid, topic, deletedat);
-- im_messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。
-- ALTER TABLE im_messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`;
-- ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id;
-- ALTER TABLE im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version;
-- ALTER TABLE im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at;
-- CREATE INDEX im_messages_tenant_topic ON im_messages(tenant_id, topic);
-- CREATE UNIQUE INDEX messages_sender_client_mid ON im_messages(tenant_id, `from`, client_msg_id);
-- im_dellog:用户侧删除过滤需要 tenant_id。
-- ALTER TABLE im_dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_dellog_tenant_topic ON im_dellog(tenant_id, topic);
-- CREATE INDEX im_dellog_topic_deletedfor_low_hi ON im_dellog(topic, deletedfor, low, hi);
-- im_fileuploads:文件 ACL、租户隔离、ETag。
-- ALTER TABLE im_fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_fileuploads ADD COLUMN etag VARCHAR(128) AFTER size;
-- CREATE INDEX im_fileuploads_tenant_status ON im_fileuploads(tenant_id, status);
-- im_filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。
-- ALTER TABLE im_filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid;
-- 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;
-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。
+1 -1
View File
@@ -4,7 +4,7 @@
> 日期:2026-08-11<br>
> 状态:后续开发基线<br>
> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md)<br>
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](./IM-服务端多租户改造方案.md)
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](obsolete/IM-服务端多租户改造方案.md)
| 版本 | 日期 | 变更 |
| --- | --- | --- |
+278
View File
@@ -0,0 +1,278 @@
-- IM group business tables design.
-- Scope: chapter 6 group chat business truth tables.
-- 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.
-- 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 (
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,
member_upload_files TINYINT NOT NULL DEFAULT 1,
member_private_chat TINYINT NOT NULL DEFAULT 1,
member_invite TINYINT NOT NULL DEFAULT 1,
max_member_count INT NOT NULL DEFAULT 0 COMMENT '0 means use tenant default or unlimited',
max_admin_count INT NOT NULL DEFAULT 10,
member_count INT NOT NULL DEFAULT 0,
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),
INDEX idx_im_group_settings_status_updated (group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 2. Group members and role truth.
-- Role changes must update this table and the Tinode Subscription ACL in the same transaction.
CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member',
member_status VARCHAR(16) NOT NULL DEFAULT 'active',
nickname VARCHAR(64) NOT NULL DEFAULT '',
pinned TINYINT NOT NULL DEFAULT 0,
mute_notifications TINYINT NOT NULL DEFAULT 0,
join_source VARCHAR(32) NOT NULL DEFAULT 'manual',
joined_by BIGINT,
joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
left_at DATETIME(3),
removed_by BIGINT,
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_group_members_topic_user (topic, user_id),
INDEX idx_im_group_members_user_status (user_id, member_status, updated_at),
INDEX idx_im_group_members_topic_role (topic, role, member_status, id),
INDEX idx_im_group_members_topic_status (topic, member_status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 3. Direct invitations.
-- If join_approval is off, accepting an invitation can directly create im_group_members + Subscription.
-- If join_approval is on, accepting may create a pending im_group_join_requests record.
CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT,
invitation_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL,
target_user_id BIGINT NOT NULL,
message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending',
result VARCHAR(32) NOT NULL DEFAULT '',
join_request_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_invitations_invitation (invitation_id),
UNIQUE INDEX uk_im_group_invitations_idempotency (inviter_user_id, idempotency_key),
INDEX idx_im_group_invitations_target (target_user_id, status, expires_at),
INDEX idx_im_group_invitations_topic_status (topic, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 4. Join requests.
-- Pending users must not have a readable Subscription before approval.
CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
applicant_user_id BIGINT NOT NULL,
inviter_user_id BIGINT,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
qr_token_hash CHAR(64) NOT NULL DEFAULT '',
message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending',
reject_reason VARCHAR(256) NOT NULL DEFAULT '',
handled_by BIGINT,
handled_at DATETIME(3),
expires_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_join_requests_request (request_id),
UNIQUE INDEX uk_im_group_join_requests_idempotency (applicant_user_id, idempotency_key),
INDEX idx_im_group_join_requests_topic_status (topic, status, id),
INDEX idx_im_group_join_requests_applicant (applicant_user_id, topic, status, id),
INDEX idx_im_group_join_requests_expire (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 5. Group announcements.
CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT,
announcement_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
author_user_id BIGINT NOT NULL,
content TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'current',
version INT NOT NULL DEFAULT 1,
published_at DATETIME(3) NOT NULL,
deleted_by BIGINT,
deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_announcements_announcement (announcement_id),
UNIQUE INDEX uk_im_group_announcements_idempotency (topic, author_user_id, idempotency_key),
INDEX idx_im_group_announcements_topic_status (topic, status, version DESC, id DESC),
INDEX idx_im_group_announcements_topic_time (topic, published_at DESC, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 6. Announcement read receipts.
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,
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)
) 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 (
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),
INDEX idx_im_group_mutes_user_status (user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 8. Group QR codes / invite tokens.
-- qr_token should remain opaque to clients. Store only token hash.
CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT,
token_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
created_by BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL,
revoked_by BIGINT,
revoked_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_qrcodes_token (token_id),
UNIQUE INDEX uk_im_group_qrcodes_hash (token_hash),
INDEX idx_im_group_qrcodes_topic_status (topic, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 9. Group file directory.
-- File binary metadata still lives in the existing file table/storage. This is the group-visible index.
CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT,
group_file_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL,
source_topic VARCHAR(32) NOT NULL DEFAULT '',
source_seq INT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file',
file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
deleted_by BIGINT,
deleted_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_files_group_file (group_file_id),
UNIQUE INDEX uk_im_group_files_topic_file (topic, file_id),
UNIQUE INDEX uk_im_group_files_idempotency (topic, uploader_id, idempotency_key),
INDEX idx_im_group_files_topic_type (topic, status, file_type, id DESC),
INDEX idx_im_group_files_topic_name (topic, status, file_name),
INDEX idx_im_group_files_uploader (uploader_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 10. Group operation audit logs.
-- Store role changes, governance changes, mutes, announcements, owner transfer, dissolve, file delete, etc.
CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
log_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
operator_id BIGINT NOT NULL,
target_user_id BIGINT,
action VARCHAR(64) NOT NULL,
object_type VARCHAR(32) NOT NULL DEFAULT '',
object_id VARCHAR(64) NOT NULL DEFAULT '',
before_data JSON,
after_data JSON,
request_id VARCHAR(128) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_admin_logs_log (log_id),
INDEX idx_im_group_admin_logs_topic_time (topic, id DESC),
INDEX idx_im_group_admin_logs_operator (operator_id, id DESC),
INDEX idx_im_group_admin_logs_object (object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 11. Group operation idempotency.
-- This can later be replaced by a global HTTP idempotency table.
CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0,
result_body JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_idempotency_request (user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 12. Group outbox events.
-- Transactional bridge for group.membership.updated, group.join_request.created,
-- group.announcement.updated, group.role.updated, group.mute.updated, group.dissolved, etc.
CREATE TABLE IF NOT EXISTS im_group_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0,
event_type VARCHAR(64) NOT NULL,
payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_events_outbox_event (event_id),
INDEX idx_im_group_events_outbox_pending (status, next_retry_at, id),
INDEX idx_im_group_events_outbox_topic (topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+434
View File
@@ -0,0 +1,434 @@
# IM 产品需求实现情况审核报告
> 审核日期:2026-08-17
> 审核对象:当前仓库代码、数据库脚本及运行配置
> 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md`
> 多租户依据:`IM-独立库多租户改造方案.md`
> 明确排除:已废弃的 `IM-服务端多租户改造方案.md`
## 1. 审核说明
本报告以代码和数据库脚本的实际状态为准,不直接沿用前后端对接文档中的“现状/目标”状态。对接文档中的状态是设计阶段快照,部分“新增”接口目前已经实现,部分已注册接口仍缺少业务闭环。
判定口径:
| 状态 | 含义 |
| --- | --- |
| 已实现 | 主流程、存储和必要权限校验已落地,可进入联调 |
| 部分实现 | 已有接口或数据模型,但缺少关键策略、通知、推送、事务或异常闭环 |
| 阻断缺陷 | 代码已存在,但当前实现会导致主流程失败、权限绕过或数据不一致 |
| 未实现 | 未找到对应路由、业务逻辑或持久化实现 |
本次为代码审核并辅以 Go 单元测试和真实 MySQL 适配器集成测试,不包含多节点 WebSocket、移动端、短信通道和真实 FCM 凭据的端到端联调。
## 2. 总体结论
当前版本已经不是初始 Tinode 的简单 topic 模型。好友关系、群业务扩展表、认证安全、收藏搜索、工作台、会议、日报月报、反馈举报等模块均已出现较完整的 HTTP 路由和存储接口,产品功能骨架已经形成。
首轮审核发现的 P0 和本报告第 4 节的 6 项 P1 已于 2026-08-17 完成代码修复。项目仍不应按“全部完成”交付,以下部署和 P2 闭环仍需处理:
1. FCM 已接通产品 Token 表,但真实环境凭据和移动端联调仍待部署完成。
2. 真实短信、多节点 WebSocket、通话竞态和后台处置工作流仍未闭环。
3. 青少年模式、文件安全、群文件和会话设备管理等 P2 能力仍待开发。
### 2.1 分章完成度
| 章节 | 模块 | 当前判断 | 摘要 |
| --- | --- | --- | --- |
| 0 | 公共协议与基础约定 | 已实现主流程 | 统一响应、公共头、消息协议、Bearer 认证和全局幂等已落地 |
| 1 | 租户、认证与安全 | 部分实现 | 租户解析、验证码、密码重置、实名提交、安全挑战已实现;真实短信、会话管理等缺失 |
| 2 | 首页、会话与消息基础 | 部分实现 | 会话、回执、撤回、编辑、能力开关、实时事件和系统通知已落地;稳定游标等仍待增强 |
| 3 | 单聊与好友关系 | 部分实现 | 好友、黑名单、隐私策略、关系事件和通知已落地;历史文件/通话组合规则仍待验收 |
| 4 | 消息能力 | 部分实现 | @、媒体、转发、收藏及协议类型兼容已有;@通知和贴纸等缺失 |
| 5 | 联系人与关系管理 | 部分实现 | 基础功能、全局幂等和实时事件已落地;审计和二维码风控仍需增强 |
| 6 | 群聊 | P0 已修复,仍部分实现 | 初始化、邀请、申请、禁言、事务和 Outbox 已修复;群文件、解散等仍缺失 |
| 7 | 收藏、搜索与文件 | 部分实现 | 收藏、上下文、媒体搜索、文件 ACL/Range/ETag 已有;群文件、安全扫描等缺失 |
| 8 | 音视频通话 | 部分实现 | Tinode 通话事件和记录已存在;离线呼叫、跨设备竞态、群通话等未闭环 |
| 9 | 工作台与办公协同 | 部分实现 | 工作台、公开租户编码、组织查询、会议通知与报表已实现;权限仍需增强 |
| 10 | 设置与设备 | 部分实现 | 资料、隐私及主要策略执行、青少年模式、版本、Push Token 已有;设备会话与青少年统一策略仍待增强 |
| 11 | 安全、举报与反馈 | 部分实现 | URL 检查、挑战、举报和反馈提交已有;风险令牌消费、处置工作流和安全通知缺失 |
## 3. 发布阻断问题
> 修复状态更新(2026-08-17):本节列出的 7 项 P0 代码问题均已处理。FCM 代码链路已经接通,部署环境仍必须提供真实 Firebase 服务账号凭据。
### P0-01 消息头协议与对接文档不一致(已修复)
对接文档约定消息头使用:
```json
{
"x-im-schema": 1,
"x-im-type": "text"
}
```
修复前的校验在 `server/store/store.go` 中存在两处不兼容:
- `x-im-schema` 被要求为字符串,文档示例中的数字 `1` 会被判为 malformed。
- 消息类型主要校验 `head["type"]`,而不是协议约定的 `head["x-im-type"]`。
- 服务端允许的 `type` 值与文档的 `voice`、`contact_card`、`forward_bundle`、`system_event` 等枚举不一致。
- `server/topic.go:1050` 仅通过 `head["type"] == "system"` 防止客户端伪造系统消息,无法覆盖 `x-im-type=system_event`。
修复结果:现已接受数字或字符串形式的 schema 1,以 `x-im-type` 为产品协议字段并兼容旧 `type`;增加类型别名兼容、双字段冲突校验和 `system_event` 防伪测试。
### P0-02 群业务表未按租户业务库自动初始化(已修复)
修复前群表脚本存在以下问题:
- SQL 在第 7 行硬编码 `CREATE DATABASE tinode`,所有表也写死在 `tinode.im_group_*`。
- `scripts/check_group_business_tables.go:44` 写死 `TABLE_SCHEMA='tinode'`。
- `server/db/mysql/adapter.go` 的业务库创建流程没有创建这些群业务表。
修复结果:12 张群表已进入 MySQL 统一初始化;服务启动和首次打开独立租户库时会执行幂等建表;SQL 与检查脚本改为使用 DSN 当前选中的租户库,不再硬编码 `tinode`。
### P0-03 群邀请会把非成员误判为已入群(已修复)
原逻辑只判断 `store.Subs.Get(...)` 的 `err == nil`,因此会把 `(nil, nil)` 的非成员判为已入群。
修复结果:邀请改为 MySQL 原子操作,在事务内判断有效 Subscription、恢复删除订阅、写邀请和成员记录、更新人数并写 Outbox;已补充非成员邀请回归测试。
### P0-04 入群申请主流程和状态一致性有问题(已修复)
原入群申请入口复用了 `requireIMGroupMember`,群外用户无法提交申请;审批状态和成员写入也不在同一事务。
修复结果:POST 入群申请只校验登录用户、群状态及当前非成员状态;审批在一个事务内锁定申请,先完成 Subscription/成员写入,再更新申请状态和 Outbox,任何一步失败都会回滚。
### P0-05 群禁言未接入消息发布权限(已修复)
原 WebSocket 发布路径没有查询成员禁言和全员禁言状态。
修复结果:消息持久化前调用群发言策略,覆盖成员禁言、全员禁言、禁言过期、管理员豁免和 Root 系统消息;已补充被禁言成员发布返回 403 的测试。
### P0-06 产品 Push Token 与 FCM 数据源断开(代码已修复)
- 产品 Push Token 接口写入 `im_device_push_tokens`,原 FCM 只读取旧 `im_devices`。
- 仓库默认配置没有内置 Firebase 凭据。
修复结果:FCM 现优先读取产品 Token 表,并保留旧设备表回退;支持通过 `IM_FCM_CREDENTIALS_FILE` 或 `IM_FCM_CREDENTIALS_JSON` 注入凭据并自动启用。真实 Android/iOS 推送仍需部署方提供有效凭据完成联调。
### P0-07 群写操作缺少事务和可靠事件机制(已修复)
原建群、成员变更、审批、退群、角色变更和群主转让分别修改 Topic、Subscription 与群扩展表,并存在忽略错误的情况。
修复结果:上述关键写操作已收口到租户 MySQL 事务,角色 ACL 与业务角色同步更新;同一事务写入 `im_group_events_outbox`,提交后通过 Hub 向在线 Topic 派发,派发失败保留 pending 状态并在后续群请求重试。其余双写路径不再忽略错误,并提供回滚补偿。
## 4. 跨模块公共问题
> 修复状态更新(2026-08-17):本节 6 项 P1 代码问题均已处理。
### P1-01 HTTP 公共请求头未统一执行(已修复)
对接文档要求产品 HTTP 请求携带 `X-Tinode-APIKey`、`X-IM-Schema` 和 `X-Request-ID`。修复前各 Handler 主要在 `authenticateIMHTTPBearer` 中校验 Bearer Token:
- `/v1/im` 认证接口未统一校验 API Key。
- 未找到统一的 HTTP `X-IM-Schema` 版本校验。
- `X-Request-ID` 仅回显,缺失时不会拒绝或生成服务端 Request ID。
修复结果:`/v1/im/` 已统一经过协议中间件,强制校验 Header 中的 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;`X-Request-ID` 缺失时服务端生成并在响应中回传。
### P1-02 幂等只检查 Key 是否存在,没有实现幂等语义(已修复)
修复前 `requireIMIdempotencyKey` 主要检查请求头非空,没有统一持久化以下信息:
- `tenant + uid + method + route + idempotency_key`
- 请求体哈希
- 首次响应状态码和响应体
修复结果:新增租户库 `im_http_idempotency` 表和统一中间件,按用户、方法、路由和 Key 建立唯一作用域,持久化 Query/Body 哈希及首次状态码/响应体;支持完整响应重放、同 Key 不同请求冲突和处理中冲突。
### P1-03 HTTP 写操作缺少在线事件与 Outbox(已修复)
修复前,消息编辑/撤回、好友申请/通过/删除/拉黑、群资料与成员变更等 HTTP 操作大多只修改数据库并返回 HTTP 响应,没有统一广播到在线会话,也没有可靠 Outbox 供重试。
修复结果:新增 `im_business_events_outbox`,通过 Hub 向 Topic 或用户其他设备发布 `im` 事件,队列满或 Hub 不可用时保留 pending 以便重试。已覆盖消息编辑/撤回、好友关系、群业务、会议和安全事件;消息、好友申请/审批、群与会议的关键写路径在业务事务内写 Outbox,其他可重复关系写由全局幂等机制保证失败后可补写。
### P1-04 系统通知只有查询能力,缺少业务生产者(已修复)
修复前,系统通知表、列表和已读接口已经存在,但好友、安全、群聊、会议等模块没有完整生产链路。
修复结果:好友申请/处理/删除、群成员与群设置、会议变更/提醒/回应和安全确认均已生产用户系统通知,通知 `event_id` 可重复执行且不会重复入箱。
### P1-05 隐私配置已存储但未全面执行(已修复)
修复前,资料可见性、加好友方式、最后在线时间、已读回执等配置已经有表和接口,但主要读写路径没有执行这些策略。
修复结果:搜索与好友申请已执行 `add_me_methods`,用户资料执行 `profile_visibility`,HTTP 回执列表执行 `read_receipts`,Tinode P2P `get.desc/get.sub` 输出执行 `last_seen_visibility`;非法隐私枚举也会在存储入口被拒绝。
### P1-06 返回了内部租户 ID(已修复)
修复前工作台数据把数字 `TenantID` 格式化后放入 `tenant_code` 返回。对接约定要求客户端只使用公开租户编码,不感知内部 `tenant_id`。
修复结果:工作台会根据当前 `TenantID` 查询租户注册表,`tenant_code` 仅返回公开 `code`,不再暴露内部数字 ID。
## 5. 按需求章节审核
### 第 1 章:租户、认证与安全
**已实现**
- 租户解析、状态检查和 WebSocket `hi` 阶段租户绑定。
- Basic/Token 登录和 Token 中的租户、`auth_version` 校验。
- 注册验证码创建/校验、注册前验证消费、密码重置。
- 独立密码设置与验证、安全挑战、实名提交与状态查询。
- 用户安全状态变化后的在线 Session 驱逐能力。
**问题与缺失**
- 验证码目前主要完成生成、哈希和记录,未接入可验证的真实短信发送通道。
- 限流以租户、场景、手机号和重发窗口为主,缺少 IP、设备、账号等组合风控。
- 未找到完整的登录失败次数、账号临时锁定和解锁闭环。
- 实名认证只有用户提交和状态查询,缺少审核/第三方认证结果回写流程。
- 缺少 Refresh Token、登录设备列表、单设备下线、全部设备下线等会话管理 API。
- 缺少 API Key 轮换、吊销和灰度兼容机制。
### 第 2 章:首页、会话与消息基础
**已实现**
- Tinode `me/sub/pres` 会话基础能力。
- Bootstrap、会话搜索、未读/已读回执、消息撤回和编辑接口。
- 系统通知列表、未读数和已读接口。
- 撤回/编辑能力开关、事务内 Outbox 和在线事件已开启。
- 好友、群、会议和安全模块可向系统通知箱生产通知。
**问题与缺失**
- 回执游标依赖当前成员集合和整数偏移,成员变化后不属于稳定游标。
### 第 3 章:单聊与好友关系
**已实现**
- 好友申请、申请列表、同意、拒绝、好友列表和删除。
- 好友备注、黑名单、关系上下文和用户资料查询。
- WebSocket P2P 发消息前调用 `CanSendP2P`,可阻止非好友或拉黑关系发送。
- 单聊消息、回执、文件上传和历史消息基础能力。
- 好友申请、处理、删除、拉黑和备注会生成可重试关系事件,需要提醒对端的操作同时生成系统通知。
- 搜索、好友申请、资料、已读回执和最后在线时间已执行隐私设置。
**问题与缺失**
- 删除好友或拉黑后的历史消息、文件下载、通话权限缺少统一规则和验收测试。
### 第 4 章:消息能力
**已实现**
- @成员和 `@all` 解析,`@all` 有管理员权限限制。
- 文本、媒体、文件等基础消息与附件上传。
- 消息转发、收藏和历史上下文查询。
- 通话消息会写入 `x-im-type=call`。
**问题与缺失**
- @消息没有形成专门通知、Push 优先级和频控策略。
- 未找到贴纸包/表情商店相关数据模型与接口。
- 媒体上传进度、失败恢复主要依赖客户端,服务端缺少分片/断点上传状态机。
- 消息 `client_mid` 的幂等写入和 topic seq 增长不是完整事务,并发重复请求可能产生序号空洞或冲突错误。
### 第 5 章:联系人和关系管理
**已实现**
- 用户搜索、好友、申请、黑名单、关系上下文等核心接口已经注册。
- 关系表和 P2P 发送权限已纳入 MySQL 租户业务存储。
- 关系写操作已纳入全局幂等、系统通知和实时事件链路。
**问题与缺失**
- 手机号/通讯录匹配的授权、脱敏和隐私规则未形成完整闭环。
- 关系独立审计记录仍需补充。
- 二维码关系入口主要依赖签名令牌,缺少服务端可吊销记录、使用次数和风险控制。
### 第 6 章:群聊
**已实现**
- 群创建、资料、成员、成员资料、邀请、入群申请、公告、角色、转让群主、退群、禁言、二维码等路由已增加。
- 已持久化群设置、群成员、邀请、入群申请、公告、公告已读和禁言数据。
- `docs/IM-群聊业务表设计.sql` 设计了 12 张群业务扩展表。
**已修复的阻断项与剩余缺失**
- 群业务表自动初始化已修复,见 P0-02。
- 群邀请误判非成员已修复,见 P0-03。
- 群外申请和审批一致性已修复,见 P0-04。
- WebSocket 禁言校验已修复,见 P0-05。
- 建群、成员、角色和群主变更事务及 Outbox 已落地,见 P0-07。
- `im_group_qrcodes`、`im_group_files`、`im_group_admin_logs` 目前仍主要是表设计;`im_group_idempotency` 和 `im_group_events_outbox` 已有生产代码。
- 未找到群解散接口和完整状态清理流程。
- 未找到群文件列表、分类、搜索和文件消息自动索引能力。
- 群二维码主要使用 HMAC Token,没有持久化,无法单码吊销、限制使用次数或查看使用记录。
- 群公告、成员、角色、禁言等变更已接入 Outbox 与系统通知,真实多端与离线 Push 仍需联调验收。
### 第 7 章:收藏、搜索与文件
**已实现**
- 收藏新增、列表、详情、删除和转发。
- 消息搜索、上下文定位和媒体消息筛选。
- 文件资源访问会校验用户权限,响应支持 ETag;本地存储通过 `ServeContent` 支持 Range,S3 支持预签名 URL。
**问题与缺失**
- 未接入病毒扫描、恶意文件识别、图片/视频内容安全和隔离状态。
- 群文件索引表没有写入及查询实现。
- 搜索和列表游标的稳定性、权限快照和数据删除后的结果一致性需要专项测试。
- 文件下载审计、敏感文件水印和生命周期清理未实现。
### 第 8 章:音视频通话
**已实现**
- Tinode 通话邀请、接听、挂断等实时事件基础能力。
- 通话记录创建、接听、结束和列表接口。
- P2P 消息入口的好友关系校验可覆盖在线通话邀请的基础关系限制。
**问题与缺失**
- FCM 代码已使用产品 Token 数据源,但离线来电仍需真实凭据和移动端联调验收。
- 多设备同时接听缺少服务端唯一接听者仲裁和状态序列控制。
- 通话状态写入、实时事件和 Push 之间没有幂等/Outbox 保证。
- 未实现通话质量指标、失败原因标准化和质量上报。
- 未找到群语音/群视频通话的完整参与者状态模型。
- 好友、黑名单、青少年模式等策略没有通过统一服务覆盖所有通话入口。
### 第 9 章:工作台与办公协同
**已实现**
- 工作台聚合接口、组织树和成员搜索。
- 会议创建、修改、取消、提醒、参会响应和查询。
- 会议变更与参会响应在同一事务内写入 Outbox 和系统通知。
- 工作台 `tenant_code` 返回注册表的公开租户编码。
- 日报/月报草稿、提交、列表、统计和提醒。
- 邮箱配置查询和问题提交。
**问题与缺失**
- 工作台功能项与权限没有完全由服务端动态下发和裁剪。
- 组织、会议和报表的数据范围/角色权限需要更严格的统一校验和跨租户测试。
- 会议提醒已有实时事件和系统通知;报表审批/提交结果、角标和真实 Push 仍需闭环。
- 邮箱提交更接近收件记录,缺少分派、处理、回复、关闭等工作流。
### 第 10 章:设置、隐私与设备
**已实现**
- 个人资料读写、隐私设置读写。
- 加好友方式、资料可见性、最后在线时间和已读回执已在主要业务入口执行。
- 青少年模式启用、关闭和查询。
- 最新版本查询和设备 Push Token 注册接口。
**问题与缺失**
- `GET /me` 的实名状态和青少年模式字段存在硬编码默认值,与独立查询接口的数据可能不一致。
- 缺少登录设备/会话列表、设备改名、单设备退出、全端退出等完整设备管理。
- 青少年模式目前以设置存储为主,夜间限制、内容过滤、通话/加好友限制没有统一策略执行层。
- Push Token 注册与 FCM 发送数据源已统一,真实 Firebase 凭据和移动端仍待联调。
- 版本接口缺少灰度发布、渠道、强更分组及回滚策略。
### 第 11 章:安全、举报与反馈
**已实现**
- URL 安全检查和基础规则判定。
- 风险挑战继续/取消流程及风险动作令牌签发。
- 举报原因列表、举报提交、反馈配置和反馈提交。
**问题与缺失**
- `risk_action_token` 签发后,未找到原始高风险业务操作对该令牌进行统一消费和一次性校验的链路。
- URL 检查主要是基础规则,缺少信誉库、重定向链分析和异步复检。
- 举报和反馈以接收为主,缺少审核、分派、处置、申诉、回复和 SLA 状态流转。
- 风险确认已写入用户可见的系统安全通知,其他安全操作仍需按场景补齐。
- 举报证据文件没有明确接入专门 ACL、安全扫描和保留/删除策略。
## 6. 数据库与迁移审核
### 已具备
- 核心 Tinode 表已统一使用 `im_` 前缀。
- 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。
- 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。
- `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。
### 需要修正
1. 建立唯一 schema 版本和迁移入口,禁止“应用启动建部分表 + 手工 SQL 建另一部分表”长期并存。
2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。
3. 新租户建库和存量租户升级必须执行同一套 migration。
4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。
5. 对已设计但未使用的群表明确去留,避免表结构被误认为功能已经完成。
6. 补充索引、唯一键和外键/逻辑引用的一致性检查,尤其是邀请、申请、成员和幂等键。
## 7. 测试与验证结果
### 本次执行
```bash
go test -tags mysql ./server/db/mysql ./server/store ./server/push/fcm ./server -count=1
```
结果:全部通过。覆盖 HTTP 协议头、幂等响应重放/冲突、消息协议、隐私策略、群业务、FCM Token 数据源和 MySQL 建表语句。
```bash
go test -tags mysql ./server/db/mysql/tests -count=1
```
结果:使用 `root/root@127.0.0.1:3306` 和临时数据库完整通过;包含租户库创建、群表、P1 基础设施表、无 `tenant_id` 约束和多租户隔离测试。测试后已恢复 `test.conf` 并删除临时数据库。
### 测试缺口
- 缺少 HTTP 写入后 WebSocket 多端同步的集成测试。
- 缺少真实 FCM 凭据和移动端设备的离线推送测试。
- 隐私策略的基础分支已有测试,但黑名单、青少年模式、文件和通话的组合权限仍缺专项矩阵。
- 幂等重放和同 Key 不同 Body 已有单测,多进程高并发压测仍未执行。
## 8. 文档一致性问题
1. `IM-服务端多租户改造方案.md` 已明确废弃,本报告未把它作为实现依据。
2. `IM-系统设计文档.md` 中仍有共享业务库加 `tenant_id` 的描述,与当前独立租户业务库方向不一致,需要更新或标记历史版本。
3. `IM-前后端对接文档.md` 的接口状态是设计阶段快照,已有多项“新增”路由落地,应按本报告和实际联调结果刷新。
4. `IM-未实现后续增强项.md` 已于 2026-08-17 按最新 P0/P1 修复状态同步:已完成项已移入各章基线,待办表仅保留未实现或待环境联调的增强项。
5. 群表设计文档与独立库多租户方案冲突,脚本中的 `tinode` 库名必须移除。
## 9. 建议开发顺序
### 第一阶段:解除主流程阻断(已完成)
1. 统一消息协议字段、类型枚举和兼容策略。
2. 把群表纳入租户库 migration,并完成存量租户升级。
3. 修复群邀请、群外申请、审批一致性和禁言绕过。
4. 为建群、成员变更、群主转让建立事务和明确错误处理。
5. 统一 Push Token 数据源并启用 FCM 测试环境。
### 第二阶段:完成生产闭环(P1 代码已完成)
1. 建立统一 HTTP Middleware:API Key、Schema、Request ID、Bearer、限流。
2. 建立统一幂等存储和响应重放机制。
3. 落地 Outbox,补齐关系、群、消息编辑/撤回、会议等实时事件。
4. 建立系统通知生产服务,并与 Push、角标联动。
5. 建立统一隐私/关系/青少年模式 Policy Service。
### 第三阶段:补齐增强能力
1. 群文件、群解散、二维码管理、管理员日志。
2. 多设备会话管理、Refresh Token、API Key 生命周期。
3. 通话多设备仲裁、质量指标及群通话。
4. 举报、反馈、实名和邮箱的后台处置工作流。
5. 文件安全扫描、版本灰度和运营配置能力。
## 10. 建议验收门槛
在声明产品需求“已完成”前,至少满足:
- 新建租户无需手工 SQL 即可完成全部模块初始化。
- 文档示例消息可直接发送、存储、搜索、转发并被旧客户端兼容处理。
- 所有写接口具备可验证的幂等语义。
- HTTP 状态变更能可靠同步到 WebSocket 在线端,并能通过 Outbox 重试。
- FCM 使用产品注册的 Token 完成真实离线推送。
- 群邀请、申请、禁言、退群、转让和解散通过真实 MySQL 集成测试。
- 好友、黑名单、隐私、青少年模式在消息、文件、资料、通话入口保持一致。
- `go test ./...` 能完整编译并通过;关键链路另有端到端测试。
@@ -1,9 +1,13 @@
# IM 服务端多租户改造方案
> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](../IM-独立库多租户改造方案.md)。**
>
> 新方案下,业务库中的 `users`、`auth`、`topics`、`messages`、`fileuploads` 和后续产品业务表不再增加 `tenant_id` 字段;`TenantID` 仍保留在 Session、Token、Hub、缓存、Push、文件授权、插件上下文和 Store 选库流程中,用于定位租户业务库和运行时隔离。
> 编写日期:2026-08-10
> 适用项目:`im_server` 当前服务端源码
> 需求背景:[IM 产品原型需求文档](./IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同”
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)
> 需求背景:[IM 产品原型需求文档](../IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同”
> 关联文档:[IM 产品需求服务端改造分析报告](../IM-产品需求服务端改造分析报告.md)
> 实施状态:第一阶段租户识别已落地。MySQL Adapter schema 117 创建 `im_tenant`、`im_tenant_config` 和默认租户;首次 `hi` 使用企业码解析并固定 Session TenantID。核心业务表的 `tenant_id`、租户化认证和路由隔离尚未完成。
+92 -81
View File
@@ -1,3 +1,14 @@
-- DEPRECATED.
--
-- 2026-08-13: the multi-tenant technical direction changed to
-- "control database + one business database per tenant". Do not execute this
-- shared-table tenant_id migration and do not continue adding tenant_id to
-- tenant business tables.
--
-- New design document:
-- docs/IM-独立库多租户改造方案.md
--
-- Original note:
-- MySQL multi-tenant schema migration draft.
-- REVIEW ONLY: do not execute before the application is fully tenant-aware.
-- Target: MySQL 8.0, final schema version 119.
@@ -57,75 +68,75 @@ DROP PROCEDURE assert_default_tenant;
-- 2. Add nullable tenant_id columns first.
-- ---------------------------------------------------------------------------
ALTER TABLE users
ALTER TABLE im_users
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE usertags
ALTER TABLE im_usertags
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE devices
ALTER TABLE im_devices
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE auth
ALTER TABLE im_auth
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE topics
ALTER TABLE im_topics
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE topictags
ALTER TABLE im_topictags
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE subscriptions
ALTER TABLE im_subscriptions
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE messages
ALTER TABLE im_messages
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE dellog
ALTER TABLE im_dellog
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE credentials
ALTER TABLE im_credentials
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE fileuploads
ALTER TABLE im_fileuploads
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE filemsglinks
ALTER TABLE im_filemsglinks
ADD COLUMN tenant_id BIGINT NULL AFTER id;
-- ---------------------------------------------------------------------------
-- 3. Backfill all existing rows into the default tenant.
-- ---------------------------------------------------------------------------
UPDATE users SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE usertags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE devices SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE auth SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE topics SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE topictags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE subscriptions SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE messages SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE dellog SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE credentials SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE fileuploads SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE filemsglinks SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_users SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_usertags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_devices SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_auth SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_topics SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_topictags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_subscriptions SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_messages SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_dellog SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_credentials SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_fileuploads SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_filemsglinks SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
-- Validate the backfill before adding NOT NULL constraints.
DELIMITER $$
CREATE PROCEDURE assert_no_null_tenant_id()
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM usertags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM devices WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM auth WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM topics WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM topictags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM subscriptions WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM messages WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM dellog WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM credentials WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM fileuploads WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM filemsglinks WHERE tenant_id IS NULL LIMIT 1)
IF EXISTS (SELECT 1 FROM im_users WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_usertags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_devices WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_auth WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_topics WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_topictags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_subscriptions WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_messages WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_dellog WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_credentials WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_fileuploads WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_filemsglinks WHERE tenant_id IS NULL LIMIT 1)
THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'tenant_id backfill is incomplete';
@@ -141,18 +152,18 @@ DROP PROCEDURE assert_no_null_tenant_id;
-- No DEFAULT is retained: new code must always provide a tenant explicitly.
-- ---------------------------------------------------------------------------
ALTER TABLE users MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE usertags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE devices MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE auth MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE topics MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE topictags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE subscriptions MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE messages MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE dellog MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE credentials MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE fileuploads MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE filemsglinks MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_users MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_usertags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_devices MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_auth MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_topics MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_topictags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_subscriptions MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_messages MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_dellog MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_credentials MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_fileuploads MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_filemsglinks MODIFY COLUMN tenant_id BIGINT NOT NULL;
-- ---------------------------------------------------------------------------
-- 5. Drop existing single-column foreign keys.
@@ -186,15 +197,15 @@ BEGIN
END$$
DELIMITER ;
CALL drop_table_foreign_keys('usertags');
CALL drop_table_foreign_keys('devices');
CALL drop_table_foreign_keys('auth');
CALL drop_table_foreign_keys('topictags');
CALL drop_table_foreign_keys('subscriptions');
CALL drop_table_foreign_keys('messages');
CALL drop_table_foreign_keys('dellog');
CALL drop_table_foreign_keys('credentials');
CALL drop_table_foreign_keys('filemsglinks');
CALL drop_table_foreign_keys('im_usertags');
CALL drop_table_foreign_keys('im_devices');
CALL drop_table_foreign_keys('im_auth');
CALL drop_table_foreign_keys('im_topictags');
CALL drop_table_foreign_keys('im_subscriptions');
CALL drop_table_foreign_keys('im_messages');
CALL drop_table_foreign_keys('im_dellog');
CALL drop_table_foreign_keys('im_credentials');
CALL drop_table_foreign_keys('im_filemsglinks');
DROP PROCEDURE drop_table_foreign_keys;
@@ -203,40 +214,40 @@ DROP PROCEDURE drop_table_foreign_keys;
-- Parent identity indexes are also added for composite foreign keys.
-- ---------------------------------------------------------------------------
ALTER TABLE users
ALTER TABLE im_users
ADD UNIQUE KEY uk_users_tenant_id_id (tenant_id, id),
ADD KEY idx_users_tenant_state_stateat (tenant_id, state, stateat),
ADD KEY idx_users_tenant_lastseen_updatedat (tenant_id, lastseen, updatedat);
ALTER TABLE usertags
ALTER TABLE im_usertags
ADD UNIQUE KEY uk_usertags_tenant_user_tag (tenant_id, userid, tag),
ADD KEY idx_usertags_tenant_tag (tenant_id, tag),
DROP INDEX usertags_userid_tag;
ALTER TABLE devices
ALTER TABLE im_devices
ADD UNIQUE KEY uk_devices_tenant_hash (tenant_id, hash),
ADD KEY idx_devices_tenant_user (tenant_id, userid),
DROP INDEX devices_hash;
ALTER TABLE auth
ALTER TABLE im_auth
ADD UNIQUE KEY uk_auth_tenant_user_scheme (tenant_id, userid, scheme),
ADD UNIQUE KEY uk_auth_tenant_uname (tenant_id, uname),
DROP INDEX auth_userid_scheme,
DROP INDEX auth_uname;
ALTER TABLE topics
ALTER TABLE im_topics
ADD UNIQUE KEY uk_topics_tenant_name (tenant_id, name),
ADD KEY idx_topics_tenant_owner (tenant_id, owner),
ADD KEY idx_topics_tenant_state_stateat (tenant_id, state, stateat),
ADD KEY idx_topics_tenant_name_state_seqid (tenant_id, name, state, seqid),
DROP INDEX topics_name;
ALTER TABLE topictags
ALTER TABLE im_topictags
ADD UNIQUE KEY uk_topictags_tenant_topic_tag (tenant_id, topic, tag),
ADD KEY idx_topictags_tenant_tag (tenant_id, tag),
DROP INDEX topictags_topic_tag;
ALTER TABLE subscriptions
ALTER TABLE im_subscriptions
ADD UNIQUE KEY uk_subscriptions_tenant_topic_user (tenant_id, topic, userid),
ADD KEY idx_subscriptions_tenant_user_topic_deleted
(tenant_id, userid, topic, deletedat),
@@ -244,30 +255,30 @@ ALTER TABLE subscriptions
(tenant_id, topic, deletedat),
DROP INDEX subscriptions_topic_userid;
ALTER TABLE messages
ALTER TABLE im_messages
ADD UNIQUE KEY uk_messages_tenant_id (tenant_id, id),
ADD UNIQUE KEY uk_messages_tenant_topic_seqid (tenant_id, topic, seqid),
DROP INDEX messages_topic_seqid;
ALTER TABLE dellog
ALTER TABLE im_dellog
ADD KEY idx_dellog_tenant_topic_delid_user
(tenant_id, topic, delid, deletedfor),
ADD KEY idx_dellog_tenant_topic_user_low_hi
(tenant_id, topic, deletedfor, low, hi),
ADD KEY idx_dellog_tenant_deletedfor (tenant_id, deletedfor);
ALTER TABLE credentials
ALTER TABLE im_credentials
ADD UNIQUE KEY uk_credentials_tenant_synthetic (tenant_id, synthetic),
ADD KEY idx_credentials_tenant_user_method
(tenant_id, userid, method, deletedat),
DROP INDEX credentials_uniqueness;
ALTER TABLE fileuploads
ALTER TABLE im_fileuploads
ADD UNIQUE KEY uk_fileuploads_tenant_id (tenant_id, id),
ADD KEY idx_fileuploads_tenant_status_updated
(tenant_id, status, updatedat);
ALTER TABLE filemsglinks
ALTER TABLE im_filemsglinks
ADD KEY idx_filemsglinks_tenant_file (tenant_id, fileid),
ADD KEY idx_filemsglinks_tenant_message (tenant_id, msgid),
ADD KEY idx_filemsglinks_tenant_topic (tenant_id, topic),
@@ -284,32 +295,32 @@ ALTER TABLE filemsglinks
-- ---------------------------------------------------------------------------
SELECT 'user_tags_without_same_tenant_user' AS check_name, COUNT(*) AS invalid_count
FROM usertags child
LEFT JOIN users parent
FROM im_usertags child
LEFT JOIN im_users parent
ON parent.tenant_id = child.tenant_id AND parent.id = child.userid
WHERE parent.id IS NULL
UNION ALL
SELECT 'subscriptions_without_same_tenant_user', COUNT(*)
FROM subscriptions child
LEFT JOIN users parent
FROM im_subscriptions child
LEFT JOIN im_users parent
ON parent.tenant_id = child.tenant_id AND parent.id = child.userid
WHERE parent.id IS NULL
UNION ALL
SELECT 'subscriptions_without_same_tenant_topic', COUNT(*)
FROM subscriptions child
LEFT JOIN topics parent
FROM im_subscriptions child
LEFT JOIN im_topics parent
ON parent.tenant_id = child.tenant_id AND parent.name = child.topic
WHERE parent.name IS NULL
UNION ALL
SELECT 'messages_without_same_tenant_topic', COUNT(*)
FROM messages child
LEFT JOIN topics parent
FROM im_messages child
LEFT JOIN im_topics parent
ON parent.tenant_id = child.tenant_id AND parent.name = child.topic
WHERE parent.name IS NULL
UNION ALL
SELECT 'file_links_without_same_tenant_file', COUNT(*)
FROM filemsglinks child
LEFT JOIN fileuploads parent
FROM im_filemsglinks child
LEFT JOIN im_fileuploads parent
ON parent.tenant_id = child.tenant_id AND parent.id = child.fileid
WHERE parent.id IS NULL;
@@ -317,11 +328,11 @@ WHERE parent.id IS NULL;
-- 9. Update the schema version only after all validation succeeds.
-- ---------------------------------------------------------------------------
UPDATE kvmeta
UPDATE im_kvmeta
SET `value` = '119'
WHERE `key` = 'version';
-- Intentionally unchanged:
-- * im_tenant: it is the tenant registry itself.
-- * kvmeta: it stores platform-level schema metadata. Tenant-scoped cache
-- * im_kvmeta: it stores platform-level schema metadata. Tenant-scoped cache
-- data must use a separate table or a mandatory tenant key namespace.
@@ -0,0 +1,183 @@
# IM 前后端对接文档 1-11 章单元测试用例
本文基于 `IM-前后端对接文档.md` 与当前实现代码整理,默认按 `httptest` + mock store + fake auth/media/push store 的方式落单元测试。
## 0. 验收准备
### 0.1 基础环境
| [ ] 项目 | 准备项 | 说明 |
|---|---|---|
| [ ] ENV-01 | 测试租户 A/B | A、B 都是 `active`,且准备一个 `suspended` 租户用于负向验证 |
| [ ] ENV-02 | 统一 API Key | 使用测试 API Key,覆盖 WS/HTTP 两类入口 |
| [ ] ENV-03 | 固定时间 | 对 Token、验证码、重置票据、过期时间使用可冻结时钟 |
| [ ] ENV-04 | Mock 存储 | `store.Users`、`store.Tenants`、`store.Verifications`、`store.Security`、`store.Files`、`store.Store` 可注入 mock/fake |
| [ ] ENV-05 | 文件沙箱 | 文件下载/上传路径使用临时目录,避免污染真实对象存储 |
| [ ] ENV-06 | 多设备上下文 | 至少准备同一用户的两个 session,验证退出/踢出/读回执聚合 |
| [ ] ENV-07 | 负向数据 | 同名 `alice`、同手机号、同群名、同 tag、同 file id 的 A/B 对照数据 |
### 0.2 章节映射
| [ ] 章 | 主要入口/能力 | 代码证据 | 现有测试证据 |
|---|---|---|---|
| [ ] MAP-01 | 第1章 账号注册、登录与身份安全 | [server/main.go](/Users/superysh/im/im_server/server/main.go:780), [server/session.go](/Users/superysh/im/im_server/server/session.go:755), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19) | [server/session_test.go](/Users/superysh/im/im_server/server/session_test.go:38), [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:19), [server/auth/token/auth_token_test.go](/Users/superysh/im/im_server/server/auth/token/auth_token_test.go:1) |
| [ ] MAP-02 | 第2章 消息首页、全局导航与系统通知 | [server/main.go](/Users/superysh/im/im_server/server/main.go:791), [server/http.go](/Users/superysh/im/im_server/server/http.go:1) | 以 bootstrap / system notification handler 单测补齐 |
| [ ] MAP-03 | 第3章 单聊会话、会话建立与消息状态 | [server/main.go](/Users/superysh/im/im_server/server/main.go:821), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10), [server/topic_test.go](/Users/superysh/im/im_server/server/topic_test.go:1) |
| [ ] MAP-04 | 第4章 消息输入、媒体内容与消息操作 | [server/main.go](/Users/superysh/im/im_server/server/main.go:827), [server/datamodel.go](/Users/superysh/im/im_server/server/datamodel.go:1) | 需补 `pub/get/set/del/note` 行为单测 |
| [ ] MAP-05 | 第5章 通讯录、好友关系与黑名单 | [server/main.go](/Users/superysh/im/im_server/server/main.go:833), [server/main.go](/Users/superysh/im/im_server/server/main.go:835) | 需补 friend/block/qrcode 单测 |
| [ ] MAP-06 | 第6章 群聊创建、资料维护与成员治理 | [server/main.go](/Users/superysh/im/im_server/server/main.go:829), [server/topic.go](/Users/superysh/im/im_server/server/topic.go:1) | [server/hub_tenant_test.go](/Users/superysh/im/im_server/server/hub_tenant_test.go:10) |
| [ ] MAP-07 | 第7章 文件下载、收藏管理与聊天记录检索 | [server/main.go](/Users/superysh/im/im_server/server/main.go:824), [server/hdl_files.go](/Users/superysh/im/im_server/server/hdl_files.go:38) | [server/hdl_files_test.go](/Users/superysh/im/im_server/server/hdl_files_test.go:11), [server/media/fs/filesys_tenant_test.go](/Users/superysh/im/im_server/server/media/fs/filesys_tenant_test.go:11) |
| [ ] MAP-08 | 第8章 实时语音与视频通话 | [server/main.go](/Users/superysh/im/im_server/server/main.go:826), [server/calls.go](/Users/superysh/im/im_server/server/calls.go:1) | 需补 call 信令状态机单测 |
| [ ] MAP-09 | 第9章 企业工作台与办公协同 | [server/main.go](/Users/superysh/im/im_server/server/main.go:792), [server/main.go](/Users/superysh/im/im_server/server/main.go:796) | 需补 workbench/org/meeting/report/mailbox 单测 |
| [ ] MAP-10 | 第10章 个人资料、通用设置、隐私与青少年模式 | [server/main.go](/Users/superysh/im/im_server/server/main.go:812), [server/main.go](/Users/superysh/im/im_server/server/main.go:815) | [server/user_verification_test.go](/Users/superysh/im/im_server/server/user_verification_test.go:104) |
| [ ] MAP-11 | 第11章 反诈安全、举报与意见反馈 | [server/main.go](/Users/superysh/im/im_server/server/main.go:785), [server/main.go](/Users/superysh/im/im_server/server/main.go:798) | 需补 risk/abuse/feedback 单测 |
### 0.3 现有护栏证据
| [ ] 证据 | 已覆盖的护栏 | 说明 |
|---|---|---|
| [ ] EVD-01 | Tenant 0 直接拒绝 | `store.ForTenant(0)` 已有测试 |
| [ ] EVD-02 | 同名 topic 按 tenant 隔离 | Hub tenant 测试已覆盖同名 `sys` 分离 |
| [ ] EVD-03 | 文件路径按 tenant 命名空间化 | tenant 文件路径测试已覆盖 |
| [ ] EVD-04 | Push channel 按 tenant 命名空间化 | tenant channel 测试已覆盖 |
| [ ] EVD-05 | 文件 HTTP 认证返回 Principal | 文件认证测试已覆盖 tenant/uid 绑定 |
| [ ] EVD-06 | Verification / reset 流程按 tenant 处理 | `user_verification_test.go` 已覆盖验证与重置主流程 |
## 1. 第1章 账号注册、登录与身份安全
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T01-01 | 企业码握手成功 | `hi.tenant=acme`,租户为 `active` | Session 只绑定一次 tenant;返回公开 tenant 信息,不暴露内部 `tenant_id` |
| [ ] T01-02 | 企业码缺失或非法 | `hi` 不带 tenant / 空白 / 超长 | 返回 400 类错误,Session 不绑定 tenant |
| [ ] T01-03 | 已绑定 Session 再发另一个 tenant | 先绑定 A,再发 B | 返回冲突类错误,tenant 不变化 |
| [ ] T01-04 | basic 登录同名跨租户 | A/B 都有 `alice`,分别用各自密码登录 | 只命中当前 tenant 的 auth 记录,token 携带当前 tenant |
| [ ] T01-05 | token 登录 tenant 不匹配 | A token 放进 B session | 认证失败,不写入 uid/authLvl |
| [ ] T01-06 | 注册验证码 tenant 隔离 | A、B 同手机号注册 | 只消费当前 tenant 的验证码记录,不串租户 |
| [ ] T01-07 | 找回密码 tenant 隔离 | A、B 同手机号触发重置 | 仅更新当前 tenant 用户,auth version 按当前 tenant 递增 |
| [ ] T01-08 | 实名/青少年/独立密码入口 | 无 tenant 或 tenant 不活跃 | 统一拒绝,不进入后续敏感流程 |
## 2. 第2章 消息首页、全局导航与系统通知
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C02-01 | bootstrap 只返回本租户菜单 | 调用 `bootstrap` | 功能开关、未读数、模块列表只来自当前 tenant |
| [ ] C02-02 | system notification 列表 | 查询/翻页已读未读 | 只返回当前 tenant 通知,分页游标稳定 |
| [ ] C02-03 | system notification read | 标记单条已读 | 只改当前 tenant 当前用户的 read state |
| [ ] C02-04 | conversation search | A 搜索 B 的联系人/会话 | 返回空或通用 NotFound,不泄露 B 的存在性 |
| [ ] C02-05 | 无网络/失效 token 恢复 | 模拟 token 失效后重连 | 进入统一错误分支,不误切 tenant |
## 3. 第3章 单聊会话、会话建立与消息状态
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T03-01 | P2P context 正常返回 | 查询同租户双方会话上下文 | `relationship`、`can_send`、`topic` 与当前 tenant 一致 |
| [ ] T03-02 | 非好友/黑名单发消息 | 在 `can_send=false` 下调用 pub | 服务端拒绝,不产生消息 seq |
| [ ] T03-03 | read/recv 回执推进 | 连续提交 `note read/recv` | 只允许单调推进,不回退读游标 |
| [ ] T03-04 | 删除好友后历史保留 | 删除好友后再查历史消息 | 历史还在,但后续发送被拒绝 |
| [ ] T03-05 | 跨租户 topic 访问 | 已知 B topic 订阅/读/写 | 统一 NotFound/Denied,不进入 Hub 缓存 |
| [ ] T03-06 | 主题状态切换 | attach/leave/reattach | session/topic 状态机不被错误 tenant 影响 |
## 4. 第4章 消息输入、媒体内容与消息操作
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C04-01 | 文本消息 schema 校验 | `x-im-schema=1`、`x-im-type=text`、`x-im-client-mid` | 通过并保持 client-mid 幂等 |
| [ ] C04-02 | 媒体消息字段校验 | image/video/voice/file 的 `content` | 必填字段缺失或超长时拒绝 |
| [ ] C04-03 | @成员与 @all | 指定群成员 / `mention_all` | 成员过滤正确,越权 `@all` 被拒绝 |
| [ ] C04-04 | 编辑/撤回/删除 | 对同一消息进行修改或撤回 | 只允许符合时限和角色的操作 |
| [ ] C04-05 | 合并转发结构 | bundle/逐条转发 | 快照字段稳定,源消息与目标消息分离 |
| [ ] C04-06 | 媒体消息重复提交 | 重试同一 `client_mid` | 返回首次写入结果,不重复落库 |
## 5. 第5章 通讯录、好友关系与黑名单
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C05-01 | 用户搜索 tenant 隔离 | A 搜索 B 仅有的账号/tag | 返回空结果,不提示跨租户命中 |
| [ ] C05-02 | 二维码解析 | 用 add_friend token 解析 | 只返回当前 tenant 可见的公开资料 |
| [ ] C05-03 | 好友申请幂等 | 同一请求重复提交 | 返回原申请记录,不重复创建 |
| [ ] C05-04 | 好友同意/拒绝 | 审批 inbound request | 双方关系和 P2P topic 同步更新 |
| [ ] C05-05 | 拉黑/解除拉黑 | `PUT/DELETE /blocks/{user_id}` | 只影响当前 tenant 当前用户关系 |
| [ ] C05-06 | 删除好友 | `DELETE /friends/{user_id}` | 历史消息不删,后续会话发送被拒绝 |
## 6. 第6章 群聊创建、资料维护与成员治理
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C06-01 | 创建群 | 创建新 group topic | topic、group settings、owner、subscription 同 tenant 落库 |
| [ ] C06-02 | 邀请/审批入群 | 邀请 B tenant 用户进 A 群 | 统一拒绝,不产生部分写入 |
| [ ] C06-03 | 群管理员变更 | 提升/降级成员角色 | 仅允许本群本 tenant 成员,ACL 一致 |
| [ ] C06-04 | 群主转让 | 指定新 owner | 事务内同步业务真源和 Topic Owner |
| [ ] C06-05 | 群禁言/全员禁言 | muted member 发消息 | 服务端拒绝,其他成员不可见 |
| [ ] C06-06 | 群文件/成员列表 | 查询群成员、群文件 | 只返回当前 tenant 当前群成员 |
## 7. 第7章 文件下载、收藏管理与聊天记录检索
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] C07-01 | 文件上传 principal | HTTP/gRPC 文件上传 | `RequestPrincipal` 必须带 tenant/uid,落库 tenant 正确 |
| [ ] C07-02 | 文件下载授权 | 用 A 用户访问 B file id | 返回 403/404,不暴露 location |
| [ ] C07-03 | 收藏去重 | 同一 `topic+seq` 重复收藏 | 只新增一条,返回 existing 计数 |
| [ ] C07-04 | 收藏转发 | 对已收藏消息做转发 | 保持 source snapshot,不越权扩散 |
| [ ] C07-05 | 会话/消息搜索 | 搜索关键字、类型、文件 | 只返回当前 tenant 且有读权限的结果 |
| [ ] C07-06 | 垃圾清理 | 过期未使用文件清理 | 只清当前 tenant,不误删其他 tenant 文件 |
## 8. 第8章 实时语音与视频通话
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T08-01 | 发起通话 | `call` invite / note started | 生成稳定 `callId`,同 tenant 双方可见 |
| [ ] T08-02 | 接听/拒绝/挂断 | accept / reject / hang-up | 状态转换正确,结束原因稳定 |
| [ ] T08-03 | 超时结束 | 不接听直到超时 | 服务端替换为 missed/timeout 结果 |
| [ ] T08-04 | 多设备回放 | 同用户多个 session | 只向合法 session 广播,不串 tenant |
| [ ] T08-05 | 跨租户呼叫 | A 呼叫 B tenant 用户 | 统一拒绝,不创建有效通话记录 |
## 9. 第9章 企业工作台与办公协同
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B09-01 | workbench 模块列表 | 查询工作台 | 模块、权限、未读数只取当前 tenant |
| [ ] B09-02 | 组织树查询 | 查部门根节点/子节点/成员 | 只返回当前 tenant 组织结构 |
| [ ] B09-03 | 会议创建/变更/取消 | 创建会议并更新参会人 | 幂等、通知、审计都在当前 tenant |
| [ ] B09-04 | 日报/月报草稿与统计 | 创建、提交、统计、提醒 | 统计范围受 tenant 与管理权限约束 |
| [ ] B09-05 | 总经理信箱/工作台入口 | 查询配置与提交 | 匿名/必填字段/附件策略按 tenant 配置 |
## 10. 第10章 个人资料、通用设置、隐私与青少年模式
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] T10-01 | me/profile 读取与更新 | 查询并 patch 个人资料 | 只允许当前用户当前 tenant 数据更新 |
| [ ] T10-02 | privacy 更新 | 修改隐私项 | 版本控制正确,其他设备能收到变更 |
| [ ] T10-03 | youth mode 开关 | 启用/关闭青少年模式 | 需要 action token / 独立密码,tenant 一致 |
| [ ] T10-04 | independent password | 设置/验证独立密码 | 明文不落库,不跨租户复用 |
| [ ] T10-05 | push token 绑定 | 更新设备 push token | `(tenant_id, uid, device_id)` 维度唯一 |
| [ ] T10-06 | app release / settings menu | 查询版本与设置菜单 | 返回当前 tenant 配置,不混入别的企业开关 |
## 11. 第11章 反诈安全、举报与意见反馈
| [ ] ID | 场景 | 输入/操作 | 预期 |
|---|---|---|---|
| [ ] B11-01 | URL 风险检查 | 检查外链 | 返回 allow/warn/block,带租户上下文 |
| [ ] B11-02 | 风险挑战确认 | `continue/cancel` | 只消费当前租户当前 challenge |
| [ ] B11-03 | 举报原因与提交 | 读取原因并提交举报 | reason、证据、目标对象按 tenant 存档 |
| [ ] B11-04 | 反馈配置与提交 | 查询反馈配置并提交反馈 | 附件、联系方式、诊断信息按 tenant 策略处理 |
| [ ] B11-05 | 系统通知联动 | 风险/举报结果产生通知 | 只推当前 tenant 相关用户,不跨租户扩散 |
## 12. 高风险迁移确认点
| [ ] ID | 确认点 | 为什么要测 | 预期 |
|---|---|---|---|
| [ ] RISK-01 | Token / Session tenant 一致性 | 这是全链路身份根 | 任意篡改都拒绝 |
| [ ] RISK-02 | 文件认证 Principal | 文件链路最容易只剩 UID | 没 tenant 直接失败 |
| [ ] RISK-03 | 同名 `sys` / Topic 隔离 | 最容易发生缓存串租户 | 不同 tenant 命中不同对象 |
| [ ] RISK-04 | 事务内幂等 | 验证码、好友、群审批、会议、举报都依赖 | 重试不重复写 |
| [ ] RISK-05 | 搜索二次 ACL | 索引命中不代表可见 | 返回前再做权限过滤 |
| [ ] RISK-06 | Push / 回调 / 外部服务 | 外部 side-effect 最易丢 tenant | 出站上下文保持 tenant |
| [ ] RISK-07 | 旧请求重放 | 防止用旧参数跨 tenant 复用 | 统一按当前 tenant 拒绝 |
| [ ] RISK-08 | 失败回滚 | 群主转让、入群、文件关联等 | 只要一处失败就全回滚 |
## 13. 建议补测顺序
1. 先补第 1 章和第 7 章,因为它们是登录态与文件安全的根。
2. 再补第 3、5、6 章,把联系人、群和消息主链路闭环。
3. 然后补第 8、9、10、11 章,覆盖通话、工作台、设置和安全合规模块。
4. 最后做跨章回归:同名数据双租户、同 token 重放、同 file id 访问、同 topic 搜索。
+66
View File
@@ -0,0 +1,66 @@
//go:build ignore
package main
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://")
if dsn == "" {
fatal("IM_MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
fatal(err)
}
var schema string
if err = db.QueryRow("SELECT DATABASE()").Scan(&schema); err != nil {
fatal(err)
}
if strings.TrimSpace(schema) == "" {
fatal("IM_MYSQL_DSN must select a tenant database")
}
raw, err := os.ReadFile("docs/IM-群聊业务表设计.sql")
if err != nil {
fatal(err)
}
sqlText := stripSQLLineComments(string(raw))
for _, stmt := range strings.Split(sqlText, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err = db.Exec(stmt); err != nil {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
fmt.Printf("group business tables applied to %s\n", schema)
}
func stripSQLLineComments(text string) string {
var lines []string
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
func fatal(v any) {
fmt.Fprintln(os.Stderr, v)
os.Exit(1)
}
+696
View File
@@ -0,0 +1,696 @@
//go:build ignore
package main
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://")
if dsn == "" {
fatal("IM_MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
fatal(err)
}
statements := []string{
"CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci",
`CREATE TABLE IF NOT EXISTS hotgo.im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
"CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci",
// createIMSecurityTables, createIMNotificationTables and createIMRelationTables
// (server/db/mysql/adapter.go) are only reached from CreateDb's fresh-install
// path, which our pre-existing tinode DB never runs. Recreate their tables here.
`CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_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_realname_submission(submission_id),
INDEX idx_im_realname_user_latest(user_id, created_at),
INDEX idx_im_realname_id_hash(id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_action_tokens_token(token_id),
INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_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_system_notifications_event(user_id, event_id),
INDEX idx_im_system_notifications_inbox(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_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_friend_requests_request(request_id),
UNIQUE INDEX uk_im_friend_requests_idempotency(applicant_id, idempotency_key),
INDEX idx_im_friend_requests_pair(applicant_id, target_id, status, created_at),
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(
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
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),
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(
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),
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(
id BIGINT NOT NULL AUTO_INCREMENT,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(favorite_id),
UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox(user_id, id DESC),
INDEX idx_im_favorites_type(user_id, item_type, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_call_records(
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id INT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
duration_ms BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(3) NOT NULL,
accepted_at DATETIME(3),
finished_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_call_records_call(call_id),
UNIQUE INDEX uk_im_call_records_topic_seq(topic, seq_id),
INDEX idx_im_call_records_caller(caller_id, id DESC),
INDEX idx_im_call_records_callee(callee_id, id DESC),
INDEX idx_im_call_records_status(status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
icon_file_id VARCHAR(64) NOT NULL DEFAULT '',
route VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
unread_key VARCHAR(64) NOT NULL DEFAULT '',
enabled TINYINT NOT NULL DEFAULT 1,
permission VARCHAR(64) NOT NULL DEFAULT 'enabled',
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_workbench_apps_code(code),
INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_org_departments(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
parent_id VARCHAR(64) NOT NULL DEFAULT 'root',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_departments_department(department_id),
INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id),
INDEX idx_im_org_departments_name(name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_org_members(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
position VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
employment_state VARCHAR(16) NOT NULL DEFAULT 'active',
contactable TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
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_org_members_department_user(department_id, user_id),
INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id),
INDEX idx_im_org_members_user(user_id),
INDEX idx_im_org_members_display_name(display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_meetings(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
location VARCHAR(256) NOT NULL DEFAULT '',
state VARCHAR(16) NOT NULL DEFAULT 'scheduled',
reminder_minutes JSON,
idempotency_key VARCHAR(128),
version INT NOT NULL DEFAULT 1,
canceled_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_meetings_meeting(meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer(organizer_id, start_at),
INDEX idx_im_meetings_state_time(state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
response VARCHAR(16) NOT NULL DEFAULT 'pending',
response_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_meeting_participants_user(meeting_id, user_id),
INDEX idx_im_meeting_participants_user_time(user_id, meeting_id),
INDEX idx_im_meeting_participants_response(meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_daily_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_date DATE NOT NULL,
content TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_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_daily_reports_report(report_id),
UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date),
INDEX idx_im_daily_reports_author_id(author_id, id DESC),
INDEX idx_im_daily_reports_date_status(report_date, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_month CHAR(7) NOT NULL,
work_summary TEXT NOT NULL,
next_plan TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_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_monthly_reports_report(report_id),
UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month),
INDEX idx_im_monthly_reports_author_id(author_id, id DESC),
INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config(
id BIGINT NOT NULL AUTO_INCREMENT,
enabled TINYINT NOT NULL DEFAULT 1,
allow_anonymous TINYINT NOT NULL DEFAULT 1,
required_fields JSON,
max_length INT NOT NULL DEFAULT 5000,
attachment_policy JSON,
notice_title VARCHAR(128) NOT NULL DEFAULT '',
notice_content TEXT NOT NULL,
notice_updated_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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
sender_id BIGINT NOT NULL,
subject VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
anonymous TINYINT NOT NULL DEFAULT 0,
attachment_file_ids JSON,
notice_version VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_mailbox_submissions_submission(submission_id),
UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key),
INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC),
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(
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 '',
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings(
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
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)
) 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,
platform VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
latest_version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64) NOT NULL,
update_type VARCHAR(16) NOT NULL DEFAULT 'none',
release_notes TEXT NOT NULL,
store_url VARCHAR(512) NOT NULL DEFAULT '',
package_url VARCHAR(512) NOT NULL DEFAULT '',
checksum VARCHAR(128) NOT NULL DEFAULT '',
remind_after_seconds INT NOT NULL DEFAULT 0,
published_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_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(
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
token VARCHAR(512) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
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),
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(
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
policy_version INT NOT NULL DEFAULT 1,
restrictions JSON,
enabled_at DATETIME(3),
failed_attempts INT NOT NULL DEFAULT 0,
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)
) 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,
challenge_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL,
notice_code VARCHAR(64) NOT NULL,
notice_params JSON,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
decision VARCHAR(16) NOT NULL DEFAULT '',
action_token_id VARCHAR(64) 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_risk_challenges_challenge(challenge_id),
INDEX idx_im_risk_challenges_user_state(user_id, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons(
id BIGINT NOT NULL AUTO_INCREMENT,
target_type VARCHAR(16) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
requires_description TINYINT NOT NULL DEFAULT 0,
allows_evidence TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT 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_abuse_reasons_type_code(target_type, reason_code),
INDEX idx_im_abuse_reasons_type_order(target_type, enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
snapshot JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
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_abuse_reports_report(report_id),
UNIQUE INDEX uk_im_abuse_reports_idempotency(reporter_id, idempotency_key),
INDEX idx_im_abuse_reports_target(target_type, target_topic, target_seq, target_user_id),
INDEX idx_im_abuse_reports_reporter(reporter_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_feedback_config(
id BIGINT NOT NULL AUTO_INCREMENT,
categories JSON,
max_length INT NOT NULL DEFAULT 1000,
attachment_policy JSON,
allow_contact TINYINT NOT NULL DEFAULT 1,
diagnostics_notice TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
category VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(128) NOT NULL DEFAULT '',
attachment_file_ids JSON,
client_context JSON,
include_diagnostics TINYINT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
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_feedback_submissions_feedback(feedback_id),
UNIQUE INDEX uk_im_feedback_submissions_idempotency(user_id, idempotency_key),
INDEX idx_im_feedback_submissions_user(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, stmt := range statements {
if _, err = db.Exec(stmt); err != nil {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
for _, stmt := range []string{
"ALTER TABLE hotgo.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled TINYINT NOT NULL DEFAULT 0 AFTER password_hash",
"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",
} {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0)
ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant_config(tenant_id,max_users,max_groups,max_group_members,created_by,updated_by)
SELECT id,0,0,0,0,0 FROM hotgo.im_tenant WHERE code='default'
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES
('organization','组织架构','/organization',10,'organization',1,'enabled'),
('meetings','会议通知','/meetings',20,'meetings',1,'enabled'),
('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'),
('monthly_reports','月报','/reports/monthly',40,'monthly_reports',1,'enabled'),
('executive_mailbox','总经理信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled')
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content)
VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_app_releases(platform,channel,latest_version,min_supported_version,update_type,release_notes,remind_after_seconds)
VALUES
('ios','stable','1.0.0','1.0.0','none','当前已是最新版本。',0),
('android','stable','1.0.0','1.0.0','none','当前已是最新版本。',0)
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_abuse_reasons(target_type,reason_code,label,requires_description,allows_evidence,sort_order)
VALUES
('user','fraud','诈骗欺诈',0,1,10),
('user','harassment','骚扰辱骂',0,1,20),
('user','illegal','违法违规',1,1,30),
('group','fraud','诈骗欺诈',0,1,10),
('group','spam','垃圾广告',0,1,20),
('group','illegal','违法违规',1,1,30),
('message','fraud','诈骗欺诈',0,1,10),
('message','spam','垃圾广告',0,1,20),
('message','harassment','骚扰辱骂',0,1,30),
('message','illegal','违法违规',1,1,40)
ON DUPLICATE KEY UPDATE label=VALUES(label),requires_description=VALUES(requires_description),allows_evidence=VALUES(allows_evidence),sort_order=VALUES(sort_order),updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_feedback_config(id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice)
VALUES(1,'["bug","suggestion","complaint","other"]',1000,'{"max_files":5,"purpose":"feedback"}',1,'仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
fatal(err)
}
if err = dropTenantIDColumns(db, "tinode"); err != nil {
fatal(err)
}
var code, dbName string
var state int
if err = db.QueryRow("SELECT code, db_name, state FROM hotgo.im_tenant WHERE code='default'").Scan(&code, &dbName, &state); err != nil {
fatal(err)
}
fmt.Printf("hotgo.im_tenant default: code=%s db_name=%s state=%d\n", code, dbName, state)
rows, err := db.Query(`SELECT table_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='tinode' AND COLUMN_NAME='tenant_id'
ORDER BY table_name`)
if err != nil {
fatal(err)
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err = rows.Scan(&table); err != nil {
fatal(err)
}
tables = append(tables, table)
}
if err = rows.Err(); err != nil {
fatal(err)
}
if len(tables) == 0 {
fmt.Println("tinode tenant_id columns: none")
} else {
fmt.Printf("tinode tenant_id columns: %s\n", strings.Join(tables, ","))
}
}
func fatal(v any) {
fmt.Fprintln(os.Stderr, v)
os.Exit(1)
}
func dropTenantIDColumns(db *sql.DB, schema string) error {
fks, err := queryPairs(db, `SELECT DISTINCT table_name, constraint_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id' AND REFERENCED_TABLE_NAME IS NOT NULL`,
schema)
if err != nil {
return err
}
for _, fk := range fks {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(fk[0]) + " DROP FOREIGN KEY " + quoteIdent(fk[1])); err != nil {
return fmt.Errorf("drop foreign key %s.%s: %w", fk[0], fk[1], err)
}
}
indexes, err := queryPairs(db, `SELECT DISTINCT table_name, index_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id' AND INDEX_NAME<>'PRIMARY'`,
schema)
if err != nil {
return err
}
for _, idx := range indexes {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(idx[0]) + " DROP INDEX " + quoteIdent(idx[1])); err != nil {
return fmt.Errorf("drop index %s.%s: %w", idx[0], idx[1], err)
}
}
rows, err := db.Query(`SELECT table_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id'
ORDER BY table_name`, schema)
if err != nil {
return err
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err = rows.Scan(&table); err != nil {
return err
}
tables = append(tables, table)
}
if err = rows.Err(); err != nil {
return err
}
for _, table := range tables {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(table) + " DROP COLUMN tenant_id"); err != nil {
return fmt.Errorf("drop column %s.tenant_id: %w", table, err)
}
}
return nil
}
func queryPairs(db *sql.DB, query string, args ...any) ([][2]string, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var pairs [][2]string
for rows.Next() {
var first, second string
if err = rows.Scan(&first, &second); err != nil {
return nil, err
}
pairs = append(pairs, [2]string{first, second})
}
return pairs, rows.Err()
}
func quoteIdent(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
+49
View File
@@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://")
sqlFile := os.Getenv("IM_SQL_FILE")
if dsn == "" || sqlFile == "" {
fatal("IM_MYSQL_DSN and IM_SQL_FILE are required")
}
if !strings.Contains(dsn, "multiStatements=") {
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "multiStatements=true"
}
body, err := os.ReadFile(sqlFile)
if err != nil {
fatal(err)
}
db, err := sql.Open("mysql", dsn)
if err != nil {
fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
fatal(err)
}
if _, err = db.Exec(string(body)); err != nil {
fatal(err)
}
fmt.Println("applied", sqlFile)
}
func fatal(v any) {
fmt.Fprintln(os.Stderr, v)
os.Exit(1)
}
+66
View File
@@ -0,0 +1,66 @@
//go:build ignore
package main
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://")
if dsn == "" {
fatal("IM_MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
fatal(err)
}
var schema string
if err = db.QueryRow("SELECT DATABASE()").Scan(&schema); err != nil {
fatal(err)
}
if strings.TrimSpace(schema) == "" {
fatal("IM_MYSQL_DSN must select a tenant database")
}
tables := []string{
"im_group_settings",
"im_group_members",
"im_group_invitations",
"im_group_join_requests",
"im_group_announcements",
"im_group_announcement_reads",
"im_group_mutes",
"im_group_qrcodes",
"im_group_files",
"im_group_admin_logs",
"im_group_idempotency",
"im_group_events_outbox",
}
for _, table := range tables {
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, schema, table).Scan(&count)
if err != nil {
fatal(err)
}
status := "missing"
if count == 1 {
status = "ok"
}
fmt.Printf("%s: %s\n", table, status)
}
}
func fatal(v any) {
fmt.Fprintln(os.Stderr, v)
os.Exit(1)
}
+61
View File
@@ -0,0 +1,61 @@
//go:build ignore
package main
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://")
query := os.Getenv("IM_SQL_QUERY")
if dsn == "" || query == "" {
fatal("IM_MYSQL_DSN and IM_SQL_QUERY are required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
fatal(err)
}
defer db.Close()
rows, err := db.Query(query)
if err != nil {
fatal(err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
fatal(err)
}
fmt.Println(strings.Join(cols, "\t"))
for rows.Next() {
values := make([]sql.NullString, len(cols))
scan := make([]any, len(cols))
for i := range values {
scan[i] = &values[i]
}
if err := rows.Scan(scan...); err != nil {
fatal(err)
}
out := make([]string, len(cols))
for i, value := range values {
if value.Valid {
out[i] = value.String
}
}
fmt.Println(strings.Join(out, "\t"))
}
if err := rows.Err(); err != nil {
fatal(err)
}
}
func fatal(v any) {
fmt.Fprintln(os.Stderr, v)
os.Exit(1)
}
+2
View File
@@ -247,6 +247,8 @@ type Rec struct {
State types.ObjState
// Credential 'method:value' associated with this record.
Credential string `json:"cred,omitempty"`
// AuthVersion invalidates previously issued login tokens when incremented.
AuthVersion int `json:"auth_version,omitempty"`
// Authenticator may request the server to create a new account.
// These are the account parameters which can be used for creating the account.
+4 -3
View File
@@ -23,9 +23,10 @@ const (
defaultMinPasswordLength = 3
)
// Token suitable as a login: starts and ends with a Unicode letter (class L) or number (class N),
// contains Unicode letters, numbers, dot and underscore.
var loginPattern = regexp.MustCompile(`^[\pL\pN][_.\pL\pN]*[\pL\pN]+$`)
// 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]+)$`)
// authenticator is the type to map authentication methods to.
type authenticator struct {
+41
View File
@@ -53,3 +53,44 @@ func TestAuthenticateRejectsMissingTenant(t *testing.T) {
t.Fatalf("got %v, want %v", err, types.ErrMalformed)
}
}
func TestCheckLoginPolicyAcceptsE164Phone(t *testing.T) {
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
minPasswordLength: defaultMinPasswordLength}
if err := a.checkLoginPolicy("+8615521120255"); err != nil {
t.Fatalf("E.164 phone login rejected: %v", err)
}
}
func TestAddRecordAcceptsE164PhoneLogin(t *testing.T) {
ctrl := gomock.NewController(t)
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
oldUsers := store.Users
store.Users = users
t.Cleanup(func() {
store.Users = oldUsers
ctrl.Finish()
})
users.EXPECT().AddAuthRecord(types.TenantID(7), types.Uid(42), auth.LevelAuth,
"basic", "+8615521120255", gomock.Any(), time.Time{}).Return(nil)
a := &authenticator{name: "basic", addToTags: true, minLoginLength: defaultMinLoginLength,
minPasswordLength: 6}
rec, err := a.AddRecord(auth.AuthContext{TenantID: 7}, &auth.Rec{TenantID: 7, Uid: 42},
[]byte("+8615521120255:123456"))
if err != nil {
t.Fatal(err)
}
if rec == nil || rec.AuthLevel != auth.LevelAuth || len(rec.Tags) != 1 || rec.Tags[0] != "basic:+8615521120255" {
t.Fatalf("unexpected auth record: %#v", rec)
}
}
func TestCheckLoginPolicyRejectsMalformedPhone(t *testing.T) {
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
minPasswordLength: defaultMinPasswordLength}
if err := a.checkLoginPolicy("+86-15521120255"); err != types.ErrPolicy {
t.Fatalf("got %v, want %v", err, types.ErrPolicy)
}
}
+28 -9
View File
@@ -23,10 +23,10 @@ type authenticator struct {
serialNumber int
}
const tokenVersion = uint8(2)
const tokenVersion = uint8(3)
// tokenLayout defines positioning of various bytes in token v2.
// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][32:signature] = 59 bytes.
// tokenLayout defines positioning of various bytes in token v3.
// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][4:authVersion][32:signature] = 63 bytes.
type tokenLayout struct {
// Token format version.
Version uint8
@@ -42,6 +42,8 @@ type tokenLayout struct {
SerialNumber uint16
// Bitmap with feature bits.
Features uint16
// User-scoped auth version.
AuthVersion uint32
}
// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.
@@ -143,13 +145,29 @@ func (ta *authenticator) Authenticate(ctx auth.AuthContext, token []byte) (*auth
return nil, nil, types.ErrExpired
}
features := auth.Feature(tl.Features)
if features&auth.FeatureNoLogin == 0 {
if store.Security == nil {
return nil, nil, types.ErrInternal
}
currentVersion, err := store.Security.GetAuthVersion(types.TenantID(tl.TenantID), types.Uid(tl.Uid))
if err != nil {
return nil, nil, err
}
if int(tl.AuthVersion) != currentVersion {
return nil, nil, types.ErrFailed
}
}
return &auth.Rec{
TenantID: types.TenantID(tl.TenantID),
Uid: types.Uid(tl.Uid),
AuthLevel: auth.Level(tl.AuthLevel),
Lifetime: auth.Duration(time.Until(expires)),
Features: auth.Feature(tl.Features),
State: types.StateUndefined}, nil, nil
TenantID: types.TenantID(tl.TenantID),
Uid: types.Uid(tl.Uid),
AuthLevel: auth.Level(tl.AuthLevel),
Lifetime: auth.Duration(time.Until(expires)),
Features: features,
State: types.StateUndefined,
AuthVersion: int(tl.AuthVersion),
}, nil, nil
}
// GenSecret generates a new token.
@@ -173,6 +191,7 @@ func (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
AuthLevel: uint16(rec.AuthLevel),
SerialNumber: uint16(ta.serialNumber),
Features: uint16(rec.Features),
AuthVersion: uint32(rec.AuthVersion),
}
buf := new(bytes.Buffer)
binary.Write(buf, binary.LittleEndian, &tl)
+118 -11
View File
@@ -6,9 +6,71 @@ import (
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testSecurityStore struct {
version int
err error
}
func (s testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
return s.version, s.err
}
func (s testSecurityStore) BumpAuthVersion(types.TenantID, types.Uid, time.Time) (int, error) {
return s.version + 1, s.err
}
func (s testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) {
return nil, types.ErrNotFound
}
func (s testSecurityStore) CreateRealNameSubmission(types.TenantID, *types.RealNameVerification) error {
return s.err
}
func (s testSecurityStore) CreateActionToken(types.TenantID, *types.ActionToken) error {
return s.err
}
func (s testSecurityStore) ConsumeActionToken(types.TenantID, types.Uid, string, string, string, time.Time) (*types.ActionToken, error) {
return nil, s.err
}
func (s testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) {
return nil, types.ErrNotFound
}
func (s testSecurityStore) UpsertIndependentPassword(types.TenantID, *types.IndependentPassword) error {
return s.err
}
func (s testSecurityStore) UpdateIndependentPasswordAttempts(types.TenantID, types.Uid, string, int, *time.Time, time.Time) error {
return s.err
}
func (s testSecurityStore) GetYouthMode(types.TenantID, types.Uid) (*types.YouthModeSettings, error) {
return nil, types.ErrNotFound
}
func (s testSecurityStore) SetYouthMode(types.TenantID, types.Uid, bool, int, time.Time) (*types.YouthModeSettings, error) {
return nil, s.err
}
func (s testSecurityStore) CreateSystemNotification(types.TenantID, *types.SystemNotification) error {
return s.err
}
func (s testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) {
return nil, "", s.err
}
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
return s.err
}
func newTestAuthenticator(t *testing.T) *authenticator {
t.Helper()
conf, err := json.Marshal(map[string]any{
@@ -26,20 +88,25 @@ func newTestAuthenticator(t *testing.T) *authenticator {
return ta
}
func TestTokenV2TenantRoundTrip(t *testing.T) {
func TestTokenV3TenantRoundTrip(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 11}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t)
rec := &auth.Rec{
TenantID: 7,
Uid: 42,
AuthLevel: auth.LevelAuth,
Lifetime: auth.Duration(time.Hour),
Features: auth.FeatureValidated,
TenantID: 7,
Uid: 42,
AuthLevel: auth.LevelAuth,
Lifetime: auth.Duration(time.Hour),
Features: auth.FeatureValidated,
AuthVersion: 11,
}
token, _, err := ta.GenSecret(rec)
if err != nil {
t.Fatal(err)
}
if got, want := len(token), 59; got != want {
if got, want := len(token), 63; got != want {
t.Fatalf("token length: got %d, want %d", got, want)
}
@@ -50,7 +117,8 @@ func TestTokenV2TenantRoundTrip(t *testing.T) {
if challenge != nil {
t.Fatal("unexpected challenge")
}
if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel || got.Features != rec.Features {
if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel ||
got.Features != rec.Features || got.AuthVersion != rec.AuthVersion {
t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec)
}
@@ -61,10 +129,14 @@ func TestTokenV2TenantRoundTrip(t *testing.T) {
}
}
func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
func TestTokenV3RejectsWrongOrTamperedTenant(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 1}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t)
token, _, err := ta.GenSecret(&auth.Rec{
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour),
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), AuthVersion: 1,
})
if err != nil {
t.Fatal(err)
@@ -81,7 +153,11 @@ func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
}
}
func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
func TestTokenV3RejectsLegacyMissingIdentityAndStaleVersion(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 2}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t)
if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed {
t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed)
@@ -92,4 +168,35 @@ func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed)
}
token, _, err := ta.GenSecret(&auth.Rec{
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), AuthVersion: 1,
})
if err != nil {
t.Fatal(err)
}
if _, _, err = ta.Authenticate(auth.AuthContext{TenantID: 7}, token); err != types.ErrFailed {
t.Fatalf("stale auth version: got %v, want %v", err, types.ErrFailed)
}
}
func TestTokenV3NoLoginDoesNotRequireAuthVersion(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 99}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t)
token, _, err := ta.GenSecret(&auth.Rec{
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), Features: auth.FeatureNoLogin,
})
if err != nil {
t.Fatal(err)
}
got, _, err := ta.Authenticate(auth.AuthContext{TenantID: 7}, token)
if err != nil {
t.Fatal(err)
}
if got.AuthVersion != 0 || got.Features&auth.FeatureNoLogin == 0 {
t.Fatalf("unexpected no-login token auth: %+v", got)
}
}
+146 -1
View File
@@ -12,9 +12,11 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
jcr "github.com/tinode/jsonco"
)
@@ -45,6 +47,9 @@ const (
constCallMsgMissed = "missed"
// Call is declined (the callee hung up before picking up).
constCallMsgDeclined = "declined"
constCallTypeAudio = "audio"
constCallTypeVideo = "video"
)
type callConfig struct {
@@ -86,10 +91,127 @@ type videoCall struct {
content any
// Call message content mime type.
contentMime any
// Product-level call identifier.
callID string
// Product-level call type: audio or video.
callType string
// Time when the call was accepted.
acceptedAt time.Time
}
func normalizeCallInvite(head map[string]any, content any) (map[string]any, any, error) {
if head == nil {
return nil, nil, types.ErrMalformed
}
state, _ := head["webrtc"].(string)
if state != "started" {
return nil, nil, types.ErrMalformed
}
call, ok := content.(map[string]any)
if !ok {
if typed, typedOK := content.(types.KVMap); typedOK {
call = map[string]any(typed)
}
}
if len(call) == 0 {
return nil, nil, types.ErrMalformed
}
callID, _ := call["callId"].(string)
callType, _ := call["callType"].(string)
if callType == "" {
callType, _ = call["media_type"].(string)
}
callType = strings.ToLower(strings.TrimSpace(callType))
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
return nil, nil, types.ErrMalformed
}
normalizedHead := make(map[string]any, len(head)+2)
for key, value := range head {
normalizedHead[key] = value
}
normalizedHead["webrtc"] = "started"
normalizedHead["x-im-type"] = "call"
if _, ok := normalizedHead["mime"]; !ok {
normalizedHead["mime"] = "application/json"
}
normalizedContent := make(map[string]any, len(call)+3)
for key, value := range call {
normalizedContent[key] = value
}
normalizedContent["callId"] = strings.TrimSpace(callID)
normalizedContent["callType"] = callType
normalizedContent["audio"] = true
normalizedContent["video"] = callType == constCallTypeVideo
return normalizedHead, normalizedContent, nil
}
func callPayloadString(payload any, key string) string {
data, ok := payload.(map[string]any)
if !ok {
if typed, typedOK := payload.(types.KVMap); typedOK {
data = map[string]any(typed)
}
}
if len(data) == 0 {
return ""
}
value, _ := data[key].(string)
return strings.TrimSpace(value)
}
func callContentString(content any, key string) string {
return callPayloadString(content, key)
}
func (call *videoCall) matchesPayload(payload any) bool {
payloadCallID := callPayloadString(payload, "callId")
return payloadCallID == "" || payloadCallID == call.callID
}
func (t *Topic) auditCallCreate(call *videoCall, caller types.Uid) {
if store.Calls == nil || call == nil || call.callID == "" || caller.IsZero() {
return
}
callee := t.p2pOtherUser(caller)
if callee.IsZero() {
return
}
now := types.TimeNow()
if err := store.Calls.Create(t.tenantID, &types.CallRecord{
TenantID: t.tenantID,
CallID: call.callID,
Topic: t.name,
SeqID: call.seq,
CallerID: caller,
CalleeID: callee,
CallType: call.callType,
Status: types.CallStatusStarted,
StartedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
logs.Warn.Printf("topic[%s]: failed to audit call start %s: %v", t.name, call.callID, err)
}
}
func (t *Topic) auditCallAccept(call *videoCall, acceptedAt time.Time) {
if store.Calls == nil || call == nil || call.callID == "" {
return
}
if err := store.Calls.Accept(t.tenantID, call.callID, call.seq, acceptedAt); err != nil {
logs.Warn.Printf("topic[%s]: failed to audit call accept %s: %v", t.name, call.callID, err)
}
}
func (t *Topic) auditCallFinish(call *videoCall, status string, durationMS int64, finishedAt time.Time) {
if store.Calls == nil || call == nil || call.callID == "" {
return
}
if err := store.Calls.Finish(t.tenantID, call.callID, call.seq, status, durationMS, finishedAt); err != nil {
logs.Warn.Printf("topic[%s]: failed to audit call finish %s: %v", t.name, call.callID, err)
}
}
// callPartySession returns a session to be stored in the call party data.
func callPartySession(sess *Session) *Session {
if sess.isProxy() {
@@ -226,12 +348,15 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {
seq: t.lastID,
content: msg.Pub.Content,
contentMime: msg.Pub.Head["mime"],
callID: callContentString(msg.Pub.Content, "callId"),
callType: callContentString(msg.Pub.Content, "callType"),
}
t.currentCall.parties[msg.sess.sid] = callPartyData{
uid: asUid,
isOriginator: true,
sess: callPartySession(msg.sess),
}
t.auditCallCreate(t.currentCall, asUid)
// Wait for constCallEstablishmentTimeout for the other side to accept the call.
t.callEstablishmentTimer.Reset(time.Duration(globals.callEstablishmentTimeout) * time.Second)
}
@@ -255,6 +380,10 @@ func (t *Topic) handleCallEvent(msg *ClientComMessage) {
logs.Info.Printf("topic[%s]: invalid seq id - current call (%d) vs received (%d)", t.name, t.currentCall.seq, call.SeqId)
return
}
if !t.currentCall.matchesPayload(call.Payload) {
logs.Info.Printf("topic[%s]: invalid call id for seq %d", t.name, t.currentCall.seq)
return
}
asUid := types.ParseUserId(msg.AsUser)
@@ -307,6 +436,7 @@ func (t *Topic) handleCallEvent(msg *ClientComMessage) {
sess: callPartySession(msg.sess),
}
t.currentCall.acceptedAt = time.Now()
t.auditCallAccept(t.currentCall, t.currentCall.acceptedAt)
// Notify other clients that the call has been accepted.
t.infoCallSubsOffline(msg.AsUser, asUid, call.Event, t.currentCall.seq, call.Payload, msg.sess.sid, false)
@@ -382,10 +512,11 @@ func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callD
originatorUid, _ := t.getCallOriginator()
var replaceWith string
var callDuration int64
finishedAt := time.Now()
if from != "" && len(t.currentCall.parties) == 2 {
// This is a call in progress.
replaceWith = constCallMsgFinished
callDuration = time.Since(t.currentCall.acceptedAt).Milliseconds()
callDuration = finishedAt.Sub(t.currentCall.acceptedAt).Milliseconds()
} else {
if from != "" {
// User originated hang-up.
@@ -418,6 +549,7 @@ func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callD
if err := t.saveAndBroadcastMessage(&msgCopy, originatorUid, false, nil, head, t.currentCall.content); err != nil {
logs.Err.Printf("topic[%s]: failed to write finalizing message for call seq id %d - '%s'", t.name, t.currentCall.seq, err)
}
t.auditCallFinish(t.currentCall, callFinalStatus(replaceWith), callDuration, finishedAt)
// Send {info} hangup event to the subscribed sessions.
t.broadcastToSessions(t.currentCall.infoMessage(constCallEventHangUp))
@@ -429,6 +561,19 @@ func (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callD
t.currentCall = nil
}
func callFinalStatus(replaceWith string) string {
switch replaceWith {
case constCallMsgFinished:
return types.CallStatusFinished
case constCallMsgMissed:
return types.CallStatusMissed
case constCallMsgDeclined:
return types.CallStatusDeclined
default:
return types.CallStatusDisconnected
}
}
// Server initiated call termination.
func (t *Topic) terminateCallInProgress(callDidTimeout bool) {
if t.currentCall == nil {
+137
View File
@@ -213,6 +213,24 @@ type Adapter interface {
// points fail closed when the configured adapter does not support tenant resolution.
type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error)
TenantGetByID(id t.TenantID) (*t.Tenant, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
}
// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used
// by product HTTP middleware and online business-event delivery.
type TenantHTTPInfrastructureAdapter interface {
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error
TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error)
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
@@ -274,9 +292,88 @@ type TenantBusinessAdapter interface {
TenantFindOne(t.TenantID, string) (string, error)
TenantMessageSave(t.TenantID, *t.Message) error
TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error)
TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error)
TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error)
TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantCallCreate(t.TenantID, *t.CallRecord) error
TenantCallAccept(t.TenantID, string, int, time.Time) error
TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error
TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error)
TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error)
TenantGroupSettingsGet(t.TenantID, string) (*t.GroupSettings, error)
TenantGroupSettingsUpdate(t.TenantID, string, t.GroupSettingsPatch) (*t.GroupSettings, error)
TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error)
TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error)
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)
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)
TenantGroupAnnouncementLatest(t.TenantID, string) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error
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
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
TenantGroupRoleUpdate(t.TenantID, string, t.Uid, t.Uid, string, t.AccessMode, time.Time) error
TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error
TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error)
TenantGroupEventListPending(t.TenantID, int, time.Time) ([]t.GroupEvent, error)
TenantGroupEventMarkDelivered(t.TenantID, string, time.Time) error
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)
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)
TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error)
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error)
TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, error)
TenantDailyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.DailyReport, string, error)
TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error)
TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error)
TenantDailyReportSaveDraft(t.TenantID, t.Uid, string, string, []string, int) (*t.DailyReport, error)
TenantDailyReportSubmit(t.TenantID, t.Uid, string, int) (*t.DailyReport, error)
TenantDailyReportStats(t.TenantID, t.Uid, time.Time, string) (*t.DailyReportStats, error)
TenantDailyReportRemind(t.TenantID, t.Uid, time.Time, []t.Uid, string) ([]t.Uid, error)
TenantMonthlyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.MonthlyReport, string, error)
TenantMonthlyReportCreateDraft(t.TenantID, t.Uid, string, string) (*t.MonthlyReport, error)
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)
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)
TenantUserProfileGet(t.TenantID, t.Uid) (*t.UserProfile, error)
TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error)
TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error)
TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*t.UserPrivacySettings, error)
TenantAppReleaseLatest(t.TenantID, string, string) (*t.AppRelease, error)
TenantDevicePushTokenUpsert(t.TenantID, *t.DevicePushToken) (*t.DevicePushToken, error)
TenantRiskChallengeCreate(t.TenantID, *t.RiskChallenge) (*t.RiskChallenge, error)
TenantRiskChallengeConfirm(t.TenantID, t.Uid, string, string, string, time.Time) (*t.RiskChallenge, error)
TenantAbuseReasons(t.TenantID, string) ([]t.AbuseReason, error)
TenantAbuseReportCreate(t.TenantID, *t.AbuseReport, string) (*t.AbuseReport, error)
TenantFeedbackConfigGet(t.TenantID, t.Uid) (*t.FeedbackConfig, error)
TenantFeedbackCreate(t.TenantID, *t.FeedbackSubmission, string) (*t.FeedbackSubmission, error)
TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error)
TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error)
TenantFavoriteGet(t.TenantID, t.Uid, string) (*t.FavoriteItem, error)
TenantFavoriteDelete(t.TenantID, t.Uid, string) error
TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
@@ -287,9 +384,49 @@ type TenantBusinessAdapter interface {
TenantFileGet(t.TenantID, string) (*t.FileDef, error)
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error
TenantFileUserHasAccess(t.TenantID, string, t.Uid) (bool, error)
TenantPCacheGet(t.TenantID, string) (string, error)
TenantPCacheUpsert(t.TenantID, string, string, bool) error
TenantPCacheDelete(t.TenantID, string) error
TenantPCacheExpire(t.TenantID, string, time.Time) error
TenantVerificationCreate(t.TenantID, *t.VerificationCode) error
TenantVerificationGet(t.TenantID, string) (*t.VerificationCode, error)
TenantVerificationGetActive(t.TenantID, string, string, time.Time) (*t.VerificationCode, error)
TenantVerificationFail(t.TenantID, string, t.VerificationState) error
TenantVerificationMarkVerified(t.TenantID, string, string, time.Time) error
TenantVerificationConsume(t.TenantID, string, string, time.Time) error
TenantUserSecurityGetAuthVersion(t.TenantID, t.Uid) (int, error)
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
TenantRealNameGet(t.TenantID, t.Uid) (*t.RealNameVerification, error)
TenantRealNameCreate(t.TenantID, *t.RealNameVerification) 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)
TenantIndependentPasswordUpsert(t.TenantID, *t.IndependentPassword) error
TenantIndependentPasswordUpdateAttempts(t.TenantID, t.Uid, string, int, *time.Time, time.Time) error
TenantYouthModeGet(t.TenantID, t.Uid) (*t.YouthModeSettings, error)
TenantYouthModeSet(t.TenantID, t.Uid, bool, int, time.Time) (*t.YouthModeSettings, error)
TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, 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)
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)
TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error
TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error)
TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, error)
TenantFriendshipUpdateRemark(t.TenantID, t.Uid, t.Uid, string, time.Time) (*t.Friendship, error)
TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error
TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, error)
TenantUserBlockList(t.TenantID, t.Uid, string, int) ([]t.BlockListItem, string, error)
TenantUserBlockUpsert(t.TenantID, t.Uid, t.Uid, time.Time) (*t.UserBlock, error)
TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error
TenantUserSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.User, string, error)
}
+1264 -732
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
//go:build mysql
// +build mysql
package mysql
// ensureIMGroupBusinessTables installs the product group-chat schema in the
// currently selected tenant database. Tenant databases intentionally do not
// carry tenant_id columns.
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,
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,
member_upload_files TINYINT NOT NULL DEFAULT 1, member_private_chat TINYINT NOT NULL DEFAULT 1,
member_invite TINYINT NOT NULL DEFAULT 1, max_member_count INT NOT NULL DEFAULT 0,
max_admin_count INT NOT NULL DEFAULT 10, member_count INT NOT NULL DEFAULT 0,
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),
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 (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active',
nickname VARCHAR(64) NOT NULL DEFAULT '', pinned TINYINT NOT NULL DEFAULT 0,
mute_notifications TINYINT NOT NULL DEFAULT 0, join_source VARCHAR(32) NOT NULL DEFAULT 'manual',
joined_by BIGINT, joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), left_at DATETIME(3),
removed_by BIGINT, 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_group_members_topic_user(topic, user_id),
INDEX idx_im_group_members_user_status(user_id, member_status, updated_at),
INDEX idx_im_group_members_topic_role(topic, role, member_status, id),
INDEX idx_im_group_members_topic_status(topic, member_status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL, target_user_id BIGINT NOT NULL, message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', result VARCHAR(32) NOT NULL DEFAULT '',
join_request_id VARCHAR(64) NOT NULL DEFAULT '', idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL, handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_invitations_invitation(invitation_id),
UNIQUE INDEX uk_im_group_invitations_idempotency(inviter_user_id, idempotency_key),
INDEX idx_im_group_invitations_target(target_user_id, status, expires_at),
INDEX idx_im_group_invitations_topic_status(topic, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
applicant_user_id BIGINT NOT NULL, inviter_user_id BIGINT, source VARCHAR(32) NOT NULL DEFAULT 'manual',
qr_token_hash CHAR(64) NOT NULL DEFAULT '', message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', reject_reason VARCHAR(256) NOT NULL DEFAULT '',
handled_by BIGINT, handled_at DATETIME(3), expires_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_join_requests_request(request_id),
UNIQUE INDEX uk_im_group_join_requests_idempotency(applicant_user_id, idempotency_key),
INDEX idx_im_group_join_requests_topic_status(topic, status, id),
INDEX idx_im_group_join_requests_applicant(applicant_user_id, topic, status, id),
INDEX idx_im_group_join_requests_expire(status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
author_user_id BIGINT NOT NULL, content TEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'current',
version INT NOT NULL DEFAULT 1, published_at DATETIME(3) NOT NULL, deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcements_announcement(announcement_id),
UNIQUE INDEX uk_im_group_announcements_idempotency(topic, author_user_id, idempotency_key),
INDEX idx_im_group_announcements_topic_status(topic, status, version DESC, id DESC),
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,
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)
) 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,
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),
INDEX idx_im_group_mutes_user_status(user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL, created_by BIGINT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL, revoked_by BIGINT, revoked_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_qrcodes_token(token_id),
UNIQUE INDEX uk_im_group_qrcodes_hash(token_hash), INDEX idx_im_group_qrcodes_topic_status(topic, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq INT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_files_group_file(group_file_id),
UNIQUE INDEX uk_im_group_files_topic_file(topic, file_id),
UNIQUE INDEX uk_im_group_files_idempotency(topic, uploader_id, idempotency_key),
INDEX idx_im_group_files_topic_type(topic, status, file_type, id DESC),
INDEX idx_im_group_files_topic_name(topic, status, file_name), INDEX idx_im_group_files_uploader(uploader_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
operator_id BIGINT NOT NULL, target_user_id BIGINT, action VARCHAR(64) NOT NULL,
object_type VARCHAR(32) NOT NULL DEFAULT '', object_id VARCHAR(64) NOT NULL DEFAULT '',
before_data JSON, after_data JSON, request_id VARCHAR(128) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_admin_logs_log(log_id),
INDEX idx_im_group_admin_logs_topic_time(topic, id DESC),
INDEX idx_im_group_admin_logs_operator(operator_id, id DESC), INDEX idx_im_group_admin_logs_object(object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0, result_body JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_idempotency_request(user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created(created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL, payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_events_outbox_event(event_id),
INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
//go:build mysql
package mysql
import (
"database/sql"
"strings"
"testing"
)
type recordingGroupSchemaExecutor struct {
statements []string
}
func (e *recordingGroupSchemaExecutor) Exec(query string, _ ...any) (sql.Result, error) {
e.statements = append(e.statements, query)
return nil, nil
}
func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 12 {
t.Fatalf("created %d group tables, want 12", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
t.Fatalf("group schema must use the selected tenant database: %s", statement)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//go:build mysql
// +build mysql
package mysql
func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL, route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing', response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB, expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_http_idempotency_scope(user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_business_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL,
target_type VARCHAR(16) NOT NULL, target VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL,
payload JSON, status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_business_events_outbox_event(event_id),
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target(target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,27 @@
//go:build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
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))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_http_idempotency", "im_business_events_outbox"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
}
}
+18 -267
View File
@@ -1,271 +1,22 @@
# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY!
# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY.
# DO NOT USE IT TO INITIALIZE THE DATABASE.
# Read installation instructions first.
# The following line will produce an intentional error.
'READ INSTALLATION INSTRUCTIONS!';
# The actual schema is below.
DROP DATABASE IF EXISTS tinode;
CREATE DATABASE tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE tinode;
CREATE TABLE im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
);
CREATE TABLE im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,1,0,0);
CREATE TABLE kvmeta(
`key` VARCHAR(64),
createdat DATETIME(3),
`value` TEXT,
PRIMARY KEY(`key`),
INDEX kvmeta_createdat_key(createdat, `key`)
);
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "117");
CREATE TABLE users(
id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
stateat DATETIME(3),
access JSON,
lastseen DATETIME,
useragent VARCHAR(255) DEFAULT '',
public JSON,
tags JSON, -- Denormalized array of tags
PRIMARY KEY(id),
INDEX users_state_stateat(state, stateat),
INDEX users_lastseen_updatedat(lastseen, updatedat)
);
# Indexed user tags.
CREATE TABLE usertags(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES users(id),
INDEX usertags_tag(tag),
UNIQUE INDEX usertags_userid_tag(userid, tag)
);
# Indexed devices. Normalized into a separate table.
CREATE TABLE devices(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
hash CHAR(16) NOT NULL,
deviceid TEXT NOT NULL,
platform VARCHAR(32),
lastseen DATETIME NOT NULL,
lang VARCHAR(8),
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES users(id),
UNIQUE INDEX devices_hash(hash)
);
# Authentication records for the basic authentication scheme.
CREATE TABLE auth(
id INT NOT NULL AUTO_INCREMENT,
uname VARCHAR(32) NOT NULL,
userid BIGINT NOT NULL,
scheme VARCHAR(16) NOT NULL,
authlvl SMALLINT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES users(id),
UNIQUE INDEX auth_userid_scheme(userid, scheme),
UNIQUE INDEX auth_uname (uname)
);
# Topics
CREATE TABLE topics(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
touchedat DATETIME(3),
state SMALLINT NOT NULL DEFAULT 0,
stateat DATETIME(3),
name CHAR(25) NOT NULL,
usebt TINYINT DEFAULT 0,
owner BIGINT NOT NULL DEFAULT 0,
access JSON,
seqid INT NOT NULL DEFAULT 0,
delid INT DEFAULT 0,
subcnt INT DEFAULT 0,
public JSON,
trusted JSON,
tags JSON, -- Denormalized array of tags
aux JSON,
PRIMARY KEY(id),
UNIQUE INDEX topics_name (name),
INDEX topics_owner(owner),
INDEX topics_state_stateat(state, stateat),
INDEX topics_name_state_seqid ON topics(name, state, seqid)
);
# Indexed topic tags.
CREATE TABLE topictags(
id INT NOT NULL AUTO_INCREMENT,
topic CHAR(25) NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES topics(name),
INDEX topictags_tag (tag),
UNIQUE INDEX topictags_topic_tag(topic, tag)
);
# Subscriptions
CREATE TABLE subscriptions(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
userid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,
delid INT DEFAULT 0,
recvseqid INT DEFAULT 0,
readseqid INT DEFAULT 0,
modewant CHAR(8),
modegiven CHAR(8),
private JSON,
PRIMARY KEY(id) ,
FOREIGN KEY(userid) REFERENCES users(id),
UNIQUE INDEX subscriptions_topic_userid(topic, userid),
INDEX subscriptions_topic(topic),
INDEX subscriptions_deletedat(deletedat),
INDEX subscriptions_user_topic_deletedat ON subscriptions(userid, topic, deletedat)
);
# Messages
CREATE TABLE messages(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
delid INT DEFAULT 0,
seqid INT NOT NULL,
topic CHAR(25) NOT NULL,
`from` BIGINT NOT NULL,
head JSON,
content JSON,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES topics(name),
UNIQUE INDEX messages_topic_seqid (topic, seqid)
);
# Deletion log
CREATE TABLE dellog(
id INT NOT NULL AUTO_INCREMENT,
topic CHAR(25) NOT NULL,
deletedfor BIGINT NOT NULL DEFAULT 0,
delid INT NOT NULL,
low INT NOT NULL,
hi INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES topics(name),
# For getting the list of deleted message ranges
INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
# Used when getting not-yet-deleted messages(messages LEFT JOIN dellog)
INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),
# Used when deleting a user
INDEX dellog_deletedfor(deletedfor)
);
# User credentials
CREATE TABLE credentials(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
method VARCHAR(16) NOT NULL,
value VARCHAR(128) NOT NULL,
synthetic VARCHAR(192) NOT NULL,
userid BIGINT NOT NULL,
resp VARCHAR(255) NOT NULL,
done TINYINT NOT NULL DEFAULT 0,
retries INT NOT NULL DEFAULT 0,
PRIMARY KEY(id),
UNIQUE credentials_uniqueness(synthetic),
FOREIGN KEY(userid) REFERENCES users(id),
);
# Records of uploaded files. Files themselves are stored elsewhere.
CREATE TABLE fileuploads(
id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
userid BIGINT,
status INT NOT NULL,
mimetype VARCHAR(255) NOT NULL,
size BIGINT NOT NULL,
location VARCHAR(2048) NOT NULL,
etag VARCHAR(128),
PRIMARY KEY(id),
INDEX fileuploads_status(status)
);
# Links between uploaded files and messages or topics.
CREATE TABLE filemsglinks(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
fileid BIGINT NOT NULL,
msgid INT,
topic CHAR(25),
userid BIGINT,
PRIMARY KEY(id),
FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY(topicid) REFERENCES topics(id) ON DELETE CASCADE,
FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
);
# Current MySQL initialization is generated by server/db/mysql/adapter.go:
#
# - Control database: hotgo
# - im_tenant
# - im_tenant_config
#
# - 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
# - 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
# avoid accidental execution. If standalone SQL initialization scripts are
# needed later, split them explicitly into:
#
# - registry_schema.sql for the hotgo control database.
# - tenant_schema.sql for every tenant business database.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) {
if !a.tenantScopedDB {
return a.routedTenantAdapter(id)
}
if err := validTenant(id); err != nil {
return nil, err
}
return a, nil
}
func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, false, err
}
if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" {
return nil, false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyBegin(id, record)
}
now := t.TimeNow()
if record.ExpiresAt.IsZero() {
record.ExpiresAt = now.Add(24 * time.Hour)
}
_, err = a.db.Exec(`INSERT INTO im_http_idempotency
(user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, decodeUID(record.UserID), record.Method, record.Route, record.Key,
record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now)
if err == nil {
record.State = t.HTTPIdempotencyProcessing
record.CreatedAt = now
record.UpdatedAt = now
return record, true, nil
}
if !isDupe(err) {
return nil, false, err
}
existing, err := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
if err == nil && !existing.ExpiresAt.After(now) {
res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, now)
if deleteErr != nil {
return nil, false, deleteErr
}
if deleted, _ := res.RowsAffected(); deleted > 0 {
return a.TenantHTTPIdempotencyBegin(id, record)
}
current, getErr := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
return current, false, getErr
}
return existing, false, err
}
func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyComplete(id, record)
}
now := t.TimeNow()
res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=?
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, decodeUID(record.UserID),
record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyAbort(id, record)
}
_, err = a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
return err
}
func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) {
row := struct {
UserID int64 `db:"user_id"`
Method string
Route string
Key string `db:"idempotency_key"`
BodyHash string `db:"body_hash"`
State string
ResponseStatus int `db:"response_status"`
ResponseBody []byte `db:"response_body"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ExpiresAt time.Time `db:"expires_at"`
}{}
err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at
FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=?`, decodeUID(uid), method, route, key)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route,
Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus,
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil
}
func (a *adapter) TenantBusinessEventCreate(id t.TenantID, event *t.BusinessEvent) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if event == nil || event.EventID == "" || event.Target == "" || event.EventType == "" ||
(event.TargetType != "user" && event.TargetType != "topic") {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventCreate(id, event)
}
now := t.TimeNow()
_, err = a.db.Exec(`INSERT INTO im_business_events_outbox
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, event.EventID,
event.TargetType, event.Target, decodeUID(event.ActorUserID), event.EventType, event.Payload,
t.BusinessEventPending, now, now)
return err
}
func (a *adapter) TenantBusinessEventListPending(id t.TenantID, limit int, now time.Time) ([]t.BusinessEvent, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventListPending(id, limit, now)
}
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := a.db.Queryx(`SELECT * FROM im_business_events_outbox
WHERE status=? AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, t.BusinessEventPending, now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]t.BusinessEvent, 0, limit)
for rows.Next() {
var row struct {
ID int64
EventID string `db:"event_id"`
TargetType string `db:"target_type"`
Target string
ActorUserID int64 `db:"actor_user_id"`
EventType string `db:"event_type"`
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
events = append(events, t.BusinessEvent{ID: row.ID, EventID: row.EventID, TargetType: row.TargetType,
Target: row.Target, ActorUserID: store.EncodeUid(row.ActorUserID), EventType: row.EventType,
Payload: row.Payload, Status: row.Status, Attempts: row.Attempts, NextRetryAt: row.NextRetryAt,
DeliveredAt: row.DeliveredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
}
return events, rows.Err()
}
func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventMarkDelivered(id, eventID, now)
}
res, err := a.db.Exec(`UPDATE im_business_events_outbox SET status=?,delivered_at=?,attempts=attempts+1,updated_at=?
WHERE event_id=? AND status=?`, t.BusinessEventDelivered, now, now, eventID, t.BusinessEventPending)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_business_events_outbox
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventID, targetType,
target, decodeUID(actor), eventType, encoded, t.BusinessEventPending, now, now)
return err
}
func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error {
encoded, err := json.Marshal(action)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_system_notifications
(user_id,event_id,event_type,title,summary,action,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title),
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, decodeUID(uid), eventID,
eventType, title, summary, encoded, now, now)
return err
}
+165 -66
View File
@@ -15,6 +15,7 @@ import (
"log"
"os"
"reflect"
"strings"
"testing"
"time"
@@ -48,17 +49,126 @@ var testData *test_data.TestData
var dummyUid1 = types.Uid(12345)
var dummyUid2 = types.Uid(54321)
func TestCreateDb(t *testing.T) {
if err := adp.CreateDb(config.Reset); err != nil {
t.Fatal(err)
}
// Saved db is closed, get a fresh one.
db = adp.GetTestDB().(*sqlx.DB)
}
func TestGroupBusinessTablesCreated(t *testing.T) {
tables := []string{
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
"im_group_files", "im_group_admin_logs", "im_group_idempotency", "im_group_events_outbox",
}
args := make([]any, len(tables))
for i, table := range tables {
args[i] = table
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",")
var count int
query := "SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("group business table count: got %d, want %d", count, len(tables))
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("tenant business tables contain %d tenant_id columns", count)
}
}
func TestHTTPInfrastructureTablesCreated(t *testing.T) {
tables := []string{"im_http_idempotency", "im_business_events_outbox"}
args := make([]any, len(tables))
for i, table := range tables {
args[i] = table
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",")
var count int
query := "SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("HTTP infrastructure table count: got %d, want %d", count, len(tables))
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("HTTP infrastructure tables contain %d tenant_id columns", count)
}
}
func TestTenantAuthIsolation(t *testing.T) {
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantAuthAdapter")
}
var routingConfig struct {
Enabled bool `json:"tenant_db_routing"`
}
if err := json.Unmarshal(config.Adapters[adp.GetName()], &routingConfig); err != nil {
t.Fatal(err)
}
if !routingConfig.Enabled {
t.Skip("tenant_db_routing is disabled in the MySQL test configuration")
}
router, ok := adp.(adapter.TenantRoutedAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantRoutedAdapter")
}
now := types.TimeNow()
suffix := fmt.Sprintf("%d", now.UnixNano())
createTenant := func(code string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,state,created_by,updated_by) VALUES(?,?,?,?,?)",
code+suffix, code, types.TenantStateActive, 0, 0)
dbNames := []string{"im_tenant_auth_a_" + suffix, "im_tenant_auth_b_" + suffix}
quoteIdent := func(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
var baseDB string
if err := db.Get(&baseDB, "SELECT DATABASE()"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM im_tenant WHERE code IN (?,?)", "test-im_auth-a-"+suffix, "test-im_auth-b-"+suffix)
for _, dbName := range dbNames {
db.Exec("DROP DATABASE IF EXISTS " + quoteIdent(dbName))
}
})
for _, dbName := range dbNames {
if _, err := db.Exec("CREATE DATABASE " + quoteIdent(dbName) +
" CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
t.Fatal(err)
}
for _, table := range []string{"im_users", "im_auth", "im_credentials"} {
query := "CREATE TABLE " + quoteIdent(dbName) + "." + quoteIdent(table) +
" LIKE " + quoteIdent(baseDB) + "." + quoteIdent(table)
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
}
createTenant := func(code, dbName string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,db_name,state,created_by,updated_by) VALUES(?,?,?,?,?,?)",
code+suffix, code, dbName, types.TenantStateActive, 0, 0)
if err != nil {
t.Fatal(err)
}
@@ -68,44 +178,41 @@ func TestTenantAuthIsolation(t *testing.T) {
}
return types.TenantID(id)
}
tenantA := createTenant("test-im_auth-a-", dbNames[0])
tenantB := createTenant("test-im_auth-b-", dbNames[1])
businessA, _, err := router.TenantAdapterFor(tenantA)
if err != nil {
t.Fatal(err)
}
if reflect.ValueOf(businessA).Pointer() == reflect.ValueOf(adp).Pointer() {
t.Skip("tenant_db_routing is disabled in the MySQL test configuration")
}
tenantA := createTenant("test-auth-a-")
tenantB := createTenant("test-auth-b-")
userAID := now.UnixNano() & 0x3fffffffffffffff
userBID := userAID + 1
t.Cleanup(func() {
db.Exec("DELETE FROM credentials WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM auth WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM users WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM im_tenant WHERE id IN (?,?)", tenantA, tenantB)
})
for tenantID, userID := range map[types.TenantID]int64{tenantA: userAID, tenantB: userBID} {
_, err := db.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) "+
"VALUES(?,?,?,?,?,?,?,?,?)", userID, tenantID, now, now, types.StateOK, `{}`, nil, nil, nil)
if err != nil {
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 {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?,?)",
tenantID, "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash"))
if err != nil {
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 {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES(?,?,?,?,?,?,?,?,?)", tenantID, now, now, "tel", "13800000000", "tel:13800000000",
userID, "", true)
if err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES(?,?,?,?,?,?,?,?)", now, now, "tel", "13800000000", "tel:13800000000", userID, "", true); err != nil {
t.Fatal(err)
}
}
uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
if err != nil || uidA != store.EncodeUid(userAID) {
t.Fatalf("tenant A auth lookup: uid=%v err=%v", uidA, err)
t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err)
}
uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
if err != nil || uidB != store.EncodeUid(userBID) {
t.Fatalf("tenant B auth lookup: uid=%v err=%v", uidB, err)
t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err)
}
credA, err := tenantAdp.TenantUserGetByCred(tenantA, "tel", "13800000000")
if err != nil || credA != uidA {
@@ -118,18 +225,10 @@ func TestTenantAuthIsolation(t *testing.T) {
if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
t.Fatalf("cross-tenant auth update: got %v, want %v", err, types.ErrNotFound)
t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound)
}
}
func TestCreateDb(t *testing.T) {
if err := adp.CreateDb(config.Reset); err != nil {
t.Fatal(err)
}
// Saved db is closed, get a fresh one.
db = adp.GetTestDB().(*sqlx.DB)
}
// ================== Create tests ================================
func TestUserCreate(t *testing.T) {
for _, user := range testData.Users {
@@ -142,12 +241,12 @@ func TestUserCreate(t *testing.T) {
if err := db.Ping(); err != nil {
logs.Err.Println("Database ping failed:", err)
}
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
err := db.QueryRow("SELECT COUNT(*) FROM im_users").Scan(&count)
if err != nil {
t.Error(err)
}
if count == 0 {
t.Error("No users created!")
t.Error("No im_users created!")
}
}
@@ -245,7 +344,7 @@ func TestTopicCreateP2P(t *testing.T) {
}
var got types.Subscription
err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
&got.UpdatedAt, &got.DeletedAt, &got.User, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &got.ModeWant, &got.ModeGiven, &got.Private)
if err != nil {
@@ -346,7 +445,7 @@ func TestUserGetAll(t *testing.T) {
t.Fatal(err)
}
if len(got) > 0 {
t.Error("result users should be zero length, got", len(got))
t.Error("result im_users should be zero length, got", len(got))
}
got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
@@ -698,7 +797,7 @@ func TestUserUpdate(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow("SELECT useragent, updatedat, createdat FROM users WHERE id=?", decodeUid(testData.Users[0].Id)).
err = db.QueryRow("SELECT useragent, updatedat, createdat FROM im_users WHERE id=?", decodeUid(testData.Users[0].Id)).
Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -775,7 +874,7 @@ func TestCredFail(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow("SELECT retries, updatedat, createdat FROM credentials WHERE userid=? AND method=? AND value=?",
err = db.QueryRow("SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=? AND method=? AND value=?",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -800,7 +899,7 @@ func TestCredConfirm(t *testing.T) {
CreatedAt time.Time
Done bool
}
err = db.QueryRow("SELECT updatedat, createdat, done FROM credentials WHERE userid=? AND method=? AND value=?",
err = db.QueryRow("SELECT updatedat, createdat, done FROM im_credentials WHERE userid=? AND method=? AND value=?",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
if err != nil {
t.Fatal(err)
@@ -822,7 +921,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Fatal(err)
}
var got []byte
err = db.QueryRow("SELECT secret FROM auth WHERE uname=?", rec.Unique).Scan(&got)
err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -839,12 +938,12 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM auth WHERE uname=?", rec.Unique).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("Old auth record not deleted")
t.Error("Old im_auth record not deleted")
}
}
@@ -863,7 +962,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
TouchedAt time.Time
SeqId int
}
err = db.QueryRow("SELECT touchedat, seqid FROM topics WHERE name=?", testData.Topics[2].Id).
err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
if err != nil {
t.Fatal(err)
@@ -883,7 +982,7 @@ func TestTopicUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow("SELECT updatedat FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow("SELECT updatedat FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -898,7 +997,7 @@ func TestTopicOwnerChange(t *testing.T) {
t.Fatal(err)
}
var got int64
err = db.QueryRow("SELECT owner FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow("SELECT owner FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -917,7 +1016,7 @@ func TestSubsUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -930,7 +1029,7 @@ func TestSubsUpdate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? LIMIT 1",
err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? LIMIT 1",
testData.Topics[1].Id).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -946,7 +1045,7 @@ func TestSubsDelete(t *testing.T) {
t.Fatal(err)
}
var deletedat sql.NullTime
err = db.QueryRow("SELECT deletedat FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
if err != nil {
t.Fatal(err)
@@ -965,7 +1064,7 @@ func TestDeviceUpsert(t *testing.T) {
DeviceId string
Platform string
}
err = db.QueryRow("SELECT deviceid, platform FROM devices WHERE userid=? LIMIT 1",
err = db.QueryRow("SELECT deviceid, platform FROM im_devices WHERE userid=? LIMIT 1",
decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
if err != nil {
t.Fatal(err)
@@ -980,7 +1079,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?",
decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -994,7 +1093,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?",
decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -1030,7 +1129,7 @@ func TestMessageAttachments(t *testing.T) {
}
// Check if attachments were linked (this would require checking filemsglinks table)
var count int
if err = db.QueryRow("SELECT COUNT(*) FROM filemsglinks WHERE msgid=?",
if err = db.QueryRow("SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=?",
types.ParseUid(testData.Msgs[1].Id)).Scan(&count); err != nil {
t.Fatal(err)
}
@@ -1066,7 +1165,7 @@ func TestDeviceDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1078,7 +1177,7 @@ func TestDeviceDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1154,7 +1253,7 @@ func TestCredDel(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1166,7 +1265,7 @@ func TestCredDel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1218,13 +1317,13 @@ func TestMessageDeleteList(t *testing.T) {
// Check messages in dellog
var count int
err = db.QueryRow("SELECT COUNT(*) FROM dellog WHERE topic=? AND deletedfor=?",
err = db.QueryRow("SELECT COUNT(*) FROM im_dellog WHERE topic=? AND deletedfor=?",
toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count == 0 {
t.Error("No dellog entries created")
t.Error("No im_dellog entries created")
}
// Hard delete test
@@ -1244,7 +1343,7 @@ func TestMessageDeleteList(t *testing.T) {
}
// Check if messages content was cleared
err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=? AND content IS NOT NULL",
err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=? AND content IS NOT NULL",
toDel.Topic).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1257,7 +1356,7 @@ func TestMessageDeleteList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1272,7 +1371,7 @@ func TestTopicDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow("SELECT state FROM topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
err = db.QueryRow("SELECT state FROM im_topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
if err != nil {
t.Fatal(err)
}
@@ -1286,7 +1385,7 @@ func TestTopicDelete(t *testing.T) {
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1311,7 +1410,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow("SELECT state FROM users WHERE id=?",
err = db.QueryRow("SELECT state FROM im_users WHERE id=?",
decodeUid(testData.Users[0].Id)).Scan(&state)
if err != nil {
t.Fatal(err)
@@ -1325,7 +1424,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE id=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
File diff suppressed because it is too large Load Diff
+45 -45
View File
@@ -66,12 +66,12 @@ func TestUserCreate(t *testing.T) {
}
var count int
err := db.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
err := db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users").Scan(&count)
if err != nil {
t.Error(err)
}
if count == 0 {
t.Error("No users created!")
t.Error("No im_users created!")
}
}
@@ -97,7 +97,7 @@ func TestCredUpsert(t *testing.T) {
t.Error("Should return duplicate error but got", err)
}
// Test add new unvalidated credentials
// Test add new unvalidated im_credentials
inserted, err := adp.CredUpsert(testData.Creds[3])
if err != nil {
t.Fatal(err)
@@ -183,7 +183,7 @@ func TestTopicCreateP2P(t *testing.T) {
var got types.Subscription
var userId int64
var modeWant, modeGiven []byte
err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
&got.UpdatedAt, &got.DeletedAt, &userId, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId,
&modeWant, &modeGiven, &got.Private)
@@ -231,7 +231,7 @@ func TestMessageSave(t *testing.T) {
}
}
// Some messages are soft deleted, but it's ignored by adp.MessageSave
// Some im_messages are soft deleted, but it's ignored by adp.MessageSave
for _, msg := range testData.Msgs {
if len(msg.DeletedFor) > 0 {
for _, del := range msg.DeletedFor {
@@ -284,7 +284,7 @@ func TestUserGetAll(t *testing.T) {
t.Fatal(err)
}
if len(got) > 0 {
t.Error("result users should be zero length, got", len(got))
t.Error("result im_users should be zero length, got", len(got))
}
got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
@@ -516,7 +516,7 @@ func TestChannelsForUser(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Should return empty slice since we don't have channel subscriptions in test data
// Should return empty slice since we don't have channel im_subscriptions in test data
if len(channels) != 0 {
t.Error(mismatchErrorString("Channels length", len(channels), 0))
}
@@ -663,7 +663,7 @@ func TestUserUpdate(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM im_users WHERE id=$1",
decodeUid(testData.Users[0].Id)).Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -726,9 +726,9 @@ func TestUserGetUnvalidated(t *testing.T) {
if err != nil {
t.Error(err)
}
// Should return empty slice since all test users are considered validated
// Should return empty slice since all test im_users are considered validated
if len(uids) > 0 {
t.Error("Expected no unvalidated users in test data")
t.Error("Expected no unvalidated im_users in test data")
}
}
@@ -744,7 +744,7 @@ func TestCredFail(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -769,7 +769,7 @@ func TestCredConfirm(t *testing.T) {
CreatedAt time.Time
Done bool
}
err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
if err != nil {
t.Fatal(err)
@@ -791,7 +791,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Fatal(err)
}
var got []byte
err = db.QueryRow(ctx, "SELECT secret FROM auth WHERE uname=$1", rec.Unique).Scan(&got)
err = db.QueryRow(ctx, "SELECT secret FROM im_auth WHERE uname=$1", rec.Unique).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -799,7 +799,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Error(mismatchErrorString("Secret", got, rec.Secret))
}
// Test with auth ID (unique) change
// Test with im_auth ID (unique) change
newId := "basic:bob12345"
err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
rec.AuthLvl, newSecret, rec.Expires)
@@ -808,12 +808,12 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM auth WHERE uname=$1", rec.Unique).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_auth WHERE uname=$1", rec.Unique).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("Old auth record not deleted")
t.Error("Old im_auth record not deleted")
}
}
@@ -832,7 +832,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
TouchedAt time.Time
SeqId int
}
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM topics WHERE name=$1", testData.Topics[2].Id).
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
if err != nil {
t.Fatal(err)
@@ -852,7 +852,7 @@ func TestTopicUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow(ctx, "SELECT updatedat FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow(ctx, "SELECT updatedat FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -870,11 +870,11 @@ func TestTopicUpdateSubCnt(t *testing.T) {
// Verify the subscription count was updated correctly
var subcnt int
err = db.QueryRow(ctx, "SELECT subcnt FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
err = db.QueryRow(ctx, "SELECT subcnt FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
if err != nil {
t.Fatal(err)
}
// Should match the number of active subscriptions
// Should match the number of active im_subscriptions
if subcnt < 0 {
t.Error("Subscription count should be non-negative")
}
@@ -886,7 +886,7 @@ func TestTopicOwnerChange(t *testing.T) {
t.Fatal(err)
}
var got int64
err = db.QueryRow(ctx, "SELECT owner FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow(ctx, "SELECT owner FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -905,7 +905,7 @@ func TestSubsUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -918,7 +918,7 @@ func TestSubsUpdate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 LIMIT 1",
err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 LIMIT 1",
testData.Topics[1].Id).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -934,7 +934,7 @@ func TestSubsDelete(t *testing.T) {
t.Fatal(err)
}
var deletedat sql.NullTime
err = db.QueryRow(ctx, "SELECT deletedat FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
if err != nil {
t.Fatal(err)
@@ -957,7 +957,7 @@ func TestDeviceUpsert(t *testing.T) {
DeviceId string
Platform string
}
err = db.QueryRow(ctx, "SELECT deviceid, platform FROM devices WHERE userid=$1 LIMIT 1",
err = db.QueryRow(ctx, "SELECT deviceid, platform FROM im_devices WHERE userid=$1 LIMIT 1",
decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
if err != nil {
t.Fatal(err)
@@ -972,7 +972,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT platform FROM devices WHERE userid=$1 AND deviceid=$2",
err = db.QueryRow(ctx, "SELECT platform FROM im_devices WHERE userid=$1 AND deviceid=$2",
decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -1001,7 +1001,7 @@ func TestMessageAttachments(t *testing.T) {
}
// Check if attachments were linked
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM filemsglinks WHERE msgid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=$1",
int64(types.ParseUid(testData.Msgs[1].Id))).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1036,9 +1036,9 @@ func TestDeviceGetAll(t *testing.T) {
if count < 1 {
t.Fatal(mismatchErrorString("count", count, ">=1"))
}
// Test that devices exist for the users
// Test that im_devices exist for the im_users
if len(gotDevs) == 0 {
t.Error("Expected devices for users")
t.Error("Expected im_devices for im_users")
}
}
@@ -1048,7 +1048,7 @@ func TestDeviceDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1 AND deviceid=$2",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1 AND deviceid=$2",
decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1061,13 +1061,13 @@ func TestDeviceDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1",
decodeUid(testData.Users[2].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("All devices not deleted:", count)
t.Error("All im_devices not deleted:", count)
}
}
@@ -1138,7 +1138,7 @@ func TestCredDel(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1150,7 +1150,7 @@ func TestCredDel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE userid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE userid=$1",
decodeUid(testData.Users[1].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1161,7 +1161,7 @@ func TestCredDel(t *testing.T) {
}
func TestAuthDelScheme(t *testing.T) {
// Test deleting auth scheme
// Test deleting im_auth scheme
err := adp.AuthDelScheme(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
if err != nil {
t.Fatal(err)
@@ -1207,15 +1207,15 @@ func TestMessageDeleteList(t *testing.T) {
t.Fatal(err)
}
// Check messages in dellog
// Check im_messages in im_dellog
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM dellog WHERE topic=$1 AND deletedfor=$2",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_dellog WHERE topic=$1 AND deletedfor=$2",
toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count == 0 {
t.Error("No dellog entries created")
t.Error("No im_dellog entries created")
}
// Hard delete test
@@ -1234,8 +1234,8 @@ func TestMessageDeleteList(t *testing.T) {
t.Fatal(err)
}
// Check if messages content was cleared
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1 AND content IS NOT NULL",
// Check if im_messages content was cleared
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1 AND content IS NOT NULL",
toDel.Topic).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1248,7 +1248,7 @@ func TestMessageDeleteList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1263,7 +1263,7 @@ func TestTopicDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow(ctx, "SELECT state FROM topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
err = db.QueryRow(ctx, "SELECT state FROM im_topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
if err != nil {
t.Fatal(err)
}
@@ -1277,7 +1277,7 @@ func TestTopicDelete(t *testing.T) {
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1302,7 +1302,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow(ctx, "SELECT state FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT state FROM im_users WHERE id=$1",
decodeUid(testData.Users[0].Id)).Scan(&state)
if err != nil {
t.Fatal(err)
@@ -1316,7 +1316,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users WHERE id=$1",
decodeUid(testData.Users[1].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
+117 -54
View File
@@ -18,6 +18,7 @@ import (
"mime"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
@@ -35,6 +36,12 @@ import (
// See https://www.iana.org/assignments/media-types/media-types.xhtml
var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"}
// RequestPrincipal is a tenant-scoped identity authenticated for HTTP/gRPC file access.
type RequestPrincipal struct {
TenantID types.TenantID
UID types.Uid
}
func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
now := types.TimeNow()
enc := json.NewEncoder(wrt)
@@ -85,7 +92,7 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
// Check authorization: either auth information or SID must be present
authMethod, secret := getHttpAuth(req)
tenantID, uid, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
principal, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
if err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return
@@ -96,14 +103,14 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
return
}
if uid.IsZero() {
if principal.UID.IsZero() {
// Not authenticated
writeHttpResponse(ErrAuthRequired("", "", now, now), errors.New("user not authenticated"))
return
}
// Check if media handler redirects or adds headers.
headers, statusCode, err := mh.Headers(tenantID, req.Method, req.URL, req.Header, true)
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
if err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return
@@ -127,17 +134,11 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
},
})
}
logs.Info.Println("media serve: completed with status", statusCode, "uid=", uid)
logs.Info.Println("media serve: completed with status", statusCode, "uid=", principal.UID)
return
}
if req.Method == http.MethodHead {
wrt.WriteHeader(http.StatusOK)
logs.Info.Println("media serve: completed", req.Method, "uid=", uid)
return
}
fd, rsc, err := mh.Download(tenantID, req.URL.String())
fd, rsc, err := mh.Download(principal.TenantID, req.URL.String())
if err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return
@@ -145,7 +146,15 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
defer rsc.Close()
if err = authorizeLargeFileDownload(principal.TenantID, principal.UID, req.URL.String(), fd); err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return
}
wrt.Header().Set("Content-Type", fd.MimeType)
if fd.ETag != "" {
wrt.Header().Set("ETag", strconv.Quote(fd.ETag))
}
asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt"))
// Force download for html files as a security measure.
asAttachment = asAttachment ||
@@ -159,12 +168,12 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
strings.HasPrefix(fd.MimeType, "multipart/") ||
strings.HasPrefix(fd.MimeType, "text/")
if asAttachment {
wrt.Header().Set("Content-Disposition", "attachment")
wrt.Header().Set("Content-Disposition", contentDispositionAttachment(fd))
}
http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc)
logs.Info.Println("media serve: OK, uid=", uid)
logs.Info.Println("media serve: OK, uid=", principal.UID)
}
// largeFileReceiveHTTP receives files from client over HTTP(S) and passes them to the configured media handler.
@@ -225,7 +234,7 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
msgID := req.FormValue("id")
// Check authorization: either auth information or SID must be present
authMethod, secret := getHttpAuth(req)
tenantID, uid, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
principal, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req))
if err != nil {
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
@@ -234,14 +243,14 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
writeHttpResponse(InfoChallenge(msgID, now, challenge), nil)
return
}
if uid.IsZero() && req.FormValue("topic") != "newacc" {
if principal.UID.IsZero() && req.FormValue("topic") != "newacc" {
// Not authenticated and not signup.
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
return
}
// Check if uploads are handled elsewhere.
headers, statusCode, err := mh.Headers(tenantID, req.Method, req.URL, req.Header, true)
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
if err != nil {
logs.Info.Println("media upload: headers check failed", err)
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
@@ -313,8 +322,8 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
ObjHeader: types.ObjHeader{
Id: store.Store.GetUidString(),
},
TenantID: tenantID,
User: uid.String(),
TenantID: principal.TenantID,
User: principal.UID.String(),
MimeType: mimeType,
}
fdef.InitTimes()
@@ -327,12 +336,12 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
url, size, err := mh.Upload(fdef, file)
if err != nil {
logs.Info.Println("media upload: failed", file, "key", fdef.Location, err)
store.Files.FinishUpload(tenantID, fdef, false, 0)
store.Files.FinishUpload(principal.TenantID, fdef, false, 0)
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
return
}
fdef, err = store.Files.FinishUpload(tenantID, fdef, true, size)
fdef, err = store.Files.FinishUpload(principal.TenantID, fdef, true, size)
if err != nil {
logs.Info.Println("media upload: failed to finalize", file, "key", fdef.Location, err)
// Best effort cleanup.
@@ -370,7 +379,7 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
if p, ok := peer.FromContext(stream.Context()); ok {
remoteAddr = p.Addr.String()
}
tenantID, uid, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr)
principal, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr)
if err != nil {
writeResponse(decodeStoreError(err, msgID, now, nil), err)
return nil
@@ -381,7 +390,7 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
return nil
}
if uid.IsZero() {
if principal.UID.IsZero() {
// Not authenticated
writeResponse(ErrAuthRequired(msgID, "", now, now), errors.New("user not authenticated"))
return nil
@@ -390,7 +399,7 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
// Check if media handler redirects or adds headers.
mh := store.Store.GetMediaHandler()
url, _ := url.Parse(req.Uri)
headers, statusCode, err := mh.Headers(tenantID, http.MethodGet, url, http.Header{}, true)
headers, statusCode, err := mh.Headers(principal.TenantID, http.MethodGet, url, http.Header{}, true)
if err != nil {
writeResponse(decodeStoreError(err, "", now, nil), err)
return nil
@@ -403,11 +412,11 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
resp.Text = http.StatusText(statusCode)
resp.RedirUrl = headers.Get("Location")
stream.Send(&resp)
logs.Info.Println("media serve: completed with status", statusCode, "uid=", uid)
logs.Info.Println("media serve: completed with status", statusCode, "uid=", principal.UID)
return nil
}
fd, rsc, err := mh.Download(tenantID, req.GetUri())
fd, rsc, err := mh.Download(principal.TenantID, req.GetUri())
if err != nil {
writeResponse(decodeStoreError(err, msgID, now, nil), err)
return nil
@@ -415,6 +424,11 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
defer rsc.Close()
if err = authorizeLargeFileDownload(principal.TenantID, principal.UID, req.GetUri(), fd); err != nil {
writeResponse(decodeStoreError(err, msgID, now, nil), err)
return nil
}
resp.Code = http.StatusOK
resp.Text = http.StatusText(http.StatusOK)
resp.Meta.Name = fd.Location
@@ -429,7 +443,7 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
if err == nil {
resp.Content = resp.Content[:n]
if err = stream.Send(&resp); err != nil {
logs.Info.Println("media serve: failed, uid=", uid, err)
logs.Info.Println("media serve: failed, uid=", principal.UID, err)
break
}
continue
@@ -441,10 +455,50 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
}
break
}
logs.Info.Println("media serve: ", result, ", uid=", uid)
logs.Info.Println("media serve: ", result, ", uid=", principal.UID)
return err
}
func authorizeLargeFileDownload(tenantID types.TenantID, uid types.Uid, rawURL string, fd *types.FileDef) error {
if fd == nil {
return types.ErrNotFound
}
fid := fd.Uid()
if fid.IsZero() && rawURL != "" && store.Store != nil {
fid = store.Store.GetMediaHandler().GetIdFromUrl(rawURL)
}
if fid.IsZero() || fd.TenantID != tenantID || fd.Status != types.UploadCompleted {
return types.ErrNotFound
}
allowed, err := store.Files.UserHasAccess(tenantID, fid.String(), uid)
if err != nil {
return err
}
if !allowed {
return types.ErrPermissionDenied
}
return nil
}
func contentDispositionAttachment(fd *types.FileDef) string {
name := strings.TrimSpace(filepath.Base(fd.Location))
if name == "." || name == "/" || name == string(filepath.Separator) {
name = fd.Uid().String()
}
clean := strings.Map(func(r rune) rune {
switch {
case r < 32 || r == 127 || r == '"' || r == '\\' || r == '/' || r == ':':
return -1
default:
return r
}
}, name)
if clean == "" {
clean = fd.Uid().String()
}
return mime.FormatMediaType("attachment", map[string]string{"filename": clean})
}
// LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP.
func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {
now := types.TimeNow()
@@ -474,7 +528,7 @@ func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer)
if p, ok := peer.FromContext(stream.Context()); ok {
remoteAddr = p.Addr.String()
}
tenantID, uid, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr)
principal, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr)
if err != nil {
writeResponse(decodeStoreError(err, msgID, now, nil), err)
return nil
@@ -485,14 +539,14 @@ func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer)
return nil
}
if uid.IsZero() {
if principal.UID.IsZero() {
// Not authenticated
writeResponse(ErrAuthRequired(msgID, "", now, now), errors.New("user not authenticated"))
return nil
}
// Check if uploads are handled elsewhere.
headers, statusCode, err := mh.Headers(tenantID, http.MethodPost, nil, http.Header{}, false)
headers, statusCode, err := mh.Headers(principal.TenantID, http.MethodPost, nil, http.Header{}, false)
if err != nil {
logs.Info.Println("media upload: headers check failed", err)
writeResponse(decodeStoreError(err, "", now, nil), nil)
@@ -507,7 +561,7 @@ func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer)
Text: http.StatusText(statusCode),
RedirUrl: headers.Get("Location"),
})
logs.Info.Println("media upload: completed with status", statusCode, "uid=", uid, err)
logs.Info.Println("media upload: completed with status", statusCode, "uid=", principal.UID, err)
return err
}
@@ -523,8 +577,8 @@ func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer)
ObjHeader: types.ObjHeader{
Id: store.Store.GetUidString(),
},
TenantID: tenantID,
User: uid.String(),
TenantID: principal.TenantID,
User: principal.UID.String(),
MimeType: mimeType,
}
fdef.InitTimes()
@@ -558,7 +612,7 @@ func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer)
}
if err != nil {
logs.Info.Println("media upload: failed", req.Meta.Name, "key", fdef.Location, err)
store.Files.FinishUpload(tenantID, fdef, false, 0)
store.Files.FinishUpload(principal.TenantID, fdef, false, 0)
writeResponse(decodeStoreError(err, msgID, now, nil), nil)
return nil
}
@@ -591,11 +645,7 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
for {
select {
case <-gcTicker:
for _, tenantID := range store.KnownTenantIDs() {
if err := store.Files.DeleteUnused(tenantID, time.Now().Add(-time.Hour), blockSize); err != nil {
logs.Warn.Println("media gc:", tenantID, err)
}
}
largeFileGarbageCollectTenants(store.KnownTenantIDs(), time.Now().Add(-time.Hour), blockSize)
case <-stop:
return
}
@@ -605,42 +655,55 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
return stop
}
func largeFileGarbageCollectTenants(tenantIDs []types.TenantID, olderThan time.Time, blockSize int) {
for _, tenantID := range tenantIDs {
if err := store.Files.DeleteUnused(tenantID, olderThan, blockSize); err != nil {
logs.Warn.Println("media gc:", tenantID, err)
}
}
}
// Authenticate non-websocket HTTP request
func authFileRequest(authMethod, secret, sid, remoteAddr string) (types.TenantID, types.Uid, []byte, error) {
var tenantID types.TenantID
var uid types.Uid
func authFileRequest(authMethod, secret, sid, remoteAddr string) (RequestPrincipal, []byte, error) {
var principal RequestPrincipal
if authMethod != "" {
decodedSecret := make([]byte, base64.StdEncoding.DecodedLen(len(secret)))
n, err := base64.StdEncoding.Decode(decodedSecret, []byte(secret))
if err != nil {
logs.Info.Println("media: invalid auth secret", authMethod, "'"+secret+"'")
return tenantID, uid, nil, types.ErrMalformed
return principal, nil, types.ErrMalformed
}
if authhdl := store.Store.GetLogicalAuthHandler(authMethod); authhdl != nil {
rec, challenge, err := authhdl.Authenticate(auth.AuthContext{RemoteAddr: remoteAddr}, decodedSecret[:n])
if err != nil {
return tenantID, uid, nil, err
return principal, nil, err
}
if challenge != nil {
return tenantID, uid, challenge, nil
return principal, challenge, nil
}
if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
return tenantID, uid, nil, types.ErrFailed
return principal, nil, types.ErrFailed
}
tenantID = rec.TenantID
uid = rec.Uid
principal.TenantID = rec.TenantID
principal.UID = rec.Uid
} else {
logs.Info.Println("media: unknown auth method", authMethod)
return tenantID, uid, nil, types.ErrMalformed
return principal, nil, types.ErrMalformed
}
} else {
// Find the session, make sure it's appropriately authenticated.
sess := globals.sessionStore.Get(sid)
if sess != nil {
tenantID = sess.tenantID
uid = sess.uid
if globals.sessionStore != nil {
sess := globals.sessionStore.Get(sid)
if sess == nil {
return principal, nil, nil
}
if sess.tenantID.IsZero() || sess.uid.IsZero() {
return principal, nil, types.ErrFailed
}
principal.TenantID = sess.tenantID
principal.UID = sess.uid
}
}
return tenantID, uid, nil, nil
return principal, nil, nil
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"encoding/base64"
"net/http/httptest"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
oldSessionStore := globals.sessionStore
globals.sessionStore = NewSessionStore(time.Minute)
defer func() { globals.sessionStore = oldSessionStore }()
sess, _ := globals.sessionStore.NewSession(httptest.NewRecorder(), "file-test-sid")
sess.uid = types.Uid(42)
if _, _, err := authFileRequest("", "", sess.sid, ""); err != types.ErrFailed {
t.Fatalf("session without tenant: got %v, want %v", err, types.ErrFailed)
}
sess.tenantID = types.TenantID(7)
principal, challenge, err := authFileRequest("", "", sess.sid, "")
if err != nil {
t.Fatal(err)
}
if challenge != nil {
t.Fatal("unexpected challenge")
}
if principal.TenantID != sess.tenantID || principal.UID != sess.uid {
t.Fatalf("principal mismatch: got %+v, want tenant=%d uid=%s", principal, sess.tenantID, sess.uid)
}
}
func TestAuthFileRequestFromTokenReturnsPrincipal(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
defer func() { store.Store = oldStore }()
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "203.0.113.10"}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
store.Store = mockStore
principal, challenge, err := authFileRequest("token", base64.StdEncoding.EncodeToString([]byte("token-1")), "", "203.0.113.10")
if err != nil {
t.Fatal(err)
}
if challenge != nil {
t.Fatal("unexpected challenge")
}
if principal.TenantID != 7 || principal.UID != 42 {
t.Fatalf("principal mismatch: %#v", principal)
}
}
func TestAuthFileRequestAllowsUnauthenticatedNewAccountPath(t *testing.T) {
principal, challenge, err := authFileRequest("", "", "", "")
if err != nil {
t.Fatal(err)
}
if challenge != nil {
t.Fatal("unexpected challenge")
}
if !principal.TenantID.IsZero() || !principal.UID.IsZero() {
t.Fatalf("principal should be empty for unauthenticated request: %+v", principal)
}
}
func TestAuthorizeLargeFileDownloadDenied(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldFiles := store.Files
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
defer func() { store.Files = oldFiles }()
fd := &types.FileDef{
ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
TenantID: 7,
Status: types.UploadCompleted,
MimeType: "text/plain",
Location: "notes.txt",
}
files.EXPECT().UserHasAccess(types.TenantID(7), types.Uid(100).String(), types.Uid(42)).Return(false, nil)
if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrPermissionDenied {
t.Fatalf("expected permission denied, got %v", err)
}
}
func TestAuthorizeLargeFileDownloadRejectsCrossTenant(t *testing.T) {
fd := &types.FileDef{
ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
TenantID: 8,
Status: types.UploadCompleted,
MimeType: "text/plain",
Location: "notes.txt",
}
if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrNotFound {
t.Fatalf("expected not found for cross-tenant file, got %v", err)
}
}
func TestContentDispositionAttachmentSanitizesName(t *testing.T) {
fd := &types.FileDef{
ObjHeader: types.ObjHeader{Id: types.Uid(100).String()},
Location: `../bad"name.txt`,
}
got := contentDispositionAttachment(fd)
if got != `attachment; filename=badname.txt` {
t.Fatalf("unexpected content disposition: %s", got)
}
}
func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldFiles := store.Files
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
defer func() { store.Files = oldFiles }()
olderThan := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
files.EXPECT().DeleteUnused(types.TenantID(7), olderThan, 25).Return(nil)
files.EXPECT().DeleteUnused(types.TenantID(8), olderThan, 25).Return(nil)
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
}
+2 -2
View File
@@ -28,13 +28,13 @@ import (
"github.com/tinode/chat/server/store/types"
)
func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop <-chan bool) error {
func listenAndServe(addr string, handler http.Handler, tlfConf *tls.Config, stop <-chan bool) error {
globals.shuttingDown = false
httpdone := make(chan bool)
server := &http.Server{
Handler: mux,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
WriteTimeout: 90 * time.Second,
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func stableIMEventID(parts ...any) string {
return types.StableEventID(parts...)
}
func enqueueIMBusinessEvent(tenantID types.TenantID, eventID, targetType, target string,
actor types.Uid, eventType string, payload any) error {
if tenantID.IsZero() || eventID == "" || target == "" || eventType == "" {
return types.ErrMalformed
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
err = store.HTTPInfrastructure.CreateEvent(tenantID, &types.BusinessEvent{
EventID: eventID, TargetType: targetType, Target: target, ActorUserID: actor,
EventType: eventType, Payload: encoded,
})
if err == nil {
dispatchIMBusinessEvents(tenantID)
}
return err
}
func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, eventType,
title, summary string, action types.KVMap) error {
if uid.IsZero() {
return types.ErrMalformed
}
return store.Security.CreateSystemNotification(tenantID, &types.SystemNotification{
TenantID: tenantID, UserID: uid, EventID: eventID, EventType: eventType,
Title: title, Summary: summary, Action: action,
})
}
func publishIMMeetingEvent(tenantID types.TenantID, actor types.Uid, meeting *types.Meeting, eventType string) error {
if meeting == nil {
return types.ErrMalformed
}
for _, participant := range meeting.Participants {
uid := participant.UserID
if uid.IsZero() {
continue
}
eventID := stableIMEventID("meeting", meeting.MeetingID, meeting.Version, eventType, uid.UserId())
if uid != actor {
if err := createIMSystemNotification(tenantID, uid, stableIMEventID(eventID, "notification"), eventType,
"会议通知", meeting.Title, types.KVMap{"meeting_id": meeting.MeetingID}); err != nil {
return err
}
}
if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, eventType,
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}); err != nil {
return err
}
}
return nil
}
func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error {
if target.IsZero() {
return types.ErrMalformed
}
eventID := stableIMEventID("relation", stableID, eventType, target.UserId())
if target != actor && title != "" {
if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), eventType,
title, summary, types.KVMap{"user_id": actor.UserId()}); err != nil {
return err
}
}
return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType,
map[string]any{"user_id": actor.UserId()})
}
func dispatchIMBusinessEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
return
}
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
if err != nil {
logs.Warn.Printf("business outbox: list tenant=%d: %v", tenantID, err)
return
}
for _, event := range events {
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
}
payload := json.RawMessage(event.Payload)
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
message := &ServerComMessage{
TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt,
Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload},
}
select {
case globals.hub.routeSrv <- message:
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
default:
logs.Warn.Printf("business outbox: hub queue full event=%s target=%s", event.EventID, strings.TrimSpace(event.Target))
return
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"strconv"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
// dispatchIMGroupEvents delivers committed outbox events to online topic
// sessions. Events remain pending when the hub is unavailable or its queue is
// full, so the next successful group mutation retries them.
func dispatchIMGroupEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.GroupBusiness == nil {
return
}
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.GroupBusiness.ListPendingEvents(tenantID, 100, now)
if err != nil {
logs.Warn.Printf("group outbox: list tenant=%d: %v", tenantID, err)
return
}
for _, event := range events {
members, memberErr := store.Topics.GetUsers(tenantID, event.Topic, &types.QueryOpt{Limit: 1000})
if memberErr != nil {
logs.Warn.Printf("group outbox: list members event=%s: %v", event.EventID, memberErr)
return
}
for _, member := range members {
uid := types.ParseUid(member.User)
if uid.IsZero() {
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
uid = store.EncodeUid(decoded)
}
}
if uid.IsZero() || uid == event.ActorUserID {
continue
}
if err = createIMSystemNotification(tenantID, uid,
stableIMEventID(event.EventID, uid.UserId(), "notification"), event.EventType,
"群聊动态", event.EventType, types.KVMap{"topic": event.Topic}); err != nil {
logs.Warn.Printf("group outbox: create notification event=%s user=%s: %v", event.EventID, uid.UserId(), err)
return
}
}
payload := json.RawMessage(event.Payload)
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
}
message := &ServerComMessage{
TenantID: tenantID,
RcptTo: event.Topic,
AsUser: from,
Timestamp: event.CreatedAt,
Info: &MsgServerInfo{
Topic: event.Topic,
From: from,
What: "group",
Event: event.EventType,
Payload: payload,
},
}
select {
case globals.hub.routeSrv <- message:
if err = store.GroupBusiness.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("group outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
default:
logs.Warn.Printf("group outbox: hub queue full event=%s", event.EventID)
return
}
}
}
+7434
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63 -1
View File
@@ -776,6 +776,68 @@ func main() {
// Handle websocket clients.
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/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)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
mux.HandleFunc(config.ApiPath+"v1/im/security/urls/check", serveIMSecurityURLCheck)
mux.HandleFunc(config.ApiPath+"v1/im/security/risk-challenges/", serveIMRiskChallengeConfirm)
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/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/search", serveIMOrganizationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse", serveIMAbuseReports)
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/statistics", serveIMDailyReportStatistics)
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/reminders", serveIMDailyReportReminders)
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily", serveIMDailyReports)
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/", serveIMDailyReportResource)
mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/statistics", serveIMMonthlyReportStatistics)
mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly", serveIMMonthlyReports)
mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/", serveIMMonthlyReportResource)
mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/config", serveIMMailboxConfig)
mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/submissions", serveIMMailboxSubmissions)
mux.HandleFunc(config.ApiPath+"v1/im/feedback/config", serveIMFeedbackConfig)
mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback)
mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest)
mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe)
mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile)
mux.HandleFunc(config.ApiPath+"v1/im/me/privacy", serveIMMePrivacy)
mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/enable", serveIMYouthModeEnable)
mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/disable", serveIMYouthModeDisable)
mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode", serveIMYouthMode)
mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password", serveIMIndependentPassword)
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/system-notifications", serveIMSystemNotifications)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource)
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/groups", serveIMGroups)
mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource)
mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes)
mux.HandleFunc(config.ApiPath+"v1/im/qrcodes/resolve", serveIMQRCodesResolve)
mux.HandleFunc(config.ApiPath+"v1/im/users/search", serveIMUserSearch)
mux.HandleFunc(config.ApiPath+"v1/im/users/", serveIMUserResource)
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests)
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction)
mux.HandleFunc(config.ApiPath+"v1/im/blocks", serveIMBlocks)
mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser)
mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends)
mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource)
// Handle long polling clients. Enable compression.
mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
if config.Media != nil {
@@ -791,7 +853,7 @@ func main() {
mux.HandleFunc("/", serve404)
}
if err = listenAndServe(config.Listen, mux, tlsConfig, signalHandler()); err != nil {
if err = listenAndServe(config.Listen, withIMHTTPProtocol(config.ApiPath+"v1/im/", mux), tlsConfig, signalHandler()); err != nil {
logs.Err.Fatal(err)
}
}
+7
View File
@@ -17,6 +17,13 @@ This adapter requires you to obtain your own credentials from Goole Firebase. If
Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa.
4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications
The server may also enable FCM without storing credentials in `tinode.conf`:
* `IM_FCM_CREDENTIALS_FILE` points to the service-account JSON file.
* `IM_FCM_CREDENTIALS_JSON` contains the service-account JSON directly.
Either variable enables the adapter even when the checked-in configuration has `enabled: false`.
### iOS and Android
1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.
+15 -2
View File
@@ -123,7 +123,7 @@ func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Me
skipDevices[deviceID] = struct{}{}
}
}
devices, count, err = store.Devices.GetAll(rcpt.TenantID, uids...)
devices, count, err = pushDevicesForUsers(rcpt.TenantID, uids...)
if err != nil {
logs.Warn.Println("fcm push: db error", err)
return nil, nil
@@ -218,7 +218,7 @@ func TenantChannelName(tenantID t.TenantID, channel string) string {
// DevicesForUser loads device IDs of the given user.
func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
ddef, count, err := store.Devices.GetAll(tenantID, uid)
ddef, count, err := pushDevicesForUsers(tenantID, uid)
if err != nil {
logs.Warn.Println("fcm devices for user: db error", err)
return nil
@@ -235,6 +235,19 @@ func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
return devices
}
func pushDevicesForUsers(tenantID t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenQueryInterface); ok {
devices, count, err := tokens.GetAll(tenantID, uids...)
if err != nil {
return nil, 0, err
}
if count > 0 {
return devices, count, nil
}
}
return store.Devices.GetAll(tenantID, uids...)
}
// ChannelsForUser loads user's channel subscriptions with P permission.
func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string {
channels, err := store.Users.GetChannels(tenantID, uid)
+65
View File
@@ -0,0 +1,65 @@
package fcm
import (
"testing"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testProductPushTokens struct {
devices map[types.Uid][]types.DeviceDef
}
func (s *testProductPushTokens) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil
}
func (s *testProductPushTokens) GetAll(_ types.TenantID, _ ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
count := 0
for _, devices := range s.devices {
count += len(devices)
}
return s.devices, count, nil
}
type testLegacyPushDevices struct {
called bool
}
func (*testLegacyPushDevices) Update(types.TenantID, types.Uid, string, *types.DeviceDef) error {
return nil
}
func (s *testLegacyPushDevices) GetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
s.called = true
return nil, 0, nil
}
func (*testLegacyPushDevices) Delete(types.TenantID, types.Uid, string) error { return nil }
func TestPushDevicesForUsersPrefersProductTokens(t *testing.T) {
oldTokens := store.DevicePushTokens
oldDevices := store.Devices
defer func() {
store.DevicePushTokens = oldTokens
store.Devices = oldDevices
}()
uid := types.Uid(42)
legacy := &testLegacyPushDevices{}
store.DevicePushTokens = &testProductPushTokens{devices: map[types.Uid][]types.DeviceDef{
uid: {{TenantID: 7, DeviceId: "product-token", Platform: "android"}},
}}
store.Devices = legacy
devices, count, err := pushDevicesForUsers(7, uid)
if err != nil {
t.Fatal(err)
}
if count != 1 || devices[uid][0].DeviceId != "product-token" {
t.Fatalf("unexpected devices: %#v count=%d", devices, count)
}
if legacy.called {
t.Fatal("legacy device store must not be read when product tokens exist")
}
}
+8
View File
@@ -70,6 +70,14 @@ func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
return false, errors.New("failed to parse config: " + err.Error())
}
if raw := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_JSON")); raw != "" {
config.Credentials = json.RawMessage(raw)
config.Enabled = true
}
if path := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_FILE")); path != "" {
config.CredentialsFile = path
config.Enabled = true
}
if !config.Enabled {
return false, nil
}
+24 -2
View File
@@ -762,6 +762,10 @@ func (s *Session) publish(msg *ClientComMessage) {
logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
}
} else if msg.RcptTo == "sys" {
if s.authLvl != auth.LevelRoot {
s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
return
}
// Publishing to "sys" topic requires no subscription.
select {
case globals.hub.routeCli <- msg:
@@ -797,9 +801,15 @@ func (s *Session) hello(msg *ClientComMessage) {
}
tenantCode := strings.TrimSpace(msg.Hi.Tenant)
if tenantCode == "" || len(tenantCode) > 64 {
if tenantCode == "" {
// Clients that predate the tenant-aware protocol (stock Tinode
// webapp/mobile SDKs never send this field) fall back to the
// default tenant rather than being rejected. Clients that do
// send an explicit code are unaffected.
tenantCode = "default"
} else if len(tenantCode) > 64 {
s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
logs.Warn.Println("s.hello: tenant code is missing or invalid", s.sid)
logs.Warn.Println("s.hello: tenant code is invalid", s.sid)
return
}
if store.Tenants == nil {
@@ -1175,6 +1185,18 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Check if the token is suitable for session authentication.
if features&auth.FeatureNoLogin == 0 {
if store.Security == nil {
logs.Warn.Println("s.onLogin: user security store is unavailable", s.sid)
reply = decodeStoreError(types.ErrInternal, msgID, timestamp, nil)
return reply
}
authVersion, err := store.Security.GetAuthVersion(s.tenantID, rec.Uid)
if err != nil {
logs.Warn.Println("s.onLogin: failed to read auth version", rec.Uid, err, s.sid)
reply = decodeStoreError(err, msgID, timestamp, nil)
return reply
}
rec.AuthVersion = authVersion
// Authenticate the session.
s.uid = rec.Uid
s.authLvl = rec.AuthLevel
+49 -5
View File
@@ -23,6 +23,10 @@ func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
return s.tenant, s.err
}
func (s testTenantStore) GetByID(types.TenantID) (*types.Tenant, error) {
return s.tenant, s.err
}
func test_makeSession(uid types.Uid) *Session {
return &Session{
send: make(chan any, 10),
@@ -105,15 +109,28 @@ func TestDispatchHello(t *testing.T) {
}
}
func TestDispatchHelloRequiresTenant(t *testing.T) {
func TestDispatchHelloDefaultsTenant(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 1,
Code: "default",
Name: "Default Tenant",
State: types.TenantStateActive,
}}
defer func() { store.Tenants = oldTenants }()
s := &Session{send: make(chan any, 1)}
s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1"}})
resp := (<-s.send).(*ServerComMessage)
if resp.Ctrl.Code != http.StatusBadRequest {
t.Fatalf("response code: expected %d, got %d", http.StatusBadRequest, resp.Ctrl.Code)
if resp.Ctrl.Code != http.StatusCreated {
t.Fatalf("response code: expected %d, got %d", http.StatusCreated, resp.Ctrl.Code)
}
if !s.tenantID.IsZero() || s.ver != 0 {
t.Fatalf("invalid hello must not bind session: tenant=%d version=%d", s.tenantID, s.ver)
if s.tenantID != 1 || s.tenantCode != "default" || s.ver == 0 {
t.Fatalf("default hello did not bind session: tenant=%d code=%q version=%d", s.tenantID, s.tenantCode, s.ver)
}
tenantParams := resp.Ctrl.Params.(map[string]any)["tenant"].(map[string]any)
if tenantParams["code"] != "default" || tenantParams["name"] != "Default Tenant" {
t.Fatalf("unexpected tenant params: %#v", tenantParams)
}
}
@@ -226,9 +243,12 @@ func TestDispatchLogin(t *testing.T) {
aa := mock_auth.NewMockAuthHandler(ctrl)
uid := types.Uid(1)
oldSecurity := store.Security
store.Store = ss
store.Security = &testSecurityStore{version: 1}
defer func() {
store.Store = nil
store.Security = oldSecurity
ctrl.Finish()
}()
@@ -335,6 +355,27 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
}
}
func TestPublishSysRequiresRoot(t *testing.T) {
s := &Session{
send: make(chan any, 1),
uid: types.Uid(42),
authLvl: auth.LevelAuth,
tenantID: 1,
tenantCode: "test",
}
s.publish(&ClientComMessage{
Original: "sys",
RcptTo: "sys",
AsUser: types.Uid(42).UserId(),
Timestamp: time.Now(),
Pub: &MsgClientPub{Id: "pub-1", Topic: "sys", Content: map[string]any{"type": "system"}},
})
resp := (<-s.send).(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusForbidden {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestDispatchRejectsCrossTenantAsUser(t *testing.T) {
ctrl := gomock.NewController(t)
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
@@ -1194,11 +1235,14 @@ func TestDispatchAccNew(t *testing.T) {
aa := mock_auth.NewMockAuthHandler(ctrl)
uid := types.Uid(1)
oldSecurity := store.Security
store.Store = ss
store.Users = uu
store.Security = &testSecurityStore{version: 1}
defer func() {
store.Store = nil
store.Users = nil
store.Security = oldSecurity
ctrl.Finish()
}()
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type HTTPInfrastructurePersistenceInterface interface {
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
CreateEvent(types.TenantID, *types.BusinessEvent) error
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type httpInfrastructureMapper struct{}
var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{}
func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
infrastructure, ok := business.(adapter.TenantHTTPInfrastructureAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return infrastructure, nil
}
func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record)
}
func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record)
}
func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
}
func (httpInfrastructureMapper) CreateEvent(tenantID types.TenantID, event *types.BusinessEvent) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventCreate(tenantID, event)
}
func (httpInfrastructureMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.BusinessEvent, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantBusinessEventListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
}
+79 -3
View File
@@ -1098,6 +1098,21 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteList", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).DeleteList), tenantID, topic, delID, forUser, msgDelAge, ranges)
}
// Edit mocks base method.
func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
ret0, _ := ret[0].(*types.Message)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Edit indicates an expected call of Edit.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Edit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Edit", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Edit), tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
// GetAll mocks base method.
func (m *MockMessagesPersistenceInterface) GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) {
m.ctrl.T.Helper()
@@ -1129,12 +1144,58 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, top
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt)
}
// Search mocks base method.
func (m *MockMessagesPersistenceInterface) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Search", tenantID, topic, forUser, query, messageType, cursor, limit)
ret0, _ := ret[0].([]types.Message)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// Search indicates an expected call of Search.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic, forUser, query, messageType, cursor, limit interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Search), tenantID, topic, forUser, query, messageType, cursor, limit)
}
// GetOne mocks base method.
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq)
ret0, _ := ret[0].(*types.Message)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOne indicates an expected call of GetOne.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic, seq interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOne", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetOne), tenantID, topic, seq)
}
// Recall mocks base method.
func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt)
ret0, _ := ret[0].(*types.Message)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Recall indicates an expected call of Recall.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Recall(tenantID, topic, seq, actor, expectedVersion, recalledAt interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recall", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Recall), tenantID, topic, seq, actor, expectedVersion, recalledAt)
}
// Save mocks base method.
func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {
func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Save", tenantID, msg, attachmentURLs, readBySender)
ret0, _ := ret[0].(error)
ret1, _ := ret[1].(bool)
ret0, _ := ret[0].(*types.MessageSaveResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -1297,6 +1358,21 @@ func (mr *MockFilePersistenceInterfaceMockRecorder) LinkAttachments(tenantID, to
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkAttachments", reflect.TypeOf((*MockFilePersistenceInterface)(nil).LinkAttachments), tenantID, topic, msgId, attachments)
}
// UserHasAccess mocks base method.
func (m *MockFilePersistenceInterface) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UserHasAccess", tenantID, fid, uid)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UserHasAccess indicates an expected call of UserHasAccess.
func (mr *MockFilePersistenceInterfaceMockRecorder) UserHasAccess(tenantID, fid, uid interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserHasAccess", reflect.TypeOf((*MockFilePersistenceInterface)(nil).UserHasAccess), tenantID, fid, uid)
}
// StartUpload mocks base method.
func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
m.ctrl.T.Helper()
+1842 -11
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -1,6 +1,7 @@
package store
import (
"encoding/json"
"testing"
"github.com/tinode/chat/server/store/types"
@@ -11,3 +12,30 @@ func TestForTenantRejectsZero(t *testing.T) {
t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err)
}
}
func TestValidateMessageHeadersProductContract(t *testing.T) {
valid := []types.KVMap{
{"x-im-schema": float64(1), "x-im-type": "text"},
{"x-im-schema": json.Number("1"), "x-im-type": "voice"},
{"x-im-schema": "1", "x-im-type": "contact_card"},
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"},
{"type": "system"},
}
for _, head := range valid {
if err := validateMessageHeaders(head); err != nil {
t.Fatalf("validateMessageHeaders(%v) = %v, want nil", head, err)
}
}
invalid := []types.KVMap{
{"x-im-schema": float64(2), "x-im-type": "text"},
{"x-im-schema": true, "x-im-type": "text"},
{"x-im-schema": float64(1), "x-im-type": "unknown"},
{"type": "text", "x-im-type": "image"},
}
for _, head := range invalid {
if err := validateMessageHeaders(head); err != types.ErrMalformed {
t.Fatalf("validateMessageHeaders(%v) = %v, want malformed", head, err)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package types
import "time"
const (
CallStatusStarted = "started"
CallStatusAccepted = "accepted"
CallStatusFinished = "finished"
CallStatusMissed = "missed"
CallStatusDeclined = "declined"
CallStatusDisconnected = "disconnected"
)
type CallRecord struct {
ID int64 `db:"id"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
CallID string `db:"call_id"`
Topic string
SeqID int `db:"seq_id"`
CallerID Uid `db:"caller_id"`
CalleeID Uid `db:"callee_id"`
CallType string `db:"call_type"`
Status string
EndReason string `db:"end_reason"`
DurationMS int64 `db:"duration_ms"`
StartedAt time.Time `db:"started_at"`
AcceptedAt *time.Time `db:"accepted_at"`
FinishedAt *time.Time `db:"finished_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
+20
View File
@@ -0,0 +1,20 @@
package types
import "time"
type FavoriteItem struct {
ID int64
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
FavoriteID string `db:"favorite_id"`
UserID Uid `db:"user_id"`
ItemType string `db:"item_type"`
SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"`
Snapshot any
AttachmentRefs any `db:"attachment_refs"`
Availability string `db:"availability"`
IdempotencyKey string `db:"idempotency_key"`
CollectedAt time.Time `db:"collected_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
+198
View File
@@ -0,0 +1,198 @@
package types
import "time"
const (
GroupStatusActive = "active"
GroupMemberRoleOwner = "owner"
GroupMemberRoleAdmin = "admin"
GroupMemberRoleMember = "member"
GroupMemberStatusActive = "active"
GroupMemberStatusLeft = "left"
GroupInvitationStatusJoined = "joined"
GroupJoinRequestStatusPending = "pending"
GroupJoinRequestStatusAccepted = "accepted"
GroupJoinRequestStatusRejected = "rejected"
GroupAnnouncementStatusCurrent = "current"
GroupAnnouncementStatusSuperseded = "superseded"
GroupMuteStatusActive = "active"
GroupMuteStatusRevoked = "revoked"
)
type GroupSettings struct {
Topic string `db:"topic"`
OwnerUserID Uid `db:"-"`
Name string `db:"name"`
AvatarFileID string `db:"avatar_file_id"`
Description string `db:"description"`
GroupStatus string `db:"group_status"`
JoinApproval bool `db:"join_approval"`
MuteAll bool `db:"mute_all"`
MemberUploadFiles bool `db:"member_upload_files"`
MemberPrivateChat bool `db:"member_private_chat"`
MemberInvite bool `db:"member_invite"`
MaxMemberCount int `db:"max_member_count"`
MaxAdminCount int `db:"max_admin_count"`
MemberCount int `db:"member_count"`
Version int `db:"version"`
CreatedBy Uid `db:"-"`
DissolvedBy Uid `db:"-"`
DissolvedAt *time.Time `db:"dissolved_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
OwnerUserIDRaw int64 `db:"owner_user_id"`
CreatedByRaw int64 `db:"created_by"`
DissolvedByRaw *int64 `db:"dissolved_by"`
}
type GroupSettingsPatch struct {
Name *string
AvatarFileID *string
Description *string
OwnerUserID *Uid
JoinApproval *bool
MuteAll *bool
MemberUploadFiles *bool
MemberPrivateChat *bool
MemberInvite *bool
MemberCountDelta int
Version int
}
type GroupMember struct {
ID int64 `db:"id"`
Topic string `db:"topic"`
UserID Uid `db:"-"`
Role string `db:"role"`
MemberStatus string `db:"member_status"`
Nickname string `db:"nickname"`
Pinned bool `db:"pinned"`
MuteNotifications bool `db:"mute_notifications"`
JoinSource string `db:"join_source"`
JoinedBy Uid `db:"-"`
JoinedAt time.Time `db:"joined_at"`
LeftAt *time.Time `db:"left_at"`
RemovedBy Uid `db:"-"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserIDRaw int64 `db:"user_id"`
JoinedByRaw *int64 `db:"joined_by"`
RemovedByRaw *int64 `db:"removed_by"`
}
type GroupMemberPatch struct {
Role *string
MemberStatus *string
Nickname *string
Pinned *bool
MuteNotifications *bool
LeftAt *time.Time
RemovedBy *Uid
Version int
}
type GroupMemberListOptions struct {
Role string
EligibleRole string
Query string
Cursor string
Limit int
}
type GroupInvitation struct {
ID int64 `db:"id"`
InvitationID string `db:"invitation_id"`
Topic string `db:"topic"`
InviterUserID Uid `db:"-"`
TargetUserID Uid `db:"-"`
Message string `db:"message"`
Status string `db:"status"`
Result string `db:"result"`
JoinRequestID string `db:"join_request_id"`
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
InviterUserRaw int64 `db:"inviter_user_id"`
TargetUserRaw int64 `db:"target_user_id"`
}
type GroupJoinRequest struct {
ID int64 `db:"id"`
RequestID string `db:"request_id"`
Topic string `db:"topic"`
ApplicantUserID Uid `db:"-"`
InviterUserID Uid `db:"-"`
Source string `db:"source"`
QRTokenHash string `db:"qr_token_hash"`
Message string `db:"message"`
Status string `db:"status"`
RejectReason string `db:"reject_reason"`
HandledBy Uid `db:"-"`
HandledAt *time.Time `db:"handled_at"`
ExpiresAt *time.Time `db:"expires_at"`
IdempotencyKey string `db:"idempotency_key"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ApplicantUserRaw int64 `db:"applicant_user_id"`
InviterUserRaw *int64 `db:"inviter_user_id"`
HandledByRaw *int64 `db:"handled_by"`
}
type GroupAnnouncement struct {
ID int64 `db:"id"`
AnnouncementID string `db:"announcement_id"`
Topic string `db:"topic"`
AuthorUserID Uid `db:"-"`
Content string `db:"content"`
Status string `db:"status"`
Version int `db:"version"`
PublishedAt time.Time `db:"published_at"`
IdempotencyKey string `db:"idempotency_key"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
AuthorUserIDRaw int64 `db:"author_user_id"`
}
type GroupMute struct {
Topic string `db:"topic"`
UserID Uid `db:"-"`
MutedBy Uid `db:"-"`
MutedAt time.Time `db:"muted_at"`
ExpiresAt *time.Time `db:"expires_at"`
Reason string `db:"reason"`
Status string `db:"status"`
RevokedBy Uid `db:"-"`
RevokedAt *time.Time `db:"revoked_at"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserIDRaw int64 `db:"user_id"`
MutedByRaw int64 `db:"muted_by"`
RevokedRaw *int64 `db:"revoked_by"`
}
type GroupEvent struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
Topic string `db:"topic"`
ActorUserID Uid `db:"-"`
EventType string `db:"event_type"`
Payload []byte `db:"payload"`
Status string `db:"status"`
Attempts int `db:"attempts"`
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ActorRaw int64 `db:"actor_user_id"`
}
+56
View File
@@ -0,0 +1,56 @@
package types
import (
"crypto/sha256"
"fmt"
"time"
)
const (
HTTPIdempotencyProcessing = "processing"
HTTPIdempotencyCompleted = "completed"
BusinessEventPending = "pending"
BusinessEventDelivered = "delivered"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
type HTTPIdempotencyRecord struct {
UserID Uid
Method string
Route string
Key string
BodyHash string
State string
ResponseStatus int
ResponseBody []byte
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
// BusinessEvent is a durable event destined for an online user or topic.
type BusinessEvent struct {
ID int64
EventID string
TargetType string
Target string
ActorUserID Uid
EventType string
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time
DeliveredAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string {
hash := sha256.New()
for _, part := range parts {
_, _ = fmt.Fprint(hash, part, "\x00")
}
return fmt.Sprintf("evt_%x", hash.Sum(nil)[:24])
}
+36
View File
@@ -0,0 +1,36 @@
package types
import "time"
const MailboxSubmissionStatusReceived = "received"
type MailboxNotice struct {
Title string
Content string
UpdatedAt time.Time
}
type MailboxConfig struct {
Enabled bool
AllowAnonymous bool
RequiredFields []string
MaxLength int
AttachmentPolicy map[string]any
Notice MailboxNotice
UpdatedAt time.Time
}
type MailboxSubmission struct {
ID int64
SubmissionID string
SenderID Uid
Subject string
Content string
Anonymous bool
AttachmentFileIDs []string
NoticeVersion string
Status string
SubmittedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
+50
View File
@@ -0,0 +1,50 @@
package types
import "time"
const (
MeetingStateScheduled = "scheduled"
MeetingStateCanceled = "canceled"
MeetingResponsePending = "pending"
MeetingResponseAccepted = "accepted"
MeetingResponseDeclined = "declined"
MeetingResponseTentative = "tentative"
)
type Meeting struct {
ID int64
MeetingID string
OrganizerID Uid
Title string
StartAt time.Time
EndAt time.Time
Location string
State string
ReminderMinutes []int
IdempotencyKey string
Version int
CanceledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
Participants []MeetingParticipant
}
type MeetingParticipant struct {
ID int64
MeetingID string
UserID Uid
Response string
ResponseAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type MeetingPatch struct {
Title *string
StartAt *time.Time
EndAt *time.Time
Location *string
ReminderMinutes []int
Version int
}
+18
View File
@@ -0,0 +1,18 @@
package types
import "time"
// SystemNotification is a product-level notification shown in the system inbox.
type SystemNotification struct {
ID int64 `db:"id" json:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
UserID Uid `db:"user_id" json:"-"`
EventID string `db:"event_id" json:"id"`
EventType string `db:"event_type" json:"event_type"`
Title string `db:"title" json:"title"`
Summary string `db:"summary" json:"summary"`
Action KVMap `db:"action" json:"action,omitempty"`
ReadAt *time.Time `db:"read_at" json:"read_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
+40
View File
@@ -0,0 +1,40 @@
package types
import "time"
type OrganizationDepartment struct {
ID int64 `db:"id"`
DepartmentID string `db:"department_id"`
Name string `db:"name"`
ParentID string `db:"parent_id"`
SortOrder int `db:"sort_order"`
ChildCount int `db:"child_count"`
MemberCount int `db:"member_count"`
Version int `db:"version"`
State string `db:"state"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type OrganizationMember struct {
ID int64 `db:"id"`
DepartmentID string `db:"department_id"`
UserID Uid `db:"-"`
DisplayName string `db:"display_name"`
Position string `db:"position"`
AvatarFileID string `db:"avatar_file_id"`
Mobile string `db:"mobile"`
Email string `db:"email"`
Status string `db:"employment_state"`
Contactable bool `db:"contactable"`
SortOrder int `db:"sort_order"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type OrganizationSearchItem struct {
Type string
Department *OrganizationDepartment
Member *OrganizationMember
}
+86
View File
@@ -0,0 +1,86 @@
package types
import "time"
type UserProfile struct {
UserID Uid
AvatarFileID string
Nickname string
BirthDate string
Signature string
Gender string
Region string
PublicAccount string
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
type UserProfilePatch struct {
AvatarFileID *string
Nickname *string
BirthDate *string
Signature *string
Gender *string
Region *string
Version int
}
type UserPrivacySettings struct {
UserID Uid
AddMeMethods map[string]bool
ProfileVisibility string
LastSeenVisibility string
ReadReceipts bool
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
type UserPrivacyPatch struct {
AddMeMethods map[string]bool
ProfileVisibility *string
LastSeenVisibility *string
ReadReceipts *bool
Version int
}
type AppRelease struct {
ID int64
Platform string
Channel string
LatestVersion string
MinSupportedVersion string
UpdateType string
ReleaseNotes string
StoreURL string
PackageURL string
Checksum string
RemindAfterSeconds int
PublishedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type YouthModeSettings struct {
UserID Uid
Enabled bool
PolicyVersion int
Restrictions []string
PasswordSet bool
CanEnable bool
EnabledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type DevicePushToken struct {
UserID Uid
DeviceID string
Provider string
Token string
AppVersion string
Platform string
UpdatedAt time.Time
CreatedAt time.Time
}
+89
View File
@@ -0,0 +1,89 @@
package types
import "time"
const (
FriendRequestPending = "pending"
FriendRequestAccepted = "accepted"
FriendRequestRejected = "rejected"
FriendRequestExpired = "expired"
RelationshipNone = "none"
RelationshipFriend = "friend"
RelationshipPendingInbound = "pending_inbound"
RelationshipPendingOutbound = "pending_outbound"
RelationshipBlockedByMe = "blocked_by_me"
RelationshipBlockedByPeer = "blocked_by_peer"
)
type FriendRequest struct {
ID int64
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
RequestID string `db:"request_id"`
ApplicantID Uid `db:"applicant_id"`
TargetID Uid `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type Friendship struct {
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
UserID Uid `db:"user_id"`
PeerID Uid `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type UserBlock struct {
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
BlockerID Uid `db:"blocker_id"`
BlockedID Uid `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type RelationshipContext struct {
TenantID TenantID `json:"-"`
UserID Uid `json:"-"`
PeerID Uid `json:"-"`
Topic string `json:"topic"`
Relationship string `json:"relationship"`
CanSend bool `json:"can_send"`
RequestID string `json:"request_id,omitempty"`
PendingStatus string `json:"pending_status,omitempty"`
Peer *User `json:"peer,omitempty"`
}
type FriendListItem struct {
Friendship Friendship
Peer *User
}
type FriendRequestListItem struct {
Request FriendRequest
Peer *User
Direction string
}
type UserSearchItem struct {
User *User
Relationship string
RequestID string
CanAdd bool
Action string
}
type BlockListItem struct {
Block UserBlock
User *User
}
+51
View File
@@ -0,0 +1,51 @@
package types
import "time"
const (
DailyReportStatusDraft = "draft"
DailyReportStatusSubmitted = "submitted"
)
type DailyReport struct {
ID int64
ReportID string
AuthorID Uid
ReportDate time.Time
Content string
Attachments []string
Status string
ReadState string
Version int
SubmittedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type DailyReportStats struct {
ExpectedCount int
SubmittedCount int
MissingCount int
}
type MonthlyReport struct {
ID int64
ReportID string
AuthorID Uid
Month string
WorkSummary string
NextPlan string
Attachments []string
Status string
ReadState string
Version int
SubmittedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type MonthlyReportStats struct {
ExpectedCount int
SubmittedCount int
MissingCount int
}
+86
View File
@@ -0,0 +1,86 @@
package types
import "time"
const (
RiskChallengeStatePending = "pending"
RiskChallengeStateContinued = "continued"
RiskChallengeStateCanceled = "canceled"
RiskChallengeStateExpired = "expired"
AbuseReportStatusReceived = "received"
FeedbackStatusReceived = "received"
)
type RiskChallenge struct {
ID int64
ChallengeID string
UserID Uid
Action string
Level string
NoticeCode string
NoticeParams map[string]any
State string
ExpiresAt time.Time
Decision string
ActionTokenID string
CreatedAt time.Time
UpdatedAt time.Time
}
type AbuseReason struct {
TargetType string
ReasonCode string
Label string
RequiresDescription bool
AllowsEvidence bool
SortOrder int
}
type AbuseTarget struct {
Type string `json:"type"`
UserID string `json:"user_id,omitempty"`
Topic string `json:"topic,omitempty"`
Seq string `json:"seq,omitempty"`
}
type AbuseReport struct {
ID int64
ReportID string
ReporterID Uid
TargetType string
TargetUserID Uid
TargetTopic string
TargetSeq int
ReasonCode string
Description string
EvidenceFileIDs []string
Snapshot map[string]any
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
type FeedbackConfig struct {
Categories []string
MaxLength int
AttachmentPolicy map[string]any
AllowContact bool
DiagnosticsNotice string
UpdatedAt time.Time
}
type FeedbackSubmission struct {
ID int64
FeedbackID string
UserID Uid
Category string
Content string
Contact string
AttachmentFileIDs []string
ClientContext map[string]any
IncludeDiagnostics bool
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
+50
View File
@@ -0,0 +1,50 @@
package types
import "time"
// RealNameVerification is the latest real-name verification submission for a user.
type RealNameVerification struct {
ID int64 `db:"id" json:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
UserID Uid `db:"user_id" json:"-"`
SubmissionID string `db:"submission_id" json:"submission_id"`
Status string `db:"status" json:"status"`
NameCipher string `db:"name_cipher" json:"-"`
IDType string `db:"id_type" json:"id_type,omitempty"`
IDNumberCipher string `db:"id_number_cipher" json:"-"`
IDNumberHash string `db:"id_number_hash" json:"-"`
MaskedName string `db:"masked_name" json:"masked_name,omitempty"`
MaskedIDNumber string `db:"masked_id_number" json:"masked_id_number,omitempty"`
ReviewReason *string `db:"review_reason" json:"review_reason,omitempty"`
VerifiedAt *time.Time `db:"verified_at" json:"verified_at,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:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
UserID Uid `db:"user_id" json:"-"`
TokenID string `db:"token_id" json:"-"`
Action string `db:"action" json:"action"`
Target string `db:"target" json:"target,omitempty"`
TokenHash string `db:"token_hash" json:"-"`
State int16 `db:"state" json:"-"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
ConsumedAt *time.Time `db:"consumed_at" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
// IndependentPassword stores the hashed PIN for a product feature such as youth mode.
type IndependentPassword struct {
TenantID TenantID `db:"tenant_id" json:"-"`
UserID Uid `db:"user_id" json:"-"`
Purpose string `db:"purpose" json:"purpose"`
PasswordHash []byte `db:"password_hash" json:"-"`
FailedAttempts int `db:"failed_attempts" json:"-"`
LockedUntil *time.Time `db:"locked_until" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
+2
View File
@@ -35,6 +35,8 @@ type Tenant struct {
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"`
DBName string `db:"db_name" json:"-"`
DBDSN string `db:"db_dsn" json:"-"`
State TenantState `db:"state" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
CreatedBy int64 `db:"created_by" json:"-"`
+27 -3
View File
@@ -1228,9 +1228,33 @@ type Message struct {
SeqId int
Topic string
// Sender's user ID as string (without 'usr' prefix), could be empty.
From string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
From string
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
Version int `json:"Version,omitempty" bson:",omitempty"`
RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"`
RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"`
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
}
// MessageRevision stores an immutable snapshot of a message before editing.
type MessageRevision struct {
ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string
SeqId int
Version int
Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
Reason string
}
// MessageSaveResult describes the outcome of a message persistence attempt.
type MessageSaveResult struct {
SeqId int
MarkedReadBySender bool
Duplicate bool
}
// Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi).
+37
View File
@@ -0,0 +1,37 @@
package types
import "time"
// VerificationState is the lifecycle state of an SMS verification challenge.
type VerificationState int16
const (
VerificationStatePending VerificationState = iota
VerificationStateVerified
VerificationStateConsumed
VerificationStateExpired
VerificationStateLocked
)
// VerificationCode is an out-of-band verification challenge.
type VerificationCode struct {
ID int64 `db:"id" json:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
ChallengeID string `db:"challenge_id" json:"challenge_id"`
Scene string `db:"scene" json:"scene"`
Action string `db:"action" json:"action,omitempty"`
PhoneHash string `db:"phone_hash" json:"-"`
PhoneMask string `db:"phone_mask" json:"phone_mask,omitempty"`
TargetUser Uid `db:"target_user" json:"-"`
CodeHash string `db:"code_hash" json:"-"`
VerificationTokenHash *string `db:"verification_token_hash" json:"-"`
State VerificationState `db:"state" json:"-"`
Attempts int `db:"attempts" json:"-"`
MaxAttempts int `db:"max_attempts" json:"-"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
ResendAfter time.Time `db:"resend_after" json:"resend_after"`
VerifiedAt *time.Time `db:"verified_at" json:"-"`
ConsumedAt *time.Time `db:"consumed_at" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
+17
View File
@@ -0,0 +1,17 @@
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"`
}
+144 -687
View File
@@ -1,689 +1,146 @@
// The JSON comments are somewhat brittle. Don't try anything too fancy.
{
// HTTP(S) address to listen on for websocket and long polling clients. Either a TCP host:port pair
// or a path to Unix socket as "unix:/path/to/socket.sock".
// The TCP port is either a numerical value or a canonical name, e.g. ":80" or ":https". May include
// the host name, e.g. "localhost:80" or "hostname.example.com:https".
// It could be blank: if TLS is not configured it will default to ":80", otherwise to ":443".
// Can be overridden from the command line, see option --listen.
"listen": ":6060",
// Base URL path for serving streaming and large file API calls.
// Can be overridden from the command line, see option --api_path.
"api_path": "/",
// Cache-Control header for static content in seconds. 39600 is 11 hours.
"cache_control": 39600,
// If true, do not attempt to negotiate websocket per message compression (RFC 7692.4).
// It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy.
"ws_compression_disabled": false,
// URL path for mounting the directory with static files.
"static_mount": "/",
// TCP host:port or unix:/path/to/socket to listen for gRPC clients.
// Leave blank to disable gRPC support.
// Could be overridden from the command line with --grpc_listen.
"grpc_listen": ":16060",
// Enable handling of gRPC keepalives https://github.com/grpc/grpc/blob/master/doc/keepalive.md
// This sets server's GRPC_ARG_KEEPALIVE_TIME_MS to 60 seconds instead of the default 2 hours.
"grpc_keepalive_enabled": true,
// Salt for signing API key. 32 random bytes base64-encoded. Use 'keygen' tool (included in this
// distro) to generate the API key and the salt.
"api_key_salt": "T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=",
// Maximum message size allowed from the clients in bytes (131072 = 128KB).
// Media files with sizes greater than this limit are sent out of band.
// Don't change this limit to a much higher value because it would likely cause failures:
// * on Android & iOS due to a limit on the SQLite cursor window size;
// * on the server-side with MySQL adapter due to the limit on the sort buffer size.
"max_message_size": 131072,
// Maximum number of subscribers per group topic.
// This is the max number of subscribers to a group topic who may be configured to have specific access
// permissions, like writing (posting), admin permissions, etc.
// This setting is unrelated to channels readers (those have immutable read-only access). Channels have no
// limit on readers.
"max_subscriber_count": 128,
// Maximum number of indexable tags per topic or user.
"max_tag_count": 16,
// If true, ordinary users cannot delete their accounts.
"permanent_accounts": false,
// URL path for exposing runtime stats. Disabled if the path is blank or "-".
// Could be overriden from the command line with --expvar.
"expvar": "/debug/vars",
// URL path for server's internal status, useful when debugging.
// Do not use this URL for docker status checks and some such. It's not a health check,
// it is a debug endpoint. Disabled if the path is blank or "-". Could be overriden
// from the command line with --server_status.
// "server_status": "/debug/status",
// Read IP address of the client from the HTTP header 'X-Forwarded-For'.
// Useful when Tinode is behind a proxy. If missing, fallback to default RemoteAddr.
"use_x_forwarded_for": true,
// Add X-Frame-Options to HTTP response headers. It should be one of "DENY", "SAMEORIGIN",
// "-" (disabled). If the option is missing then it's treated as SAMEORIGIN.
"x_frame_options": "SAMEORIGIN",
// 2-letter country code to assign to sessions by default when the country isn't specified
// by the client explicitly and it's impossible to infer it.
// If missing, the server will default to "US".
"default_country_code": "",
// Permit hard-deleting messages in p2p topics for both participants.
// If it's set to 'false' then the message is only deleted for the peer who issued the command.
// If it's 'true' then the message is deleted completely by either participant.
// Changing the value affects the ability to hard-delete (the added or removed the D permission)
// only for new topics going forward.
"p2p_delete_enabled": true,
// The maximum age of a message in seconds when it can be deleted by users with the 'D' permission.
// E.g. 600 means messages up to 10 minutes old can be deleted, older than that cannot be deleted.
// Missing or 0 means no age limit.
// Does not affect topic owners: owners can delete any message.
"msg_delete_age": 600,
// Globally unique namespace. This is a special tag namespace which is used to store
// aliases of the user. The alias is a tag which is not a valid Tinode user ID.
"alias_tag": "alias",
// Large media/blob handlers: large files/images included in messages.
"media": {
// The name of the media handler to use.
"use_handler": "fs",
// Maximum size of uploaded file (8MB here for testing, maybe increase to 100MB = 104857600 in prod)
"max_size": 8388608,
// Garbage collection periodicity in seconds: unused or abandoned uploads are deleted.
"gc_period": 60,
// The number of unused/abandoned entries to delete in one pass.
"gc_block_size": 100,
// Configurations of individual handlers.
"handlers": {
// File system storage.
"fs": {
// File system location to store uploaded files. In case of a cluster it
// must be accessible by all cluster members, i.e. a network drive like https://www.samba.org/
"upload_dir": "uploads",
// Cache-Control header to use for uploaded files. 86400 seconds = 24 hours.
"cache_control": "max-age=86400",
// Origin URLs allowed to download/upload files, e.g. ["https://www.example.com", "http://example.com", "https://*.example.com", "http://*.*.example.com"].
// Not necessary in most cases.
// "cors_origins": ["*"]
},
// Amazon AWS S3 storage.
// See detailed explanation at https://pkg.go.dev/github.com/aws/aws-sdk-go/aws#Config
"s3":{
// Use AWS console to get Access Key ID and Secret Access Key.
// https://aws.amazon.com/blogs/security/wheres-my-secret-access-key/
"access_key_id": "your_s3_access_key_id",
"secret_access_key": "your_s3_secret_access_key",
// Region where the bucket is hosted.
"region": "s3 region, like us-east-2",
// Name of the S3 bucket.
"bucket": "your_s3_bucket_name",
// Set this to `true` to disable SSL when sending requests. Defaults to `false`.
"disable_ssl": false,
// Set this to `true` to force the request to use path-style addressing,
// i.e., `http://s3.amazonaws.com/BUCKET/KEY`. By default, the S3 client
// will use virtual hosted bucket addressing when possible
// (`http://BUCKET.s3.amazonaws.com/KEY`).
"force_path_style": false,
// An optional endpoint URL (hostname only or fully qualified URI)
// to override the default generated endpoint, or `""` to use the default generated endpoint.
// The endpoint can be of any S3-compatible service, such as "minio-api.x.io".
"endpoint": "",
// Expiration time for presigned URLs in seconds.
"presign_ttl": 3600,
// Cache-Control header to use for uploaded files. 86400 seconds = 24 hours.
"cache_control": "max-age=86400",
// Origin URLs allowed to download files, e.g. ["https://www.example.com", "http://example.com"].
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
"cors_origins": ["*"]
}
}
},
// TLS (httpS) configuration. Applies to both web and gRPC interfaces.
"tls": {
// Enable TLS.
"enabled": false,
// Listen for connections on this port and redirect them to HTTPS port.
// Cannot be a Unix socket.
"http_redirect": ":80",
// Add Strict-Transport-Security to headers, the value signifies age.
// Zero or negative value turns it off.
"strict_max_age": 604800,
// Letsencrypt configuration.
"autocert": {
// Location of certificates.
"cache": "/etc/letsencrypt/live/your.domain.here",
// Contact address for this installation. LetsEncrypt will send
// messages to this address in case of problems. Replace with your
// own address or remove this line.
"email": "noreply@example.com",
// Domains served. Replace with your own domain name.
"domains": ["whatever.example.com"]
},
// If "autocert" config is not defined, read static certificates from
// these locations. Ignored if "autocert" is defined.
"cert_file": "/etc/httpd/conf/your.domain.crt",
"key_file": "/etc/httpd/conf/your.domain.key"
},
// Authentication configuration.
"auth_config": {
// Optional mapping of externally-visible authenticator names to internal names.
// For example use ["my-auth:basic", "basic:"] to rename "basic" authenticator to
// "my-auth" and make "basic" unaccessible by the old name. If you want to use REST-auth, then
// the config is ["basic:rest", "rest:"].
// Default is identity mapping.
"logical_names": [],
// Basic (login + password) authentication.
"basic": {
// Add 'auth-name:username' to tags making user discoverable by username.
"add_to_tags": true,
// The minimum length of a login in unicode runes, i.e. "登录" is length 2, not 6.
// The maximum length is 32 and it cannot be changed.
"min_login_length": 4,
// The minimum length of a password in unicode runes, "пароль" is length 6, not 12.
// There is no limit on maximum length, but MySQL & PgSQL adapters have a limit of 32 bytes.
"min_password_length": 6
},
// Token authentication
"token": {
// Lifetime of a security token in seconds. 1209600 = 2 weeks.
"expire_in": 1209600,
// Serial number of the token. Can be used to invalidate all issued tokens at once.
"serial_num": 1,
// Secret key (HMAC salt) for signing the tokens. Generate your own then keep it secret.
// Any 32 random bytes base64 encoded.
//
// === IMPORTANT ===
//
// CHANGE IT IN PRODUCTION!!! Otherwise anyone will be able to log in
// to your server without the password. It's just random base64-encoded bytes, use any suitable
// means to get it. For example:
// Linux/Mac:
// echo $(head -c 32 /dev/urandom | base64 | tr -d '\n')
// Windows:
// powershell -command "[Convert]::ToBase64String((1..32|%{[byte](Get-Random -Max 256)}))"
"key": "wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc="
},
// Short code authenticator for resetting passwords.
"code": {
// Lifetime of a security code in seconds. 900 seconds = 15 minutes.
"expire_in": 900,
// Number of times a user can try to enter the code.
"max_retries": 3,
// Length of the secret code.
"code_length": 6
}
},
// Database configuration
"store_config": {
// XTEA encryption key for user IDs and topic names. 16 random bytes base64-encoded.
// Generate your own and keep it secret. Otherwise your user IDs will be predictable
// and it will be easy to spam your users.
"uid_key": "la6YsO+bNX/+XIkOqc5Svw==",
// Maximum number of results fetched in one DB call.
"max_results": 1024,
// DB adapter name to communicate with the DB backend.
// Must be one of the adapters from the list below.
"use_adapter": "mysql",
// Configurations of individual adapters.
"adapters": {
// PostgreSQL configuration. See https://godoc.org/github.com/jackc/pgx#Config
// for other possible options.
"postgres": {
// PostgreSQL connection settings.
// Don't change the username before reading the FAQ!
"User": "postgres",
"Passwd": "postgres",
"Host": "localhost",
"Port": "5432",
"DBName": "tinode",
"SSLMode": "disable",
// DSN: alternative way of specifying database configuration, passed unchanged
// to the driver. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
// "dsn": "postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10",
// PostgreSQL connection pool settings.
// Maximum number of open connections to the database. Zero means unlimited.
"max_open_conns": 50,
// Maximum number of connections in the idle connection pool. Zero means no idle connections are retained.
"max_idle_conns": 50,
// Maximum amount of time a connection may be reused. Zero means unlimited.
"conn_max_lifetime": 60,
// Maximum amount of time waiting for a connection from the pool. Zero means no timeout.
"sql_timeout": 10
},
// MySQL configuration. See https://godoc.org/github.com/go-sql-driver/mysql#Config
// for other possible options.
"mysql": {
// MySQL connection settings via DSN.
// See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax.
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
// MySQL connection pool settings.
// Maximum number of open connections to the database. Default: 0 (unlimited).
"max_open_conns": 64,
// Maximum number of connections in the idle connection pool. If negative or zero,
// no idle connections are retained.
"max_idle_conns": 64,
// Maximum amount of time a connection may be reused (in seconds).
"conn_max_lifetime": 60,
// DB request timeout (in seconds).
// If not set (or <= 0), DB queries and transactions will run without a timeout.
"sql_timeout": 10
},
// RethinkDB configuration. See
// https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts for other possible
// options.
"rethinkdb": {
// Address(es) of RethinkDB node(s): either a string or an array of strings.
"addresses": "localhost:28015",
// Name of the main database.
"database": "tinode"
},
// MongoDB configuration.
"mongodb": {
// Connection string https://www.mongodb.com/docs/manual/reference/connection-string/
// Options configured with the 'uri' connection string override all other options
// (only 'uri' is sent to the server, all other options are ignored).
// If you are using Atlas, then you MUST use 'uri' to connect. See here:
// https://www.mongodb.com/docs/manual/reference/connection-string/#std-label-connections-dns-seedlist
// Something like
// "uri": "mongodb+srv://CREDENTIALS@PROJECT.gmuaq.mongodb.net/DATABASE?retryWrites=true&w=majority",
"uri": "",
// The only supported server API version is "1". May or maynot be needed depending on server version.
"api_version": "",
// Address(es) of MongoDB node(s): either a string or an array of strings.
"addresses": "localhost:27017",
// Name of the main database.
"database": "tinode",
// Name of replica set of mongodb instance. Remove this line to use a standalone instance.
// If replica_set is disabled, transactions will be disabled as well.
"replica_set": "rs0",
// Authentication options. Uncomment if auth is configured on your MongoDB.
// Authentication mechanism. See https://www.mongodb.com/docs/manual/core/authentication/
// Default "SCRAM-SHA-256"
// "auth_mechanism": "SCRAM-SHA-256",
// The name of database that has the collection with the user credentials. Default "admin".
// "auth_source": "admin",
// Username:
// "username": "tinode",
// Password:
// "password": "tinode",
// Driver's TLS configuration. Uncomment to enable TLS.
// "tls": true,
// Path to the client certificate. Optional.
// "tls_cert_file": "/path/to/cert_file",
// Path to private key. Optional.
// "tls_private_key": "/path/to/private_key",
// Specifies whether or not certificates and hostnames received from the server should be validated.
// Not recommended to enable in production. Default is false.
// "tls_skip_verify": false
}
}
},
// Account validators (email or SMS or captcha).
"acc_validation": {
// Email validator config.
"email": {
// Restrict use of "email" namespace: make users searchable by their emails,
// disable manual creation of email: tags.
"add_to_tags": true,
// List of authentication levels which require this validation method.
// Remove this line to disable email validation.
"required": ["auth"],
// Configuration passed to the validator unchanged.
"config": {
// Address of the host where the Tinode server is running. This will be used
// in URLs in the email.
"host_url": "http://localhost:6060/",
// Address of the SMPT server to use.
"smtp_server": "smtp.example.com",
// SMTP port to use. "25" for basic email RFC 5321 (2821, 821), "587" for RFC 3207 (TLS).
"smtp_port": "25",
// RFC 5322 email address to show in the From: field.
"sender": "\"Tinode\" <noreply@example.com>",
// Optional login to use for authentication; if missing, the connection is not authenticated.
"login": "john.doe@example.com",
// Password to use when authenticating the sender; used only if "login" is provided.
"sender_password": "your-password-here",
// Authentication mechanism to use, optional. One of "login", "cram-md5", "plain" (default).
"auth_mechanism": "login",
// FQDN to use in SMTP HELO/EHLO command; if missing, the hostname from "host_url" is used.
"smtp_helo_host": "example.com",
// Skip verification of the server's certificate chain and host name.
// In this mode, TLS is susceptible to man-in-the-middle attacks.
"insecure_skip_verify": false,
// Optional list of human languages to try to load templates for. If you don't care about i18n,
// leave it blank or remove. The first language in the list is the default language.
"languages": ["en", "es", "fr", "pt", "ru", "uk", "vi", "zh", "zh-TW"],
// Message template for credential validation.
// The file path itself is treated as a template. It's resolved by using the
// "languages" field above. One template per language.
// See the template file for the explanation of the expected structure.
"validation_templ": "./templ/email-validation-{{.Language}}.templ",
// Message template for resetting authentication secret.
// One template per language. See email-validation-en template for the explanation
// of the expected structure.
"reset_secret_templ": "./templ/email-password-reset-{{.Language}}.templ",
// Allow this many confirmation attempts before blocking the credential.
"max_retries": 3,
// List of email domains allowed to be used for registration.
// Missing or empty list means any email domain is accepted.
"domains": [],
// Dummy response to accept.
//
// === IMPORTANT ===
//
// REMOVE IN PRODUCTION!!! Otherwise anyone will be able to register
// with fake emails.
"debug_response": "123456"
}
},
// Placeholder validator for SMS and voice validation. Disabled by default.
// Use something like twilio.com or sinch.com in production.
"tel": {
"add_to_tags": true,
"config": {
// Address of the host where the Tinode server is running. This will be used
// in URLs in the SMS.
"host_url": "http://localhost:6060/",
// Optional list of locales to try to load templates for. If you don't care about i18n,
// leave it blank or remove. The first language in the list is the default language.
"languages": ["en", "es", "fr", "pt", "ru", "uk", "vi", "zh", "zh-TW"],
// String to use in the From field of the SMS.
"sender": "Tinode",
// Message template for credential validation and password reset. The file path itself is
// treated as a template. It's resolved by using the "languages" field above. One template
// per language.
"universal_templ": "./templ/sms-universal-{{.Language}}.templ",
// Allow this many confirmation attempts before blocking the credential.
"max_retries": 3,
// Twilio configuration (optional).
//"twilio_conf": {
// "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
// "auth_token": "f2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
//},
// Dummy response to accept.
//
// === IMPORTANT ===
//
// REMOVE IN PRODUCTION!!! Otherwise anyone will be able to register
// with fake phone numbers.
"debug_response": "123456"
}
}
},
// Configuration for stale account garbage collector: remove
// stale unvalidated user accounts which have been last updated at least
// 'gc_min_account_age' hours ago.
"acc_gc_config": {
"enabled": true,
// How often to run GC (seconds).
"gc_period": 3600,
// Number of accounts to delete in one pass.
"gc_block_size": 10,
// Minimum hours since account was last modified.
"gc_min_account_age": 30
},
// Configuration of push notifications.
"push": [
{
// Notificator which writes to STDOUT. Useful for debugging.
"name":"stdout",
"config": {
// Disabled.
"enabled": false
}
},
{
// Google FCM notificator.
"name":"fcm",
"config": {
// Disabled. Won't work without the server key anyway. See below.
"enabled": false,
// Firebase project ID.
"project_id": "your-project-id",
// Service account credentials as json.
// See instructions how to download the service account credentials file:
// https://cloud.google.com/iam/docs/creating-managing-service-account-keys
// Then insert the file contents here. Yes, this is convoluted, but that's Google's fault.
"credentials": {
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "some-random-looking-hex-number",
"private_key": "-----BEGIN PRIVATE KEY----- base64-encoded bits of your private key \n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-abc123@your-project-id.iam.gserviceaccount.com",
"client_id": "1234567890123456789",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-abc123%40your-project-id.iam.gserviceaccount.com"
},
// An alternative way to provide Firebase service account credentials.
"credentials_file": "/path/to/service-account-file-with-credentials.json",
// Time in seconds before notification is discarded (by Google) if undelivered.
"time_to_live": 3600,
// Payload of AndroidNotification. If enabled, this will take precedence over data payload.
"android": {
// Set to false to push a data-only message.
"enabled": false,
// Android drawable resource ID to use as a notification icon.
"icon": "ic_logo_push",
// Notification color.
"color": "#3949AB",
// Name of intent filter which will catch this notification.
"click_action": ".MessageActivity",
// Notification of a new message. You can include custom "icon", "color", "click_action"
// into this section and it will override the value above.
"msg": {
// Literal title string. Not recommended because it's not localized.
"title": "",
// Literal message body. Not recommended because it's not localized.
"body": "",
// Android string resource ID to use as a notification title. Localized.
// Takes precedence over "title". "new_message" is "New message" in Tindroid.
"title_loc_key": "new_message",
// Android string resource ID to use as a notification body. Localized.
// Takes precedence over "body".
"body_loc_key": ""
},
// Notification of a new subscription. Same rules as section "msg" above.
"sub": {
// Android resource string ID to use as notification title. Localized.
// "new_chat" is "New chat" in Tindroid.
"title_loc_key": "new_chat",
// Android resource string ID to use as notification body. Localized.
"body_loc_key": ""
}
}
}
},
{
// Tinode Push Gateway, see https://github.com/tinode/chat/tree/master/server/push/tnpg.
"name":"tnpg",
"config": {
// Disabled. Configure first then enable.
"enabled": false,
// Short name (URL) of the organization you registered at console.tinode.co.
"org": "test",
// Authentication token obtained from console.tinode.co
"token": "jwt-security-token-obtained-from-console.tinode.co",
}
}
],
// Configuration for voice and video calls.
"webrtc": {
// Disabled. Won't work without functioning ice_servers (see below).
"enabled": false,
// Timeout in seconds before a video/voice call is dropped if not answered.
"call_establishment_timeout": 30,
// Interactive Communication Establishment (ICE) STUN and TURN server configuration for video calls.
// You need to configure your own servers or consider https://www.metered.ca/tools/openrelay/.
// Video calls will not work if both parties are behind NAT and no ICE servers are configured.
"ice_servers": [
{
"urls": [
"stun:stun.example.com"
]
},
{
"username": "user-name-to-use-for-authentication-with-the-server",
"credential": "your-password",
"urls": [
"turn:turn.example.com:80?transport=udp",
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:80?transport=tcp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:443?transport=tcp",
"turns:turn.example.com:5349?transport=tcp"
]
}
],
// An alternative way to provide STUN/TURN configuration.
"ice_servers_file": "/path/to/ice-servers-config.json"
},
// Cluster-mode configuration.
"cluster_config": {
// Name of this node. Can be assigned from the command line as --cluster_self.
// Empty string disables clustering.
"self": "",
// List of available nodes.
"nodes": [
// Name and TCP address of every node in the cluster. The ports 12001..12003
// are cluster communication ports. They don't need to be exposed to end-users.
{"name": "one", "addr":"localhost:12001"},
{"name": "two", "addr":"localhost:12002"},
{"name": "three", "addr":"localhost:12003"}
],
// Failover config. No need to change unless you are doing something unusual.
"failover": {
// Failover is enabled.
"enabled": true,
// Time in milliseconds between heartbeats.
"heartbeat": 100,
// Initiate leader election when the leader is not available for this many heartbeats.
"vote_after": 8,
// Consider node failed when it missed this many heartbeats.
"node_fail_after": 16
}
},
// Configuration of plugins.
"plugins": [
{
// Enable or disable this plugin.
"enabled": false,
// Name of the plugin, must be unique.
"name": "python_chat_bot",
// Timeout in microseconds.
"timeout": 20000,
// Events to send to the plugin.
"filters": {
// Account creation events.
"account": "C"
},
// Error code to use in case plugin has failed; 0 means to ignore the failures.
"failure_code": 0,
// Text of an error message to report in case of plugin failure.
"failure_text": null,
// Address of the plugin.
"service_addr": "tcp://localhost:40051"
}
]
"listen": ":6060",
"api_path": "/",
"cache_control": 39600,
"ws_compression_disabled": false,
"static_mount": "/",
"static_data": "static",
"grpc_listen": ":16060",
"grpc_keepalive_enabled": true,
"api_key_salt": "T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=",
"max_message_size": 131072,
"max_subscriber_count": 128,
"max_tag_count": 16,
"permanent_accounts": false,
"expvar": "/debug/vars",
"server_status": "-",
"use_x_forwarded_for": true,
"x_frame_options": "SAMEORIGIN",
"default_country_code": "CN",
"p2p_delete_enabled": true,
"msg_delete_age": 600,
"alias_tag": "alias",
"media": {
"use_handler": "fs",
"max_size": 104857600,
"gc_period": 3600,
"gc_block_size": 100,
"handlers": {
"fs": {
"upload_dir": "uploads",
"cache_control": "max-age=86400",
"cors_origins": ["*"]
}
}
},
"tls": {
"enabled": false,
"http_redirect": "",
"strict_max_age": 0
},
"auth_config": {
"logical_names": [],
"basic": {
"add_to_tags": true,
"min_login_length": 4,
"min_password_length": 6
},
"token": {
"expire_in": 1209600,
"serial_num": 1,
"key": "wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc="
},
"code": {
"expire_in": 900,
"max_retries": 3,
"code_length": 6
}
},
"store_config": {
"uid_key": "la6YsO+bNX/+XIkOqc5Svw==",
"max_results": 1024,
"use_adapter": "mysql",
"adapters": {
"mysql": {
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(64.118.137.182:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo",
"tenant_db_routing": true,
"max_open_conns": 64,
"max_idle_conns": 64,
"conn_max_lifetime": 60,
"sql_timeout": 10
}
}
},
"acc_validation": {
"email": {
"add_to_tags": true,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"debug_response": "123456",
"max_retries": 3
}
},
"tel": {
"add_to_tags": true,
"required": [],
"config": {
"host_url": "http://64.118.137.182:6060/",
"sender": "Tinode",
"debug_response": "123456",
"max_retries": 3
}
}
},
"acc_gc_config": {
"enabled": true,
"gc_period": 3600,
"gc_block_size": 10,
"gc_min_account_age": 30
},
"push": [
{
"name": "stdout",
"config": {
"enabled": false
}
},
{
"name": "fcm",
"config": {
"enabled": false
}
},
{
"name": "tnpg",
"config": {
"enabled": false
}
}
],
"webrtc": {
"enabled": false,
"call_establishment_timeout": 30,
"ice_servers": []
},
"cluster_config": {
"self": "",
"nodes": [],
"failover": {
"enabled": false
}
},
"plugins": []
}
+150 -9
View File
@@ -1013,8 +1013,22 @@ func (t *Topic) sendSubNotifications(asUid types.Uid, sid, userAgent string) {
// in response to a client request (msg, asUid) and broadcasts it to the attached sessions.
func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, noEcho bool, attachments []string, head map[string]any, content any) error {
pud, userFound := t.perUser[asUid]
// Anyone is allowed to post to 'sys' topic.
if t.cat != types.TopicCatSys {
if !userFound && msg.sess != nil {
asUid = msg.sess.uid
pud, userFound = t.perUser[asUid]
}
if !userFound && msg.sess != nil {
if psd, ok := t.sessions[msg.sess]; ok {
asUid = psd.uid
pud, userFound = t.perUser[asUid]
}
}
if t.cat == types.TopicCatSys {
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
} else {
// If it's not 'sys' check write permission.
if !(pud.modeWant & pud.modeGiven).IsWriter() {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
@@ -1032,9 +1046,55 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
// Make sure the received Head does not include a fake "sender" header.
delete(head, "sender")
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
legacyType, _ := head["type"].(string)
productType, _ := head["x-im-type"].(string)
if legacyType == "system" || legacyType == "system_event" ||
productType == "system" || productType == "system_event" {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
}
if t.cat == types.TopicCatGrp && head != nil && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
normalized, err := t.validateAndNormalizeMentions(asUid, head)
if err != nil {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return err
}
head = normalized
}
if t.cat == types.TopicCatGrp && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.GroupBusiness != nil && store.Store != nil && store.Store.IsOpen() {
access := pud.modeWant & pud.modeGiven
canPost, err := store.GroupBusiness.CanPost(t.tenantID, t.name, asUid, access.IsAdmin(), msg.Timestamp)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to check group mute policy: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
return err
}
if !canPost {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
}
if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.Relations != nil && store.Store != nil && store.Store.IsOpen() {
peer := t.p2pOtherUser(asUid)
if !peer.IsZero() {
canSend, _, err := store.Relations.CanSendP2P(t.tenantID, asUid, peer)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to check p2p relationship: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
return err
}
if !canSend {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
}
}
markedReadBySender := false
if err, unreadUpdated := store.Messages.Save(t.tenantID,
saveResult, err := store.Messages.Save(t.tenantID,
&types.Message{
ObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp},
TenantID: t.tenantID,
@@ -1043,14 +1103,22 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
From: asUid.String(),
Head: head,
Content: content,
}, attachments, (pud.modeGiven & pud.modeWant).IsReader()); err != nil {
}, attachments, (pud.modeGiven & pud.modeWant).IsReader())
if err != nil {
logs.Warn.Printf("topic[%s]: failed to save message: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
return err
} else {
markedReadBySender = unreadUpdated
}
if saveResult != nil && saveResult.Duplicate {
if msg.Id != "" && msg.sess != nil {
reply := NoErrAccepted(msg.Id, t.original(asUid), msg.Timestamp)
reply.Ctrl.Params = map[string]any{"seq": saveResult.SeqId}
msg.sess.queueOut(reply)
}
return nil
}
markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender
t.lastID++
t.touched = msg.Timestamp
@@ -1107,6 +1175,9 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
// This is a NON-proxy broadcast.
func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
asUid := types.ParseUserId(msg.AsUser)
if asUid.IsZero() && msg.sess != nil {
asUid = msg.sess.uid
}
if t.isInactive() {
// Ignore broadcast - topic is paused or being deleted.
msg.sess.queueOut(ErrLocked(msg.Id, t.original(asUid), msg.Timestamp))
@@ -1132,6 +1203,13 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.sess.queueOut(ErrCallBusyReply(msg, types.TimeNow()))
return
}
head, content, err := normalizeCallInvite(msg.Pub.Head, msg.Pub.Content)
if err != nil {
msg.sess.queueOut(ErrMalformedReply(msg, types.TimeNow()))
return
}
msg.Pub.Head = head
msg.Pub.Content = content
}
// Save to DB at master topic.
@@ -1150,6 +1228,67 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
}
}
func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) {
mentionAll, _ := head["mention_all"].(bool)
rawMentions, hasMentions := head["mentions"]
if !mentionAll && !hasMentions {
return head, nil
}
pud := t.perUser[asUid]
access := pud.modeGiven & pud.modeWant
if mentionAll && !access.IsAdmin() {
return nil, types.ErrPermissionDenied
}
validMembers := make(map[string]struct{}, len(t.perUser))
for uid, pud := range t.perUser {
mode := pud.modeGiven & pud.modeWant
if !pud.deleted && mode.IsJoiner() && mode.IsReader() {
validMembers[uid.UserId()] = struct{}{}
}
}
normalized := make(map[string]any, len(head))
for key, value := range head {
normalized[key] = value
}
if hasMentions {
filtered := filterValidMentions(rawMentions, validMembers)
if len(filtered) == 0 {
delete(normalized, "mentions")
} else {
normalized["mentions"] = filtered
}
}
return normalized, nil
}
func filterValidMentions(raw any, validMembers map[string]struct{}) []map[string]any {
mentions, ok := raw.([]any)
if !ok {
if typed, ok := raw.([]map[string]any); ok {
mentions = make([]any, 0, len(typed))
for _, item := range typed {
mentions = append(mentions, item)
}
}
}
if len(mentions) == 0 {
return nil
}
filtered := make([]map[string]any, 0, len(mentions))
for _, item := range mentions {
mention, ok := item.(map[string]any)
if !ok {
continue
}
userID, _ := mention["user_id"].(string)
if _, ok = validMembers[userID]; !ok {
continue
}
filtered = append(filtered, mention)
}
return filtered
}
// handleNoteBroadcast fans out {note} -> {info} messages to recipients in a master topic.
// This is a NON-proxy broadcast (at master topic).
func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {
@@ -2179,7 +2318,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
// because to stay in memory at least one of the users must be connected to topic.
// FIXME(gene): it breaks when user A stays active in one session and connects-disconnects
// from another session. The second session will not see correct LastSeen time and UserAgent.
if pud.lastSeen != nil {
if pud.lastSeen != nil && imCanViewLastSeen(t.tenantID, asUid, t.p2pOtherUser(asUid)) {
desc.LastSeen = &MsgLastSeenInfo{
When: pud.lastSeen,
UserAgent: pud.lastUA,
@@ -2611,8 +2750,10 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
// Reporting user's subscriptions to other topics. P2P topic name is the
// UID of the other user.
with := sub.GetWith()
canViewLastSeen := true
if with != "" {
mts.Topic = with
canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with))
mts.Online = t.perSubs[with].online && !deleted && presencer
} else if strings.HasPrefix(sub.Topic, "slf") {
mts.Topic = "slf"
@@ -2637,7 +2778,7 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
}
lastSeen := sub.GetLastSeen()
if lastSeen != nil && !mts.Online {
if lastSeen != nil && !mts.Online && canViewLastSeen {
mts.LastSeen = &MsgLastSeenInfo{
When: lastSeen,
UserAgent: sub.GetUserAgent(),
+316 -21
View File
@@ -49,6 +49,13 @@ type TopicTestHelper struct {
uu *mock_store.MockUsersPersistenceInterface
tt *mock_store.MockTopicsPersistenceInterface
ss *mock_store.MockSubsPersistenceInterface
oldMessages store.MessagesPersistenceInterface
oldUsers store.UsersPersistenceInterface
oldTopics store.TopicsPersistenceInterface
oldSubs store.SubsPersistenceInterface
oldRelations store.RelationsPersistenceInterface
oldModeCP2P types.AccessMode
}
func (b *TopicTestHelper) finish() {
@@ -67,12 +74,14 @@ func (b *TopicTestHelper) finish() {
func (b *TopicTestHelper) newSession(sid string, uid types.Uid) (*Session, *responses) {
s := &Session{
tenantID: 1,
sid: sid,
uid: uid,
subs: make(map[string]*Subscription),
send: make(chan any, 10),
detach: make(chan string, 10),
tenantID: 1,
tenantCode: "test",
sid: sid,
uid: uid,
authLvl: auth.LevelAuth,
subs: make(map[string]*Subscription),
send: make(chan any, 10),
detach: make(chan string, 10),
}
r := &responses{}
b.sessWg.Add(1)
@@ -95,10 +104,18 @@ func (b *TopicTestHelper) setUp(t *testing.T, numUsers int, cat types.TopicCat,
b.uu = mock_store.NewMockUsersPersistenceInterface(b.ctrl)
b.tt = mock_store.NewMockTopicsPersistenceInterface(b.ctrl)
b.ss = mock_store.NewMockSubsPersistenceInterface(b.ctrl)
b.oldMessages = store.Messages
b.oldUsers = store.Users
b.oldTopics = store.Topics
b.oldSubs = store.Subs
store.Messages = b.mm
store.Users = b.uu
store.Topics = b.tt
store.Subs = b.ss
b.oldRelations = store.Relations
store.Relations = nil
b.oldModeCP2P = globals.typesModeCP2P
globals.typesModeCP2P = types.ModeCP2P
// Sessions.
b.sessions = make([]*Session, b.numUsers)
b.results = make([]*responses, b.numUsers)
@@ -162,10 +179,12 @@ func (b *TopicTestHelper) setUp(t *testing.T, numUsers int, cat types.TopicCat,
func (b *TopicTestHelper) tearDown() {
globals.hub = nil
store.Messages = nil
store.Users = nil
store.Topics = nil
store.Subs = nil
store.Messages = b.oldMessages
store.Users = b.oldUsers
store.Topics = b.oldTopics
store.Subs = b.oldSubs
store.Relations = b.oldRelations
globals.typesModeCP2P = b.oldModeCP2P
b.ctrl.Finish()
}
@@ -200,7 +219,8 @@ func TestHandleBroadcastDataP2P(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
defer helper.tearDown()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
@@ -278,6 +298,76 @@ func TestHandleBroadcastDataP2P(t *testing.T) {
}
}
func TestHandleBroadcastDataP2PRequiresRelationship(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
defer helper.tearDown()
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(helper.ctrl)
mockStore.EXPECT().IsOpen().Return(true)
store.Store = mockStore
canSend := false
store.Relations = &testRelationsStore{canSend: &canSend, reason: types.RelationshipNone}
defer func() {
store.Store = oldStore
}()
from := helper.uids[0].UserId()
msg := &ClientComMessage{
AsUser: from,
Original: from,
Pub: &MsgClientPub{
Topic: "p2p",
Content: "test",
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[0], []int{http.StatusForbidden}, t)
if len(helper.results[1].messages) != 0 {
t.Fatalf("peer: expected 0 messages, got %d", len(helper.results[1].messages))
}
}
func TestHandleBroadcastDataGroupRejectsMutedMember(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, 2, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
mockStore := mock_store.NewMockPersistentStorageInterface(helper.ctrl)
mockStore.EXPECT().IsOpen().Return(true)
store.Store = mockStore
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{Topic: "grp-test", MuteAll: true}}
memberState := helper.topic.perUser[helper.uids[1]]
memberState.modeWant = types.ModeCPublic
memberState.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[1]] = memberState
defer func() {
store.Store = oldStore
store.GroupBusiness = oldGroupBusiness
}()
msg := &ClientComMessage{
Id: "muted-1", AsUser: helper.uids[1].UserId(), Original: "grp-test",
Pub: &MsgClientPub{Topic: "grp-test", Content: "blocked"},
sess: helper.sessions[1],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[1], []int{http.StatusForbidden}, t)
if helper.topic.lastID != 0 {
t.Fatalf("muted publish changed topic sequence: %d", helper.topic.lastID)
}
}
func TestHandleBroadcastCall(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
@@ -285,7 +375,8 @@ func TestHandleBroadcastCall(t *testing.T) {
globals.iceServers = []iceServer{{Username: "dummy"}}
helper.topic.lastID = 5
defer helper.tearDown()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
@@ -294,7 +385,7 @@ func TestHandleBroadcastCall(t *testing.T) {
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"webrtc": "started"},
Content: "test",
Content: map[string]any{"callId": "call-1", "callType": "audio"},
NoEcho: true,
},
sess: helper.sessions[0],
@@ -326,11 +417,15 @@ func TestHandleBroadcastCall(t *testing.T) {
if r.Data.Topic != from {
t.Errorf("Response[0] topic: expected '%s', got '%s'", from, r.Data.Topic)
}
if r.Data.Content.(string) != "test" {
t.Errorf("Response[0] content: expected 'test', got '%s'", r.Data.Content.(string))
content := r.Data.Content.(map[string]any)
if content["callId"] != "call-1" || content["callType"] != "audio" || content["video"] != false {
t.Errorf("Response[0] content: unexpected call payload: %#v", content)
}
if r.Data.Head == nil || r.Data.Head["webrtc"].(string) != "started" {
t.Errorf("Response[0] head: expected {'webrtc': 'started'}', got '%s'", r.Data.Content.(string))
t.Errorf("Response[0] head: expected {'webrtc': 'started'}', got %#v", r.Data.Head)
}
if r.Data.Head["x-im-type"] != "call" || r.Data.Head["mime"] != "application/json" {
t.Errorf("Response[0] head: expected normalized call head, got %#v", r.Data.Head)
}
if r.Data.From != from {
t.Errorf("Response[0] from: expected '%s', got '%s'", from, r.Data.From)
@@ -388,6 +483,37 @@ func TestHandleBroadcastCall(t *testing.T) {
}
}
func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
globals.iceServers = []iceServer{{Username: "dummy"}}
defer func() {
globals.iceServers = nil
helper.tearDown()
}()
from := helper.uids[0].UserId()
msg := &ClientComMessage{
AsUser: from,
Original: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"webrtc": "started"},
Content: "test",
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[0], []int{http.StatusBadRequest}, t)
if len(helper.results[1].messages) != 0 {
t.Fatalf("peer: expected 0 messages, got %d", len(helper.results[1].messages))
}
}
func TestHandleBroadcastDataGroup(t *testing.T) {
topicName := "grp-test"
numUsers := 4
@@ -397,7 +523,8 @@ func TestHandleBroadcastDataGroup(t *testing.T) {
store.Messages = nil
helper.tearDown()
}()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
// User 3 isn't allowed to read.
pu3 := helper.topic.perUser[helper.uids[3]]
@@ -557,7 +684,7 @@ func TestHandleBroadcastDataDbError(t *testing.T) {
defer helper.tearDown()
// DB returns an error.
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(types.ErrInternal, false)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, types.ErrInternal)
// Make test message.
from := helper.uids[0].UserId()
@@ -605,6 +732,171 @@ func TestHandleBroadcastDataDbError(t *testing.T) {
}
}
func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
defer helper.tearDown()
helper.topic.lastID = 7
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 4, Duplicate: true}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "retry-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"x-im-client-mid": "mid-1"},
Content: "test",
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if helper.topic.lastID != 7 {
t.Errorf("Topic.lastID: expected duplicate to keep 7, found %d", helper.topic.lastID)
}
if len(helper.results[0].messages) != 1 {
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
}
ack := helper.results[0].messages[0].(*ServerComMessage)
if ack.Ctrl == nil {
t.Fatal("User 1 is expected to receive a ctrl message")
}
if ack.Ctrl.Code != 202 {
t.Errorf("User1: expected ctrl.code 202, received %d", ack.Ctrl.Code)
}
params, ok := ack.Ctrl.Params.(map[string]any)
if !ok {
t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params)
}
if seq, ok := params["seq"].(int); !ok || seq != 4 {
t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"])
}
if len(helper.results[1].messages) != 0 {
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
}
if len(helper.hubMessages) != 0 {
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
}
}
func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
defer helper.tearDown()
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "sys-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"},
Content: "test",
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if helper.topic.lastID != 0 {
t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID)
}
if len(helper.results[0].messages) != 1 {
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
}
resp := helper.results[0].messages[0].(*ServerComMessage)
if resp.Ctrl == nil || resp.Ctrl.Code != 403 {
t.Fatalf("User 1 expected 403 ctrl, received %#v", resp)
}
if len(helper.results[1].messages) != 0 {
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
}
if len(helper.hubMessages) != 0 {
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
}
}
func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
pud := helper.topic.perUser[helper.uids[0]]
pud.modeWant = types.ModeCPublic
pud.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[0]] = pud
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "mention-all-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "grp-test",
Head: map[string]any{"mention_all": true},
Content: "hello all",
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[0], []int{http.StatusForbidden}, t)
if helper.topic.lastID != 0 {
t.Fatalf("Topic.lastID: expected 0, got %d", helper.topic.lastID)
}
}
func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
mentions, ok := msg.Head["mentions"].([]map[string]any)
if !ok || len(mentions) != 1 || mentions[0]["user_id"] != helper.uids[1].UserId() {
t.Fatalf("unexpected normalized mentions: %#v", msg.Head["mentions"])
}
return &types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil
})
from := helper.uids[0].UserId()
msg := &ClientComMessage{
Id: "mention-1",
AsUser: from,
Pub: &MsgClientPub{
Topic: "grp-test",
Head: map[string]any{"mentions": []any{
map[string]any{"user_id": helper.uids[1].UserId(), "offset": 0, "length": 2},
map[string]any{"user_id": types.Uid(99).UserId(), "offset": 3, "length": 2},
}},
Content: "hi",
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, t)
}
func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
@@ -2876,7 +3168,8 @@ func TestHandleBroadcastDataWithAttachments(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
defer helper.tearDown()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
@@ -3039,7 +3332,8 @@ func TestHandleBroadcastDataGroupWithMutedUser(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)
defer helper.tearDown()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
// User 2 has muted the topic (no Pres permission)
pu2 := helper.topic.perUser[helper.uids[2]]
@@ -3103,7 +3397,8 @@ func TestUnregisterSessionWithPendingCall(t *testing.T) {
isOriginator: true,
sess: s,
}
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
leave := &ClientComMessage{
Leave: &MsgClientLeave{
+60
View File
@@ -3,6 +3,7 @@ package main
import (
"container/heap"
"math/rand"
"strings"
"time"
"slices"
@@ -383,6 +384,19 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
continue
}
if cr.Response != "" {
if value, 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
}
}
isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken)
if err != nil {
return nil, nil, err
@@ -413,6 +427,52 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
return validated, extraTags, nil
}
func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient,
preCheck func(string, map[string]any) (string, error)) (string, bool, error) {
challengeID, _ := cr.Params["challenge_id"].(string)
challengeID = strings.TrimSpace(challengeID)
if challengeID == "" {
return "", false, nil
}
if store.Verifications == nil {
return "", true, types.ErrInternal
}
normalized, err := preCheck(cr.Value, cr.Params)
if err != nil {
return "", true, err
}
value := strings.TrimPrefix(normalized, cr.Method+":")
vc, err := store.Verifications.Get(tenantID, challengeID)
if err != nil {
return "", true, err
}
if vc.Scene != "register" || vc.State != types.VerificationStateVerified || vc.PhoneHash == "" {
return "", true, types.ErrInvalidResponse
}
tokenHash := hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, cr.Response)
if vc.VerificationTokenHash == nil || *vc.VerificationTokenHash != tokenHash {
return "", true, types.ErrInvalidResponse
}
now := time.Now().UTC().Round(time.Millisecond)
if !now.Before(vc.ExpiresAt) {
return "", true, types.ErrExpired
}
if err := store.Verifications.Consume(tenantID, vc.ChallengeID, tokenHash, now); err != nil {
return "", true, err
}
_, err = store.Users.UpsertCred(tenantID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: uid.String(),
Method: cr.Method,
Value: value,
Done: true,
})
if err != nil {
return "", true, err
}
return value, true, nil
}
// validatedCreds returns the list of validated credentials including those validated in this call.
// Returns all validated methods including those validated earlier and now.
// Returns either a full set of tags or nil for tags if tags are unchanged.
+376
View File
@@ -0,0 +1,376 @@
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
func TestConsumeProductVerificationCred(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
token := "verified-token"
tokenHash := hashIMSecret(7, "register", "challenge-1", "phone-hash", token)
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
if cred.User != types.Uid(42).String() || cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
t.Fatalf("unexpected credential: %#v", cred)
}
return true, nil
})
store.Users = users
value, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
Method: "tel",
Value: "+8613800138000",
Response: token,
Params: map[string]any{"challenge_id": "challenge-1", "countryCode": "CN"},
}, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if err != nil || !ok || value != "+8613800138000" {
t.Fatalf("consumeProductVerificationCred = value %q ok %v err %v", value, ok, err)
}
if !store.Verifications.(*testVerificationStore).consumed {
t.Fatal("expected verification token to be consumed")
}
}
func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) {
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
tokenHash := hashIMSecret(7, "register", "challenge-1", "phone-hash", "good-token")
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
_, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
Method: "tel",
Value: "+8613800138000",
Response: "bad-token",
Params: map[string]any{"challenge_id": "challenge-1"},
}, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if !ok || err != types.ErrInvalidResponse {
t.Fatalf("consumeProductVerificationCred bad token ok %v err %v", ok, err)
}
if store.Verifications.(*testVerificationStore).consumed {
t.Fatal("bad token must not be consumed")
}
}
func TestServeIMPasswordResetVerify(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme", State: types.TenantStateActive}}
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "reset_password",
PhoneHash: "phone-hash",
CodeHash: hashIMSecret(7, "reset_password", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/challenges/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMPasswordResetVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
token, _ := data["reset_token"].(string)
if !strings.HasPrefix(token, "challenge-1.") {
t.Fatalf("unexpected reset token: %#v", data)
}
}
func TestServeIMPasswordResetConfirm(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldUsers := store.Users
oldStore := store.Store
oldSecurity := store.Security
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
store.Users = oldUsers
store.Store = oldStore
store.Security = oldSecurity
}()
makeTestAPIKey(t)
token := "reset-secret"
tokenHash := hashIMSecret(7, "reset_password", "challenge-1", "phone-hash", token)
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme", State: types.TenantStateActive}}
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "reset_password",
PhoneHash: "phone-hash",
TargetUser: 42,
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic").
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
store.Users = users
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
basicAuth.EXPECT().IsInitialized().Return(true)
basicAuth.EXPECT().UpdateRecord(auth.AuthContext{TenantID: 7}, gomock.Any(), []byte(":new-password")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil)
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("basic").Return(basicAuth)
store.Store = mockStore
security := &testSecurityStore{}
store.Security = security
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/confirm",
bytes.NewBufferString(`{"reset_token":"challenge-1.`+token+`","new_password":"new-password"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMPasswordResetConfirm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !store.Verifications.(*testVerificationStore).consumed {
t.Fatal("expected reset token to be consumed")
}
if !security.bumped || security.bumpedUser != types.Uid(42) {
t.Fatalf("expected auth version bump, got %#v", security)
}
}
type testSecurityStore struct {
version int
err error
bumped bool
bumpedUser types.Uid
realName *types.RealNameVerification
actionToken *types.ActionToken
independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings
attempts int
lockedUntil *time.Time
notifications []types.SystemNotification
readNotificationID string
realNameTenant types.TenantID
realNameUser types.Uid
listTenant types.TenantID
listUser types.Uid
listCursor string
listLimit int
nextCursor string
readTenant types.TenantID
readUser types.Uid
}
func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
if s.version == 0 {
return 1, s.err
}
return s.version, s.err
}
func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ time.Time) (int, error) {
s.bumped = true
s.bumpedUser = uid
if s.version == 0 {
s.version = 1
}
s.version++
return s.version, s.err
}
func (s *testSecurityStore) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) {
s.realNameTenant = tenantID
s.realNameUser = uid
if s.err != nil {
return nil, s.err
}
if s.realName == nil {
return nil, types.ErrNotFound
}
return s.realName, nil
}
func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types.RealNameVerification) error {
s.realName = rv
return s.err
}
func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error {
s.actionToken = token
return s.err
}
func (s *testSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _ string, action string, target string, _ time.Time) (*types.ActionToken, error) {
if s.err != nil {
return nil, s.err
}
if s.actionToken == nil {
return &types.ActionToken{Action: action, Target: target}, nil
}
return s.actionToken, nil
}
func (s *testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) {
if s.err != nil {
return nil, s.err
}
if s.independentPassword == nil {
return nil, types.ErrNotFound
}
return s.independentPassword, nil
}
func (s *testSecurityStore) UpsertIndependentPassword(_ types.TenantID, pass *types.IndependentPassword) error {
s.independentPassword = pass
return s.err
}
func (s *testSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, attempts int, lockedUntil *time.Time, _ time.Time) error {
s.attempts = attempts
s.lockedUntil = lockedUntil
if s.independentPassword != nil {
s.independentPassword.FailedAttempts = attempts
s.independentPassword.LockedUntil = lockedUntil
}
return s.err
}
func (s *testSecurityStore) GetYouthMode(_ types.TenantID, uid types.Uid) (*types.YouthModeSettings, error) {
if s.err != nil {
return nil, s.err
}
if s.youthMode == nil {
now := time.Now().UTC().Round(time.Millisecond)
s.youthMode = &types.YouthModeSettings{
UserID: uid,
Enabled: false,
PolicyVersion: 1,
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
PasswordSet: s.independentPassword != nil,
CanEnable: s.independentPassword != nil,
CreatedAt: now,
UpdatedAt: now,
}
}
return s.youthMode, nil
}
func (s *testSecurityStore) SetYouthMode(_ types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) {
if s.err != nil {
return nil, s.err
}
if s.youthMode == nil {
s.youthMode = &types.YouthModeSettings{UserID: uid, Restrictions: []string{"night_quiet", "content_filter", "payment_guard"}}
}
s.youthMode.Enabled = enabled
s.youthMode.PolicyVersion = policyVersion
s.youthMode.PasswordSet = true
s.youthMode.CanEnable = !enabled
s.youthMode.UpdatedAt = now
if enabled {
s.youthMode.EnabledAt = &now
} else {
s.youthMode.EnabledAt = nil
}
return s.youthMode, nil
}
func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error {
if notification != nil {
s.notifications = append(s.notifications, *notification)
}
return s.err
}
func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) {
s.listTenant = tenantID
s.listUser = uid
s.listCursor = cursor
s.listLimit = limit
return s.notifications, s.nextCursor, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid
s.readNotificationID = eventID
for i := range s.notifications {
if s.notifications[i].EventID == eventID {
s.notifications[i].ReadAt = &readAt
return s.err
}
}
if s.err != nil {
return s.err
}
return types.ErrNotFound
}
+4 -4
View File
@@ -334,7 +334,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
if timestamp.After(now) {
now = timestamp
}
if err, _ = store.Messages.Save(tenantID, &types.Message{
if _, err = store.Messages.Save(tenantID, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: timestamp},
TenantID: tenantID,
SeqId: seqId,
@@ -357,7 +357,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
for _, gt := range data.Grouptopics {
seqIds[nameIndex[gt.Name]] = 1
if err, _ = store.Messages.Save(tenantID, &types.Message{
if _, err = store.Messages.Save(tenantID, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: now},
TenantID: tenantID,
SeqId: 1,
@@ -377,7 +377,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
}
usedp2p[nameIndex[sub.pair]] = true
seqIds[nameIndex[sub.pair]] = 1
if err, _ = store.Messages.Save(tenantID, &types.Message{
if _, err = store.Messages.Save(tenantID, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: now},
TenantID: tenantID,
SeqId: 1,
@@ -401,7 +401,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
seqIds[nameIndex[sub.pair]]++
seqId := seqIds[nameIndex[sub.pair]]
if sub.Users[0].Name == botAccount || sub.Users[1].Name == botAccount {
if err, _ = store.Messages.Save(tenantID, &types.Message{
if _, err = store.Messages.Save(tenantID, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: ts},
TenantID: tenantID,
SeqId: seqId,
+3 -1
View File
@@ -10,7 +10,9 @@
},
"mysql": {
"database": "tinode",
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_unicode_ci"
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(64.118.137.182:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo",
"tenant_db_routing": true
},
"rethinkdb": {
"database": "tinode",