feat: migrate IM identity and snowflake primary keys

This commit is contained in:
richjohnrambo
2026-08-18 01:15:21 +08:00
parent db322d80dd
commit bf562c7d56
27 changed files with 1071 additions and 482 deletions
+2 -2
View File
@@ -395,7 +395,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 - 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。
- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 - 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。
- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会根据已校验的 `cred` 建立手机号凭证;客户端主动设置 `tags:["tel:..."]` 会被 Tinode 权限策略拒绝并返回 `403 params.what=tags`。 - WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会把已校验手机号写入 `im_users.phone`,并保留验证凭证记录;用户标签已废弃,客户端设置 `tags` 会返回 `403 params.what=tags`。
- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 - 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。
```json ```json
@@ -1679,7 +1679,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** **状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 - 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
+32 -7
View File
@@ -7,6 +7,7 @@
-- 2. Tenant-scoped business tables live in each tenant database and do not include tenant_id. -- 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. -- 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. -- 4. Table definitions are aligned with server/db/mysql/adapter.go plus im_verification_codes used by chapter 1 verification APIs.
-- 5. Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator.
SET NAMES utf8mb4; SET NAMES utf8mb4;
@@ -54,12 +55,14 @@ CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config (
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_security ( CREATE TABLE IF NOT EXISTS tinode.im_user_security (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1, auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3), password_updated_at DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id) PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_security_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_verification_codes ( CREATE TABLE IF NOT EXISTS tinode.im_verification_codes (
@@ -125,6 +128,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_action_tokens (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings ( CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL, purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL, password_hash VARBINARY(255) NOT NULL,
@@ -136,7 +140,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
locked_until DATETIME(3), locked_until DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, purpose) PRIMARY KEY (id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose (user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================ -- ============================================================
@@ -234,6 +239,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_friend_requests (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_friendships ( CREATE TABLE IF NOT EXISTS tinode.im_friendships (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL, peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL,
@@ -241,16 +247,19 @@ CREATE TABLE IF NOT EXISTS tinode.im_friendships (
topic CHAR(25) NOT NULL DEFAULT '', topic CHAR(25) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, peer_id), PRIMARY KEY (id),
UNIQUE INDEX uk_im_friendships_pair (user_id, peer_id),
INDEX idx_im_friendships_peer (peer_id, status) INDEX idx_im_friendships_peer (peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_user_blocks ( CREATE TABLE IF NOT EXISTS tinode.im_user_blocks (
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL, blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (blocker_id, blocked_id), PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_blocks_pair (blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked (blocked_id) INDEX idx_im_user_blocks_blocked (blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -553,6 +562,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions (
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_profiles ( CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '',
@@ -560,14 +570,26 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
signature VARCHAR(256) NOT NULL DEFAULT '', signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '', gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id) PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_profiles_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing database migration (the application performs the same migration
-- idempotently at startup):
-- 1. Add im_users.phone VARCHAR(32) NULL and a UNIQUE index.
-- 2. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''.
-- 3. Backfill phone/email from validated im_credentials first, then use
-- tel:/email: rows in im_usertags as a legacy fallback.
-- 4. Drop im_usertags and im_users.tags only after the backfill is verified.
-- im_topics.tags and im_topictags are topic discovery data and must be retained.
CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
add_me_methods JSON, add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
@@ -576,7 +598,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id) PRIMARY KEY (id),
UNIQUE INDEX uk_im_user_privacy_settings_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_app_releases ( CREATE TABLE IF NOT EXISTS tinode.im_app_releases (
@@ -599,6 +622,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_app_releases (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens ( CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens (
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL, device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL, provider VARCHAR(32) NOT NULL,
@@ -607,7 +631,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens (
platform VARCHAR(32) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (device_id), PRIMARY KEY (id),
UNIQUE INDEX uk_im_device_push_tokens_device (device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token (provider, token), UNIQUE INDEX uk_im_device_push_tokens_provider_token (provider, token),
INDEX idx_im_device_push_tokens_user (user_id, updated_at) INDEX idx_im_device_push_tokens_user (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+2
View File
@@ -127,6 +127,8 @@
9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。 9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。
10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。 10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。
11. 好友个人主页已执行 `profile_visibility`。 11. 好友个人主页已执行 `profile_visibility`。
12. 用户搜索已改用昵称、公开账号、登录账号和 `im_users.phone` 等结构化字段,不再依赖用户标签。
13. 注册手机号以 `im_users.phone` 为真源,邮箱作为 `im_user_profiles.email` 资料字段;旧 `im_users.tags/im_usertags` 已迁移删除,Topic 标签保留。
### 5.2 后续增强项 ### 5.2 后续增强项
@@ -3,6 +3,7 @@
-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。 -- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。
-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。 -- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。
-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。 -- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。
-- 本文件中的非自增 BIGINT 主键由服务端雪花算法生成。
SET NAMES utf8mb4; SET NAMES utf8mb4;
@@ -79,13 +80,15 @@ CREATE TABLE IF NOT EXISTS im_verification_codes(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_security( CREATE TABLE IF NOT EXISTS im_user_security(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1, auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3), password_updated_at DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id,user_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_security_tenant_user(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -132,6 +135,7 @@ CREATE TABLE IF NOT EXISTS im_action_tokens(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_youth_mode_settings( CREATE TABLE IF NOT EXISTS im_youth_mode_settings(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL, purpose VARCHAR(64) NOT NULL,
@@ -140,7 +144,8 @@ CREATE TABLE IF NOT EXISTS im_youth_mode_settings(
locked_until DATETIME(3), locked_until DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, user_id, purpose), PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_settings_tenant_user_purpose(tenant_id, user_id, purpose),
CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -192,6 +197,7 @@ CREATE UNIQUE INDEX uk_im_friend_requests_idempotency
ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, ''))); ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_friendships( CREATE TABLE IF NOT EXISTS im_friendships(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL, peer_id BIGINT NOT NULL,
@@ -200,18 +206,21 @@ CREATE TABLE IF NOT EXISTS im_friendships(
topic CHAR(25) NOT NULL DEFAULT '', topic CHAR(25) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, user_id, peer_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_tenant_pair(tenant_id, user_id, peer_id),
INDEX idx_im_friendships_peer(tenant_id, peer_id, status), 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 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_blocks( CREATE TABLE IF NOT EXISTS im_user_blocks(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL, blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, blocker_id, blocked_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_tenant_pair(tenant_id, blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), 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 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -312,5 +321,6 @@ CREATE TABLE IF NOT EXISTS im_message_revisions(
-- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic; -- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic;
-- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid); -- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid);
-- im_usertags/im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id; -- im_usertags 已废弃;im_users.phone 是手机号真源,邮箱存放于 im_user_profiles.email。
-- im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id;
-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。 -- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。
+7 -1
View File
@@ -205,7 +205,7 @@ HTTP API 通用规则:
| 模块 | 目标表 | 主要职责 | | 模块 | 目标表 | 主要职责 |
| --- | --- | --- | | --- | --- | --- |
| 好友关系 | `im_friendships`、`im_friend_requests`、`im_user_blocks` | 好友申请、同意、拒绝、删除、拉黑 | | 好友关系 | `im_friendships`、`im_friend_requests`、`im_user_blocks` | 好友申请、同意、拒绝、删除、拉黑 |
| 用户资料 | `im_user_profiles`、`im_user_privacy_settings` | 昵称、生日、签名、可见性和添加方式 | | 用户资料 | `im_users`、`im_user_profiles`、`im_user_privacy_settings` | 注册手机号、昵称、邮箱、生日、签名、可见性和添加方式 |
| 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_announcements`、`im_group_mutes`、`im_group_admin_logs` | 入群审批、公告、禁言、管理员、转让群主 | | 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_announcements`、`im_group_mutes`、`im_group_admin_logs` | 入群审批、公告、禁言、管理员、转让群主 |
| 群文件 | `im_group_files` | 群文件目录、搜索、删除权限 | | 群文件 | `im_group_files` | 群文件目录、搜索、删除权限 |
| 收藏 | `im_favorites` | 收藏去重、分类、来源、搜索和分享 | | 收藏 | `im_favorites` | 收藏去重、分类、来源、搜索和分享 |
@@ -234,6 +234,10 @@ HTTP API 通用规则:
| 群成员 | `im_group_members` | 群成员业务状态、角色、入群来源和操作者 | | 群成员 | `im_group_members` | 群成员业务状态、角色、入群来源和操作者 |
| 消息版本 | `im_message_revisions` | 编辑前内容、版本、操作者和变更原因 | | 消息版本 | `im_message_revisions` | 编辑前内容、版本、操作者和变更原因 |
MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键。`im_user_security`、`im_kvmeta`、用户资料/隐私、设备 Push Token、好友/黑名单、青少年模式以及群设置/公告已读/禁言等原先使用自然键或复合主键的表,统一改为服务端雪花算法生成的 `BIGINT id` 主键;原业务键保留唯一索引,查询和幂等语义不变。服务启动时会对当前租户库执行幂等迁移,为存量行补齐雪花 ID 后再替换主键。迁移期间不得同时运行不写入 `id` 的旧版本服务。
用户身份字段约定:`im_users.phone` 是注册、密码找回和敏感操作验证使用的手机号真源,新注册必须提供已验证手机号;历史无手机号账号迁移后允许暂时为 `NULL`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。
### 7.3 企业码与登录流程 ### 7.3 企业码与登录流程
1. 未认证客户端先通过 `hi.tenant` 提交企业码;服务端只按 `im_tenant.code` 查找启用租户并把 `TenantID` 固定到 Session。 1. 未认证客户端先通过 `hi.tenant` 提交企业码;服务端只按 `im_tenant.code` 查找启用租户并把 `TenantID` 固定到 Session。
@@ -506,6 +510,8 @@ end_reason, created_at, updated_at
| --- | --- | | --- | --- |
| 头像、昵称、签名 | User Public + `im_user_profiles` | | 头像、昵称、签名 | User Public + `im_user_profiles` |
| 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 | | 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 |
| 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 |
| 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 |
| 实名姓名、证件号 | 实名表加密存储,不进入 Public | | 实名姓名、证件号 | 实名表加密存储,不进入 Public |
| 群昵称 | `im_group_members`,按需投影到 Subscription Private | | 群昵称 | `im_group_members`,按需投影到 Subscription Private |
+10 -3
View File
@@ -3,10 +3,12 @@
-- Database: the currently selected tenant database. -- Database: the currently selected tenant database.
-- Important: one tenant owns one database, so these tables intentionally do not contain tenant_id. -- 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. -- This SQL is for review first. Do not execute directly in production before confirming indexes and migration order.
-- Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator.
-- 1. Group profile and governance truth. -- 1. Group profile and governance truth.
-- Topic/Subscription remains the communication projection; this table is the product business truth. -- Topic/Subscription remains the communication projection; this table is the product business truth.
CREATE TABLE IF NOT EXISTS im_group_settings ( CREATE TABLE IF NOT EXISTS im_group_settings (
id BIGINT NOT NULL,
topic VARCHAR(32) NOT NULL, topic VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL, owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '', name VARCHAR(128) NOT NULL DEFAULT '',
@@ -27,7 +29,8 @@ CREATE TABLE IF NOT EXISTS im_group_settings (
dissolved_at DATETIME(3), dissolved_at DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic), PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_settings_topic (topic),
INDEX idx_im_group_settings_owner (owner_user_id, group_status), INDEX idx_im_group_settings_owner (owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated (group_status, updated_at) INDEX idx_im_group_settings_status_updated (group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -135,19 +138,22 @@ CREATE TABLE IF NOT EXISTS im_group_announcements (
-- 6. Announcement read receipts. -- 6. Announcement read receipts.
CREATE TABLE IF NOT EXISTS im_group_announcement_reads ( CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
id BIGINT NOT NULL,
announcement_id VARCHAR(64) NOT NULL, announcement_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL, topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
read_at DATETIME(3) NOT NULL, read_at DATETIME(3) NOT NULL,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (announcement_id, user_id), PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_announcement_reads_announcement_user (announcement_id, user_id),
INDEX idx_im_group_announcement_reads_topic_user (topic, user_id, read_at) INDEX idx_im_group_announcement_reads_topic_user (topic, user_id, read_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 7. Current member mute state. -- 7. Current member mute state.
-- Historical changes are stored in im_group_admin_logs. -- Historical changes are stored in im_group_admin_logs.
CREATE TABLE IF NOT EXISTS im_group_mutes ( CREATE TABLE IF NOT EXISTS im_group_mutes (
id BIGINT NOT NULL,
topic VARCHAR(32) NOT NULL, topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
muted_by BIGINT NOT NULL, muted_by BIGINT NOT NULL,
@@ -160,7 +166,8 @@ CREATE TABLE IF NOT EXISTS im_group_mutes (
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (topic, user_id), PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_mutes_topic_user (topic, user_id),
INDEX idx_im_group_mutes_topic_status (topic, status, expires_at), INDEX idx_im_group_mutes_topic_status (topic, status, expires_at),
INDEX idx_im_group_mutes_user_status (user_id, 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+5 -1
View File
@@ -5,6 +5,7 @@
> 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md` > 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md`
> 多租户依据:`IM-独立库多租户改造方案.md` > 多租户依据:`IM-独立库多租户改造方案.md`
> 明确排除:已废弃的 `IM-服务端多租户改造方案.md` > 明确排除:已废弃的 `IM-服务端多租户改造方案.md`
> 数据库更新:2026-08-18 已完成无独立 `id` 表的雪花主键改造和本地库迁移。
## 1. 审核说明 ## 1. 审核说明
@@ -353,10 +354,13 @@
- 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。 - 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。
- 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。 - 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。
- `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。 - `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。
- MySQL 业务表已统一具备独立 `id` 主键;原先使用自然键或复合主键的 11 张表已改用服务端雪花 `BIGINT id`,原业务键保留唯一索引。
- 应用启动会在当前租户库幂等补列、回填存量雪花 ID 并切换主键;迁移使用数据库级 advisory lock,独立租户库与残留 `tenant_id` 旧结构均可保留原业务唯一性。
- 用户身份字段已结构化:手机号迁入 `im_users.phone`,邮箱迁入 `im_user_profiles.email`;用户 `tags/im_usertags` 在回填后删除,Topic 标签体系继续保留。
### 需要修正 ### 需要修正
1. 建立唯一 schema 版本和迁移入口,禁止“应用启动建部分表 + 手工 SQL 建另一部分表”长期并存。 1. 继续把其他历史建表/升级逻辑收敛到唯一 schema 版本和迁移入口;本次雪花主键改造已具备统一自动迁移入口。
2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。 2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。
3. 新租户建库和存量租户升级必须执行同一套 migration。 3. 新租户建库和存量租户升级必须执行同一套 migration。
4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。 4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。
+21 -6
View File
@@ -132,6 +132,7 @@ func main() {
INDEX idx_im_friend_requests_inbox(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`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friendships( `CREATE TABLE IF NOT EXISTS tinode.im_friendships(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL, peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL,
@@ -139,15 +140,18 @@ func main() {
topic CHAR(25) NOT NULL DEFAULT '', topic CHAR(25) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id, peer_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status) INDEX idx_im_friendships_peer(peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_blocks( `CREATE TABLE IF NOT EXISTS tinode.im_user_blocks(
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL, blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(blocker_id, blocked_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id) INDEX idx_im_user_blocks_blocked(blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_favorites( `CREATE TABLE IF NOT EXISTS tinode.im_favorites(
@@ -356,6 +360,7 @@ func main() {
INDEX idx_im_mailbox_submissions_status(status, id DESC) INDEX idx_im_mailbox_submissions_status(status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_profiles( `CREATE TABLE IF NOT EXISTS tinode.im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '',
@@ -363,13 +368,16 @@ func main() {
signature VARCHAR(256) NOT NULL DEFAULT '', signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '', gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id) PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_profiles_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings( `CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
add_me_methods JSON, add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
@@ -378,7 +386,8 @@ func main() {
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id) PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_privacy_settings_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_app_releases( `CREATE TABLE IF NOT EXISTS tinode.im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -399,6 +408,7 @@ func main() {
UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens( `CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL, device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL, provider VARCHAR(32) NOT NULL,
@@ -407,11 +417,13 @@ func main() {
platform VARCHAR(32) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(device_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at) INDEX idx_im_device_push_tokens_user(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings( `CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL, purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL, password_hash VARBINARY(255) NOT NULL,
@@ -423,7 +435,8 @@ func main() {
locked_until DATETIME(3), locked_until DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id, purpose) PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges( `CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges(
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -524,6 +537,8 @@ func main() {
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN policy_version INT NOT NULL DEFAULT 1 AFTER enabled", "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN 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 restrictions JSON AFTER policy_version",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions", "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions",
"ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent",
"ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region",
} { } {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") { if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
+9 -10
View File
@@ -203,18 +203,17 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
return nil, err return nil, err
} }
// Remove old tag from the list of tags if a.addToTags {
oldTag := a.name + ":" + login oldTag := a.name + ":" + login
for i, tag := range rec.Tags { for i, tag := range rec.Tags {
if tag == oldTag { if tag == oldTag {
rec.Tags[i] = rec.Tags[len(rec.Tags)-1] rec.Tags[i] = rec.Tags[len(rec.Tags)-1]
rec.Tags = rec.Tags[:len(rec.Tags)-1] rec.Tags = rec.Tags[:len(rec.Tags)-1]
break
break }
} }
rec.Tags = append(rec.Tags, a.name+":"+uname)
} }
// Add new tag
rec.Tags = append(rec.Tags, a.name+":"+uname)
return rec, nil return rec, nil
} }
+123 -151
View File
@@ -246,6 +246,12 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil { if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error()) return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
} }
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
}
if err = ensureUserIdentitySchema(a.db); err != nil {
return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error())
}
} }
} }
return err return err
@@ -440,6 +446,14 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close() db.Close()
return nil, nil, err return nil, nil, err
} }
if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureUserIdentitySchema(db); err != nil {
db.Close()
return nil, nil, err
}
tenantAdp := &adapter{ tenantAdp := &adapter{
db: db, db: db,
dsn: mysqlDefaultString(dsn, a.dsn), dsn: mysqlDefaultString(dsn, a.dsn),
@@ -589,7 +603,13 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil { if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil {
return err return err
} }
if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(registryDB)); err != nil { if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
return err
}
if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant_config"); err != nil {
return err
}
if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant"); err != nil {
return err return err
} }
} }
@@ -616,10 +636,11 @@ func (a *adapter) CreateDb(reset bool) error {
access JSON, access JSON,
lastseen DATETIME, lastseen DATETIME,
useragent VARCHAR(255) DEFAULT '', useragent VARCHAR(255) DEFAULT '',
phone VARCHAR(32),
public JSON, public JSON,
trusted JSON, trusted JSON,
tags JSON,
PRIMARY KEY(id), PRIMARY KEY(id),
UNIQUE INDEX users_phone(phone),
INDEX users_state_stateat(state, stateat), INDEX users_state_stateat(state, stateat),
INDEX users_lastseen_updatedat(lastseen, updatedat) INDEX users_lastseen_updatedat(lastseen, updatedat)
)`); err != nil { )`); err != nil {
@@ -628,12 +649,14 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec( if _, err = tx.Exec(
`CREATE TABLE im_user_security( `CREATE TABLE im_user_security(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1, auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3), password_updated_at DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id) PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_security_user(user_id)
)`); err != nil { )`); err != nil {
return err return err
} }
@@ -645,20 +668,6 @@ func (a *adapter) CreateDb(reset bool) error {
return err return err
} }
// Indexed user tags.
if _, err = tx.Exec(
`CREATE TABLE im_usertags(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
INDEX usertags_tag(tag),
UNIQUE INDEX usertags_userid_tag(userid, tag)
)`); err != nil {
return err
}
// Indexed devices. Normalized into a separate table. // Indexed devices. Normalized into a separate table.
if _, err = tx.Exec( if _, err = tx.Exec(
`CREATE TABLE im_devices( `CREATE TABLE im_devices(
@@ -919,15 +928,21 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec( if _, err = tx.Exec(
`CREATE TABLE im_kvmeta(` + `CREATE TABLE im_kvmeta(` +
"id BIGINT NOT NULL," +
"`key` VARCHAR(64) NOT NULL," + "`key` VARCHAR(64) NOT NULL," +
"createdat DATETIME(3)," + "createdat DATETIME(3)," +
"`value` TEXT," + "`value` TEXT," +
"PRIMARY KEY(`key`)," + "PRIMARY KEY(id)," +
"UNIQUE INDEX uk_im_kvmeta_key(`key`)," +
"INDEX kvmeta_createdat_key(createdat, `key`)" + "INDEX kvmeta_createdat_key(createdat, `key`)" +
`)`); err != nil { `)`); err != nil {
return err return err
} }
if _, err = tx.Exec("INSERT INTO im_kvmeta(`key`, `value`) VALUES('version',?)", adpVersion); err != nil { versionID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO im_kvmeta(id, `key`, `value`) VALUES(?,'version',?)", versionID, adpVersion); err != nil {
return err return err
} }
@@ -1225,6 +1240,7 @@ func createIMMailboxTables(tx *sql.Tx) error {
func createIMUserProductTables(tx *sql.Tx) error { func createIMUserProductTables(tx *sql.Tx) error {
statements := []string{ statements := []string{
`CREATE TABLE im_user_profiles( `CREATE TABLE im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '',
@@ -1232,13 +1248,16 @@ func createIMUserProductTables(tx *sql.Tx) error {
signature VARCHAR(256) NOT NULL DEFAULT '', signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '', gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id) PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_profiles_user(user_id)
)`, )`,
`CREATE TABLE im_user_privacy_settings( `CREATE TABLE im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
add_me_methods JSON, add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
@@ -1247,7 +1266,8 @@ func createIMUserProductTables(tx *sql.Tx) error {
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id) PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_privacy_settings_user(user_id)
)`, )`,
`CREATE TABLE im_app_releases( `CREATE TABLE im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -1268,6 +1288,7 @@ func createIMUserProductTables(tx *sql.Tx) error {
UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel)
)`, )`,
`CREATE TABLE im_device_push_tokens( `CREATE TABLE im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL, device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL, provider VARCHAR(32) NOT NULL,
@@ -1276,7 +1297,8 @@ func createIMUserProductTables(tx *sql.Tx) error {
platform VARCHAR(32) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(device_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at) INDEX idx_im_device_push_tokens_user(user_id, updated_at)
)`, )`,
@@ -1443,6 +1465,7 @@ func createIMRelationTables(exec sqlExecutor) error {
INDEX idx_im_friend_requests_inbox(target_id, status, created_at) INDEX idx_im_friend_requests_inbox(target_id, status, created_at)
)`, )`,
`CREATE TABLE im_friendships( `CREATE TABLE im_friendships(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL, peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL,
@@ -1450,15 +1473,18 @@ func createIMRelationTables(exec sqlExecutor) error {
topic CHAR(25) NOT NULL DEFAULT '', topic CHAR(25) NOT NULL DEFAULT '',
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id, peer_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status) INDEX idx_im_friendships_peer(peer_id, status)
)`, )`,
`CREATE TABLE im_user_blocks( `CREATE TABLE im_user_blocks(
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL, blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL,
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(blocker_id, blocked_id), PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id) INDEX idx_im_user_blocks_blocked(blocked_id)
)`, )`,
`CREATE TABLE im_favorites( `CREATE TABLE im_favorites(
@@ -1536,6 +1562,7 @@ func createIMSecurityTables(tx *sql.Tx) error {
} }
_, err := tx.Exec( _, err := tx.Exec(
`CREATE TABLE im_youth_mode_settings( `CREATE TABLE im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL, purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL, password_hash VARBINARY(255) NOT NULL,
@@ -1547,7 +1574,8 @@ func createIMSecurityTables(tx *sql.Tx) error {
locked_until DATETIME(3), locked_until DATETIME(3),
created_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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(user_id, purpose) PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose)
)`) )`)
return err return err
} }
@@ -1650,27 +1678,31 @@ func (a *adapter) UserCreate(user *t.User) error {
}() }()
decoded_uid := store.DecodeUid(user.Uid()) decoded_uid := store.DecodeUid(user.Uid())
if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)", securityID, err := nextSnowflakeID()
if err != nil {
return err
}
var phone any
if strings.TrimSpace(user.Phone) != "" {
phone = strings.TrimSpace(user.Phone)
}
if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)",
decoded_uid, decoded_uid,
user.CreatedAt, user.CreatedAt,
user.UpdatedAt, user.UpdatedAt,
user.State, user.State,
user.Access, user.Access,
phone,
common.ToJSON(user.Public), common.ToJSON(user.Public),
common.ToJSON(user.Trusted), common.ToJSON(user.Trusted)); err != nil {
user.Tags); err != nil {
return err return err
} }
if _, err = tx.Exec("INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?)", if _, err = tx.Exec("INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)",
decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil { securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
// Save user's tags to a separate table to make user findable.
if err = addTags(tx, "im_usertags", "userid", decoded_uid, user.Tags, false); err != nil {
return err return err
} }
user.Tags = nil
return tx.Commit() return tx.Commit()
} }
@@ -1895,7 +1927,8 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
defer cancel() defer cancel()
} }
var user t.User var user t.User
err := a.db.GetContext(ctx, &user, "SELECT * FROM im_users WHERE id=? AND state!=?", store.DecodeUid(uid), t.StateDeleted) err := a.db.GetContext(ctx, &user, `SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil { if err == nil {
user.SetUid(uid) user.SetUid(uid)
user.Public = common.FromJSON(user.Public) user.Public = common.FromJSON(user.Public)
@@ -1925,7 +1958,8 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
q, uids, _ := sqlx.In("SELECT * FROM im_users WHERE id IN (?) AND state!=?", uids, t.StateDeleted) q, uids, _ := sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...) rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2048,10 +2082,6 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
return err return err
} }
if _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_users WHERE id=?", decoded_uid); err != nil { if _, err = tx.Exec("DELETE FROM im_users WHERE id=?", decoded_uid); err != nil {
return err return err
} }
@@ -2150,7 +2180,11 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
} }
}() }()
update = withoutUserTags(update)
cols, args := common.UpdateByMap(update) cols, args := common.UpdateByMap(update)
if len(cols) == 0 {
return tx.Commit()
}
decoded_uid := store.DecodeUid(uid) decoded_uid := store.DecodeUid(uid)
args = append(args, decoded_uid) args = append(args, decoded_uid)
_, err = tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) _, err = tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...)
@@ -2166,76 +2200,12 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
} }
} }
// Tags are also stored in a separate table
if tags := common.ExtractTags(update); tags != nil {
// First delete all user tags
_, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid)
if err != nil {
return err
}
// Now insert new tags
err = addTags(tx, "im_usertags", "userid", decoded_uid, tags, false)
if err != nil {
return err
}
}
return tx.Commit() return tx.Commit()
} }
// UserUpdateTags adds, removes, or resets user's tags. // UserUpdateTags is retained for adapter compatibility; user tags are disabled.
func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) { func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
ctx, cancel := a.getContextForTx() return nil, nil
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded_uid := store.DecodeUid(uid)
if reset != nil {
// Delete all tags first if resetting.
_, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid)
if err != nil {
return nil, err
}
add = reset
remove = nil
}
// Now insert new tags. Ignore duplicates if resetting.
err = addTags(tx, "im_usertags", "userid", decoded_uid, add, reset == nil)
if err != nil {
return nil, err
}
// Delete tags.
err = removeTags(tx, "im_usertags", "userid", decoded_uid, remove)
if err != nil {
return nil, err
}
var allTags []string
err = tx.Select(&allTags, "SELECT tag FROM im_usertags WHERE userid=?", decoded_uid)
if err != nil {
return nil, err
}
_, err = tx.Exec("UPDATE im_users SET tags=? WHERE id=?", t.StringSlice(allTags), decoded_uid)
if err != nil {
return nil, err
}
return allTags, tx.Commit()
} }
// UserGetByCred returns user ID for the given validated credential. // UserGetByCred returns user ID for the given validated credential.
@@ -2245,7 +2215,12 @@ func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
defer cancel() defer cancel()
} }
var decoded_uid int64 var decoded_uid int64
err := a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value) var err error
if method == "tel" {
err = a.db.GetContext(ctx, &decoded_uid, "SELECT id FROM im_users WHERE phone=? AND state!=?", value, t.StateDeleted)
} else {
err = a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value)
}
if err == nil { if err == nil {
return store.EncodeUid(decoded_uid), nil return store.EncodeUid(decoded_uid), nil
} }
@@ -2266,6 +2241,22 @@ func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string)
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.UserGetByCred(method, value) return tenantAdp.UserGetByCred(method, value)
} }
if !a.tenantScopedDB && method == "tel" {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var decodedUID int64
err = a.db.GetContext(ctx, &decodedUID,
"SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", tenantID, value, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decodedUID), nil
}
return a.UserGetByCred(method, value) return a.UserGetByCred(method, value)
} }
@@ -3329,17 +3320,21 @@ func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error {
return tx.Commit() return tx.Commit()
} }
// Find returns a list of users or group topics who match given tags, such as "email:jdoe@example.com" or "tel:+18003287448". // Find returns group topics which match the requested discovery tags.
func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) { func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
index := make(map[string]struct{})
allReq := t.FlattenDoubleSlice(req)
allTags := append(append([]string{}, allReq...), opt...)
if len(allTags) == 0 {
return nil, nil
}
var args []any var args []any
stateConstraint := "" stateConstraint := ""
if activeOnly { if activeOnly {
args = append(args, t.StateOK) args = append(args, t.StateOK)
stateConstraint = "u.state=? AND " stateConstraint = "t.state=? AND "
} }
index := make(map[string]struct{}) for _, tag := range allTags {
allReq := t.FlattenDoubleSlice(req)
for _, tag := range append(allReq, opt...) {
args = append(args, tag) args = append(args, tag)
index[tag] = struct{}{} index[tag] = struct{}{}
} }
@@ -3352,29 +3347,9 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string,
matcher = "COUNT(*)" matcher = "COUNT(*)"
} }
query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " + query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_users AS u JOIN im_usertags AS tg ON tg.userid=u.id " +
"WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " +
"GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags "
if len(allReq) > 0 {
q, a := common.DisjunctionSql(req, "tg.tag")
query += q
args = append(args, a...)
}
query += "UNION ALL "
if activeOnly {
args = append(args, t.StateOK)
stateConstraint = "t.state=? AND "
}
for _, tag := range append(allReq, opt...) {
args = append(args, tag)
}
query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " + "FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " +
"WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " + "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags " "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
if len(allReq) > 0 { if len(allReq) > 0 {
q, a := common.DisjunctionSql(req, "tg.tag") q, a := common.DisjunctionSql(req, "tg.tag")
@@ -3443,29 +3418,17 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string,
return subs, err return subs, err
} }
// FindOne returns the first topic or user which matches the given tag. // FindOne returns the first topic which matches the given tag.
func (a *adapter) FindOne(tag string) (string, error) { func (a *adapter) FindOne(tag string) (string, error) {
var args []any
query := "SELECT t.name AS topic FROM im_topics AS t LEFT JOIN im_topictags AS tt ON t.name=tt.topic " + query := "SELECT t.name AS topic FROM im_topics AS t LEFT JOIN im_topictags AS tt ON t.name=tt.topic " +
"WHERE tt.tag=?" "WHERE tt.tag=? LIMIT 1"
args = append(args, tag)
query += " UNION ALL "
query += "SELECT u.id AS topic FROM im_users AS u LEFT JOIN im_usertags AS ut ON ut.userid=u.id " +
"WHERE ut.tag=?"
args = append(args, tag)
// LIMIT is applied to all resultant rows.
query += " LIMIT 1"
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, query, args...) rows, err := a.db.QueryxContext(ctx, query, tag)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -3998,6 +3961,11 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {
} }
return true, err return true, err
} }
if cred.Done && cred.Method == "tel" {
if _, err = tx.Exec("UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')", cred.Value, userId); err != nil {
return true, err
}
}
return true, tx.Commit() return true, tx.Commit()
} }
@@ -4635,7 +4603,11 @@ func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) e
action = "REPLACE" action = "REPLACE"
} }
_, err := a.db.ExecContext(ctx, action+" INTO im_kvmeta(`key`,createdat,`value`) VALUES(?,?,?)", key, t.TimeNow(), value) id, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, action+" INTO im_kvmeta(id,`key`,createdat,`value`) VALUES(?,?,?,?)", id, key, t.TimeNow(), value)
if isDupe(err) { if isDupe(err) {
return t.ErrDuplicate return t.ErrDuplicate
} }
+9 -6
View File
@@ -9,7 +9,7 @@ package mysql
func ensureIMGroupBusinessTables(exec sqlExecutor) error { func ensureIMGroupBusinessTables(exec sqlExecutor) error {
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_settings ( `CREATE TABLE IF NOT EXISTS im_group_settings (
topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL, id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', 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', 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, join_approval TINYINT NOT NULL DEFAULT 0, mute_all TINYINT NOT NULL DEFAULT 0,
@@ -19,7 +19,8 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
version INT NOT NULL DEFAULT 1, created_by BIGINT NOT NULL, dissolved_by BIGINT, 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), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic), INDEX idx_im_group_settings_owner(owner_user_id, group_status), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_settings_topic(topic),
INDEX idx_im_group_settings_owner(owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at) INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_members ( `CREATE TABLE IF NOT EXISTS im_group_members (
@@ -77,18 +78,20 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_announcements_topic_time(topic, published_at DESC, id DESC) INDEX idx_im_group_announcements_topic_time(topic, published_at DESC, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcement_reads ( `CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(announcement_id, user_id), INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at) PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcement_reads_announcement_user(announcement_id, user_id),
INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_mutes ( `CREATE TABLE IF NOT EXISTS im_group_mutes (
topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL, id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
muted_at DATETIME(3) NOT NULL, expires_at DATETIME(3), reason VARCHAR(256) NOT NULL DEFAULT '', 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), 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), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic, user_id), INDEX idx_im_group_mutes_topic_status(topic, status, expires_at), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_mutes_topic_user(topic, user_id),
INDEX idx_im_group_mutes_topic_status(topic, status, expires_at),
INDEX idx_im_group_mutes_user_status(user_id, status, expires_at) INDEX idx_im_group_mutes_user_status(user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_qrcodes ( `CREATE TABLE IF NOT EXISTS im_group_qrcodes (
+10
View File
@@ -30,4 +30,14 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
t.Fatalf("group schema must use the selected tenant database: %s", statement) t.Fatalf("group schema must use the selected tenant database: %s", statement)
} }
} }
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, expected := range []string{
"primary key(id), unique index uk_im_group_settings_topic(topic)",
"primary key(id), unique index uk_im_group_announcement_reads_announcement_user(announcement_id, user_id)",
"primary key(id), unique index uk_im_group_mutes_topic_user(topic, user_id)",
} {
if !strings.Contains(joined, expected) {
t.Fatalf("group schema is missing snowflake primary-key definition %q", expected)
}
}
} }
+178
View File
@@ -0,0 +1,178 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
)
type snowflakePrimaryKeySpec struct {
table string
uniqueIndex string
businessKey []string
}
var snowflakePrimaryKeySpecs = []snowflakePrimaryKeySpec{
{table: "im_user_security", uniqueIndex: "uk_im_user_security_user", businessKey: []string{"user_id"}},
{table: "im_kvmeta", uniqueIndex: "uk_im_kvmeta_key", businessKey: []string{"key"}},
{table: "im_user_profiles", uniqueIndex: "uk_im_user_profiles_user", businessKey: []string{"user_id"}},
{table: "im_user_privacy_settings", uniqueIndex: "uk_im_user_privacy_settings_user", businessKey: []string{"user_id"}},
{table: "im_device_push_tokens", uniqueIndex: "uk_im_device_push_tokens_device", businessKey: []string{"device_id"}},
{table: "im_friendships", uniqueIndex: "uk_im_friendships_pair", businessKey: []string{"user_id", "peer_id"}},
{table: "im_user_blocks", uniqueIndex: "uk_im_user_blocks_pair", businessKey: []string{"blocker_id", "blocked_id"}},
{table: "im_youth_mode_settings", uniqueIndex: "uk_im_youth_mode_settings_user_purpose", businessKey: []string{"user_id", "purpose"}},
{table: "im_group_settings", uniqueIndex: "uk_im_group_settings_topic", businessKey: []string{"topic"}},
{table: "im_group_announcement_reads", uniqueIndex: "uk_im_group_announcement_reads_announcement_user", businessKey: []string{"announcement_id", "user_id"}},
{table: "im_group_mutes", uniqueIndex: "uk_im_group_mutes_topic_user", businessKey: []string{"topic", "user_id"}},
}
const snowflakeMigrationLock = "CONCAT('im_snowflake_pk:', LEFT(SHA2(DATABASE(), 256), 40))"
func nextSnowflakeID() (int64, error) {
uid := store.Store.GetUid()
if uid.IsZero() {
return 0, errors.New("mysql: snowflake generator is not initialized")
}
id := store.DecodeUid(uid)
if id <= 0 {
return 0, errors.New("mysql: snowflake generator returned an invalid ID")
}
return id, nil
}
// ensureSnowflakePrimaryKeys upgrades legacy business-key primary keys in the
// selected tenant database. The former primary-key columns remain unique.
func ensureSnowflakePrimaryKeys(db *sqlx.DB) error {
ctx := context.Background()
conn, err := db.Connx(ctx)
if err != nil {
return err
}
defer conn.Close()
var locked sql.NullInt64
if err = conn.QueryRowContext(ctx,
"SELECT GET_LOCK("+snowflakeMigrationLock+", 60)").Scan(&locked); err != nil {
return err
}
if !locked.Valid || locked.Int64 != 1 {
return errors.New("mysql: timed out waiting for snowflake primary-key migration lock")
}
defer conn.ExecContext(ctx, "SELECT RELEASE_LOCK("+snowflakeMigrationLock+")")
for _, spec := range snowflakePrimaryKeySpecs {
if err = migrateSnowflakePrimaryKey(ctx, conn, spec); err != nil {
return fmt.Errorf("mysql: migrate %s primary key: %w", spec.table, err)
}
}
return nil
}
func migrateSnowflakePrimaryKey(ctx context.Context, conn *sqlx.Conn, spec snowflakePrimaryKeySpec) error {
var exists int
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?)`, spec.table).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return nil
}
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name='id')`, spec.table).Scan(&exists); err != nil {
return err
}
if exists == 0 {
if _, err := conn.ExecContext(ctx, "ALTER TABLE "+quoteIdent(spec.table)+" ADD COLUMN id BIGINT NULL FIRST"); err != nil {
return err
}
}
for {
if err := conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM "+quoteIdent(spec.table)+" WHERE id IS NULL OR id=0 LIMIT 1)").Scan(&exists); err != nil {
return err
}
if exists == 0 {
break
}
id, err := nextSnowflakeID()
if err != nil {
return err
}
result, err := conn.ExecContext(ctx, "UPDATE "+quoteIdent(spec.table)+" SET id=? WHERE id IS NULL OR id=0 LIMIT 1", id)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return err
}
return errors.New("could not backfill exactly one row")
}
}
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`,
spec.table, spec.uniqueIndex).Scan(&exists); err != nil {
return err
}
if exists == 0 {
businessKey := spec.businessKey
var hasTenantID int
if err := conn.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name='tenant_id')`,
spec.table).Scan(&hasTenantID); err != nil {
return err
}
if hasTenantID == 1 {
businessKey = append([]string{"tenant_id"}, businessKey...)
}
columns := make([]string, len(businessKey))
for i, column := range businessKey {
columns[i] = quoteIdent(column)
}
statement := "CREATE UNIQUE INDEX " + quoteIdent(spec.uniqueIndex) + " ON " +
quoteIdent(spec.table) + "(" + strings.Join(columns, ",") + ")"
if _, err := conn.ExecContext(ctx, statement); err != nil {
return err
}
}
var primaryColumns sql.NullString
if err := conn.QueryRowContext(ctx, `SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',')
FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name=? AND index_name='PRIMARY'`, spec.table).Scan(&primaryColumns); err != nil {
return err
}
if primaryColumns.Valid && primaryColumns.String == "id" {
var dataType, nullable, extra string
if err := conn.QueryRowContext(ctx, `SELECT data_type,is_nullable,extra
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name=? AND column_name='id'`, spec.table).
Scan(&dataType, &nullable, &extra); err != nil {
return err
}
if strings.EqualFold(dataType, "bigint") && strings.EqualFold(nullable, "NO") &&
!strings.Contains(strings.ToLower(extra), "auto_increment") {
return nil
}
_, err := conn.ExecContext(ctx, "ALTER TABLE "+quoteIdent(spec.table)+" MODIFY COLUMN id BIGINT NOT NULL")
return err
}
statement := "ALTER TABLE " + quoteIdent(spec.table) + " MODIFY COLUMN id BIGINT NOT NULL"
if primaryColumns.Valid && primaryColumns.String != "" {
statement += ", DROP PRIMARY KEY"
}
statement += ", ADD PRIMARY KEY(id)"
_, err := conn.ExecContext(ctx, statement)
return err
}
+116 -123
View File
@@ -152,7 +152,8 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
defer cancel() defer cancel()
} }
var user t.User var user t.User
err = a.db.GetContext(ctx, &user, "SELECT * FROM im_users WHERE tenant_id=? AND id=? AND state!=?", err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`,
id, decodeUID(uid), t.StateDeleted) id, decodeUID(uid), t.StateDeleted)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -200,9 +201,11 @@ func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.Tena
var q string var q string
var params []any var params []any
if a.tenantScopedDB { if a.tenantScopedDB {
q, params, _ = sqlx.In("SELECT * FROM im_users WHERE id IN (?) AND state!=?", args, t.StateDeleted) q, params, _ = sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted)
} else { } else {
q, params, _ = sqlx.In("SELECT * FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?", id, args, t.StateDeleted) q, params, _ = sqlx.In(`SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?`, id, args, t.StateDeleted)
} }
query := a.db.Rebind(q) query := a.db.Rebind(q)
var rows *sqlx.Rows var rows *sqlx.Rows
@@ -308,9 +311,6 @@ func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
if _, err = tx.Exec("DELETE FROM im_credentials WHERE userid=?", decoded); err != nil { if _, err = tx.Exec("DELETE FROM im_credentials WHERE userid=?", decoded); err != nil {
return err return err
} }
if _, err = tx.Exec("DELETE FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_users WHERE tenant_id=? AND id=?", id, decoded); err != nil { if _, err = tx.Exec("DELETE FROM im_users WHERE tenant_id=? AND id=?", id, decoded); err != nil {
return err return err
} }
@@ -389,7 +389,11 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a
} }
}() }()
decoded := decodeUID(uid) decoded := decodeUID(uid)
update = withoutUserTags(update)
cols, args := common.UpdateByMap(update) cols, args := common.UpdateByMap(update)
if len(cols) == 0 {
return tx.Commit()
}
args = append(args, id, decoded) args = append(args, id, decoded)
res, err := tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...) res, err := tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...)
if err != nil { if err != nil {
@@ -404,14 +408,6 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a
return err return err
} }
} }
if tags := common.ExtractTags(update); tags != nil {
if _, err = tx.Exec("DELETE FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return err
}
if err = addTenantTags(tx, id, "im_usertags", "userid", decoded, tags, false); err != nil {
return err
}
}
return tx.Commit() return tx.Commit()
} }
@@ -426,43 +422,7 @@ func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, re
if a.tenantScopedDB { if a.tenantScopedDB {
return a.UserUpdateTags(uid, add, remove, reset) return a.UserUpdateTags(uid, add, remove, reset)
} }
ctx, cancel := a.getContextForTx() return nil, nil
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded := decodeUID(uid)
if reset != nil {
if _, err = tx.Exec("DELETE FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return nil, err
}
add, remove = reset, nil
}
if err = addTenantTags(tx, id, "im_usertags", "userid", decoded, add, reset == nil); err != nil {
return nil, err
}
if len(remove) > 0 {
q, params, _ := sqlx.In("DELETE FROM im_usertags WHERE tenant_id=? AND userid=? AND tag IN (?)", id, decoded, remove)
if _, err = tx.Exec(tx.Rebind(q), params...); err != nil {
return nil, err
}
}
var allTags []string
if err = tx.Select(&allTags, "SELECT tag FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return nil, err
}
if _, err = tx.Exec("UPDATE im_users SET tags=? WHERE tenant_id=? AND id=?", t.StringSlice(allTags), id, decoded); err != nil {
return nil, err
}
return allTags, tx.Commit()
} }
func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) { func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) {
@@ -1520,7 +1480,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]
stateConstraint := "" stateConstraint := ""
if activeOnly { if activeOnly {
args = append(args, t.StateOK) args = append(args, t.StateOK)
stateConstraint = "u.state=? AND " stateConstraint = "t.state=? AND "
} }
index := make(map[string]struct{}) index := make(map[string]struct{})
for _, tag := range allTags { for _, tag := range allTags {
@@ -1531,27 +1491,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]
if promoPrefix != "" { if promoPrefix != "" {
matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)" matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)"
} }
query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " + query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_users AS u JOIN im_usertags AS tg ON tg.tenant_id=u.tenant_id AND tg.userid=u.id " +
"WHERE u.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
"GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags "
if len(allReq) > 0 {
q, addArgs := common.DisjunctionSql(req, "tg.tag")
query += q
args = append(args, addArgs...)
}
query += "UNION ALL "
args = append(args, id)
if activeOnly {
args = append(args, t.StateOK)
stateConstraint = "t.state=? AND "
} else {
stateConstraint = ""
}
for _, tag := range allTags {
args = append(args, tag)
}
query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_topics AS t JOIN im_topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " + "FROM im_topics AS t JOIN im_topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " +
"WHERE t.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " + "WHERE t.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " +
"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags " "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
@@ -1622,9 +1562,7 @@ func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) {
} }
rows, err := a.db.QueryxContext(ctx, rows, err := a.db.QueryxContext(ctx,
"SELECT tp.name AS topic FROM im_topics AS tp LEFT JOIN im_topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+ "SELECT tp.name AS topic FROM im_topics AS tp LEFT JOIN im_topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+
"WHERE tp.tenant_id=? AND tt.tag=? UNION ALL "+ "WHERE tp.tenant_id=? AND tt.tag=? LIMIT 1", id, tag)
"SELECT u.id AS topic FROM im_users AS u LEFT JOIN im_usertags AS ut ON u.tenant_id=ut.tenant_id AND ut.userid=u.id "+
"WHERE u.tenant_id=? AND ut.tag=? LIMIT 1", id, tag, id, tag)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -3816,7 +3754,11 @@ func (a *adapter) TenantUserProfileGet(id t.TenantID, uid t.Uid) (*t.UserProfile
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(user_id,public_account) VALUES(?,?)`, decodeUID(uid), uid.UserId()); err != nil { profileID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil {
return nil, err return nil, err
} }
return a.userProfileByUID(ctx, uid) return a.userProfileByUID(ctx, uid)
@@ -3839,7 +3781,11 @@ func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.User
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(user_id,public_account) VALUES(?,?)`, decodeUID(uid), uid.UserId()); err != nil { profileID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil {
return nil, err return nil, err
} }
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
@@ -3873,6 +3819,10 @@ func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.User
sets = append(sets, "region=?") sets = append(sets, "region=?")
args = append(args, strings.TrimSpace(*patch.Region)) args = append(args, strings.TrimSpace(*patch.Region))
} }
if patch.Email != nil {
sets = append(sets, "email=?")
args = append(args, strings.TrimSpace(*patch.Email))
}
args = append(args, decodeUID(uid), patch.Version) args = append(args, decodeUID(uid), patch.Version)
res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...) res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...)
if err != nil { if err != nil {
@@ -3902,7 +3852,11 @@ func (a *adapter) TenantUserPrivacyGet(id t.TenantID, uid t.Uid) (*t.UserPrivacy
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(user_id,add_me_methods) VALUES(?,'{"account":true,"phone":true,"qr":true,"group":true}')`, decodeUID(uid)); err != nil { privacyID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil {
return nil, err return nil, err
} }
return a.userPrivacyByUID(ctx, uid) return a.userPrivacyByUID(ctx, uid)
@@ -3925,7 +3879,11 @@ func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.User
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(user_id,add_me_methods) VALUES(?,'{"account":true,"phone":true,"qr":true,"group":true}')`, decodeUID(uid)); err != nil { privacyID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil {
return nil, err return nil, err
} }
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
@@ -4048,12 +4006,16 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush
token.Token = strings.TrimSpace(token.Token) token.Token = strings.TrimSpace(token.Token)
token.AppVersion = strings.TrimSpace(token.AppVersion) token.AppVersion = strings.TrimSpace(token.AppVersion)
token.Platform = strings.ToLower(strings.TrimSpace(token.Platform)) token.Platform = strings.ToLower(strings.TrimSpace(token.Platform))
_, err := a.db.ExecContext(ctx, tokenID, err := nextSnowflakeID()
`INSERT INTO im_device_push_tokens(device_id,user_id,provider,token,app_version,platform,created_at,updated_at) if err != nil {
VALUES(?,?,?,?,?,?,?,?) return nil, err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_device_push_tokens(id,device_id,user_id,provider,token,app_version,platform,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE device_id=VALUES(device_id),user_id=VALUES(user_id),provider=VALUES(provider),token=VALUES(token), ON DUPLICATE KEY UPDATE device_id=VALUES(device_id),user_id=VALUES(user_id),provider=VALUES(provider),token=VALUES(token),
app_version=VALUES(app_version),platform=VALUES(platform),updated_at=VALUES(updated_at)`, app_version=VALUES(app_version),platform=VALUES(platform),updated_at=VALUES(updated_at)`,
token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now) tokenID, token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -4515,12 +4477,14 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi
Signature string Signature string
Gender string Gender string
Region string Region string
Email string
PublicAccount string `db:"public_account"` PublicAccount string `db:"public_account"`
Version int Version int
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
}{} }{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil { if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,avatar_file_id,nickname,birth_date,signature,gender,region,email,
public_account,version,created_at,updated_at FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil {
return nil, err return nil, err
} }
profile := &t.UserProfile{ profile := &t.UserProfile{
@@ -4530,6 +4494,7 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi
Signature: row.Signature, Signature: row.Signature,
Gender: row.Gender, Gender: row.Gender,
Region: row.Region, Region: row.Region,
Email: row.Email,
PublicAccount: row.PublicAccount, PublicAccount: row.PublicAccount,
Version: row.Version, Version: row.Version,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
@@ -4552,7 +4517,8 @@ func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPriva
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
}{} }{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility,
read_receipts,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil {
return nil, err return nil, err
} }
settings := &t.UserPrivacySettings{ settings := &t.UserPrivacySettings{
@@ -5615,11 +5581,15 @@ func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (in
} }
decoded := decodeUID(uid) decoded := decodeUID(uid)
now := t.TimeNow() now := t.TimeNow()
_, err := a.db.ExecContext(ctx, securityID, err := nextSnowflakeID()
`INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) if err != nil {
VALUES(?,?,?,?) return 0, err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at)
VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE auth_version=auth_version`, ON DUPLICATE KEY UPDATE auth_version=auth_version`,
decoded, 1, now, now) securityID, decoded, 1, now, now)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -5666,14 +5636,18 @@ func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, up
} }
}() }()
decoded := decodeUID(uid) decoded := decodeUID(uid)
securityID, err := nextSnowflakeID()
if err != nil {
return 0, err
}
if _, err = tx.ExecContext(ctx, if _, err = tx.ExecContext(ctx,
`INSERT INTO im_user_security(user_id,auth_version,password_updated_at,created_at,updated_at) `INSERT INTO im_user_security(id,user_id,auth_version,password_updated_at,created_at,updated_at)
VALUES(?,?,?,?,?) VALUES(?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
auth_version=auth_version+1, auth_version=auth_version+1,
password_updated_at=VALUES(password_updated_at), password_updated_at=VALUES(password_updated_at),
updated_at=VALUES(updated_at)`, updated_at=VALUES(updated_at)`,
decoded, 2, updatedAt, updatedAt, updatedAt); err != nil { securityID, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil {
return 0, err return 0, err
} }
var version int var version int
@@ -5911,12 +5885,16 @@ func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.Indepen
now := t.TimeNow() now := t.TimeNow()
pass.CreatedAt = now pass.CreatedAt = now
pass.UpdatedAt = now pass.UpdatedAt = now
_, err := a.db.ExecContext(ctx, settingsID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_youth_mode_settings `INSERT INTO im_youth_mode_settings
(user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at) (id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at)
VALUES(?,?,?,0,NULL,?,?) VALUES(?,?,?,?,0,NULL,?,?)
ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`, ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`,
decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt) settingsID, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt)
return err return err
} }
@@ -6478,10 +6456,14 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
row.UpdatedAt = handledAt row.UpdatedAt = handledAt
} }
for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} { for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
friendshipID, idErr := nextSnowflakeID()
if idErr != nil {
return nil, idErr
}
if _, err = tx.ExecContext(ctx, if _, err = tx.ExecContext(ctx,
"INSERT INTO im_friendships(user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?) "+ "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil { friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil {
return nil, err return nil, err
} }
} }
@@ -6642,10 +6624,14 @@ func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, to
defer cancel() defer cancel()
} }
for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} { for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} {
friendshipID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, if _, err := a.db.ExecContext(ctx,
"INSERT INTO im_friendships(user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?) "+ "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil { friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil {
return err return err
} }
} }
@@ -6680,13 +6666,13 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
} }
if q := strings.TrimSpace(queryText); q != "" { if q := strings.TrimSpace(queryText); q != "" {
like := "%" + q + "%" like := "%" + q + "%"
where = append(where, "(fs.remark LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR CAST(u.tags AS CHAR) LIKE ?)") where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ?)")
args = append(args, like, like, like) args = append(args, like, like, like, like)
} }
if initial = strings.TrimSpace(initial); initial != "" { if initial = strings.TrimSpace(initial); initial != "" {
like := initial + "%" like := initial + "%"
where = append(where, "(fs.remark LIKE ? OR CAST(u.public AS CHAR) LIKE ?)") where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR CAST(u.public AS CHAR) LIKE ?)")
args = append(args, like, like) args = append(args, like, like, like)
} }
args = append(args, limit+1) args = append(args, limit+1)
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
@@ -6696,6 +6682,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted
FROM im_friendships AS fs FROM im_friendships AS fs
JOIN im_users AS u ON u.id=fs.peer_id JOIN im_users AS u ON u.id=fs.peer_id
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE `+strings.Join(where, " AND ")+` WHERE `+strings.Join(where, " AND ")+`
ORDER BY fs.peer_id ASC LIMIT ?`, args...) ORDER BY fs.peer_id ASC LIMIT ?`, args...)
if err != nil { if err != nil {
@@ -6776,7 +6763,8 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
}{} }{}
err := a.db.GetContext(ctx, &row, "SELECT * FROM im_friendships WHERE user_id=? AND peer_id=?", err := a.db.GetContext(ctx, &row, `SELECT user_id,peer_id,status,remark,topic,created_at,updated_at
FROM im_friendships WHERE user_id=? AND peer_id=?`,
decodeUID(userID), decodeUID(peerID)) decodeUID(userID), decodeUID(peerID))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -6871,7 +6859,8 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid)
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
}{} }{}
err := a.db.GetContext(ctx, &row, "SELECT * FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?", err := a.db.GetContext(ctx, &row, `SELECT blocker_id,blocked_id,created_at,updated_at
FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?`,
decodeUID(blockerID), decodeUID(blockedID)) decodeUID(blockerID), decodeUID(blockedID))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -6919,7 +6908,7 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, `SELECT b.*,u.public,u.trusted rows, err := a.db.QueryxContext(ctx, `SELECT b.blocker_id,b.blocked_id,b.created_at,b.updated_at,u.public,u.trusted
FROM im_user_blocks AS b FROM im_user_blocks AS b
LEFT JOIN im_users AS u ON u.id=b.blocked_id LEFT JOIN im_users AS u ON u.id=b.blocked_id
WHERE `+strings.Join(where, " AND ")+` WHERE `+strings.Join(where, " AND ")+`
@@ -6986,10 +6975,14 @@ func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Ui
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
_, err := a.db.ExecContext(ctx, blockID, err := nextSnowflakeID()
"INSERT INTO im_user_blocks(blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?) "+ if err != nil {
return nil, err
}
_, err = a.db.ExecContext(ctx,
"INSERT INTO im_user_blocks(id,blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)", "ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)",
decodeUID(blockerID), decodeUID(blockedID), now, now) blockID, decodeUID(blockerID), decodeUID(blockedID), now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -7048,24 +7041,23 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
where = append(where, "u.id>?") where = append(where, "u.id>?")
args = append(args, parsed) args = append(args, parsed)
} }
joins := []string{"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id"} joins := []string{
"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id",
"LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id",
}
addMethod := "account" addMethod := "account"
switch searchType { switch searchType {
case "", "account": case "", "account":
like := "%" + queryText + "%" like := "%" + queryText + "%"
joins = append(joins, "LEFT JOIN im_usertags AS ut ON ut.userid=u.id") where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)")
where = append(where, "(ut.tag LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)") args = append(args, like, like, like, userIDNumericArg(queryText))
args = append(args, like, like, userIDNumericArg(queryText))
case "exact_account": case "exact_account":
joins = append(joins, "LEFT JOIN im_usertags AS ut ON ut.userid=u.id") joins = append(joins, "LEFT JOIN im_auth AS account_auth ON account_auth.userid=u.id AND account_auth.scheme='basic'")
where = append(where, "(ut.tag=? OR u.id=?)") where = append(where, "(profile.public_account=? OR account_auth.uname=? OR u.id=?)")
args = append(args, queryText, userIDNumericArg(queryText)) args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText))
case "phone": case "phone":
addMethod = "phone" addMethod = "phone"
where = append(where, `EXISTS ( where = append(where, "u.phone=?")
SELECT 1 FROM im_credentials AS c
WHERE c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true
)`)
args = append(args, queryText) args = append(args, queryText)
default: default:
return nil, "", t.ErrMalformed return nil, "", t.ErrMalformed
@@ -7080,7 +7072,8 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* FROM im_users AS u `+strings.Join(joins, " ")+` rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...) WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
+38 -15
View File
@@ -16,6 +16,13 @@ import (
t "github.com/tinode/chat/server/store/types" t "github.com/tinode/chat/server/store/types"
) )
const (
groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,
member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,
created_by,dissolved_by,dissolved_at,created_at,updated_at`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
)
func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) { func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil { if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err return nil, err
@@ -45,11 +52,15 @@ func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSett
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_settings settingsID, err := nextSnowflakeID()
(topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) if err != nil {
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) return nil, err
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_group_settings
(id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE name=VALUES(name),avatar_file_id=VALUES(avatar_file_id),description=VALUES(description),updated_at=VALUES(updated_at)`, ON DUPLICATE KEY UPDATE name=VALUES(name),avatar_file_id=VALUES(avatar_file_id),description=VALUES(description),updated_at=VALUES(updated_at)`,
settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus, settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus,
settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite,
settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt) settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
if err != nil { if err != nil {
@@ -626,8 +637,12 @@ func (a *adapter) TenantGroupAnnouncementRead(id t.TenantID, topic, announcement
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(announcement_id,topic,user_id,read_at) readID, err := nextSnowflakeID()
VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, announcementID, topic, decodeUID(uid), readAt) if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(id,announcement_id,topic,user_id,read_at)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, readID, announcementID, topic, decodeUID(uid), readAt)
return err return err
} }
@@ -652,7 +667,7 @@ func (a *adapter) TenantGroupMuteList(id t.TenantID, topic, cursor string, limit
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_group_mutes WHERE "+strings.Join(where, " AND ")+" ORDER BY user_id LIMIT ?", args...) rows, err := a.db.QueryxContext(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE "+strings.Join(where, " AND ")+" ORDER BY user_id LIMIT ?", args...)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -696,10 +711,14 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version) muteID, err := nextSnowflakeID()
VALUES(?,?,?,?,?,?,?,1) if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
VALUES(?,?,?,?,?,?,?,?,1)
ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`, ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`,
mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status) muteID, mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -711,7 +730,7 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupMuteByQuery(ctx, "SELECT * FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID)) return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID))
} }
func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor t.Uid, now time.Time) error { func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor t.Uid, now time.Time) error {
@@ -1090,9 +1109,13 @@ func (a *adapter) TenantGroupEventMarkDelivered(id t.TenantID, eventID string, n
} }
func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error { func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error {
_, err := tx.Exec(`INSERT INTO im_group_settings settingsID, err := nextSnowflakeID()
(topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) if err != nil {
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, return err
}
_, err = tx.Exec(`INSERT INTO im_group_settings
(id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name,
settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll, settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll,
settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount,
settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt) settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
@@ -1143,7 +1166,7 @@ func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) {
func (a *adapter) groupSettingsByTopic(ctx context.Context, topic string) (*t.GroupSettings, error) { func (a *adapter) groupSettingsByTopic(ctx context.Context, topic string) (*t.GroupSettings, error) {
var item t.GroupSettings var item t.GroupSettings
err := a.db.QueryRowxContext(ctx, "SELECT * FROM im_group_settings WHERE topic=?", topic).StructScan(&item) err := a.db.QueryRowxContext(ctx, "SELECT "+groupSettingsSelectColumns+" FROM im_group_settings WHERE topic=?", topic).StructScan(&item)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, t.ErrNotFound return nil, t.ErrNotFound
} }
+95 -53
View File
@@ -117,6 +117,89 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) {
} }
} }
func TestUserIdentitySchema(t *testing.T) {
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='phone'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("im_users.phone is missing")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_user_profiles' AND column_name='email'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("im_user_profiles.email is missing")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='tags'`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("legacy im_users.tags still exists")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='im_usertags'`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("legacy im_usertags still exists")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='im_topictags'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("topic tag table must be retained")
}
}
func TestAllIMTablesUseIDPrimaryKey(t *testing.T) {
var tables []string
if err := db.Select(&tables, `SELECT t.table_name
FROM information_schema.tables AS t
WHERE t.table_schema=DATABASE() AND t.table_type='BASE TABLE' AND t.table_name LIKE 'im\_%'
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns AS c
WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name AND c.column_name='id'
)
ORDER BY t.table_name`); err != nil {
t.Fatal(err)
}
if len(tables) != 0 {
t.Fatalf("IM tables without id column: %v", tables)
}
tables = nil
if err := db.Select(&tables, `SELECT s.table_name
FROM information_schema.statistics AS s
WHERE s.table_schema=DATABASE() AND s.index_name='PRIMARY'
GROUP BY s.table_name
HAVING GROUP_CONCAT(s.column_name ORDER BY s.seq_in_index) <> 'id'
ORDER BY s.table_name`); err != nil {
t.Fatal(err)
}
if len(tables) != 0 {
t.Fatalf("IM tables whose primary key is not id: %v", tables)
}
targets := make([]string, 0, 11)
if err := db.Select(&targets, `SELECT table_name
FROM information_schema.columns
WHERE table_schema=DATABASE() AND column_name='id' AND extra LIKE '%auto_increment%'
AND table_name IN ('im_user_security','im_kvmeta','im_user_profiles','im_user_privacy_settings',
'im_device_push_tokens','im_friendships','im_user_blocks','im_youth_mode_settings',
'im_group_settings','im_group_announcement_reads','im_group_mutes')
ORDER BY table_name`); err != nil {
t.Fatal(err)
}
if len(targets) != 0 {
t.Fatalf("snowflake primary keys unexpectedly use AUTO_INCREMENT: %v", targets)
}
}
func TestTenantAuthIsolation(t *testing.T) { func TestTenantAuthIsolation(t *testing.T) {
tenantAdp, ok := adp.(adapter.TenantAuthAdapter) tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
if !ok { if !ok {
@@ -192,8 +275,8 @@ func TestTenantAuthIsolation(t *testing.T) {
userBID := userAID + 1 userBID := userAID + 1
for index, userID := range []int64{userAID, userBID} { for index, userID := range []int64{userAID, userBID} {
tenantDB := quoteIdent(dbNames[index]) tenantDB := quoteIdent(dbNames[index])
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,public,trusted,tags) "+ if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,phone,public,trusted) "+
"VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, nil, nil, nil); err != nil { "VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)", if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)",
@@ -740,8 +823,8 @@ func TestFind(t *testing.T) {
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
if len(got) != 3 { if len(got) != 1 {
t.Error(mismatchErrorString("result length", len(got), 3)) t.Error(mismatchErrorString("topic-only result length", len(got), 1))
} }
} }
@@ -811,54 +894,13 @@ func TestUserUpdate(t *testing.T) {
} }
func TestUserUpdateTags(t *testing.T) { func TestUserUpdateTags(t *testing.T) {
addTags := testData.Tags[0]
removeTags := testData.Tags[1]
resetTags := testData.Tags[2]
uid := types.ParseUserId("usr" + testData.Users[0].Id) uid := types.ParseUserId("usr" + testData.Users[0].Id)
got, err := adp.UserUpdateTags(uid, []string{"legacy"}, nil, nil)
got, err := adp.UserUpdateTags(uid, addTags, nil, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := []string{"alice", "tag1"} if got != nil {
if !reflect.DeepEqual(got, want) { t.Fatalf("user tags must stay disabled, got %v", got)
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, nil, removeTags, nil)
if err != nil {
t.Fatal(err)
}
want = nil
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, nil, nil, resetTags)
if err != nil {
t.Fatal(err)
}
want = []string{"alice", "tag111", "tag333"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
if err != nil {
t.Fatal(err)
}
want = []string{"tag111", "tag333"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
}
got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)
if err != nil {
t.Fatal(err)
}
want = []string{"tag111", "tag333"}
if !reflect.DeepEqual(got, want) {
t.Error(mismatchErrorString("Tags", got, want))
} }
} }
@@ -1505,6 +1547,11 @@ func init() {
if adp.IsOpen() { if adp.IsOpen() {
log.Print("Connection is already opened") log.Print("Connection is already opened")
} }
testData = test_data.InitTestData()
if testData == nil {
log.Fatal("Failed to initialize test data")
}
store.SetTestUidGenerator(*testData.UGen)
err := adp.Open(config.Adapters[adp.GetName()]) err := adp.Open(config.Adapters[adp.GetName()])
if err != nil { if err != nil {
@@ -1512,9 +1559,4 @@ func init() {
} }
db = adp.GetTestDB().(*sqlx.DB) db = adp.GetTestDB().(*sqlx.DB)
testData = test_data.InitTestData()
if testData == nil {
log.Fatal("Failed to initialize test data")
}
store.SetTestUidGenerator(*testData.UGen)
} }
+17
View File
@@ -0,0 +1,17 @@
//go:build mysql
// +build mysql
package mysql
func withoutUserTags(update map[string]any) map[string]any {
if update == nil {
return nil
}
clean := make(map[string]any, len(update))
for key, value := range update {
if key != "Tags" && key != "tags" {
clean[key] = value
}
}
return clean
}
+304
View File
@@ -0,0 +1,304 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
)
const userIdentityMigrationLock = "CONCAT('im_user_identity:', LEFT(SHA2(DATABASE(), 256), 40))"
type identityValue struct {
TenantID int64 `db:"tenant_id"`
UserID int64 `db:"user_id"`
Value string `db:"value"`
}
// ensureUserIdentitySchema moves user phone/email out of the legacy tag store.
// Topic tags are intentionally unaffected.
func ensureUserIdentitySchema(db *sqlx.DB) error {
ctx := context.Background()
conn, err := db.Connx(ctx)
if err != nil {
return err
}
defer conn.Close()
var locked sql.NullInt64
if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK("+userIdentityMigrationLock+", 60)").Scan(&locked); err != nil {
return err
}
if !locked.Valid || locked.Int64 != 1 {
return errors.New("mysql: timed out waiting for user identity migration lock")
}
defer conn.ExecContext(ctx, "SELECT RELEASE_LOCK("+userIdentityMigrationLock+")")
usersExist, err := identityTableExists(ctx, conn, "im_users")
if err != nil || !usersExist {
return err
}
usersScoped, err := identityColumnExists(ctx, conn, "im_users", "tenant_id")
if err != nil {
return err
}
profilesExist, err := identityTableExists(ctx, conn, "im_user_profiles")
if err != nil {
return err
}
if !profilesExist {
profileScope := ""
profileUniqueKey := "user_id"
if usersScoped {
profileScope = "tenant_id BIGINT NOT NULL,"
profileUniqueKey = "tenant_id,user_id"
}
if _, err = conn.ExecContext(ctx, `CREATE TABLE im_user_profiles(
id BIGINT NOT NULL,`+profileScope+`user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',nickname VARCHAR(64) NOT NULL DEFAULT '',
birth_date DATE,signature VARCHAR(256) NOT NULL DEFAULT '',gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),UNIQUE INDEX uk_im_user_profiles_user(`+profileUniqueKey+`))`); err != nil {
return err
}
profilesExist = true
}
if exists, err := identityColumnExists(ctx, conn, "im_users", "phone"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent"); err != nil {
return err
}
}
if profilesExist {
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region"); err != nil {
return err
}
}
}
if err = backfillUserPhones(ctx, conn, usersScoped); err != nil {
return err
}
if profilesExist {
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
return err
}
}
indexName := "users_phone"
if usersScoped {
indexName = "uk_im_users_tenant_phone"
}
if exists, err := identityIndexExists(ctx, conn, "im_users", indexName); err != nil {
return err
} else if !exists {
columns := "phone"
if usersScoped {
columns = "tenant_id,phone"
}
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_users("+columns+")"); err != nil {
return fmt.Errorf("create unique phone index: %w", err)
}
}
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "DROP TABLE im_usertags"); err != nil {
return err
}
}
if exists, err := identityColumnExists(ctx, conn, "im_users", "tags"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP COLUMN tags"); err != nil {
return err
}
}
return nil
}
func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
candidates, err := identityCandidates(ctx, conn, scoped, "tel")
if err != nil {
return err
}
seen := make(map[string]bool)
var existing []identityValue
prefix := "0 AS tenant_id,"
if scoped {
prefix = "tenant_id,"
}
if err = conn.SelectContext(ctx, &existing, "SELECT "+prefix+"id AS user_id,phone AS value FROM im_users WHERE phone IS NOT NULL AND phone<>''"); err != nil {
return err
}
for _, item := range existing {
seen[identityUniqueKey(item.TenantID, item.Value, scoped)] = true
}
updatedUsers := make(map[string]bool)
for _, item := range candidates {
item.Value = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(item.Value, "tel:"), "basic:"))
userKey := identityUserKey(item.TenantID, item.UserID, scoped)
valueKey := identityUniqueKey(item.TenantID, item.Value, scoped)
if item.Value == "" || updatedUsers[userKey] || seen[valueKey] {
continue
}
query := "UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')"
args := []any{item.Value, item.UserID}
if scoped {
query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=? AND (phone IS NULL OR phone='')"
args = []any{item.Value, item.TenantID, item.UserID}
}
result, err := conn.ExecContext(ctx, query, args...)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected > 0 {
updatedUsers[userKey] = true
seen[valueKey] = true
}
}
return nil
}
func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error {
candidates, err := identityCandidates(ctx, conn, usersScoped, "email")
if err != nil {
return err
}
profilesScoped, err := identityColumnExists(ctx, conn, "im_user_profiles", "tenant_id")
if err != nil {
return err
}
if profilesScoped != usersScoped {
return errors.New("mysql: im_users and im_user_profiles tenant scope mismatch")
}
processed := make(map[string]bool)
for _, item := range candidates {
item.Value = strings.TrimSpace(strings.TrimPrefix(item.Value, "email:"))
key := identityUserKey(item.TenantID, item.UserID, usersScoped)
if item.Value == "" || processed[key] {
continue
}
processed[key] = true
id, err := nextSnowflakeID()
if err != nil {
return err
}
account := store.EncodeUid(item.UserID).UserId()
if usersScoped {
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,tenant_id,user_id,email,public_account)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`,
id, item.TenantID, item.UserID, item.Value, account)
} else {
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,user_id,email,public_account)
VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`,
id, item.UserID, item.Value, account)
}
if err != nil {
return err
}
}
return nil
}
func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
var out []identityValue
appendQuery := func(query string, args ...any) error {
var rows []identityValue
if err := conn.SelectContext(ctx, &rows, query, args...); err != nil {
return err
}
out = append(out, rows...)
return nil
}
prefix := "0 AS tenant_id,u.id AS user_id,"
credentialJoin := "c.userid=u.id"
tagJoin := "ut.userid=u.id"
if scoped {
prefix = "u.tenant_id AS tenant_id,u.id AS user_id,"
credentialJoin = "c.tenant_id=u.tenant_id AND c.userid=u.id"
tagJoin = "ut.tenant_id=u.tenant_id AND ut.userid=u.id"
}
if exists, err := identityTableExists(ctx, conn, "im_credentials"); err != nil {
return nil, err
} else if exists {
if err = appendQuery("SELECT "+prefix+"c.value AS value FROM im_users AS u JOIN im_credentials AS c ON "+credentialJoin+
" WHERE c.method=? AND c.done=1 AND c.deletedat IS NULL ORDER BY u.id,c.updatedat DESC", method); err != nil {
return nil, err
}
}
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
return nil, err
} else if exists {
if err = appendQuery("SELECT "+prefix+"ut.tag AS value FROM im_users AS u JOIN im_usertags AS ut ON "+tagJoin+
" WHERE ut.tag LIKE ? ORDER BY u.id,ut.id", method+":%"); err != nil {
return nil, err
}
}
if method == "tel" {
if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil {
return nil, err
} else if exists {
authJoin := "a.userid=u.id"
if scoped {
authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id"
}
if err = appendQuery("SELECT " + prefix + "a.uname AS value FROM im_users AS u JOIN im_auth AS a ON " + authJoin +
" WHERE a.scheme='basic' AND (a.uname LIKE '+%' OR a.uname LIKE 'basic:+%') ORDER BY u.id"); err != nil {
return nil, err
}
}
}
return out, nil
}
func identityTableExists(ctx context.Context, conn *sqlx.Conn, table string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name=?)`, table).Scan(&exists)
return exists == 1, err
}
func identityColumnExists(ctx context.Context, conn *sqlx.Conn, table, column string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name=? AND column_name=?)`, table, column).Scan(&exists)
return exists == 1, err
}
func identityIndexExists(ctx context.Context, conn *sqlx.Conn, table, index string) (bool, error) {
var exists int
err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`, table, index).Scan(&exists)
return exists == 1, err
}
func identityUserKey(tenantID, userID int64, scoped bool) string {
if scoped {
return fmt.Sprintf("%d:%d", tenantID, userID)
}
return fmt.Sprintf("%d", userID)
}
func identityUniqueKey(tenantID int64, value string, scoped bool) string {
if scoped {
return fmt.Sprintf("%d:%s", tenantID, value)
}
return value
}
+7 -3
View File
@@ -2128,6 +2128,7 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
Signature *string `json:"signature"` Signature *string `json:"signature"`
Gender *string `json:"gender"` Gender *string `json:"gender"`
Region *string `json:"region"` Region *string `json:"region"`
Email *string `json:"email"`
Version int `json:"version"` Version int `json:"version"`
} }
if !decodeIMJSONBody(wrt, req, &body) { if !decodeIMJSONBody(wrt, req, &body) {
@@ -2146,6 +2147,7 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
Signature: body.Signature, Signature: body.Signature,
Gender: body.Gender, Gender: body.Gender,
Region: body.Region, Region: body.Region,
Email: body.Email,
Version: body.Version, Version: body.Version,
}) })
if err != nil { if err != nil {
@@ -4860,14 +4862,14 @@ func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
} }
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
creds, err := store.Users.GetAllCreds(tenantID, uid, "tel", true) user, err := store.Users.Get(tenantID, uid)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(creds) == 0 || strings.TrimSpace(creds[0].Value) == "" { if user == nil || strings.TrimSpace(user.Phone) == "" {
return "", types.ErrNotFound return "", types.ErrNotFound
} }
return strings.TrimSpace(creds[0].Value), nil return strings.TrimSpace(user.Phone), nil
} }
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
@@ -6075,6 +6077,7 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
"signature": profile.Signature, "signature": profile.Signature,
"gender": profile.Gender, "gender": profile.Gender,
"region": profile.Region, "region": profile.Region,
"email": profile.Email,
"public_account": profile.PublicAccount, "public_account": profile.PublicAccount,
"version": profile.Version, "version": profile.Version,
"updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano), "updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano),
@@ -6083,6 +6086,7 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
"signature": map[string]any{"max_length": 120}, "signature": map[string]any{"max_length": 120},
"birth_date": map[string]any{"format": "YYYY-MM-DD"}, "birth_date": map[string]any{"format": "YYYY-MM-DD"},
"gender": []string{"male", "female", "other", ""}, "gender": []string{"male", "female", "other", ""},
"email": map[string]any{"max_length": 128},
}, },
} }
return data return data
+5 -2
View File
@@ -593,6 +593,9 @@ func (s *testProfilesStore) Update(_ types.TenantID, uid types.Uid, patch types.
if patch.Signature != nil { if patch.Signature != nil {
profile.Signature = *patch.Signature profile.Signature = *patch.Signature
} }
if patch.Email != nil {
profile.Email = *patch.Email
}
profile.Version = patch.Version + 1 profile.Version = patch.Version + 1
profile.UpdatedAt = time.Now().UTC().Round(time.Millisecond) profile.UpdatedAt = time.Now().UTC().Round(time.Millisecond)
return profile, nil return profile, nil
@@ -2023,7 +2026,7 @@ func TestServeIMMeProfile(t *testing.T) {
store.Profiles = profiles store.Profiles = profiles
defer func() { store.Profiles = oldProfiles }() defer func() { store.Profiles = oldProfiles }()
req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","version":1}`)) req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","email":"me@example.com","version":1}`))
req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMMeProfile(rec, req) serveIMMeProfile(rec, req)
@@ -2032,7 +2035,7 @@ func TestServeIMMeProfile(t *testing.T) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["nickname"] != "新昵称" || data["version"].(float64) != 2 { if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 {
t.Fatalf("unexpected profile: %#v", data) t.Fatalf("unexpected profile: %#v", data)
} }
} }
+3 -4
View File
@@ -1236,24 +1236,25 @@ func TestDispatchAccNew(t *testing.T) {
uid := types.Uid(1) uid := types.Uid(1)
oldSecurity := store.Security oldSecurity := store.Security
oldVerifications := store.Verifications
store.Store = ss store.Store = ss
store.Users = uu store.Users = uu
store.Security = &testSecurityStore{version: 1} store.Security = &testSecurityStore{version: 1}
store.Verifications = nil
defer func() { defer func() {
store.Store = nil store.Store = nil
store.Users = nil store.Users = nil
store.Security = oldSecurity store.Security = oldSecurity
store.Verifications = oldVerifications
ctrl.Finish() ctrl.Finish()
}() }()
remoteAddr := "192.168.0.1" remoteAddr := "192.168.0.1"
secret := "<==auth-secret==>" secret := "<==auth-secret==>"
tags := []string{"tag1", "tag2"}
authRec := &auth.Rec{ authRec := &auth.Rec{
TenantID: 1, TenantID: 1,
Uid: uid, Uid: uid,
AuthLevel: auth.LevelAuth, AuthLevel: auth.LevelAuth,
Tags: tags,
State: types.StateOK, State: types.StateOK,
} }
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
@@ -1271,7 +1272,6 @@ func TestDispatchAccNew(t *testing.T) {
ss.EXPECT().GetLogicalAuthHandler("token").Return(aa) ss.EXPECT().GetLogicalAuthHandler("token").Return(aa)
token := "<==auth-token==>" token := "<==auth-token==>"
aa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil) aa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil)
uu.EXPECT().UpdateTags(types.TenantID(1), uid, tags, nil, nil).Return(tags, nil)
s := &Session{ s := &Session{
send: make(chan any, 10), send: make(chan any, 10),
@@ -1293,7 +1293,6 @@ func TestDispatchAccNew(t *testing.T) {
User: "newXYZ", User: "newXYZ",
Scheme: "basic", Scheme: "basic",
Secret: []byte(secret), Secret: []byte(secret),
Tags: []string{"abc", "123"},
Desc: &MsgSetDesc{Public: public}, Desc: &MsgSetDesc{Public: public},
}, },
} }
+4 -1
View File
@@ -2034,7 +2034,7 @@ func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool {
} }
func validUserProfilePatch(patch types.UserProfilePatch) bool { func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil { if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil && patch.Email == nil {
return false return false
} }
if patch.Nickname != nil { if patch.Nickname != nil {
@@ -2046,6 +2046,9 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.Signature != nil && len([]rune(*patch.Signature)) > 120 { if patch.Signature != nil && len([]rune(*patch.Signature)) > 120 {
return false return false
} }
if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 {
return false
}
return true return true
} }
+2
View File
@@ -10,6 +10,7 @@ type UserProfile struct {
Signature string Signature string
Gender string Gender string
Region string Region string
Email string
PublicAccount string PublicAccount string
Version int Version int
CreatedAt time.Time CreatedAt time.Time
@@ -23,6 +24,7 @@ type UserProfilePatch struct {
Signature *string Signature *string
Gender *string Gender *string
Region *string Region *string
Email *string
Version int Version int
} }
+2
View File
@@ -519,6 +519,8 @@ type User struct {
LastSeen *time.Time LastSeen *time.Time
// User agent provided when accessing the topic last time // User agent provided when accessing the topic last time
UserAgent string UserAgent string
// Canonical mobile number used for registration and account recovery.
Phone string
Public any Public any
Trusted any Trusted any
+3 -3
View File
@@ -45,7 +45,7 @@
"auth_config": { "auth_config": {
"logical_names": [], "logical_names": [],
"basic": { "basic": {
"add_to_tags": true, "add_to_tags": false,
"min_login_length": 4, "min_login_length": 4,
"min_password_length": 6 "min_password_length": 6
}, },
@@ -80,7 +80,7 @@
"acc_validation": { "acc_validation": {
"email": { "email": {
"add_to_tags": true, "add_to_tags": false,
"required": [], "required": [],
"config": { "config": {
"host_url": "http://64.118.137.182:6060/", "host_url": "http://64.118.137.182:6060/",
@@ -89,7 +89,7 @@
} }
}, },
"tel": { "tel": {
"add_to_tags": true, "add_to_tags": false,
"required": [], "required": [],
"config": { "config": {
"host_url": "http://64.118.137.182:6060/", "host_url": "http://64.118.137.182:6060/",
+12 -1
View File
@@ -3071,6 +3071,10 @@ func (t *Topic) replySetTags(sess *Session, asUid types.Uid, msg *ClientComMessa
sess.queueOut(ErrOperationNotAllowedReply(msg, now)) sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("invalid topic category to assign tags") return errors.New("invalid topic category to assign tags")
} }
if t.cat == types.TopicCatMe {
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("user tags are no longer supported")
}
if t.cat == types.TopicCatGrp && t.owner != asUid { if t.cat == types.TopicCatGrp && t.owner != asUid {
sess.queueOut(ErrPermissionDeniedReply(msg, now)) sess.queueOut(ErrPermissionDeniedReply(msg, now))
@@ -3168,7 +3172,6 @@ func (t *Topic) replyGetCreds(sess *Session, asUid types.Uid, msg *ClientComMess
sess.queueOut(ErrOperationNotAllowedReply(msg, now)) sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("invalid topic category for getting credentials") return errors.New("invalid topic category for getting credentials")
} }
screds, err := store.Users.GetAllCreds(sess.tenantID, asUid, "", false) screds, err := store.Users.GetAllCreds(sess.tenantID, asUid, "", false)
if err != nil { if err != nil {
sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, msg.Timestamp, nil)) sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, msg.Timestamp, nil))
@@ -3206,6 +3209,10 @@ func (t *Topic) replySetCred(sess *Session, asUid types.Uid, authLevel auth.Leve
sess.queueOut(ErrOperationNotAllowedReply(msg, now)) sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("invalid topic category for updating credentials") return errors.New("invalid topic category for updating credentials")
} }
if set.Cred.Method == "tel" || set.Cred.Method == "email" {
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("phone and email are managed by product profile APIs")
}
var err error var err error
var tags []string var tags []string
@@ -3484,6 +3491,10 @@ func (t *Topic) replyDelCred(sess *Session, asUid types.Uid, authLvl auth.Level,
sess.queueOut(ErrMalformedReply(msg, now)) sess.queueOut(ErrMalformedReply(msg, now))
return errors.New("del.cred: missing method") return errors.New("del.cred: missing method")
} }
if del.Cred.Method == "tel" || del.Cred.Method == "email" {
sess.queueOut(ErrOperationNotAllowedReply(msg, now))
return errors.New("phone and email are managed by product profile APIs")
}
tags, err := deleteCred(sess.tenantID, asUid, authLvl, del.Cred) tags, err := deleteCred(sess.tenantID, asUid, authLvl, del.Cred)
if tags != nil { if tags != nil {
+40 -85
View File
@@ -73,31 +73,47 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
user.State = state user.State = state
} }
// Ensure tags are unique and not restricted. if msg.Acc.Tags != nil {
if tags := normalizeTags(msg.Acc.Tags, globals.maxTagCount); tags != nil { logs.Warn.Println("create user: user tags are no longer supported, sid=", s.sid)
if !restrictedTagsEqual(tags, nil, globals.immutableTagNS) { msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp)
logs.Warn.Println("create user: attempt to directly assign restricted tags, sid=", s.sid) msg.Ctrl.Params = map[string]any{"what": "tags"}
msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp) s.queueOut(msg)
msg.Ctrl.Params = map[string]any{"what": "tags"} return
s.queueOut(msg)
return
}
user.Tags = tags
} }
// Pre-check credentials for validity. We don't know user's access level // Pre-check credentials for validity. We don't know user's access level
// consequently cannot check presence of required credentials. Must do that later. // consequently cannot check presence of required credentials. Must do that later.
creds := normalizeCredentials(msg.Acc.Cred, true) creds := normalizeCredentials(msg.Acc.Cred, true)
var registrationPhone string
for i := range creds { for i := range creds {
cr := &creds[i] cr := &creds[i]
vld := store.Store.GetValidator(cr.Method) vld := store.Store.GetValidator(cr.Method)
if _, err := vld.PreCheck(cr.Value, cr.Params); err != nil { normalized, err := vld.PreCheck(cr.Value, cr.Params)
if err != nil {
logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid) logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
map[string]any{"what": cr.Method})) map[string]any{"what": cr.Method}))
return return
} }
if cr.Method == "tel" {
phone := strings.TrimSpace(strings.TrimPrefix(normalized, "tel:"))
challengeID, _ := cr.Params["challenge_id"].(string)
if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" ||
(registrationPhone != "" && registrationPhone != phone) {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}}))
return
}
registrationPhone = phone
}
} }
if registrationPhone == "" && store.Verifications != nil {
logs.Warn.Println("create user: verified phone is required, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}}))
return
}
user.Phone = registrationPhone
// Assign default access values in case the acc creator has not provided them // Assign default access values in case the acc creator has not provided them
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) | user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) |
@@ -138,9 +154,9 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return return
} }
// Add authentication record. The authhdl.AddRecord may change tags. // Add authentication record.
rec, err := authhdl.AddRecord(authCtx, rec, err := authhdl.AddRecord(authCtx,
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret) &auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
if err != nil { if err != nil {
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid) logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
@@ -167,14 +183,14 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return return
} }
// Save credentials, update tags if necessary. // Save and validate credentials.
tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{ tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{
TenantID: s.tenantID, TenantID: s.tenantID,
Uid: user.Uid(), Uid: user.Uid(),
AuthLevel: auth.LevelAuth, AuthLevel: auth.LevelAuth,
Lifetime: auth.Duration(time.Hour * 24), Lifetime: auth.Duration(time.Hour * 24),
}) })
validated, _, err := addCreds(s.tenantID, user.Uid(), creds, rec.Tags, s.lang, tmpToken) validated, _, err := addCreds(s.tenantID, user.Uid(), creds, nil, s.lang, tmpToken)
if err != nil { if err != nil {
logs.Warn.Println("create user: failed to save or validate credential", err, "sid=", s.sid) logs.Warn.Println("create user: failed to save or validate credential", err, "sid=", s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
@@ -351,17 +367,11 @@ func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types.
// TODO(gene): support adding new auth schemes // TODO(gene): support adding new auth schemes
rec, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr}, _, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr},
&auth.Rec{TenantID: tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret) &auth.Rec{TenantID: tenantID, Uid: user.Uid()}, msg.Acc.Secret)
if err != nil { if err != nil {
return err return err
} }
// Tags may have been changed by authhdl.UpdateRecord, reset them.
// Can't do much with the error here, logging it but not returning.
if _, err = store.Users.UpdateTags(tenantID, user.Uid(), nil, nil, rec.Tags); err != nil {
logs.Warn.Println("updateUserAuth tags update failed:", err)
}
return nil return nil
} }
@@ -369,11 +379,8 @@ func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types.
return types.ErrMalformed return types.ErrMalformed
} }
// addCreds adds new credentials and re-send validation request for existing ones. // addCreds adds credentials and re-sends validation requests for existing ones.
// It also adds credential-defined tags if necessary. func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, _ []string,
// Returns methods validated in this call only. Returns either a full set of tags
// or nil for tags when tags are unchanged.
func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, extraTags []string,
lang string, tmpToken []byte) ([]string, []string, error) { lang string, tmpToken []byte) ([]string, []string, error) {
var validated []string var validated []string
for i := range creds { for i := range creds {
@@ -385,14 +392,11 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
} }
if cr.Response != "" { if cr.Response != "" {
if value, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil { if _, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil {
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
validated = append(validated, cr.Method) validated = append(validated, cr.Method)
if globals.validators[cr.Method].addToTags {
extraTags = append(extraTags, cr.Method+":"+value)
}
continue continue
} }
} }
@@ -407,24 +411,9 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
// successfully validated. // successfully validated.
validated = append(validated, cr.Method) validated = append(validated, cr.Method)
// Generate tags for these confirmed credentials.
if globals.validators[cr.Method].addToTags {
extraTags = append(extraTags, cr.Method+":"+cr.Value)
}
} }
} }
return validated, nil, nil
// Save tags potentially changed by the validator.
if len(extraTags) > 0 {
if utags, err := store.Users.UpdateTags(tenantID, uid, extraTags, nil, nil); err == nil {
extraTags = utags
} else {
logs.Warn.Println("add cred tags update failed:", err)
}
} else {
extraTags = nil
}
return validated, extraTags, nil
} }
func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient, func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient,
@@ -475,7 +464,6 @@ func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *
// validatedCreds returns the list of validated credentials including those validated in this call. // validatedCreds returns the list of validated credentials including those validated in this call.
// Returns all validated methods including those validated earlier and now. // Returns all validated methods including those validated earlier and now.
// Returns either a full set of tags or nil for tags if tags are unchanged.
func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, creds []MsgCredClient, func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, creds []MsgCredClient,
errorOnFail bool) ([]string, []string, error) { errorOnFail bool) ([]string, []string, error) {
// Check if credential validation is required. // Check if credential validation is required.
@@ -497,7 +485,6 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
// Add credentials which are validated in this call. // Add credentials which are validated in this call.
// Unknown validators are removed. // Unknown validators are removed.
creds = normalizeCredentials(creds, false) creds = normalizeCredentials(creds, false)
var tagsToAdd []string
for i := range creds { for i := range creds {
cr := &creds[i] cr := &creds[i]
if cr.Response == "" { if cr.Response == "" {
@@ -506,7 +493,7 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
} }
vld := store.Store.GetValidator(cr.Method) // No need to check for nil, unknown methods are removed earlier. vld := store.Store.GetValidator(cr.Method) // No need to check for nil, unknown methods are removed earlier.
value, err := vld.Check(tenantID, uid, cr.Response) _, err := vld.Check(tenantID, uid, cr.Response)
if err != nil { if err != nil {
// Check failed. // Check failed.
@@ -525,23 +512,6 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
// Check did not return an error: the request was successfully validated. // Check did not return an error: the request was successfully validated.
methods[cr.Method] = struct{}{} methods[cr.Method] = struct{}{}
// Add validated credential to user's tags.
if globals.validators[cr.Method].addToTags {
tagsToAdd = append(tagsToAdd, cr.Method+":"+value)
}
}
var tags []string
if len(tagsToAdd) > 0 {
// Save update to tags
if utags, err := store.Users.UpdateTags(tenantID, uid, tagsToAdd, nil, nil); err == nil {
tags = utags
} else {
logs.Warn.Println("validated creds tags update failed:", err)
tags = nil
}
} else {
tags = nil
} }
validated := make([]string, 0, len(methods)) validated := make([]string, 0, len(methods))
@@ -549,11 +519,10 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level,
validated = append(validated, method) validated = append(validated, method)
} }
return validated, tags, nil return validated, nil, nil
} }
// deleteCred deletes user's credential. // deleteCred deletes user's credential.
// Returns full set of remaining tags or nil if tags are unchanged.
func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) { func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) {
vld := store.Store.GetValidator(cred.Method) vld := store.Store.GetValidator(cred.Method)
if vld == nil || cred.Value == "" { if vld == nil || cred.Value == "" {
@@ -601,21 +570,7 @@ func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred
return nil, err return nil, err
} }
// Remove generated tags for the deleted credential. return nil, nil
var tags []string
if globals.validators[cred.Method].addToTags {
// This error should not be returned to user.
if utags, err := store.Users.UpdateTags(tenantID, uid, nil, []string{cred.Method + ":" + cred.Value}, nil); err == nil {
tags = utags
} else {
logs.Warn.Println("delete cred: failed to update tags:", err)
tags = nil
}
} else {
tags = nil
}
return tags, nil
} }
// Change user state: suspended/normal (ok). // Change user state: suspended/normal (ok).