feat: complete shared database tenant sharding
This commit is contained in:
@@ -79,8 +79,11 @@ func (s testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(types.Ten
|
||||
return 0, s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
|
||||
return s.err
|
||||
func (s testSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &types.SystemNotification{EventID: eventID, EventType: "system.notice", Title: "通知", Summary: "通知", ReadAt: &readAt, CreatedAt: readAt}, nil
|
||||
}
|
||||
|
||||
func newTestAuthenticator(t *testing.T) *authenticator {
|
||||
|
||||
@@ -90,16 +90,15 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginAuditQuerySpecsExposeUserIDAsString(t *testing.T) {
|
||||
for label, spec := range map[string]*internalQuerySpec{
|
||||
"user-login-logs": internalTenantResourceSpecs["user-login-logs"],
|
||||
"login-failure-logs": internalTenantResourceSpecs["login-failure-logs"],
|
||||
} {
|
||||
if spec == nil {
|
||||
t.Fatalf("%s query spec missing", label)
|
||||
}
|
||||
if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") {
|
||||
t.Fatalf("%s query must expose 64-bit user IDs as strings: %s", label, spec.Select)
|
||||
}
|
||||
spec := internalTenantResourceSpecs["user-login-logs"]
|
||||
if spec == nil {
|
||||
t.Fatal("user-login-logs query spec missing")
|
||||
}
|
||||
if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") {
|
||||
t.Fatalf("user-login-logs query must expose 64-bit user IDs as strings: %s", spec.Select)
|
||||
}
|
||||
if internalTenantResourceSpecs["login-failure-logs"] != nil {
|
||||
t.Fatal("login-failure-logs query spec must be removed with im_login_failure_logs")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,14 +261,8 @@ type TenantRoutedAdapter interface {
|
||||
}
|
||||
|
||||
// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used
|
||||
// by product HTTP middleware and online business-event delivery.
|
||||
// by Kafka dead-letter handling.
|
||||
type TenantHTTPInfrastructureAdapter interface {
|
||||
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
|
||||
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
|
||||
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error
|
||||
TenantKafkaConsumeBegin(t.TenantID, *t.KafkaConsumeRecord, time.Time) (*t.KafkaConsumeRecord, bool, error)
|
||||
TenantKafkaConsumeSucceed(t.TenantID, string, string, time.Time) error
|
||||
TenantKafkaConsumeFail(t.TenantID, string, string, string, string, time.Time) (*t.KafkaConsumeRecord, error)
|
||||
TenantKafkaConsumeDead(t.TenantID, *t.KafkaDeadLetter, time.Time) error
|
||||
TenantKafkaDeadLetterList(t.TenantID, t.KafkaDeadLetterListOptions) (*t.KafkaDeadLetterPage, error)
|
||||
TenantKafkaDeadLetterGet(t.TenantID, string) (*t.KafkaDeadLetter, error)
|
||||
@@ -476,7 +470,7 @@ type TenantBusinessAdapter interface {
|
||||
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
|
||||
TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, error)
|
||||
TenantSystemNotificationUnreadCountByEventPrefix(t.TenantID, t.Uid, string) (int, error)
|
||||
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
|
||||
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) (*t.SystemNotification, error)
|
||||
|
||||
TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error)
|
||||
TenantFriendRequestList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendRequestListItem, string, error)
|
||||
|
||||
@@ -1148,25 +1148,6 @@ func createIMSecurityTables(tx *sql.Tx) error {
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`CREATE TABLE im_action_tokens(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_id VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
target VARCHAR(128) NOT NULL DEFAULT '',
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
state SMALLINT NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
consumed_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_action_tokens_token(token_id),
|
||||
INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
`CREATE TABLE im_youth_mode_settings(
|
||||
id BIGINT NOT NULL,
|
||||
|
||||
@@ -21,33 +21,6 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
|
||||
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
|
||||
INDEX idx_im_user_login_request(request_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_login_failure_logs (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT,
|
||||
account_type VARCHAR(24) NOT NULL DEFAULT '', account_hash CHAR(64) NOT NULL DEFAULT '',
|
||||
account_mask VARCHAR(128) NOT NULL DEFAULT '', failure_type VARCHAR(32) NOT NULL,
|
||||
reason VARCHAR(96) NOT NULL DEFAULT '', detail VARCHAR(500) NOT NULL DEFAULT '',
|
||||
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
|
||||
device_id VARCHAR(255) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
app_version VARCHAR(32) NOT NULL DEFAULT '', user_agent VARCHAR(512) NOT NULL DEFAULT '',
|
||||
risk_level VARCHAR(16) NOT NULL DEFAULT 'medium', risk_action VARCHAR(24) NOT NULL DEFAULT 'audit_only',
|
||||
request_id VARCHAR(64) NOT NULL DEFAULT '', attempt_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id), INDEX idx_im_login_failure_account_time(account_hash, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_user_time(user_id, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_ip_time(ip, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_request(request_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_login_lockouts (
|
||||
id BIGINT NOT NULL, user_id BIGINT,
|
||||
account_hash CHAR(64) NOT NULL,
|
||||
failed_attempts INT NOT NULL DEFAULT 0,
|
||||
window_started_at DATETIME(3), locked_until DATETIME(3), last_attempt_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id), UNIQUE INDEX uk_im_login_lockouts_account(account_hash),
|
||||
INDEX idx_im_login_lockouts_user(user_id), INDEX idx_im_login_lockouts_locked(locked_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
|
||||
|
||||
@@ -13,15 +13,20 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
|
||||
if err := ensureIMAdminGovernanceTables(exec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exec.statements) != 5 {
|
||||
t.Fatalf("created %d admin governance tables, want 5", len(exec.statements))
|
||||
if len(exec.statements) != 3 {
|
||||
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
|
||||
}
|
||||
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
|
||||
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_login_lockouts", "im_message_admin_actions", "im_user_ip_whitelists"} {
|
||||
for _, table := range []string{"im_user_login_logs", "im_message_admin_actions", "im_user_ip_whitelists"} {
|
||||
if !strings.Contains(joined, table) {
|
||||
t.Fatalf("missing table %s", table)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"im_login_failure_logs", "im_login_lockouts"} {
|
||||
if strings.Contains(joined, table) {
|
||||
t.Fatalf("hot login state table must not be created: %s", table)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
|
||||
t.Fatalf("admin governance schema must stay inside the selected tenant database: %s", joined)
|
||||
}
|
||||
|
||||
@@ -25,24 +25,6 @@ func ensureGroupMessageShardInfrastructure(exec sqlExecutor, tenantID t.TenantID
|
||||
PRIMARY KEY(tenant_id, shard_month), UNIQUE INDEX uk_im_group_message_shards_table(tenant_id, table_name),
|
||||
INDEX idx_im_group_message_shards_status(tenant_id, status, shard_month)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, topic VARCHAR(64) NOT NULL, shard_month CHAR(6) NOT NULL,
|
||||
min_seq BIGINT NOT NULL, max_seq BIGINT NOT NULL, message_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NOT NULL, max_created_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(tenant_id, topic, shard_month), INDEX idx_im_group_message_topic_shards_seq(tenant_id, topic, max_seq),
|
||||
INDEX idx_im_group_message_topic_shards_month(tenant_id, shard_month, topic)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_group_message_dedup (
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, topic VARCHAR(64) NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64) NOT NULL,
|
||||
seqid BIGINT NOT NULL, shard_month CHAR(6) NOT NULL, message_id BIGINT,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', expires_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(tenant_id, topic, from_user, client_msg_id), INDEX idx_im_group_message_dedup_expires(expires_at),
|
||||
INDEX idx_im_group_message_dedup_topic_seq(tenant_id, topic, seqid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := exec.Exec(statement); err != nil {
|
||||
@@ -129,22 +111,13 @@ func groupMessageShardTable(tenantID t.TenantID, month string) (string, bool) {
|
||||
|
||||
func groupMessageTablesForTopic(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string) ([]string, error) {
|
||||
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
|
||||
redisAvailable := err == nil
|
||||
if err != nil {
|
||||
cached = false
|
||||
}
|
||||
if !cached {
|
||||
routes = nil
|
||||
if err = sqlx.SelectContext(ctx, q, &routes,
|
||||
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
|
||||
FROM im_group_message_topic_shards WHERE tenant_id=? AND topic=? ORDER BY shard_month DESC`, tenantID, topic); err != nil {
|
||||
if routes, err = groupMessageAllShardRoutes(q, ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if redisAvailable {
|
||||
for _, route := range routes {
|
||||
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := make([]string, 0, len(routes)+1)
|
||||
for _, route := range routes {
|
||||
@@ -157,23 +130,13 @@ func groupMessageTablesForTopic(q sqlx.QueryerContext, ctx context.Context, tena
|
||||
|
||||
func groupMessageTablesForSeq(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64) ([]string, error) {
|
||||
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
|
||||
redisAvailable := err == nil
|
||||
if err != nil {
|
||||
cached = false
|
||||
}
|
||||
if !cached {
|
||||
routes = nil
|
||||
if err = sqlx.SelectContext(ctx, q, &routes,
|
||||
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
|
||||
FROM im_group_message_topic_shards WHERE tenant_id=? AND topic=? AND min_seq<=? AND max_seq>=?
|
||||
ORDER BY shard_month DESC`, tenantID, topic, seq, seq); err != nil {
|
||||
if routes, err = groupMessageAllShardRoutes(q, ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if redisAvailable {
|
||||
for _, route := range routes {
|
||||
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := make([]string, 0, 2)
|
||||
for _, route := range routes {
|
||||
@@ -186,6 +149,22 @@ func groupMessageTablesForSeq(q sqlx.QueryerContext, ctx context.Context, tenant
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func groupMessageAllShardRoutes(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID) ([]store.GroupMessageShardRoute, error) {
|
||||
var rows []struct {
|
||||
Month string `db:"month"`
|
||||
}
|
||||
if err := sqlx.SelectContext(ctx, q, &rows, `SELECT shard_month AS month FROM im_group_message_shards WHERE tenant_id=? ORDER BY shard_month DESC`, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes := make([]store.GroupMessageShardRoute, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if validShardMonth(row.Month) {
|
||||
routes = append(routes, store.GroupMessageShardRoute{Month: row.Month, MinSeq: 1, MaxSeq: 1<<62 - 1, Count: 0})
|
||||
}
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func groupMessageMonthFromTable(table string) string {
|
||||
const suffixLength = 6
|
||||
if len(table) > suffixLength && validShardMonth(table[len(table)-suffixLength:]) {
|
||||
|
||||
@@ -149,15 +149,6 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
|
||||
INDEX idx_im_group_admin_logs_topic_time(topic, id DESC),
|
||||
INDEX idx_im_group_admin_logs_operator(operator_id, id DESC), INDEX idx_im_group_admin_logs_object(object_type, object_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_group_idempotency (
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, method VARCHAR(8) NOT NULL,
|
||||
route VARCHAR(256) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
|
||||
result_code INT NOT NULL DEFAULT 0, result_body JSON,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_idempotency_request(user_id, method, route, idempotency_key),
|
||||
INDEX idx_im_group_idempotency_created(created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')))
|
||||
WHERE name LIKE 'grp%' AND JSON_VALID(public)
|
||||
AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.fn')), '') = ''
|
||||
|
||||
@@ -24,8 +24,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
|
||||
if err := ensureIMGroupBusinessTables(exec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exec.statements) != 25 {
|
||||
t.Fatalf("executed %d group schema statements, want 25", len(exec.statements))
|
||||
if len(exec.statements) != 22 {
|
||||
t.Fatalf("executed %d group schema statements, want 22", len(exec.statements))
|
||||
}
|
||||
for _, statement := range exec.statements {
|
||||
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
|
||||
@@ -36,6 +36,9 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
|
||||
if strings.Contains(joined, "im_group_events_outbox") {
|
||||
t.Fatal("group event outbox table must not be created")
|
||||
}
|
||||
if strings.Contains(joined, "im_group_idempotency") {
|
||||
t.Fatal("group idempotency table must not be created")
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"primary key(id), unique index uk_im_group_settings_topic(topic)",
|
||||
"primary key(id), unique index uk_im_group_announcement_reads_announcement_user(announcement_id, user_id)",
|
||||
@@ -48,11 +51,17 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
|
||||
if !strings.Contains(joined, "json_set(public, '$.fn'") {
|
||||
t.Fatal("group schema is missing historical public.fn backfill")
|
||||
}
|
||||
for _, expected := range []string{"im_group_message_shards", "im_group_message_topic_shards", "im_group_message_dedup", "primary key(message_id)"} {
|
||||
for _, expected := range []string{"im_group_message_shards", "primary key(message_id)"} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("group shard schema is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "im_group_message_topic_shards") {
|
||||
t.Fatal("group message topic shard routes must not be created in MySQL")
|
||||
}
|
||||
if strings.Contains(joined, "im_group_message_dedup") {
|
||||
t.Fatal("group message dedup table must not be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMessageShardTableRejectsUnsafeMonth(t *testing.T) {
|
||||
@@ -71,11 +80,15 @@ func TestTenantPartitionTableNames(t *testing.T) {
|
||||
tenantID types.TenantID
|
||||
friends string
|
||||
messages string
|
||||
topics string
|
||||
members string
|
||||
subs string
|
||||
notices string
|
||||
group string
|
||||
}{
|
||||
{tenantID: 1, friends: "im_001_friendships", messages: "im_001_messages", group: "im_001_group_messages_202609"},
|
||||
{tenantID: 10, friends: "im_010_friendships", messages: "im_010_messages", group: "im_010_group_messages_202609"},
|
||||
{tenantID: 1000, friends: "im_1000_friendships", messages: "im_1000_messages", group: "im_1000_group_messages_202609"},
|
||||
{tenantID: 1, friends: "im_001_friendships", messages: "im_001_messages", topics: "im_001_topics", members: "im_001_group_members", subs: "im_001_subscriptions", notices: "im_001_system_notifications", group: "im_001_group_messages_202609"},
|
||||
{tenantID: 10, friends: "im_010_friendships", messages: "im_010_messages", topics: "im_010_topics", members: "im_010_group_members", subs: "im_010_subscriptions", notices: "im_010_system_notifications", group: "im_010_group_messages_202609"},
|
||||
{tenantID: 1000, friends: "im_1000_friendships", messages: "im_1000_messages", topics: "im_1000_topics", members: "im_1000_group_members", subs: "im_1000_subscriptions", notices: "im_1000_system_notifications", group: "im_1000_group_messages_202609"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tenantFriendshipsTable(tc.tenantID); got != tc.friends {
|
||||
@@ -84,8 +97,34 @@ func TestTenantPartitionTableNames(t *testing.T) {
|
||||
if got := tenantMessagesTable(tc.tenantID); got != tc.messages {
|
||||
t.Fatalf("tenant %d message table: got %q, want %q", tc.tenantID, got, tc.messages)
|
||||
}
|
||||
if got := tenantTopicsTable(tc.tenantID); got != tc.topics {
|
||||
t.Fatalf("tenant %d topics table: got %q, want %q", tc.tenantID, got, tc.topics)
|
||||
}
|
||||
if got := tenantGroupMembersTable(tc.tenantID); got != tc.members {
|
||||
t.Fatalf("tenant %d group members table: got %q, want %q", tc.tenantID, got, tc.members)
|
||||
}
|
||||
if got := tenantSubscriptionsTable(tc.tenantID); got != tc.subs {
|
||||
t.Fatalf("tenant %d subscriptions table: got %q, want %q", tc.tenantID, got, tc.subs)
|
||||
}
|
||||
if got := tenantSystemNotificationsTable(tc.tenantID); got != tc.notices {
|
||||
t.Fatalf("tenant %d system notifications table: got %q, want %q", tc.tenantID, got, tc.notices)
|
||||
}
|
||||
if got, ok := tenantGroupMessagesTable(tc.tenantID, "202609"); !ok || got != tc.group {
|
||||
t.Fatalf("tenant %d group message table: got %q, ok=%v, want %q", tc.tenantID, got, ok, tc.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantShardSQL(t *testing.T) {
|
||||
query := tenantShardSQL(7, "SELECT * FROM im_topics t JOIN im_group_members gm ON gm.topic=t.name JOIN im_subscriptions s ON s.topic=gm.topic; INSERT INTO im_system_notifications(event_id) VALUES(?)")
|
||||
for _, expected := range []string{"im_007_topics", "im_007_group_members", "im_007_subscriptions", "im_007_system_notifications"} {
|
||||
if !strings.Contains(query, expected) {
|
||||
t.Fatalf("tenant shard query is missing %q: %s", expected, query)
|
||||
}
|
||||
}
|
||||
for _, legacy := range []string{" im_topics", " im_group_members", " im_subscriptions", " im_system_notifications"} {
|
||||
if strings.Contains(query, legacy) {
|
||||
t.Fatalf("tenant shard query still references %q: %s", legacy, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,6 @@ package mysql
|
||||
|
||||
func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS im_http_idempotency (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL,
|
||||
method VARCHAR(8) NOT NULL, route VARCHAR(512) NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'processing', response_status INT NOT NULL DEFAULT 0,
|
||||
response_body MEDIUMBLOB, expires_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_http_idempotency_scope(tenant_id, user_id, method, route, idempotency_key),
|
||||
INDEX idx_im_http_idempotency_expiry(expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(96) NOT NULL,
|
||||
consumer_group VARCHAR(128) NOT NULL, source_topic VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(96) NOT NULL DEFAULT '', biz_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
biz_id VARCHAR(128) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'processing',
|
||||
retry_count INT NOT NULL DEFAULT 0, last_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
last_error VARCHAR(1000) NOT NULL DEFAULT '', processing_deadline DATETIME(3),
|
||||
succeeded_at DATETIME(3), dead_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id), UNIQUE INDEX uk_im_kafka_consume_log_event_group(event_id, consumer_group),
|
||||
INDEX idx_im_kafka_consume_log_status(status, processing_deadline),
|
||||
INDEX idx_im_kafka_consume_log_biz(biz_type, biz_id),
|
||||
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT, dead_id VARCHAR(96) NOT NULL,
|
||||
event_id VARCHAR(96) NOT NULL, parent_event_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
@@ -82,15 +55,5 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
migrations := []ignoreSQLError{
|
||||
{`ALTER TABLE im_http_idempotency ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 0 AFTER id`, 1060},
|
||||
{`ALTER TABLE im_http_idempotency DROP INDEX uk_im_http_idempotency_scope`, 1091},
|
||||
{`ALTER TABLE im_http_idempotency ADD UNIQUE INDEX uk_im_http_idempotency_scope(tenant_id, user_id, method, route, idempotency_key)`, 1061},
|
||||
}
|
||||
for _, migration := range migrations {
|
||||
if err := execIgnoreMySQLError(exec, migration.sql, migration.code); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,13 +12,11 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
|
||||
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exec.statements) != 9 {
|
||||
t.Fatalf("executed %d infrastructure schema statements, want 9", len(exec.statements))
|
||||
if len(exec.statements) != 4 {
|
||||
t.Fatalf("executed %d infrastructure schema statements, want 4", len(exec.statements))
|
||||
}
|
||||
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
|
||||
for _, table := range []string{
|
||||
"im_http_idempotency",
|
||||
"im_kafka_consume_log",
|
||||
"im_kafka_dead_letters",
|
||||
"im_realname_appeals",
|
||||
"im_conversation_settings",
|
||||
@@ -31,12 +29,15 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
|
||||
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") {
|
||||
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "tenant_id") || !strings.Contains(joined, "uk_im_http_idempotency_scope(tenant_id, user_id") {
|
||||
t.Fatalf("HTTP idempotency schema must be tenant scoped in shared DB mode: %s", joined)
|
||||
if strings.Contains(joined, "im_http_idempotency") {
|
||||
t.Fatalf("HTTP idempotency must not create MySQL tables: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "im_kafka_outbox") {
|
||||
t.Fatalf("kafka outbox table must not be created: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "im_kafka_consume_log") {
|
||||
t.Fatalf("kafka consume state must not create MySQL tables: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "im_business_events_outbox") {
|
||||
t.Fatalf("business event outbox table must not be created: %s", joined)
|
||||
}
|
||||
|
||||
@@ -4,203 +4,45 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func (a *adapter) routedLoginLockoutAdapter(id t.TenantID) (*adapter, error) {
|
||||
if err := validTenant(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.tenantScopedDB {
|
||||
return a, nil
|
||||
}
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp, nil
|
||||
}
|
||||
|
||||
type loginLockoutRow struct {
|
||||
ID int64 `db:"id"`
|
||||
UserID sql.NullInt64 `db:"user_id"`
|
||||
AccountHash string `db:"account_hash"`
|
||||
FailedAttempts int `db:"failed_attempts"`
|
||||
WindowStartAt sql.NullTime `db:"window_started_at"`
|
||||
LockedUntil sql.NullTime `db:"locked_until"`
|
||||
LastAttemptAt sql.NullTime `db:"last_attempt_at"`
|
||||
UpdatedAt sql.NullTime `db:"updated_at"`
|
||||
}
|
||||
|
||||
func loginLockoutFromRow(id t.TenantID, row loginLockoutRow) *t.LoginLockout {
|
||||
out := &t.LoginLockout{
|
||||
TenantID: id,
|
||||
ID: row.ID,
|
||||
AccountHash: row.AccountHash,
|
||||
FailedAttempts: row.FailedAttempts,
|
||||
}
|
||||
if row.UserID.Valid {
|
||||
out.UserID = row.UserID.Int64
|
||||
}
|
||||
if row.WindowStartAt.Valid {
|
||||
v := row.WindowStartAt.Time
|
||||
out.WindowStartAt = &v
|
||||
}
|
||||
if row.LockedUntil.Valid {
|
||||
v := row.LockedUntil.Time
|
||||
out.LockedUntil = &v
|
||||
}
|
||||
if row.LastAttemptAt.Valid {
|
||||
v := row.LastAttemptAt.Time
|
||||
out.LastAttemptAt = &v
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
v := row.UpdatedAt.Time
|
||||
out.UpdatedAt = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const loginLockoutColumns = `id,user_id,account_hash,failed_attempts,window_started_at,locked_until,last_attempt_at,updated_at`
|
||||
|
||||
// TenantLoginLockoutGet reads the lockout counter for the given account hash.
|
||||
// Login lockout counters are runtime security state and are stored in Redis by
|
||||
// the store layer. The MySQL adapter keeps these methods only to satisfy older
|
||||
// adapter interfaces while preventing accidental writes to im_login_lockouts.
|
||||
func (a *adapter) TenantLoginLockoutGet(id t.TenantID, accountHash string) (*t.LoginLockout, error) {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutGet(id, accountHash)
|
||||
}
|
||||
if accountHash == "" {
|
||||
if err := validTenant(id); err != nil || accountHash == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var row loginLockoutRow
|
||||
err = a.db.GetContext(ctx, &row,
|
||||
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loginLockoutFromRow(id, row), nil
|
||||
return nil, t.ErrUnsupported
|
||||
}
|
||||
|
||||
// TenantLoginLockoutRecordFailure atomically inserts or increments the failure
|
||||
// counter. windowCutoff is "now minus window seconds": an older window start
|
||||
// resets the counter to 1. The updated row is returned to the caller.
|
||||
func (a *adapter) TenantLoginLockoutRecordFailure(id t.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*t.LoginLockout, error) {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutRecordFailure(id, accountHash, userID, windowCutoff, now)
|
||||
}
|
||||
if accountHash == "" || now.IsZero() {
|
||||
if err := validTenant(id); err != nil || accountHash == "" || now.IsZero() {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rowID, err := nextSnowflakeID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// MySQL evaluates ON DUPLICATE KEY UPDATE assignments left to right, so
|
||||
// failed_attempts is recomputed before window_started_at is overwritten.
|
||||
_, err = a.db.ExecContext(ctx, `INSERT INTO im_login_lockouts
|
||||
(id,user_id,account_hash,failed_attempts,window_started_at,last_attempt_at)
|
||||
VALUES(?,?,?,1,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
failed_attempts = IF(window_started_at IS NULL OR window_started_at < ?, 1, failed_attempts + 1),
|
||||
window_started_at = IF(window_started_at IS NULL OR window_started_at < ?, ?, window_started_at),
|
||||
last_attempt_at = ?,
|
||||
user_id = COALESCE(user_id, ?)`,
|
||||
rowID, userID, accountHash, now, now,
|
||||
windowCutoff, windowCutoff, now, now, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row loginLockoutRow
|
||||
err = a.db.GetContext(ctx, &row,
|
||||
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loginLockoutFromRow(id, row), nil
|
||||
return nil, t.ErrUnsupported
|
||||
}
|
||||
|
||||
// TenantLoginLockoutLock sets locked_until unless a newer lock already covers now.
|
||||
func (a *adapter) TenantLoginLockoutLock(id t.TenantID, accountHash string, lockedUntil, now time.Time) error {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutLock(id, accountHash, lockedUntil, now)
|
||||
}
|
||||
if accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
|
||||
if err := validTenant(id); err != nil || accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx,
|
||||
`UPDATE im_login_lockouts SET locked_until=?, updated_at=? WHERE account_hash=? AND (locked_until IS NULL OR locked_until < ?)`,
|
||||
lockedUntil, now, accountHash, now)
|
||||
return err
|
||||
return t.ErrUnsupported
|
||||
}
|
||||
|
||||
// TenantLoginLockoutReset removes the counter after a successful login.
|
||||
func (a *adapter) TenantLoginLockoutReset(id t.TenantID, accountHash string) error {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutReset(id, accountHash)
|
||||
}
|
||||
if accountHash == "" {
|
||||
if err := validTenant(id); err != nil || accountHash == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `DELETE FROM im_login_lockouts WHERE account_hash=?`, accountHash)
|
||||
return err
|
||||
return t.ErrUnsupported
|
||||
}
|
||||
|
||||
// TenantLoginLockoutRelease clears the lock and zeroes the counter (admin unlock).
|
||||
func (a *adapter) TenantLoginLockoutRelease(id t.TenantID, accountHash string, now time.Time) error {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutRelease(id, accountHash, now)
|
||||
}
|
||||
if accountHash == "" || now.IsZero() {
|
||||
if err := validTenant(id); err != nil || accountHash == "" || now.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx,
|
||||
`UPDATE im_login_lockouts SET failed_attempts=0, window_started_at=NULL, locked_until=NULL, updated_at=? WHERE account_hash=?`,
|
||||
now, accountHash)
|
||||
return err
|
||||
return t.ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# - Shared tenant business database: meeting_im
|
||||
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
|
||||
# - im_http_idempotency for HTTP write idempotency
|
||||
# - Redis short-state keys for HTTP write idempotency
|
||||
# - im_file_review_tasks for asynchronous content safety and malware checks
|
||||
# - business tables store tenant_id; high-volume tables are physically partitioned by tenant
|
||||
#
|
||||
|
||||
+119
-210
@@ -322,11 +322,11 @@ func messageHeadBool(head t.KVMap, key string) bool {
|
||||
|
||||
func (a *adapter) tenantTopicBumpMutationVersionTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, topic string, at time.Time) error {
|
||||
if a.tenantScopedDB {
|
||||
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE name=?",
|
||||
_, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE name=?"),
|
||||
at, at, topic)
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE tenant_id=? AND name=?",
|
||||
_, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE tenant_id=? AND name=?"),
|
||||
at, at, id, topic)
|
||||
return err
|
||||
}
|
||||
@@ -634,7 +634,7 @@ func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
|
||||
}()
|
||||
|
||||
var ownTopics []string
|
||||
q := "SELECT name FROM im_topics WHERE tenant_id=? AND owner=?"
|
||||
q := tenantShardSQL(id, "SELECT name FROM im_topics WHERE tenant_id=? AND owner=?")
|
||||
args := []any{id, decoded}
|
||||
if !hard {
|
||||
q += " AND state!=?"
|
||||
@@ -663,20 +663,20 @@ func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
|
||||
return err
|
||||
}
|
||||
if len(ownTopics) > 0 {
|
||||
if _, err = tx.Exec("DELETE dl FROM im_dellog AS dl JOIN im_topics AS tp ON tp.tenant_id=dl.tenant_id AND tp.name=dl.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "DELETE dl FROM im_dellog AS dl JOIN im_topics AS tp ON tp.tenant_id=dl.tenant_id AND tp.name=dl.topic WHERE tp.tenant_id=? AND tp.owner=?"), id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(fmt.Sprintf("DELETE m FROM %s AS m JOIN im_topics AS tp ON tp.tenant_id=m.tenant_id AND tp.name=m.topic WHERE tp.tenant_id=? AND tp.owner=?", tenantMessagesTable(id)), id, decoded); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, fmt.Sprintf("DELETE m FROM %s AS m JOIN im_topics AS tp ON tp.tenant_id=m.tenant_id AND tp.name=m.topic WHERE tp.tenant_id=? AND tp.owner=?", tenantMessagesTable(id))), id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
sqlq, params, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, ownAll)
|
||||
sqlq, params, _ := sqlx.In(tenantShardSQL(id, "DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)"), id, ownAll)
|
||||
if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("DELETE tt FROM im_topictags AS tt JOIN im_topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "DELETE tt FROM im_topictags AS tt JOIN im_topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?"), id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND owner=?", id, decoded); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "DELETE FROM im_topics WHERE tenant_id=? AND owner=?"), id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -694,23 +694,23 @@ func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
|
||||
return err
|
||||
}
|
||||
if len(ownAll) > 0 {
|
||||
sqlq, params, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, ownAll)
|
||||
sqlq, params, _ := sqlx.In(tenantShardSQL(id, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)"), now, now, id, ownAll)
|
||||
if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?",
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?"),
|
||||
now, now, t.StateDeleted, now, id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
|
||||
"SET tp.updatedat=?,tp.touchedat=?,tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.name LIKE 'p2p%'",
|
||||
now, now, t.StateDeleted, now, id, decoded); err != nil {
|
||||
), now, now, t.StateDeleted, now, id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE im_subscriptions AS s_one JOIN im_subscriptions AS s_two ON s_one.tenant_id=s_two.tenant_id AND s_one.topic=s_two.topic "+
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "UPDATE im_subscriptions AS s_one JOIN im_subscriptions AS s_two ON s_one.tenant_id=s_two.tenant_id AND s_one.topic=s_two.topic "+
|
||||
"SET s_two.updatedat=?,s_two.deletedat=? WHERE s_one.tenant_id=? AND s_one.userid=? AND s_one.topic LIKE 'p2p%'",
|
||||
now, now, id, decoded); err != nil {
|
||||
), now, now, id, decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE im_users SET updatedat=?,state=?,stateat=? WHERE tenant_id=? AND id=?",
|
||||
@@ -729,13 +729,13 @@ func (a *adapter) tenantTopicStateForUser(tx *sqlx.Tx, tenantID t.TenantID, deco
|
||||
if now.IsZero() {
|
||||
now = t.TimeNow()
|
||||
}
|
||||
if _, err := tx.Exec("UPDATE im_topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?",
|
||||
if _, err := tx.Exec(tenantShardSQL(tenantID, "UPDATE im_topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?"),
|
||||
objState, now, tenantID, decodedUID, t.StateDeleted); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
|
||||
_, err := tx.Exec(tenantShardSQL(tenantID, "UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
|
||||
"SET tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.state!=?",
|
||||
objState, now, tenantID, decodedUID, t.StateDeleted)
|
||||
), objState, now, tenantID, decodedUID, t.StateDeleted)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -822,10 +822,10 @@ func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid
|
||||
if len(decoded) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
q, args, _ := sqlx.In("SELECT s.userid,SUM(tp.seqid)-SUM(s.readseqid) AS unreadcount "+
|
||||
q, args, _ := sqlx.In(tenantShardSQL(id, "SELECT s.userid,SUM(tp.seqid)-SUM(s.readseqid) AS unreadcount "+
|
||||
"FROM im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+
|
||||
"WHERE s.tenant_id=? AND s.userid IN (?) AND s.deletedat IS NULL AND tp.state!=? "+
|
||||
"AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 GROUP BY s.userid", id, decoded, t.StateDeleted)
|
||||
"AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 GROUP BY s.userid"), id, decoded, t.StateDeleted)
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
@@ -886,9 +886,9 @@ func tenantTopicCreate(tx *sqlx.Tx, topic *t.Topic) error {
|
||||
if topic == nil || topic.TenantID.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
_, err := tx.Exec("INSERT INTO im_topics(tenant_id,createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
|
||||
_, err := tx.Exec(tenantShardSQL(topic.TenantID, "INSERT INTO im_topics(tenant_id,createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
topic.TenantID, topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
|
||||
), topic.TenantID, topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
|
||||
decodeUID(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
|
||||
topic.Tags, common.ToJSON(topic.Aux))
|
||||
if err != nil {
|
||||
@@ -913,25 +913,25 @@ func createTenantSubscription(tx *sqlx.Tx, tenantID t.TenantID, sub *t.Subscript
|
||||
DeletedAt sql.NullTime `db:"deletedat"`
|
||||
}{}
|
||||
err := tx.Get(&existing,
|
||||
"SELECT id,deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=? ORDER BY deletedat IS NULL DESC,id ASC LIMIT 1",
|
||||
tenantShardSQL(tenantID, "SELECT id,deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=? ORDER BY deletedat IS NULL DESC,id ASC LIMIT 1"),
|
||||
tenantID, sub.Topic, decodedUID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
if existing.DeletedAt.Valid {
|
||||
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
"delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND id=?",
|
||||
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, existing.ID)
|
||||
), sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, existing.ID)
|
||||
} else {
|
||||
_, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,modeWant=?,modeGiven=?,private=? WHERE tenant_id=? AND id=?",
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_subscriptions SET updatedat=?,modeWant=?,modeGiven=?,private=? WHERE tenant_id=? AND id=?"),
|
||||
sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, existing.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isOwner {
|
||||
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodedUID, tenantID, sub.Topic)
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?"), decodedUID, tenantID, sub.Topic)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -941,30 +941,30 @@ func createTenantSubscription(tx *sqlx.Tx, tenantID t.TenantID, sub *t.Subscript
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO im_subscriptions(id,tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
||||
"VALUES(?,?,?,?,NULL,?,?,?,?,?)",
|
||||
tenantShardSQL(tenantID, "INSERT INTO im_subscriptions(id,tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
||||
"VALUES(?,?,?,?,NULL,?,?,?,?,?)"),
|
||||
subID, tenantID, sub.CreatedAt, sub.UpdatedAt, decodedUID, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
|
||||
if isIDOutOfRange(err) {
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO im_subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
||||
"VALUES(?,?,?,NULL,?,?,?,?,?)",
|
||||
tenantShardSQL(tenantID, "INSERT INTO im_subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
||||
"VALUES(?,?,?,NULL,?,?,?,?,?)"),
|
||||
tenantID, sub.CreatedAt, sub.UpdatedAt, decodedUID, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
|
||||
}
|
||||
|
||||
if err != nil && isDupe(err) {
|
||||
if undelete {
|
||||
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
"delid=0,recvseqid=0,readseqid=0 WHERE tenant_id=? AND topic=? AND userid=?",
|
||||
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), tenantID, sub.Topic, decodedUID)
|
||||
), sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), tenantID, sub.Topic, decodedUID)
|
||||
} else {
|
||||
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
"delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND topic=? AND userid=?",
|
||||
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, sub.Topic, decodedUID)
|
||||
), sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, tenantID, sub.Topic, decodedUID)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && isOwner {
|
||||
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodedUID, tenantID, sub.Topic)
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?"), decodedUID, tenantID, sub.Topic)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1033,7 +1033,7 @@ func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription)
|
||||
topic.ObjHeader.MergeTimes(&one.ObjHeader)
|
||||
topic.TouchedAt = one.GetTouchedAt()
|
||||
var existingTopicID int64
|
||||
err = tx.Get(&existingTopicID, "SELECT id FROM im_topics WHERE tenant_id=? AND name=? ORDER BY id ASC LIMIT 1", id, topic.Id)
|
||||
err = tx.Get(&existingTopicID, tenantShardSQL(id, "SELECT id FROM im_topics WHERE tenant_id=? AND name=? ORDER BY id ASC LIMIT 1"), id, topic.Id)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
@@ -1041,7 +1041,7 @@ func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription)
|
||||
if err = tenantTopicCreate(tx, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE tenant_id=? AND id=?",
|
||||
} else if _, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE tenant_id=? AND id=?"),
|
||||
topic.UpdatedAt, topic.TouchedAt, t.StateOK, id, existingTopicID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1067,7 +1067,7 @@ func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error)
|
||||
var top t.Topic
|
||||
err = a.db.GetContext(ctx, &top,
|
||||
"SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted,tags,aux "+
|
||||
"FROM im_topics WHERE tenant_id=? AND name=?", id, topic)
|
||||
tenantShardSQL(id, "FROM im_topics WHERE tenant_id=? AND name=?"), id, topic)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1077,13 +1077,13 @@ func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error)
|
||||
if t.GetTopicCat(topic) == t.TopicCatGrp {
|
||||
var subCnt int
|
||||
if err = a.db.GetContext(ctx, &subCnt,
|
||||
"SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL",
|
||||
tenantShardSQL(id, "SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL"),
|
||||
id, topic, t.GrpToChn(topic)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if subCnt != top.SubCnt {
|
||||
top.SubCnt = subCnt
|
||||
if _, err = a.db.ExecContext(ctx, "UPDATE im_topics SET subcnt=? WHERE tenant_id=? AND name=?", subCnt, id, topic); err != nil {
|
||||
if _, err = a.db.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET subcnt=? WHERE tenant_id=? AND name=?"), subCnt, id, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -1106,6 +1106,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
||||
}
|
||||
q := `SELECT tenant_id,createdat,updatedat,deletedat,topic,delid,recvseqid,
|
||||
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND userid=?`
|
||||
q = tenantShardSQL(id, q)
|
||||
args := []any{id, decodeUID(uid)}
|
||||
if !keepDeleted {
|
||||
q += " AND deletedat IS NULL"
|
||||
@@ -1190,7 +1191,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
||||
return nil, err
|
||||
}
|
||||
if len(topq) > 0 {
|
||||
q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)"
|
||||
q = tenantShardSQL(id, "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)")
|
||||
q, args, _ = sqlx.In(q, id, topq)
|
||||
if !keepDeleted {
|
||||
q += " AND state!=?"
|
||||
@@ -1306,6 +1307,7 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
|
||||
FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id
|
||||
LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid
|
||||
WHERE s.tenant_id=? AND s.topic=?`
|
||||
q = tenantShardSQL(id, q)
|
||||
args := []any{id, topic}
|
||||
if !keepDeleted {
|
||||
q += " AND u.state!=?"
|
||||
@@ -1426,7 +1428,7 @@ func (a *adapter) TenantOwnTopics(id t.TenantID, uid t.Uid) ([]string, error) {
|
||||
if a.tenantScopedDB {
|
||||
return a.OwnTopics(uid)
|
||||
}
|
||||
return a.tenantTopicNames("SELECT name FROM im_topics WHERE tenant_id=? AND owner=? AND state!=?",
|
||||
return a.tenantTopicNames(tenantShardSQL(id, "SELECT name FROM im_topics WHERE tenant_id=? AND owner=? AND state!=?"),
|
||||
id, decodeUID(uid), t.StateDeleted)
|
||||
}
|
||||
|
||||
@@ -1441,8 +1443,8 @@ func (a *adapter) TenantChannelsForUser(id t.TenantID, uid t.Uid) ([]string, err
|
||||
if a.tenantScopedDB {
|
||||
return a.ChannelsForUser(uid)
|
||||
}
|
||||
return a.tenantTopicNames("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+
|
||||
"AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", id, decodeUID(uid))
|
||||
return a.tenantTopicNames(tenantShardSQL(id, "SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+
|
||||
"AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL"), id, decodeUID(uid))
|
||||
}
|
||||
|
||||
func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscription) error {
|
||||
@@ -1480,7 +1482,7 @@ func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscr
|
||||
}
|
||||
}
|
||||
if topic != "" {
|
||||
if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt+? WHERE tenant_id=? AND name=?", len(subs), id, topic); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "UPDATE im_topics SET subcnt=subcnt+? WHERE tenant_id=? AND name=?"), len(subs), id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1563,11 +1565,7 @@ func groupMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDe
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec("DELETE FROM im_group_message_topic_shards WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec("DELETE FROM im_group_message_dedup WHERE tenant_id=? AND topic=?", tenantID, topic)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
delRanges := toDel.SeqIdRanges
|
||||
if toDel.DeletedFor == "" {
|
||||
@@ -1659,7 +1657,7 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo
|
||||
}
|
||||
}()
|
||||
if hard {
|
||||
q, args, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, topics)
|
||||
q, args, _ := sqlx.In(tenantShardSQL(id, "DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)"), id, topics)
|
||||
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1674,16 +1672,16 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo
|
||||
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "DELETE FROM im_topics WHERE tenant_id=? AND name=?"), id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
now := t.TimeNow()
|
||||
q, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, topics)
|
||||
q, args, _ := sqlx.In(tenantShardSQL(id, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)"), now, now, id, topics)
|
||||
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?",
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?"),
|
||||
now, now, t.StateDeleted, now, id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1706,7 +1704,7 @@ func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, "UPDATE im_topics SET seqid=?,touchedat=? WHERE tenant_id=? AND name=?",
|
||||
_, err = a.db.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET seqid=?,touchedat=? WHERE tenant_id=? AND name=?"),
|
||||
msg.SeqId, msg.CreatedAt, id, topic)
|
||||
return err
|
||||
}
|
||||
@@ -1727,7 +1725,7 @@ func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx,
|
||||
"UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?",
|
||||
tenantShardSQL(id, "UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?"),
|
||||
id, topic, t.GrpToChn(topic), id, topic)
|
||||
return err
|
||||
}
|
||||
@@ -1761,7 +1759,7 @@ func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[stri
|
||||
}
|
||||
cols, args := common.UpdateByMap(update)
|
||||
args = append(args, id, topic)
|
||||
res, err := tx.Exec("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?", args...)
|
||||
res, err := tx.Exec(tenantShardSQL(id, "UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?"), args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1813,6 +1811,7 @@ func (a *adapter) TenantTopicBumpMutationVersion(id t.TenantID, topic string, at
|
||||
query = "SELECT mutation_version FROM im_topics WHERE tenant_id=? AND name=?"
|
||||
args = []any{id, topic}
|
||||
}
|
||||
query = tenantShardSQL(id, query)
|
||||
if err = tx.GetContext(ctx, &version, query, args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1837,7 +1836,7 @@ func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Ui
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodeUID(owner), id, topic)
|
||||
_, err = a.db.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?"), decodeUID(owner), id, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1858,6 +1857,7 @@ func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid,
|
||||
}
|
||||
query := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
|
||||
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=?`
|
||||
query = tenantShardSQL(id, query)
|
||||
if !keepDeleted {
|
||||
query += " AND deletedat IS NULL"
|
||||
}
|
||||
@@ -1890,8 +1890,8 @@ func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription,
|
||||
subs, err := a.SubsForUser(uid)
|
||||
return stampTenantSubs(id, subs), err
|
||||
}
|
||||
rows, err := a.db.Queryx(`SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
|
||||
readseqid,modewant,modegiven FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`,
|
||||
rows, err := a.db.Queryx(tenantShardSQL(id, `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
|
||||
readseqid,modewant,modegiven FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`),
|
||||
id, decodeUID(uid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1923,6 +1923,7 @@ func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bo
|
||||
}
|
||||
q := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
|
||||
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND topic=?`
|
||||
q = tenantShardSQL(id, q)
|
||||
args := []any{id, topic}
|
||||
if !keepDeleted {
|
||||
q += " AND deletedat IS NULL"
|
||||
@@ -1976,7 +1977,7 @@ func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, updat
|
||||
return a.SubsUpdate(topic, uid, update)
|
||||
}
|
||||
cols, args := common.UpdateByMap(update)
|
||||
q := "UPDATE im_subscriptions SET " + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?"
|
||||
q := tenantShardSQL(id, "UPDATE im_subscriptions SET ") + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?"
|
||||
args = append(args, id, topic)
|
||||
if !uid.IsZero() {
|
||||
q += " AND userid=?"
|
||||
@@ -2016,7 +2017,7 @@ func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error
|
||||
}()
|
||||
now := t.TimeNow()
|
||||
decoded := decodeUID(uid)
|
||||
res, err := tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL",
|
||||
res, err := tx.Exec(tenantShardSQL(id, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"),
|
||||
now, now, id, topic, decoded)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2030,7 +2031,7 @@ func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error
|
||||
}
|
||||
}
|
||||
if t.GetTopicCat(topic) == t.TopicCatGrp {
|
||||
if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name=?", id, t.ChnToGrp(topic)); err != nil {
|
||||
if _, err = tx.Exec(tenantShardSQL(id, "UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name=?"), id, t.ChnToGrp(topic)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -2038,7 +2039,7 @@ func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error
|
||||
}
|
||||
|
||||
func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, hard bool) error {
|
||||
rows, err := tx.Query("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL", tenantID, decodedUID)
|
||||
rows, err := tx.Query(tenantShardSQL(tenantID, "SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL"), tenantID, decodedUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2060,16 +2061,16 @@ func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, ha
|
||||
return err
|
||||
}
|
||||
if len(topics) > 0 {
|
||||
q, args, _ := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)", tenantID, topics)
|
||||
q, args, _ := sqlx.In(tenantShardSQL(tenantID, "UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)"), tenantID, topics)
|
||||
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hard {
|
||||
_, err = tx.Exec("DELETE FROM im_subscriptions WHERE tenant_id=? AND userid=?", tenantID, decodedUID)
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "DELETE FROM im_subscriptions WHERE tenant_id=? AND userid=?"), tenantID, decodedUID)
|
||||
} else {
|
||||
now := t.TimeNow()
|
||||
_, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND userid=? AND deletedat IS NULL",
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND userid=? AND deletedat IS NULL"),
|
||||
now, now, tenantID, decodedUID)
|
||||
}
|
||||
return err
|
||||
@@ -2111,6 +2112,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]
|
||||
"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) + ") " +
|
||||
"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags "
|
||||
query = tenantShardSQL(id, query)
|
||||
if len(allReq) > 0 {
|
||||
q, addArgs := common.DisjunctionSql(req, "tg.tag")
|
||||
query += q
|
||||
@@ -2177,8 +2179,8 @@ func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) {
|
||||
defer cancel()
|
||||
}
|
||||
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 "+
|
||||
"WHERE tp.tenant_id=? AND tt.tag=? LIMIT 1", id, tag)
|
||||
tenantShardSQL(id, "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=? LIMIT 1"), id, tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -2273,17 +2275,6 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
messageID := store.DecodeUid(msg.Uid())
|
||||
if clientMsgID.Valid {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_dedup
|
||||
(tenant_id,topic,from_user,client_msg_id,seqid,shard_month,message_id,status,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,'done',?)`, id, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID,
|
||||
msg.SeqId, msg.ShardMonth, messageID, time.Now().UTC().Add(30*24*time.Hour)); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
query := fmt.Sprintf(`INSERT INTO %s
|
||||
(tenant_id,message_id,topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
|
||||
file_count,content_size,version,created_at,updated_at,content_nonce,key_version)
|
||||
@@ -2298,21 +2289,6 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_topic_shards
|
||||
(tenant_id,topic,shard_month,min_seq,max_seq,message_count,min_created_at,max_created_at)
|
||||
VALUES(?,?,?,?,?,1,?,?) ON DUPLICATE KEY UPDATE
|
||||
min_seq=LEAST(min_seq,VALUES(min_seq)),max_seq=GREATEST(max_seq,VALUES(max_seq)),
|
||||
message_count=message_count+1,min_created_at=LEAST(min_created_at,VALUES(min_created_at)),
|
||||
max_created_at=GREATEST(max_created_at,VALUES(max_created_at))`,
|
||||
id, msg.Topic, msg.ShardMonth, msg.SeqId, msg.SeqId, msg.CreatedAt, msg.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_group_message_shards SET row_count=row_count+1,
|
||||
min_created_at=IF(min_created_at IS NULL,?,LEAST(min_created_at,?)),
|
||||
max_created_at=IF(max_created_at IS NULL,?,GREATEST(max_created_at,?)) WHERE tenant_id=? AND shard_month=?`,
|
||||
msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, id, msg.ShardMonth); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -2366,19 +2342,6 @@ func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, f
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var seq int64
|
||||
if err = a.db.QueryRowContext(ctx, `SELECT seqid FROM im_group_message_dedup
|
||||
WHERE tenant_id=? AND topic=? AND from_user=? AND client_msg_id=?`, id, topic, decodeUID(from), clientMsgID).Scan(&seq); err == nil {
|
||||
msg, getErr := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
|
||||
if getErr == nil {
|
||||
return msg, nil
|
||||
}
|
||||
if getErr != sql.ErrNoRows {
|
||||
return nil, getErr
|
||||
}
|
||||
} else if err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -3111,14 +3074,14 @@ func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid,
|
||||
}
|
||||
var currentVersion int64
|
||||
if a.tenantScopedDB {
|
||||
if err := a.db.GetContext(ctx, ¤tVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE name=?", topic); err != nil {
|
||||
if err := a.db.GetContext(ctx, ¤tVersion, tenantShardSQL(id, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE name=?"), topic); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, "", false, 0, t.ErrNotFound
|
||||
}
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := a.db.GetContext(ctx, ¤tVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
|
||||
if err := a.db.GetContext(ctx, ¤tVersion, tenantShardSQL(id, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE tenant_id=? AND name=?"), id, topic); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, "", false, 0, t.ErrNotFound
|
||||
}
|
||||
@@ -5261,7 +5224,7 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
|
||||
defer cancel()
|
||||
}
|
||||
var allowed int
|
||||
err = a.db.GetContext(ctx, &allowed, fmt.Sprintf(`
|
||||
err = a.db.GetContext(ctx, &allowed, tenantShardSQL(id, fmt.Sprintf(`
|
||||
SELECT COUNT(*) FROM im_fileuploads AS fu
|
||||
WHERE fu.tenant_id=? AND (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND (
|
||||
fu.userid=?
|
||||
@@ -5310,7 +5273,7 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
|
||||
WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%%')) AND gf.status='active'
|
||||
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
|
||||
)
|
||||
) LIMIT 1`, tenantFriendshipsTable(id), tenantMessagesTable(id)),
|
||||
) LIMIT 1`, tenantFriendshipsTable(id), tenantMessagesTable(id))),
|
||||
id, decodeUID(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), t.RelationshipFriend,
|
||||
decodeUID(uid), decodeUID(uid), t.GroupMemberStatusActive, decodeUID(uid), decodeUID(uid),
|
||||
decodeUID(uid), fid, fid)
|
||||
@@ -5379,33 +5342,14 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode
|
||||
return t.ErrMalformed
|
||||
}
|
||||
|
||||
ctx, cancel := a.getContextForTx()
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
now := t.TimeNow()
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE im_verification_codes
|
||||
SET state=?,updated_at=?
|
||||
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=?`,
|
||||
t.VerificationStateExpired, now, id, vc.Scene, vc.PhoneHash, t.VerificationStatePending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vc.CreatedAt = now
|
||||
vc.UpdatedAt = now
|
||||
_, err = tx.ExecContext(ctx,
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_verification_codes
|
||||
(tenant_id,challenge_id,scene,action,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
|
||||
expires_at,resend_after,created_at,updated_at)
|
||||
@@ -5418,7 +5362,7 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) {
|
||||
@@ -5854,23 +5798,10 @@ func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) e
|
||||
token.TokenID == "" || token.Action == "" || token.TokenHash == "" || token.ExpiresAt.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
token.CreatedAt = now
|
||||
token.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_action_tokens
|
||||
(tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State,
|
||||
token.ExpiresAt, token.CreatedAt, token.UpdatedAt)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, action, target string, now time.Time) (*t.ActionToken, error) {
|
||||
@@ -5893,50 +5824,7 @@ func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, acti
|
||||
if !ok || tokenID == "" || secret == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
tokenHash := securityTokenHash(id, uid, action, target, tokenID, secret)
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
var at t.ActionToken
|
||||
err = tx.GetContext(ctx, &at,
|
||||
`SELECT id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at
|
||||
FROM im_action_tokens
|
||||
WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`,
|
||||
id, decodeUID(uid), tokenID, action, target)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.State != 0 || at.TokenHash != tokenHash || !now.Before(at.ExpiresAt) {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE im_action_tokens SET state=1,consumed_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`,
|
||||
now, now, id, decodeUID(uid), tokenID, action, target, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at.TenantID = id
|
||||
return &at, nil
|
||||
return nil, t.ErrUnsupported
|
||||
}
|
||||
|
||||
func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose string) (*t.IndependentPassword, error) {
|
||||
@@ -6166,7 +6054,7 @@ func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.
|
||||
notification.CreatedAt = now
|
||||
notification.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_system_notifications
|
||||
tenantShardSQL(id, `INSERT INTO im_system_notifications
|
||||
(tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
@@ -6174,7 +6062,7 @@ func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.
|
||||
title=VALUES(title),
|
||||
summary=VALUES(summary),
|
||||
action=VALUES(action),
|
||||
updated_at=VALUES(updated_at)`,
|
||||
updated_at=VALUES(updated_at)`),
|
||||
id, decodeUID(notification.UserID), notification.EventID, notification.EventType, notification.Title,
|
||||
notification.Summary, common.ToJSON(notification.Action), notification.ReadAt, notification.CreatedAt, notification.UpdatedAt)
|
||||
return err
|
||||
@@ -6207,6 +6095,7 @@ func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor
|
||||
query := `SELECT id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at
|
||||
FROM im_system_notifications
|
||||
WHERE tenant_id=? AND user_id=?`
|
||||
query = tenantShardSQL(id, query)
|
||||
args := []any{id, decodeUID(uid)}
|
||||
if lastID > 0 {
|
||||
query += " AND id<?"
|
||||
@@ -6265,7 +6154,7 @@ func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid)
|
||||
}
|
||||
var count int
|
||||
if err := a.db.GetContext(ctx, &count,
|
||||
`SELECT COUNT(*) FROM im_system_notifications WHERE tenant_id=? AND user_id=? AND read_at IS NULL`,
|
||||
tenantShardSQL(id, `SELECT COUNT(*) FROM im_system_notifications WHERE tenant_id=? AND user_id=? AND read_at IS NULL`),
|
||||
id, decodeUID(uid)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -6292,25 +6181,25 @@ func (a *adapter) TenantSystemNotificationUnreadCountByEventPrefix(id t.TenantID
|
||||
}
|
||||
var count int
|
||||
if err := a.db.GetContext(ctx, &count,
|
||||
`SELECT COUNT(*) FROM im_system_notifications WHERE tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE ?`,
|
||||
tenantShardSQL(id, `SELECT COUNT(*) FROM im_system_notifications WHERE tenant_id=? AND user_id=? AND read_at IS NULL AND event_type LIKE ?`),
|
||||
id, decodeUID(uid), eventPrefix+"%"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error {
|
||||
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) (*t.SystemNotification, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantSystemNotificationMarkRead(id, uid, eventID, readAt)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || eventID == "" {
|
||||
return t.ErrMalformed
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
if readAt.IsZero() {
|
||||
readAt = t.TimeNow()
|
||||
@@ -6319,18 +6208,38 @@ func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eve
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx,
|
||||
`UPDATE im_system_notifications
|
||||
SET read_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND event_id=?`,
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
tenantShardSQL(id, `UPDATE im_system_notifications
|
||||
SET read_at=COALESCE(read_at,?),updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND event_id=?`),
|
||||
readAt, readAt, id, decodeUID(uid), eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return t.ErrNotFound
|
||||
notification, err := a.tenantSystemNotificationGet(ctx, id, uid, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func (a *adapter) tenantSystemNotificationGet(ctx context.Context, id t.TenantID, uid t.Uid, eventID string) (*t.SystemNotification, error) {
|
||||
var notification t.SystemNotification
|
||||
err := a.db.QueryRowxContext(ctx, tenantShardSQL(id, `SELECT id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at
|
||||
FROM im_system_notifications
|
||||
WHERE tenant_id=? AND user_id=? AND event_id=?`),
|
||||
id, decodeUID(uid), eventID).StructScan(¬ification)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if action, ok := common.FromJSON(notification.Action).(map[string]any); ok {
|
||||
notification.Action = t.KVMap(action)
|
||||
}
|
||||
notification.TenantID = id
|
||||
return ¬ification, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFriendRequestCreate(id t.TenantID, req *t.FriendRequest) (*t.FriendRequest, error) {
|
||||
@@ -6705,10 +6614,10 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
|
||||
targetSub.SetTouchedAt(handledAt)
|
||||
targetSub.SetPublic(applicantUser.Public)
|
||||
targetSub.SetTrusted(applicantUser.Trusted)
|
||||
if err = createSubscription(tx, applicantSub, true); err != nil {
|
||||
if err = createTenantSubscription(tx, id, applicantSub, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = createSubscription(tx, targetSub, true); err != nil {
|
||||
if err = createTenantSubscription(tx, id, targetSub, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topicRow := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: topic, CreatedAt: handledAt, UpdatedAt: handledAt}, TouchedAt: handledAt}
|
||||
@@ -6721,7 +6630,7 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
|
||||
if !isDupe(err) {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE tenant_id=? AND name=?",
|
||||
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE tenant_id=? AND name=?"),
|
||||
handledAt, handledAt, t.StateOK, id, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,12 +20,16 @@ const (
|
||||
groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,
|
||||
member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,
|
||||
created_by,dissolved_by,dissolved_at,created_at,updated_at`
|
||||
groupAnnouncementSelectColumns = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at,
|
||||
groupAnnouncementSelectColumnsBase = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at,
|
||||
(SELECT COUNT(*) FROM im_group_announcement_reads AS ar WHERE ar.topic=im_group_announcements.topic AND ar.announcement_id=im_group_announcements.announcement_id) AS read_count,
|
||||
(SELECT COUNT(*) FROM im_group_members AS gm WHERE gm.topic=im_group_announcements.topic AND gm.member_status='active') AS member_count`
|
||||
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
|
||||
)
|
||||
|
||||
func groupAnnouncementSelectColumns(tenantID t.TenantID) string {
|
||||
return tenantShardSQL(tenantID, groupAnnouncementSelectColumnsBase)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) {
|
||||
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
|
||||
return nil, err
|
||||
@@ -174,7 +178,7 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if patch.MuteAll != nil {
|
||||
if err = syncGroupMuteAllTx(tx, topic, *patch.MuteAll, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
||||
if err = syncGroupMuteAllTx(tx, id, topic, *patch.MuteAll, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -214,19 +218,19 @@ func (a *adapter) TenantGroupDissolve(id t.TenantID, topic string, actor t.Uid,
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_subscriptions
|
||||
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_subscriptions
|
||||
SET updatedat=?,deletedat=?
|
||||
WHERE topic IN (?,?) AND deletedat IS NULL`, now, now, strings.TrimSpace(topic), t.GrpToChn(strings.TrimSpace(topic))); err != nil {
|
||||
WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL`), now, now, id, strings.TrimSpace(topic), t.GrpToChn(strings.TrimSpace(topic))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_group_members
|
||||
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_group_members
|
||||
SET member_status=?,left_at=?,removed_by=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND member_status=?`, t.GroupMemberStatusLeft, now, decodeUID(actor), now, strings.TrimSpace(topic), t.GroupMemberStatusActive); err != nil {
|
||||
WHERE tenant_id=? AND topic=? AND member_status=?`), t.GroupMemberStatusLeft, now, decodeUID(actor), now, id, strings.TrimSpace(topic), t.GroupMemberStatusActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_topics
|
||||
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_topics
|
||||
SET updatedat=?,touchedat=?,state=?,stateat=?,subcnt=0
|
||||
WHERE name=?`, now, now, t.StateDeleted, now, strings.TrimSpace(topic)); err != nil {
|
||||
WHERE tenant_id=? AND name=?`), now, now, t.StateDeleted, now, id, strings.TrimSpace(topic)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -270,16 +274,16 @@ func (a *adapter) TenantGroupMemberUpsert(id t.TenantID, member *t.GroupMember)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_members
|
||||
(topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
_, err := a.db.ExecContext(ctx, tenantShardSQL(id, `INSERT INTO im_group_members
|
||||
(tenant_id,topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname),pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source),joined_by=VALUES(joined_by),joined_at=IF(member_status='active',joined_at,VALUES(joined_at)),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
|
||||
member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned, member.MuteNotifications,
|
||||
), id, member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned, member.MuteNotifications,
|
||||
member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version, member.CreatedAt, member.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.recountGroupMembers(ctx, member.Topic)
|
||||
_ = a.recountGroupMembers(ctx, id, member.Topic)
|
||||
return a.TenantGroupMemberGet(id, member.Topic, member.UserID)
|
||||
}
|
||||
|
||||
@@ -296,7 +300,7 @@ func (a *adapter) TenantGroupMemberGet(id t.TenantID, topic string, uid t.Uid) (
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid))
|
||||
return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT * FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid))
|
||||
}
|
||||
|
||||
func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.GroupMemberListOptions) ([]t.GroupMember, string, error) {
|
||||
@@ -312,8 +316,8 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
|
||||
opts.Limit = 30
|
||||
}
|
||||
memberIndex := "idx_im_group_members_topic_status"
|
||||
where := []string{"gm.topic=?", "gm.member_status=?"}
|
||||
args := []any{topic, t.GroupMemberStatusActive}
|
||||
where := []string{"gm.tenant_id=?", "gm.topic=?", "gm.member_status=?"}
|
||||
args := []any{id, topic, t.GroupMemberStatusActive}
|
||||
if opts.Role != "" {
|
||||
memberIndex = "idx_im_group_members_topic_role"
|
||||
where = append(where, "gm.role=?")
|
||||
@@ -343,14 +347,14 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.trusted,
|
||||
rows, err := a.db.QueryxContext(ctx, tenantShardSQL(id, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.trusted,
|
||||
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
|
||||
FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`)
|
||||
JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
|
||||
JOIN im_users AS u ON u.id=gm.user_id AND u.state!=?
|
||||
LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id
|
||||
WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`, append([]any{t.StateDeleted}, args...)...)
|
||||
JOIN im_subscriptions AS s ON s.tenant_id=gm.tenant_id AND s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
|
||||
JOIN im_users AS u ON u.tenant_id=gm.tenant_id AND u.id=gm.user_id AND u.state!=?
|
||||
LEFT JOIN im_user_profiles AS p ON p.tenant_id=gm.tenant_id AND p.user_id=gm.user_id
|
||||
WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`), append([]any{t.StateDeleted}, args...)...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -422,8 +426,8 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
|
||||
set = append(set, "removed_by=?")
|
||||
args = append(args, nullableUID(*patch.RemovedBy))
|
||||
}
|
||||
args = append(args, topic, decodeUID(uid))
|
||||
where := "topic=? AND user_id=?"
|
||||
args = append(args, id, topic, decodeUID(uid))
|
||||
where := "tenant_id=? AND topic=? AND user_id=?"
|
||||
if patch.Version > 0 {
|
||||
where += " AND version=?"
|
||||
args = append(args, patch.Version)
|
||||
@@ -437,7 +441,7 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...)
|
||||
res, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -447,13 +451,13 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
|
||||
}
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err = recountGroupMembersTx(tx, topic); err != nil {
|
||||
if err = recountGroupMembersTx(tx, id, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid))
|
||||
return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT * FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid))
|
||||
}
|
||||
|
||||
func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitation) (*t.GroupInvitation, error) {
|
||||
@@ -572,8 +576,8 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
|
||||
return &inv, nil
|
||||
}
|
||||
var deletedAt sql.NullTime
|
||||
err = tx.QueryRowxContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
|
||||
inv.Topic, decodeUID(uid)).Scan(&deletedAt)
|
||||
err = tx.QueryRowxContext(ctx, tenantShardSQL(id, "SELECT deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=?"),
|
||||
id, inv.Topic, decodeUID(uid)).Scan(&deletedAt)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
@@ -581,20 +585,20 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
|
||||
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: inv.Topic,
|
||||
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
|
||||
sub.InitTimes()
|
||||
if err = createSubscription(tx, sub, deletedAt.Valid); err != nil {
|
||||
if err = createTenantSubscription(tx, id, sub, deletedAt.Valid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
member := &t.GroupMember{Topic: inv.Topic, UserID: uid, Role: t.GroupMemberRoleMember,
|
||||
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inv.InviterUserID, JoinedAt: now,
|
||||
Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err = upsertGroupMemberTx(tx, member); err != nil {
|
||||
if err = upsertGroupMemberTx(tx, id, member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, inv.Topic); err != nil {
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, id, inv.Topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = updateGroupTopicSubCountTx(tx, inv.Topic); err != nil {
|
||||
if err = updateGroupTopicSubCountTx(tx, id, inv.Topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations
|
||||
@@ -761,13 +765,13 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
|
||||
sub := &t.Subscription{TenantID: id, User: current.ApplicantUserID.String(), Topic: topic,
|
||||
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
|
||||
sub.InitTimes()
|
||||
if err = createSubscription(tx, sub, false); err != nil {
|
||||
if err = createTenantSubscription(tx, id, sub, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member := &t.GroupMember{Topic: topic, UserID: current.ApplicantUserID, Role: t.GroupMemberRoleMember,
|
||||
MemberStatus: t.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: actor, JoinedAt: now,
|
||||
Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err = upsertGroupMemberTx(tx, member); err != nil {
|
||||
if err = upsertGroupMemberTx(tx, id, member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -776,10 +780,10 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
|
||||
return nil, err
|
||||
}
|
||||
if action == "accept" {
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, topic); err != nil {
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, id, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
|
||||
if err = updateGroupTopicSubCountTx(tx, id, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -799,7 +803,7 @@ func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
|
||||
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementID string) (*t.GroupAnnouncement, error) {
|
||||
@@ -812,7 +816,7 @@ func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementI
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns+" FROM im_group_announcements WHERE topic=? AND announcement_id=? LIMIT 1", topic, announcementID)
|
||||
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE topic=? AND announcement_id=? LIMIT 1", topic, announcementID)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) {
|
||||
@@ -1233,7 +1237,7 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
|
||||
ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(),
|
||||
Topic: topic.Id, ModeGiven: t.ModeCFull, ModeWant: topic.GetAccess(owner), Private: ownerPrivate}
|
||||
ownerSub.InitTimes()
|
||||
if err = createSubscription(tx, ownerSub, false); err != nil {
|
||||
if err = createTenantSubscription(tx, id, ownerSub, false); err != nil {
|
||||
return err
|
||||
}
|
||||
settings.MemberCount = 1
|
||||
@@ -1243,7 +1247,7 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
|
||||
ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner,
|
||||
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
|
||||
Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err = upsertGroupMemberTx(tx, ownerMember); err != nil {
|
||||
if err = upsertGroupMemberTx(tx, id, ownerMember); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[t.Uid]bool{owner: true}
|
||||
@@ -1255,20 +1259,20 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
|
||||
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: topic.Id,
|
||||
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
|
||||
sub.InitTimes()
|
||||
if err = createSubscription(tx, sub, false); err != nil {
|
||||
if err = createTenantSubscription(tx, id, sub, false); err != nil {
|
||||
return err
|
||||
}
|
||||
member := &t.GroupMember{Topic: topic.Id, UserID: uid, Role: t.GroupMemberRoleMember,
|
||||
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
|
||||
Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err = upsertGroupMemberTx(tx, member); err != nil {
|
||||
if err = upsertGroupMemberTx(tx, id, member); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = recountGroupMembersTx(tx, topic.Id); err != nil {
|
||||
if err = recountGroupMembersTx(tx, id, topic.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = updateGroupTopicSubCountTx(tx, topic.Id); err != nil {
|
||||
if err = updateGroupTopicSubCountTx(tx, id, topic.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
@@ -1291,7 +1295,7 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var deletedAt sql.NullTime
|
||||
err = tx.QueryRowContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=? FOR UPDATE", topic, decodeUID(target)).Scan(&deletedAt)
|
||||
err = tx.QueryRowContext(ctx, tenantShardSQL(id, "SELECT deletedat FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=? FOR UPDATE"), id, topic, decodeUID(target)).Scan(&deletedAt)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return false, err
|
||||
}
|
||||
@@ -1301,7 +1305,7 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
|
||||
sub := &t.Subscription{TenantID: id, User: target.String(), Topic: topic,
|
||||
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: private}
|
||||
sub.InitTimes()
|
||||
if err = createSubscription(tx, sub, false); err != nil {
|
||||
if err = createTenantSubscription(tx, id, sub, false); err != nil {
|
||||
return false, err
|
||||
}
|
||||
invitationID := "ginv_" + store.Store.GetUidString()
|
||||
@@ -1319,13 +1323,13 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
|
||||
member := &t.GroupMember{Topic: topic, UserID: target, Role: t.GroupMemberRoleMember,
|
||||
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inviter, JoinedAt: now,
|
||||
Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err = upsertGroupMemberTx(tx, member); err != nil {
|
||||
if err = upsertGroupMemberTx(tx, id, member); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, topic); err != nil {
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, id, topic); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
|
||||
if err = updateGroupTopicSubCountTx(tx, id, topic); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -1349,21 +1353,21 @@ func (a *adapter) TenantGroupLeave(id t.TenantID, topic string, uid t.Uid, now t
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decodeUID(uid))
|
||||
res, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"), now, now, id, topic, decodeUID(uid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND user_id=? AND member_status=?`, t.GroupMemberStatusLeft, now, now, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=?
|
||||
WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=?`), t.GroupMemberStatusLeft, now, now, id, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, topic); err != nil {
|
||||
if err = recountGroupMembersAndBumpVersionTx(tx, id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
|
||||
if err = updateGroupTopicSubCountTx(tx, id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
@@ -1385,16 +1389,16 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
|
||||
modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target))
|
||||
res, err := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"),
|
||||
modeGiven.String(), modeGiven.String(), now, id, topic, decodeUID(target))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
res, err = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
|
||||
role, now, topic, decodeUID(target), t.GroupMemberStatusActive)
|
||||
res, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=?"),
|
||||
role, now, id, topic, decodeUID(target), t.GroupMemberStatusActive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1430,16 +1434,16 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
|
||||
role string
|
||||
}{{previousOwner, groupAdminAccessMode(), t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
|
||||
for _, update := range updates {
|
||||
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
|
||||
update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid))
|
||||
res, execErr := tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL"),
|
||||
update.mode.String(), update.mode.String(), now, id, topic, decodeUID(update.uid))
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
res, execErr = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
|
||||
update.role, now, topic, decodeUID(update.uid), t.GroupMemberStatusActive)
|
||||
res, execErr = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=?"),
|
||||
update.role, now, id, topic, decodeUID(update.uid), t.GroupMemberStatusActive)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
@@ -1447,7 +1451,7 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
|
||||
return t.ErrNotFound
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET owner=?,updatedat=? WHERE name=?", decodeUID(newOwner), now, topic); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET owner=?,updatedat=? WHERE tenant_id=? AND name=?"), decodeUID(newOwner), now, id, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
args := []any{decodeUID(newOwner), now, topic}
|
||||
@@ -1504,48 +1508,48 @@ func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func upsertGroupMemberTx(tx *sqlx.Tx, member *t.GroupMember) error {
|
||||
_, err := tx.Exec(`INSERT INTO im_group_members
|
||||
(topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
func upsertGroupMemberTx(tx *sqlx.Tx, tenantID t.TenantID, member *t.GroupMember) error {
|
||||
_, err := tx.Exec(tenantShardSQL(tenantID, `INSERT INTO im_group_members
|
||||
(tenant_id,topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname),
|
||||
pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source),
|
||||
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
|
||||
member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned,
|
||||
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`),
|
||||
tenantID, member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned,
|
||||
member.MuteNotifications, member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version,
|
||||
member.CreatedAt, member.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func recountGroupMembersTx(tx *sqlx.Tx, topic string) error {
|
||||
_, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
|
||||
WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic)
|
||||
func recountGroupMembersTx(tx *sqlx.Tx, tenantID t.TenantID, topic string) error {
|
||||
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
|
||||
WHERE tenant_id=? AND topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND topic=?`), tenantID, topic, t.GroupMemberStatusActive, tenantID, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
func recountGroupMembersAndBumpVersionTx(tx *sqlx.Tx, topic string) error {
|
||||
_, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
|
||||
WHERE topic=? AND member_status=?),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic)
|
||||
func recountGroupMembersAndBumpVersionTx(tx *sqlx.Tx, tenantID t.TenantID, topic string) error {
|
||||
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
|
||||
WHERE tenant_id=? AND topic=? AND member_status=?),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND topic=?`), tenantID, topic, t.GroupMemberStatusActive, tenantID, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
func updateGroupTopicSubCountTx(tx *sqlx.Tx, topic string) error {
|
||||
_, err := tx.Exec(`UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions
|
||||
WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?`, topic, t.GrpToChn(topic), topic)
|
||||
func updateGroupTopicSubCountTx(tx *sqlx.Tx, tenantID t.TenantID, topic string) error {
|
||||
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions
|
||||
WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?`), tenantID, topic, t.GrpToChn(topic), tenantID, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
func syncGroupMuteAllTx(tx *sqlx.Tx, topic string, muteAll bool, now time.Time) error {
|
||||
func syncGroupMuteAllTx(tx *sqlx.Tx, tenantID t.TenantID, topic string, muteAll bool, now time.Time) error {
|
||||
mode := t.ModeCPublic
|
||||
if muteAll {
|
||||
mode = t.ModeCPublic &^ t.ModeWrite
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE im_subscriptions AS s
|
||||
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_subscriptions AS s
|
||||
JOIN im_group_members AS gm ON gm.topic=s.topic AND gm.user_id=s.userid
|
||||
SET s.modewant=?,s.modegiven=?,s.updatedat=?,
|
||||
gm.version=gm.version+1,gm.updated_at=?
|
||||
WHERE s.topic=? AND s.deletedat IS NULL
|
||||
AND gm.member_status=? AND gm.role=?`,
|
||||
AND gm.member_status=? AND gm.role=?`),
|
||||
mode.String(), mode.String(), now, now, topic, t.GroupMemberStatusActive, t.GroupMemberRoleMember)
|
||||
return err
|
||||
}
|
||||
@@ -1656,8 +1660,8 @@ func (a *adapter) groupFileByQuery(ctx context.Context, query string, args ...an
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
|
||||
func (a *adapter) recountGroupMembers(ctx context.Context, topic string) error {
|
||||
_, err := a.db.ExecContext(ctx, "UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?", topic, t.GroupMemberStatusActive, topic)
|
||||
func (a *adapter) recountGroupMembers(ctx context.Context, tenantID t.TenantID, topic string) error {
|
||||
_, err := a.db.ExecContext(ctx, tenantShardSQL(tenantID, "UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members WHERE tenant_id=? AND topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND topic=?"), tenantID, topic, t.GroupMemberStatusActive, tenantID, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,12 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/tinode/chat/server/store"
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
@@ -26,214 +23,6 @@ func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" {
|
||||
return nil, false, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantHTTPIdempotencyBegin(id, record)
|
||||
}
|
||||
now := t.TimeNow()
|
||||
if record.ExpiresAt.IsZero() {
|
||||
record.ExpiresAt = now.Add(24 * time.Hour)
|
||||
}
|
||||
_, err = a.db.Exec(`INSERT INTO im_http_idempotency
|
||||
(tenant_id,user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, id, decodeUID(record.UserID), record.Method, record.Route, record.Key,
|
||||
record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now)
|
||||
if err == nil {
|
||||
record.State = t.HTTPIdempotencyProcessing
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
return record, true, nil
|
||||
}
|
||||
if !isDupe(err) {
|
||||
return nil, false, err
|
||||
}
|
||||
existing, err := a.httpIdempotencyGet(id, record.UserID, record.Method, record.Route, record.Key)
|
||||
if err == nil && !existing.ExpiresAt.After(now) {
|
||||
res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency
|
||||
WHERE tenant_id=? AND user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`,
|
||||
id, decodeUID(record.UserID), record.Method, record.Route, record.Key, now)
|
||||
if deleteErr != nil {
|
||||
return nil, false, deleteErr
|
||||
}
|
||||
if deleted, _ := res.RowsAffected(); deleted > 0 {
|
||||
return a.TenantHTTPIdempotencyBegin(id, record)
|
||||
}
|
||||
current, getErr := a.httpIdempotencyGet(id, record.UserID, record.Method, record.Route, record.Key)
|
||||
return current, false, getErr
|
||||
}
|
||||
return existing, false, err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record == nil || record.UserID.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantHTTPIdempotencyComplete(id, record)
|
||||
}
|
||||
now := t.TimeNow()
|
||||
res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
|
||||
t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, id, decodeUID(record.UserID),
|
||||
record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := res.RowsAffected(); count == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record == nil || record.UserID.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantHTTPIdempotencyAbort(id, record)
|
||||
}
|
||||
_, err = a.db.Exec(`DELETE FROM im_http_idempotency
|
||||
WHERE tenant_id=? AND user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
|
||||
id, decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) httpIdempotencyGet(tenantID t.TenantID, uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) {
|
||||
row := struct {
|
||||
UserID int64 `db:"user_id"`
|
||||
Method string
|
||||
Route string
|
||||
Key string `db:"idempotency_key"`
|
||||
BodyHash string `db:"body_hash"`
|
||||
State string
|
||||
ResponseStatus int `db:"response_status"`
|
||||
ResponseBody []byte `db:"response_body"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}{}
|
||||
err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at
|
||||
FROM im_http_idempotency WHERE tenant_id=? AND user_id=? AND method=? AND route=? AND idempotency_key=?`,
|
||||
tenantID, decodeUID(uid), method, route, key)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route,
|
||||
Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus,
|
||||
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantKafkaConsumeBegin(id t.TenantID, record *t.KafkaConsumeRecord, deadline time.Time) (*t.KafkaConsumeRecord, bool, error) {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if record == nil || record.EventID == "" || record.ConsumerGroup == "" || record.SourceTopic == "" {
|
||||
return nil, false, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantKafkaConsumeBegin(id, record, deadline)
|
||||
}
|
||||
now := t.TimeNow()
|
||||
_, err = a.db.Exec(`INSERT INTO im_kafka_consume_log
|
||||
(event_id,consumer_group,source_topic,event_type,biz_type,biz_id,status,processing_deadline,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, record.EventID, record.ConsumerGroup, record.SourceTopic, record.EventType,
|
||||
record.BizType, record.BizID, t.KafkaConsumeProcessing, deadline, now, now)
|
||||
if err == nil {
|
||||
record.Status = t.KafkaConsumeProcessing
|
||||
record.ProcessingDeadline = &deadline
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
return record, true, nil
|
||||
}
|
||||
if !isDupe(err) {
|
||||
return nil, false, err
|
||||
}
|
||||
existing, err := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if existing.Status == t.KafkaConsumeSucceeded || existing.Status == t.KafkaConsumeDead {
|
||||
return existing, false, nil
|
||||
}
|
||||
if existing.Status == t.KafkaConsumeProcessing && existing.ProcessingDeadline != nil && existing.ProcessingDeadline.After(now) {
|
||||
return existing, false, nil
|
||||
}
|
||||
res, err := a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,source_topic=?,event_type=?,biz_type=?,biz_id=?,
|
||||
processing_deadline=?,updated_at=? WHERE event_id=? AND consumer_group=? AND status<>?`,
|
||||
t.KafkaConsumeProcessing, record.SourceTopic, record.EventType, record.BizType, record.BizID,
|
||||
deadline, now, record.EventID, record.ConsumerGroup, t.KafkaConsumeSucceeded)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
current, getErr := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup)
|
||||
return current, false, getErr
|
||||
}
|
||||
current, err := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup)
|
||||
return current, err == nil, err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantKafkaConsumeSucceed(id t.TenantID, eventID, consumerGroup string, now time.Time) error {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if eventID == "" || consumerGroup == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantKafkaConsumeSucceed(id, eventID, consumerGroup, now)
|
||||
}
|
||||
res, err := a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,succeeded_at=?,processing_deadline=NULL,updated_at=?
|
||||
WHERE event_id=? AND consumer_group=?`, t.KafkaConsumeSucceeded, now, now, eventID, consumerGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantKafkaConsumeFail(id t.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*t.KafkaConsumeRecord, error) {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if eventID == "" || consumerGroup == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantKafkaConsumeFail(id, eventID, consumerGroup, reason, message, now)
|
||||
}
|
||||
_, err = a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,retry_count=retry_count+1,last_reason=?,
|
||||
last_error=?,processing_deadline=NULL,updated_at=? WHERE event_id=? AND consumer_group=?`,
|
||||
t.KafkaConsumeFailed, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000), now, eventID, consumerGroup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.kafkaConsumeGet(eventID, consumerGroup)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter, now time.Time) error {
|
||||
tenantAdp, err := a.infrastructureAdapter(id)
|
||||
if err != nil {
|
||||
@@ -245,12 +34,7 @@ func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter,
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantKafkaConsumeDead(id, dead, now)
|
||||
}
|
||||
tx, err := a.db.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec(`INSERT INTO im_kafka_dead_letters
|
||||
if _, err = a.db.Exec(`INSERT INTO im_kafka_dead_letters
|
||||
(dead_id,event_id,parent_event_id,source_topic,consumer_group,event_type,biz_type,biz_id,envelope,
|
||||
retry_count,last_reason,last_error,status,replay_count,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
@@ -260,13 +44,7 @@ func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter,
|
||||
truncateKafkaString(dead.LastError, 1000), t.KafkaDeadStatusDead, dead.ReplayCount, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`UPDATE im_kafka_consume_log SET status=?,dead_at=?,processing_deadline=NULL,last_reason=?,
|
||||
last_error=?,updated_at=? WHERE event_id=? AND consumer_group=?`,
|
||||
t.KafkaConsumeDead, now, truncateKafkaString(dead.LastReason, 64), truncateKafkaString(dead.LastError, 1000),
|
||||
now, dead.EventID, dead.ConsumerGroup); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantKafkaDeadLetterList(id t.TenantID, opts t.KafkaDeadLetterListOptions) (*t.KafkaDeadLetterPage, error) {
|
||||
@@ -390,22 +168,7 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantKafkaDeadLetterMarkReplay(id, deadID, resolvedBy, note, now)
|
||||
}
|
||||
tx, err := a.db.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var row struct {
|
||||
EventID string `db:"event_id"`
|
||||
ConsumerGroup string `db:"consumer_group"`
|
||||
}
|
||||
if err = tx.Get(&row, `SELECT event_id,consumer_group FROM im_kafka_dead_letters WHERE dead_id=? FOR UPDATE`, deadID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE im_kafka_dead_letters SET status=?,replay_count=replay_count+1,resolved_by=?,
|
||||
res, err := a.db.Exec(`UPDATE im_kafka_dead_letters SET status=?,replay_count=replay_count+1,resolved_by=?,
|
||||
resolved_at=?,resolve_note=?,updated_at=? WHERE dead_id=? AND status IN (?,?)`,
|
||||
t.KafkaDeadStatusPendingReplay, truncateKafkaString(resolvedBy, 64), now, truncateKafkaString(note, 1000),
|
||||
now, deadID, t.KafkaDeadStatusDead, t.KafkaDeadStatusPendingReplay)
|
||||
@@ -415,13 +178,6 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if _, err = tx.Exec(`DELETE FROM im_kafka_consume_log WHERE event_id=? AND consumer_group=?`,
|
||||
row.EventID, row.ConsumerGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.TenantKafkaDeadLetterGet(id, deadID)
|
||||
}
|
||||
|
||||
@@ -469,39 +225,6 @@ func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) kafkaConsumeGet(eventID, consumerGroup string) (*t.KafkaConsumeRecord, error) {
|
||||
row := struct {
|
||||
EventID string `db:"event_id"`
|
||||
ConsumerGroup string `db:"consumer_group"`
|
||||
SourceTopic string `db:"source_topic"`
|
||||
EventType string `db:"event_type"`
|
||||
BizType string `db:"biz_type"`
|
||||
BizID string `db:"biz_id"`
|
||||
Status string `db:"status"`
|
||||
RetryCount int `db:"retry_count"`
|
||||
LastReason string `db:"last_reason"`
|
||||
LastError string `db:"last_error"`
|
||||
ProcessingDeadline *time.Time `db:"processing_deadline"`
|
||||
SucceededAt *time.Time `db:"succeeded_at"`
|
||||
DeadAt *time.Time `db:"dead_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
err := a.db.Get(&row, `SELECT event_id,consumer_group,source_topic,event_type,biz_type,biz_id,status,retry_count,
|
||||
last_reason,last_error,processing_deadline,succeeded_at,dead_at,created_at,updated_at
|
||||
FROM im_kafka_consume_log WHERE event_id=? AND consumer_group=?`, eventID, consumerGroup)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t.KafkaConsumeRecord{EventID: row.EventID, ConsumerGroup: row.ConsumerGroup, SourceTopic: row.SourceTopic,
|
||||
EventType: row.EventType, BizType: row.BizType, BizID: row.BizID, Status: row.Status, RetryCount: row.RetryCount,
|
||||
LastReason: row.LastReason, LastError: row.LastError, ProcessingDeadline: row.ProcessingDeadline,
|
||||
SucceededAt: row.SucceededAt, DeadAt: row.DeadAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
|
||||
}
|
||||
|
||||
func truncateKafkaString(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
@@ -519,10 +242,10 @@ func insertSystemNotificationTx(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, eve
|
||||
return err
|
||||
}
|
||||
now := t.TimeNow()
|
||||
_, err = tx.Exec(`INSERT INTO im_system_notifications
|
||||
_, err = tx.Exec(tenantShardSQL(tenantID, `INSERT INTO im_system_notifications
|
||||
(tenant_id,user_id,event_id,event_type,title,summary,action,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title),
|
||||
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, tenantID, decodeUID(uid), eventID,
|
||||
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`), tenantID, decodeUID(uid), eventID,
|
||||
eventType, title, summary, encoded, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -68,21 +68,7 @@ func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureE
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginFailureCreate(id, event)
|
||||
}
|
||||
metadata, err := json.Marshal(event.Metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var userID any
|
||||
if !event.UserID.IsZero() {
|
||||
userID = decodeUID(event.UserID)
|
||||
}
|
||||
_, err = a.db.Exec(`INSERT INTO im_login_failure_logs
|
||||
(user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail,
|
||||
event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent,
|
||||
event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata))
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error {
|
||||
|
||||
@@ -5,10 +5,18 @@ package mysql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func tenantShardSQL(tenantID t.TenantID, query string) string {
|
||||
query = strings.ReplaceAll(query, "im_topics", tenantTopicsTable(tenantID))
|
||||
query = strings.ReplaceAll(query, "im_group_members", tenantGroupMembersTable(tenantID))
|
||||
query = strings.ReplaceAll(query, "im_subscriptions", tenantSubscriptionsTable(tenantID))
|
||||
return strings.ReplaceAll(query, "im_system_notifications", tenantSystemNotificationsTable(tenantID))
|
||||
}
|
||||
|
||||
func tenantTablePrefix(tenantID t.TenantID) string {
|
||||
return fmt.Sprintf("im_%03d_", tenantID)
|
||||
}
|
||||
@@ -21,6 +29,22 @@ func tenantMessagesTable(tenantID t.TenantID) string {
|
||||
return tenantTablePrefix(tenantID) + "messages"
|
||||
}
|
||||
|
||||
func tenantTopicsTable(tenantID t.TenantID) string {
|
||||
return tenantTablePrefix(tenantID) + "topics"
|
||||
}
|
||||
|
||||
func tenantGroupMembersTable(tenantID t.TenantID) string {
|
||||
return tenantTablePrefix(tenantID) + "group_members"
|
||||
}
|
||||
|
||||
func tenantSubscriptionsTable(tenantID t.TenantID) string {
|
||||
return tenantTablePrefix(tenantID) + "subscriptions"
|
||||
}
|
||||
|
||||
func tenantSystemNotificationsTable(tenantID t.TenantID) string {
|
||||
return tenantTablePrefix(tenantID) + "system_notifications"
|
||||
}
|
||||
|
||||
func tenantGroupMessagesTable(tenantID t.TenantID, month string) (string, bool) {
|
||||
if !validShardMonth(month) {
|
||||
return "", false
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestGroupBusinessTablesCreated(t *testing.T) {
|
||||
tables := []string{
|
||||
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
|
||||
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
|
||||
"im_group_files", "im_group_admin_logs", "im_group_idempotency",
|
||||
"im_group_files", "im_group_admin_logs",
|
||||
}
|
||||
args := make([]any, len(tables))
|
||||
for i, table := range tables {
|
||||
@@ -103,8 +103,8 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) {
|
||||
if err := db.Get(&count, query, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != len(tables) {
|
||||
t.Fatalf("HTTP infrastructure table count: got %d, want %d", count, len(tables))
|
||||
if count != 0 {
|
||||
t.Fatalf("HTTP idempotency must not create MySQL table: got %d", count)
|
||||
}
|
||||
|
||||
query = "SELECT COUNT(*) FROM information_schema.columns " +
|
||||
@@ -112,8 +112,8 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) {
|
||||
if err := db.Get(&count, query, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != len(tables) {
|
||||
t.Fatalf("HTTP infrastructure tenant_id columns: got %d, want %d", count, len(tables))
|
||||
if count != 0 {
|
||||
t.Fatalf("HTTP idempotency tenant_id columns should not exist: got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+123
-29
@@ -52,6 +52,7 @@ const (
|
||||
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
|
||||
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
||||
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
|
||||
imReasonIdempotencyService = "IDEMPOTENCY_UNAVAILABLE"
|
||||
imReasonInternal = "INTERNAL_ERROR"
|
||||
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
|
||||
imReasonFileUnavailable = "FILE_UNAVAILABLE"
|
||||
@@ -69,6 +70,75 @@ const (
|
||||
registrationTypePersonal = 1
|
||||
)
|
||||
|
||||
const (
|
||||
imHTTPIdempotencyTTL = 24 * time.Hour
|
||||
imHTTPIdempotencyNamespace = "http_idempotency"
|
||||
)
|
||||
|
||||
type imHTTPIdempotencyReplay struct {
|
||||
BodyHash string `json:"body_hash"`
|
||||
ResponseStatus int `json:"response_status"`
|
||||
ResponseBody []byte `json:"response_body"`
|
||||
}
|
||||
|
||||
var errIMHTTPIdempotencyConflict = errors.New("http idempotency conflict")
|
||||
|
||||
var (
|
||||
beginIMHTTPIdempotency = redisBeginIMHTTPIdempotency
|
||||
completeIMHTTPIdempotency = redisCompleteIMHTTPIdempotency
|
||||
abortIMHTTPIdempotency = redisAbortIMHTTPIdempotency
|
||||
)
|
||||
|
||||
func imHTTPIdempotencyID(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
|
||||
return strconv.FormatInt(int64(tenantID), 10) + ":" + uid.String() + ":" + method + ":" + route + ":" + key
|
||||
}
|
||||
|
||||
func redisBeginIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
|
||||
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
||||
claimed, sameValue, enabled, err := store.ClaimShortStateValue(imHTTPIdempotencyNamespace, id, bodyHash, imHTTPIdempotencyTTL)
|
||||
if err != nil || !enabled || claimed {
|
||||
return nil, claimed, enabled, err
|
||||
}
|
||||
if !sameValue {
|
||||
return nil, false, true, errIMHTTPIdempotencyConflict
|
||||
}
|
||||
raw, found, enabled, err := store.GetShortStateValue(imHTTPIdempotencyNamespace, id+":response")
|
||||
if err != nil || !enabled || !found {
|
||||
return nil, false, enabled, err
|
||||
}
|
||||
var replay imHTTPIdempotencyReplay
|
||||
if err = json.Unmarshal([]byte(raw), &replay); err != nil {
|
||||
_, _ = store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
|
||||
return nil, false, true, err
|
||||
}
|
||||
if replay.BodyHash != bodyHash {
|
||||
return nil, false, true, nil
|
||||
}
|
||||
return &replay, false, true, nil
|
||||
}
|
||||
|
||||
func redisCompleteIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
|
||||
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
||||
payload, err := json.Marshal(imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: body})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enabled, err := store.SetShortStateValue(imHTTPIdempotencyNamespace, id+":response", string(payload), imHTTPIdempotencyTTL)
|
||||
if !enabled {
|
||||
return errors.New("http idempotency redis unavailable")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func redisAbortIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
|
||||
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
||||
if _, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
|
||||
return err
|
||||
}
|
||||
|
||||
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
@@ -232,53 +302,54 @@ func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...)
|
||||
bodyHash := sha256.Sum256(hashInput)
|
||||
record := &types.HTTPIdempotencyRecord{
|
||||
UserID: principal.Uid, Method: req.Method, Route: req.URL.Path, Key: key,
|
||||
BodyHash: fmt.Sprintf("%x", bodyHash[:]), ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
bodyHashText := fmt.Sprintf("%x", bodyHash[:])
|
||||
existing, acquired, enabled, err := beginIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText)
|
||||
if errors.Is(err, errIMHTTPIdempotencyConflict) {
|
||||
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
|
||||
return
|
||||
}
|
||||
existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record)
|
||||
if err != nil {
|
||||
logs.Warn.Printf("im http idempotency begin failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
||||
logs.Warn.Printf("im http redis idempotency begin failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
||||
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, err)
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
logs.Warn.Printf("im http redis idempotency unavailable request_id=%s tenant=%d user=%s method=%s route=%s",
|
||||
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path)
|
||||
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
if existing == nil || existing.BodyHash != record.BodyHash {
|
||||
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
|
||||
if existing == nil {
|
||||
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
|
||||
return
|
||||
}
|
||||
if existing.State == types.HTTPIdempotencyCompleted {
|
||||
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
wrt.Header().Set("X-Idempotent-Replay", "true")
|
||||
wrt.WriteHeader(existing.ResponseStatus)
|
||||
_, _ = wrt.Write(existing.ResponseBody)
|
||||
return
|
||||
}
|
||||
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
|
||||
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
wrt.Header().Set("X-Idempotent-Replay", "true")
|
||||
wrt.WriteHeader(existing.ResponseStatus)
|
||||
_, _ = wrt.Write(existing.ResponseBody)
|
||||
return
|
||||
}
|
||||
|
||||
buffered := newIMBufferedResponseWriter()
|
||||
next.ServeHTTP(buffered, req)
|
||||
record.ResponseStatus = buffered.status
|
||||
record.ResponseBody = append([]byte(nil), buffered.body.Bytes()...)
|
||||
if buffered.status >= http.StatusInternalServerError {
|
||||
if abortErr := store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record); abortErr != nil {
|
||||
logs.Warn.Printf("im http idempotency abort failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
||||
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
|
||||
logs.Warn.Printf("im http redis idempotency abort failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
||||
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
|
||||
}
|
||||
buffered.flushTo(wrt)
|
||||
return
|
||||
}
|
||||
if err = store.HTTPInfrastructure.CompleteIdempotency(principal.TenantID, record); err != nil {
|
||||
logs.Warn.Printf("im http idempotency complete failed request_id=%s tenant=%d user=%s method=%s route=%s status=%d err=%v",
|
||||
if err = completeIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText, buffered.status, append([]byte(nil), buffered.body.Bytes()...)); err != nil {
|
||||
logs.Warn.Printf("im http redis idempotency complete failed request_id=%s tenant=%d user=%s method=%s route=%s status=%d err=%v",
|
||||
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, buffered.status, err)
|
||||
if abortErr := store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record); abortErr != nil {
|
||||
logs.Warn.Printf("im http idempotency abort after complete failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
||||
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
|
||||
logs.Warn.Printf("im http redis idempotency abort after complete failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
|
||||
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
|
||||
}
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
|
||||
return
|
||||
}
|
||||
buffered.flushTo(wrt)
|
||||
@@ -2412,7 +2483,8 @@ func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
readAt = parsed.UTC().Round(time.Millisecond)
|
||||
}
|
||||
if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil {
|
||||
notification, err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.ErrNotFound) {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
|
||||
return
|
||||
@@ -2420,7 +2492,9 @@ func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)})
|
||||
payload := systemNotificationPayload(*notification)
|
||||
payload["status"] = "ok"
|
||||
writeIMSuccess(wrt, req, payload)
|
||||
}
|
||||
|
||||
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
|
||||
@@ -7978,7 +8052,7 @@ func favoritePayload(item types.FavoriteItem) map[string]any {
|
||||
"favorite_id": item.FavoriteID,
|
||||
"type": item.ItemType,
|
||||
"snapshot": item.Snapshot,
|
||||
"source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq},
|
||||
"source": favoriteSourcePayload(item.SourceTopic, item.SourceSeq, types.ZeroUid),
|
||||
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
|
||||
"availability": item.Availability,
|
||||
}
|
||||
@@ -8015,7 +8089,27 @@ func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem
|
||||
item.Availability = "removed"
|
||||
}
|
||||
}
|
||||
return favoritePayload(item), true
|
||||
return favoritePayloadForViewer(principal.Uid, item), true
|
||||
}
|
||||
|
||||
func favoritePayloadForViewer(viewer types.Uid, item types.FavoriteItem) map[string]any {
|
||||
payload := favoritePayload(item)
|
||||
payload["source"] = favoriteSourcePayload(item.SourceTopic, item.SourceSeq, viewer)
|
||||
return payload
|
||||
}
|
||||
|
||||
func favoriteSourcePayload(topic string, seq int64, viewer types.Uid) map[string]any {
|
||||
source := map[string]any{"topic": topic, "seq": seq}
|
||||
if viewer.IsZero() || types.GetTopicCat(topic) != types.TopicCatP2P {
|
||||
return source
|
||||
}
|
||||
peerTopic, err := types.P2PNameForUser(viewer, topic)
|
||||
if err != nil || strings.TrimSpace(peerTopic) == "" {
|
||||
return source
|
||||
}
|
||||
source["peer_user_id"] = peerTopic
|
||||
source["display_topic"] = peerTopic
|
||||
return source
|
||||
}
|
||||
|
||||
func legacyFavoriteSnapshotOnly(item types.FavoriteItem) bool {
|
||||
|
||||
+74
-40
@@ -41,7 +41,6 @@ type testVerificationStore struct {
|
||||
}
|
||||
|
||||
type testHTTPInfrastructureStore struct {
|
||||
records map[string]*types.HTTPIdempotencyRecord
|
||||
deadLetters []types.KafkaDeadLetter
|
||||
}
|
||||
|
||||
@@ -104,39 +103,6 @@ func (h *testPublicMediaHandler) UploadPublic(def *media.PublicFileDef, file io.
|
||||
return def.Location, int64(len(data)), nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
|
||||
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
|
||||
if s.records == nil {
|
||||
s.records = make(map[string]*types.HTTPIdempotencyRecord)
|
||||
}
|
||||
key := s.recordKey(record)
|
||||
if existing := s.records[key]; existing != nil {
|
||||
copy := *existing
|
||||
copy.ResponseBody = append([]byte(nil), existing.ResponseBody...)
|
||||
return ©, false, nil
|
||||
}
|
||||
copy := *record
|
||||
copy.State = types.HTTPIdempotencyProcessing
|
||||
s.records[key] = ©
|
||||
return ©, true, nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
|
||||
copy := *record
|
||||
copy.State = types.HTTPIdempotencyCompleted
|
||||
copy.ResponseBody = append([]byte(nil), record.ResponseBody...)
|
||||
s.records[s.recordKey(record)] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
|
||||
delete(s.records, s.recordKey(record))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record *types.KafkaConsumeRecord, _ time.Time) (*types.KafkaConsumeRecord, bool, error) {
|
||||
copy := *record
|
||||
copy.Status = types.KafkaConsumeProcessing
|
||||
@@ -692,8 +658,8 @@ func (s *testUserSecurityStore) CountUnreadSystemNotificationsByEventPrefix(_ ty
|
||||
return s.unreadByPrefix[prefix], nil
|
||||
}
|
||||
|
||||
func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, _ string, _ time.Time) error {
|
||||
return nil
|
||||
func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
|
||||
return &types.SystemNotification{EventID: eventID, EventType: "system.notice", Title: "通知", Summary: "通知", ReadAt: &readAt, CreatedAt: readAt}, nil
|
||||
}
|
||||
|
||||
func (s *testProfilesStore) Get(_ types.TenantID, uid types.Uid) (*types.UserProfile, error) {
|
||||
@@ -1229,10 +1195,45 @@ func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldInfrastructure := store.HTTPInfrastructure
|
||||
infrastructure := &testHTTPInfrastructureStore{}
|
||||
store.HTTPInfrastructure = infrastructure
|
||||
defer func() { store.HTTPInfrastructure = oldInfrastructure }()
|
||||
oldBegin, oldComplete, oldAbort := beginIMHTTPIdempotency, completeIMHTTPIdempotency, abortIMHTTPIdempotency
|
||||
type cachedResponse struct {
|
||||
bodyHash string
|
||||
replay *imHTTPIdempotencyReplay
|
||||
}
|
||||
records := map[string]*cachedResponse{}
|
||||
recordKey := func(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
|
||||
return imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
||||
}
|
||||
beginIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
|
||||
id := recordKey(tenantID, uid, method, route, key)
|
||||
if existing := records[id]; existing != nil {
|
||||
if existing.bodyHash != bodyHash {
|
||||
return nil, false, true, errIMHTTPIdempotencyConflict
|
||||
}
|
||||
if existing.replay == nil {
|
||||
return nil, false, true, nil
|
||||
}
|
||||
copy := *existing.replay
|
||||
copy.ResponseBody = append([]byte(nil), existing.replay.ResponseBody...)
|
||||
return ©, false, true, nil
|
||||
}
|
||||
records[id] = &cachedResponse{bodyHash: bodyHash}
|
||||
return nil, true, true, nil
|
||||
}
|
||||
completeIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
|
||||
records[recordKey(tenantID, uid, method, route, key)] = &cachedResponse{
|
||||
bodyHash: bodyHash,
|
||||
replay: &imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: append([]byte(nil), body...)},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
abortIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
|
||||
delete(records, recordKey(tenantID, uid, method, route, key))
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
beginIMHTTPIdempotency, completeIMHTTPIdempotency, abortIMHTTPIdempotency = oldBegin, oldComplete, oldAbort
|
||||
}()
|
||||
|
||||
called := 0
|
||||
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
|
||||
@@ -3085,6 +3086,26 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
|
||||
if security.readTenant != 7 || security.readUser != 42 || security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
|
||||
t.Fatalf("notification was not marked read: %#v", security)
|
||||
}
|
||||
readData := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if readData["id"] != "evt-1" || readData["status"] != "ok" || readData["read_at"] != security.notifications[0].ReadAt.UTC().Format(time.RFC3339Nano) {
|
||||
t.Fatalf("read response did not return persisted notification state: %#v", readData)
|
||||
}
|
||||
|
||||
expectIMBearer(t, ctrl, "token-read-again", 7, 42)
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-2/read", bytes.NewBufferString(`{"read_at":"2026-09-01T00:00:00Z"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-read-again")
|
||||
req.Header.Set("Idempotency-Key", "read-2")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
serveIMSystemNotificationRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read again status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
readAgainData := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if readAgainData["id"] != "evt-2" || readAgainData["read_at"] != readAt.UTC().Format(time.RFC3339Nano) {
|
||||
t.Fatalf("read again response should keep persisted read_at: %#v", readAgainData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageReceipts(t *testing.T) {
|
||||
@@ -5211,6 +5232,19 @@ func TestServeIMFavoriteBatchCreateNormalizesP2PUserTopic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteSourcePayloadIncludesP2PPeer(t *testing.T) {
|
||||
viewer := types.Uid(42)
|
||||
peer := types.Uid(43)
|
||||
topic := viewer.P2PName(peer)
|
||||
source := favoriteSourcePayload(topic, 17, viewer)
|
||||
if source["topic"] != topic || source["seq"] != int64(17) {
|
||||
t.Fatalf("favorite source should keep stored topic and seq: %#v", source)
|
||||
}
|
||||
if source["peer_user_id"] != peer.UserId() || source["display_topic"] != peer.UserId() {
|
||||
t.Fatalf("favorite source should expose addressable p2p peer: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
@@ -68,11 +68,6 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
|
||||
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
||||
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
|
||||
},
|
||||
"login-failure-logs": {
|
||||
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,account_hash,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
|
||||
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
||||
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
|
||||
},
|
||||
"audit-logs": {
|
||||
Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent,
|
||||
JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`,
|
||||
@@ -439,9 +434,7 @@ func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
|
||||
selects = append(selects, `SELECT id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user,
|
||||
msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status,
|
||||
content,head,reply_seqid,mentioned_user_ids,created_at,updated_at
|
||||
FROM im_group_messages AS legacy WHERE NOT EXISTS (
|
||||
SELECT 1 FROM im_group_message_topic_shards AS route
|
||||
WHERE route.topic=legacy.topic COLLATE utf8mb4_0900_ai_ci AND legacy.seqid BETWEEN route.min_seq AND route.max_seq)`)
|
||||
FROM im_group_messages AS legacy`)
|
||||
return "(" + strings.Join(selects, " UNION ALL ") + ")", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,9 @@ type groupMessageRuntime interface {
|
||||
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
|
||||
ClaimShortState(namespace, id string, ttl time.Duration) (bool, error)
|
||||
ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool, bool, error)
|
||||
SetShortStateValue(namespace, id, value string, ttl time.Duration) error
|
||||
GetShortStateValue(namespace, id string) (string, bool, error)
|
||||
DeleteShortState(namespace, id string) error
|
||||
ShortStateExists(namespace, id string) (bool, error)
|
||||
StoreLoginSession(token []byte, session LoginSession, ttl time.Duration) error
|
||||
}
|
||||
@@ -266,6 +269,28 @@ func ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool,
|
||||
return claimed, sameValue, true, err
|
||||
}
|
||||
|
||||
func SetShortStateValue(namespace, id, value string, ttl time.Duration) (bool, error) {
|
||||
if groupMsgRuntime == nil || namespace == "" || id == "" || value == "" {
|
||||
return false, nil
|
||||
}
|
||||
return true, groupMsgRuntime.SetShortStateValue(namespace, id, value, ttl)
|
||||
}
|
||||
|
||||
func GetShortStateValue(namespace, id string) (string, bool, bool, error) {
|
||||
if groupMsgRuntime == nil || namespace == "" || id == "" {
|
||||
return "", false, false, nil
|
||||
}
|
||||
value, found, err := groupMsgRuntime.GetShortStateValue(namespace, id)
|
||||
return value, found, true, err
|
||||
}
|
||||
|
||||
func DeleteShortState(namespace, id string) (bool, error) {
|
||||
if groupMsgRuntime == nil || namespace == "" || id == "" {
|
||||
return false, nil
|
||||
}
|
||||
return true, groupMsgRuntime.DeleteShortState(namespace, id)
|
||||
}
|
||||
|
||||
func RevokeLoginToken(tenantID types.TenantID, token []byte, ttl time.Duration) (bool, error) {
|
||||
if groupMsgRuntime == nil || !tenantID.IsValid() || len(token) == 0 || ttl <= 0 {
|
||||
return false, nil
|
||||
@@ -1272,6 +1297,28 @@ func (r *redisGroupMessageRuntime) ClaimShortStateValue(namespace, id, value str
|
||||
return false, existing == value, nil
|
||||
}
|
||||
|
||||
func (r *redisGroupMessageRuntime) SetShortStateValue(namespace, id, value string, ttl time.Duration) error {
|
||||
if ttl <= 0 {
|
||||
ttl = time.Duration(r.cfg.ShortTTL) * time.Second
|
||||
}
|
||||
return r.client.Set(context.Background(), r.shortStateKey(namespace, id), value, ttl).Err()
|
||||
}
|
||||
|
||||
func (r *redisGroupMessageRuntime) GetShortStateValue(namespace, id string) (string, bool, error) {
|
||||
value, err := r.client.Get(context.Background(), r.shortStateKey(namespace, id)).Result()
|
||||
if err == redis.Nil {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func (r *redisGroupMessageRuntime) DeleteShortState(namespace, id string) error {
|
||||
return r.client.Del(context.Background(), r.shortStateKey(namespace, id)).Err()
|
||||
}
|
||||
|
||||
func (r *redisGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) {
|
||||
count, err := r.client.Exists(context.Background(), r.shortStateKey(namespace, id)).Result()
|
||||
return count > 0, err
|
||||
@@ -1450,7 +1497,7 @@ func (r *redisGroupMessageRuntime) loginSessionKey(tokenHash string) string {
|
||||
|
||||
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
|
||||
ttl := r.cfg.CacheTTL
|
||||
if namespace == "verification" || namespace == "action_token" {
|
||||
if namespace == "verification" || namespace == "action_token" || namespace == "login_lockout" {
|
||||
ttl = r.cfg.ShortTTL
|
||||
}
|
||||
return time.Duration(ttl) * time.Second
|
||||
|
||||
@@ -15,6 +15,7 @@ type testGroupMessageRuntime struct {
|
||||
claims map[string]*groupMessageUniqueRecord
|
||||
seqs map[string]int64
|
||||
shortStates map[string]bool
|
||||
shortValues map[string]string
|
||||
objects map[string][]byte
|
||||
deletedPrefixes []string
|
||||
deletedObjects []string
|
||||
@@ -25,6 +26,7 @@ func newTestGroupMessageRuntime() *testGroupMessageRuntime {
|
||||
claims: make(map[string]*groupMessageUniqueRecord),
|
||||
seqs: make(map[string]int64),
|
||||
shortStates: make(map[string]bool),
|
||||
shortValues: make(map[string]string),
|
||||
objects: make(map[string][]byte),
|
||||
}
|
||||
}
|
||||
@@ -209,8 +211,29 @@ func (r *testGroupMessageRuntime) ClaimShortState(namespace, id string, _ time.D
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) ClaimShortStateValue(string, string, string, time.Duration) (bool, bool, error) {
|
||||
return true, true, nil
|
||||
func (r *testGroupMessageRuntime) ClaimShortStateValue(namespace, id, value string, _ time.Duration) (bool, bool, error) {
|
||||
key := namespace + ":" + id
|
||||
if existing, ok := r.shortValues[key]; ok {
|
||||
return false, existing == value, nil
|
||||
}
|
||||
r.shortValues[key] = value
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) SetShortStateValue(namespace, id, value string, _ time.Duration) error {
|
||||
r.shortValues[namespace+":"+id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) GetShortStateValue(namespace, id string) (string, bool, error) {
|
||||
value, ok := r.shortValues[namespace+":"+id]
|
||||
return value, ok, nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) DeleteShortState(namespace, id string) error {
|
||||
delete(r.shortStates, namespace+":"+id)
|
||||
delete(r.shortValues, namespace+":"+id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *testGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) {
|
||||
|
||||
+156
-38
@@ -1,6 +1,10 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
adapter "github.com/tinode/chat/server/db"
|
||||
@@ -8,9 +12,6 @@ import (
|
||||
)
|
||||
|
||||
type HTTPInfrastructurePersistenceInterface interface {
|
||||
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
|
||||
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
|
||||
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
|
||||
BeginKafkaConsume(types.TenantID, *types.KafkaConsumeRecord, time.Time) (*types.KafkaConsumeRecord, bool, error)
|
||||
MarkKafkaConsumeSucceeded(types.TenantID, string, string, time.Time) error
|
||||
MarkKafkaConsumeFailed(types.TenantID, string, string, string, string, time.Time) (*types.KafkaConsumeRecord, error)
|
||||
@@ -25,6 +26,49 @@ type httpInfrastructureMapper struct{}
|
||||
|
||||
var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{}
|
||||
|
||||
const (
|
||||
kafkaConsumeNamespace = "kafka_consume"
|
||||
kafkaConsumeRecordTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var errKafkaConsumeRedisUnavailable = errors.New("kafka consume redis unavailable")
|
||||
|
||||
func kafkaConsumeKey(tenantID types.TenantID, eventID, consumerGroup string) string {
|
||||
return strconv.FormatInt(int64(tenantID), 10) + ":" + strings.TrimSpace(eventID) + ":" + strings.TrimSpace(consumerGroup)
|
||||
}
|
||||
|
||||
func kafkaConsumeRecordKey(tenantID types.TenantID, eventID, consumerGroup string) string {
|
||||
return kafkaConsumeKey(tenantID, eventID, consumerGroup) + ":record"
|
||||
}
|
||||
|
||||
func kafkaConsumeRead(tenantID types.TenantID, eventID, consumerGroup string) (*types.KafkaConsumeRecord, bool, bool, error) {
|
||||
raw, found, enabled, err := GetShortStateValue(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, eventID, consumerGroup))
|
||||
if err != nil || !enabled || !found {
|
||||
return nil, found, enabled, err
|
||||
}
|
||||
var record types.KafkaConsumeRecord
|
||||
if err = json.Unmarshal([]byte(raw), &record); err != nil {
|
||||
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, eventID, consumerGroup))
|
||||
return nil, false, true, err
|
||||
}
|
||||
return &record, true, true, nil
|
||||
}
|
||||
|
||||
func kafkaConsumeWrite(tenantID types.TenantID, record *types.KafkaConsumeRecord) error {
|
||||
if record == nil {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enabled, err := SetShortStateValue(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, record.EventID, record.ConsumerGroup), string(payload), kafkaConsumeRecordTTL)
|
||||
if !enabled {
|
||||
return errKafkaConsumeRedisUnavailable
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) {
|
||||
business, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
@@ -37,52 +81,109 @@ func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTT
|
||||
return infrastructure, nil
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
|
||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record)
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
|
||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record)
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
|
||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) BeginKafkaConsume(tenantID types.TenantID, record *types.KafkaConsumeRecord, deadline time.Time) (*types.KafkaConsumeRecord, bool, error) {
|
||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||
if err != nil {
|
||||
if record == nil || strings.TrimSpace(record.EventID) == "" || strings.TrimSpace(record.ConsumerGroup) == "" || strings.TrimSpace(record.SourceTopic) == "" {
|
||||
return nil, false, types.ErrMalformed
|
||||
}
|
||||
if existing, found, enabled, err := kafkaConsumeRead(tenantID, record.EventID, record.ConsumerGroup); err != nil || !enabled {
|
||||
if err == nil {
|
||||
err = errKafkaConsumeRedisUnavailable
|
||||
}
|
||||
return nil, false, err
|
||||
} else if found && (existing.Status == types.KafkaConsumeSucceeded || existing.Status == types.KafkaConsumeDead) {
|
||||
return existing, false, nil
|
||||
} else if found {
|
||||
record.RetryCount = existing.RetryCount
|
||||
record.LastReason = existing.LastReason
|
||||
record.LastError = existing.LastError
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
ttl := time.Until(deadline)
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
claimed, _, enabled, err := ClaimShortStateValue(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, record.EventID, record.ConsumerGroup), types.KafkaConsumeProcessing, ttl)
|
||||
if err != nil || !enabled {
|
||||
if err == nil {
|
||||
err = errKafkaConsumeRedisUnavailable
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return infrastructure.TenantKafkaConsumeBegin(tenantID, record, deadline)
|
||||
if !claimed {
|
||||
existing, found, enabled, err := kafkaConsumeRead(tenantID, record.EventID, record.ConsumerGroup)
|
||||
if err != nil || !enabled {
|
||||
if err == nil {
|
||||
err = errKafkaConsumeRedisUnavailable
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if found {
|
||||
return existing, false, nil
|
||||
}
|
||||
copy := *record
|
||||
copy.Status = types.KafkaConsumeProcessing
|
||||
copy.ProcessingDeadline = &deadline
|
||||
return ©, false, nil
|
||||
}
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
copy := *record
|
||||
copy.Status = types.KafkaConsumeProcessing
|
||||
copy.ProcessingDeadline = &deadline
|
||||
if copy.CreatedAt.IsZero() {
|
||||
copy.CreatedAt = now
|
||||
}
|
||||
copy.UpdatedAt = now
|
||||
if err = kafkaConsumeWrite(tenantID, ©); err != nil {
|
||||
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, record.EventID, record.ConsumerGroup))
|
||||
return nil, false, err
|
||||
}
|
||||
return ©, true, nil
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) MarkKafkaConsumeSucceeded(tenantID types.TenantID, eventID, consumerGroup string, now time.Time) error {
|
||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||
if err != nil {
|
||||
record, found, enabled, err := kafkaConsumeRead(tenantID, eventID, consumerGroup)
|
||||
if err != nil || !enabled {
|
||||
if err == nil {
|
||||
err = errKafkaConsumeRedisUnavailable
|
||||
}
|
||||
return err
|
||||
}
|
||||
return infrastructure.TenantKafkaConsumeSucceed(tenantID, eventID, consumerGroup, now)
|
||||
if !found {
|
||||
return types.ErrNotFound
|
||||
}
|
||||
record.Status = types.KafkaConsumeSucceeded
|
||||
record.SucceededAt = &now
|
||||
record.ProcessingDeadline = nil
|
||||
record.UpdatedAt = now
|
||||
if err = kafkaConsumeWrite(tenantID, record); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, eventID, consumerGroup))
|
||||
return err
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) MarkKafkaConsumeFailed(tenantID types.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*types.KafkaConsumeRecord, error) {
|
||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
||||
if err != nil {
|
||||
record, found, enabled, err := kafkaConsumeRead(tenantID, eventID, consumerGroup)
|
||||
if err != nil || !enabled {
|
||||
if err == nil {
|
||||
err = errKafkaConsumeRedisUnavailable
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return infrastructure.TenantKafkaConsumeFail(tenantID, eventID, consumerGroup, reason, message, now)
|
||||
if !found {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
record.Status = types.KafkaConsumeFailed
|
||||
record.RetryCount++
|
||||
record.LastReason = reason
|
||||
record.LastError = message
|
||||
record.ProcessingDeadline = nil
|
||||
record.UpdatedAt = now
|
||||
if err = kafkaConsumeWrite(tenantID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, eventID, consumerGroup))
|
||||
return record, err
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) MarkKafkaConsumeDead(tenantID types.TenantID, dead *types.KafkaDeadLetter, now time.Time) error {
|
||||
@@ -90,7 +191,18 @@ func (httpInfrastructureMapper) MarkKafkaConsumeDead(tenantID types.TenantID, de
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return infrastructure.TenantKafkaConsumeDead(tenantID, dead, now)
|
||||
if err = infrastructure.TenantKafkaConsumeDead(tenantID, dead, now); err != nil {
|
||||
return err
|
||||
}
|
||||
record := &types.KafkaConsumeRecord{
|
||||
EventID: dead.EventID, ConsumerGroup: dead.ConsumerGroup, SourceTopic: dead.SourceTopic,
|
||||
EventType: dead.EventType, BizType: dead.BizType, BizID: dead.BizID,
|
||||
Status: types.KafkaConsumeDead, RetryCount: dead.RetryCount, LastReason: dead.LastReason,
|
||||
LastError: dead.LastError, DeadAt: &now, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
_ = kafkaConsumeWrite(tenantID, record)
|
||||
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, dead.EventID, dead.ConsumerGroup))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (httpInfrastructureMapper) ListKafkaDeadLetters(tenantID types.TenantID, opts types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) {
|
||||
@@ -122,5 +234,11 @@ func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantI
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
|
||||
dead, err := infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeKey(tenantID, dead.EventID, dead.ConsumerGroup))
|
||||
_, _ = DeleteShortState(kafkaConsumeNamespace, kafkaConsumeRecordKey(tenantID, dead.EventID, dead.ConsumerGroup))
|
||||
return dead, nil
|
||||
}
|
||||
|
||||
+128
-85
@@ -3,6 +3,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -4326,7 +4327,7 @@ type UserSecurityPersistenceInterface interface {
|
||||
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error)
|
||||
CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error)
|
||||
CountUnreadSystemNotificationsByEventPrefix(tenantID types.TenantID, uid types.Uid, eventPrefix string) (int, error)
|
||||
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error
|
||||
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error)
|
||||
}
|
||||
|
||||
type userSecurityMapper struct{}
|
||||
@@ -4384,30 +4385,39 @@ func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *type
|
||||
if token == nil || token.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tenantAdp.TenantActionTokenCreate(tenantID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheErr := CacheObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(token.UserID, token.TokenID, token.Action, token.Target), token); cacheErr != nil {
|
||||
logs.Warn.Printf("cache action token[%s]: write failed: %v", token.TokenID, cacheErr)
|
||||
}
|
||||
return nil
|
||||
now := types.TimeNow()
|
||||
token.CreatedAt = now
|
||||
token.UpdatedAt = now
|
||||
return CacheObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(token.UserID, token.TokenID, token.Action, token.Target), token)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if !tenantID.IsValid() || uid.IsZero() || token == "" || action == "" {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = types.TimeNow()
|
||||
}
|
||||
tokenID, secret, ok := strings.Cut(token, ".")
|
||||
if !ok || tokenID == "" || secret == "" {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
cacheID := actionTokenCacheID(uid, tokenID, action, target)
|
||||
var cached types.ActionToken
|
||||
ok, err := GetCachedObject(tenantID, cacheNamespaceActionToken, cacheID, &cached)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
consumed, err := tenantAdp.TenantActionTokenConsume(tenantID, uid, token, action, target, now)
|
||||
if err == nil {
|
||||
tokenID, _, _ := strings.Cut(token, ".")
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(uid, tokenID, action, target))
|
||||
if !ok || cached.State != 0 || cached.TokenHash != actionTokenHash(tenantID, uid, action, target, tokenID, secret) || !now.Before(cached.ExpiresAt) {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
return consumed, err
|
||||
cached.State = 1
|
||||
cached.ConsumedAt = &now
|
||||
cached.UpdatedAt = now
|
||||
if err = DeleteCachedObject(tenantID, cacheNamespaceActionToken, cacheID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
func (userSecurityMapper) GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) {
|
||||
@@ -4500,10 +4510,10 @@ func (userSecurityMapper) CountUnreadSystemNotificationsByEventPrefix(tenantID t
|
||||
return tenantAdp.TenantSystemNotificationUnreadCountByEventPrefix(tenantID, uid, eventPrefix)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
|
||||
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantSystemNotificationMarkRead(tenantID, uid, eventID, readAt)
|
||||
}
|
||||
@@ -4512,26 +4522,30 @@ func (verificationMapper) Create(tenantID types.TenantID, vc *types.Verification
|
||||
if vc == nil || vc.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil {
|
||||
return cacheErr
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
logs.Warn.Printf("verification[%s]: audit adapter unavailable: %v", vc.ChallengeID, err)
|
||||
return nil
|
||||
}
|
||||
if err = tenantAdp.TenantVerificationCreate(tenantID, vc); err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil {
|
||||
logs.Warn.Printf("cache verification[%s]: write failed: %v", vc.ChallengeID, cacheErr)
|
||||
logs.Warn.Printf("verification[%s]: audit insert failed: %v", vc.ChallengeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (verificationMapper) Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error) {
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
var vc types.VerificationCode
|
||||
ok, err := GetCachedObject(tenantID, cacheNamespaceVerification, challengeID, &vc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantVerificationGet(tenantID, challengeID)
|
||||
if !ok {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
return &vc, nil
|
||||
}
|
||||
|
||||
func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error) {
|
||||
@@ -4547,74 +4561,59 @@ func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash st
|
||||
}
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, activeCacheID)
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vc, err := tenantAdp.TenantVerificationGetActive(tenantID, scene, phoneHash, now)
|
||||
if err == nil && vc != nil {
|
||||
if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil {
|
||||
logs.Warn.Printf("cache verification active[%s]: write failed: %v", activeCacheID, cacheErr)
|
||||
}
|
||||
}
|
||||
return vc, err
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
func (verificationMapper) Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
vc, err := Verifications.Get(tenantID, challengeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tenantAdp.TenantVerificationFail(tenantID, challengeID, state); err != nil {
|
||||
return err
|
||||
}
|
||||
if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil {
|
||||
_ = cacheVerificationCode(tenantID, vc)
|
||||
} else {
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
|
||||
}
|
||||
return nil
|
||||
vc.Attempts++
|
||||
vc.State = state
|
||||
vc.UpdatedAt = types.TimeNow()
|
||||
return cacheVerificationCode(tenantID, vc)
|
||||
}
|
||||
|
||||
func (verificationMapper) MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
vc, err := Verifications.Get(tenantID, challengeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tenantAdp.TenantVerificationMarkVerified(tenantID, challengeID, tokenHash, verifiedAt); err != nil {
|
||||
return err
|
||||
if vc.State != types.VerificationStatePending || !verifiedAt.Before(vc.ExpiresAt) {
|
||||
return types.ErrNotFound
|
||||
}
|
||||
if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil {
|
||||
_ = cacheVerificationCode(tenantID, vc)
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash))
|
||||
} else {
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
|
||||
}
|
||||
return nil
|
||||
vc.State = types.VerificationStateVerified
|
||||
vc.VerificationTokenHash = &tokenHash
|
||||
vc.VerifiedAt = &verifiedAt
|
||||
vc.UpdatedAt = verifiedAt
|
||||
return cacheVerificationCode(tenantID, vc)
|
||||
}
|
||||
|
||||
func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
vc, err := Verifications.Get(tenantID, challengeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt); err != nil {
|
||||
if vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil || *vc.VerificationTokenHash != tokenHash || !consumedAt.Before(vc.ExpiresAt) {
|
||||
return types.ErrNotFound
|
||||
}
|
||||
vc.State = types.VerificationStateConsumed
|
||||
vc.ConsumedAt = &consumedAt
|
||||
vc.UpdatedAt = consumedAt
|
||||
if err = cacheVerificationCode(tenantID, vc); err != nil {
|
||||
return err
|
||||
}
|
||||
if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil {
|
||||
_ = cacheVerificationCode(tenantID, vc)
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash))
|
||||
} else {
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID)
|
||||
}
|
||||
return nil
|
||||
return DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash))
|
||||
}
|
||||
|
||||
func cacheVerificationCode(tenantID types.TenantID, vc *types.VerificationCode) error {
|
||||
if vc == nil {
|
||||
return nil
|
||||
}
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceVerification, vc.ChallengeID)
|
||||
if err := CacheObject(tenantID, cacheNamespaceVerification, vc.ChallengeID, vc); err != nil {
|
||||
return err
|
||||
}
|
||||
if vc.State == types.VerificationStatePending {
|
||||
return CacheObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash), vc.ChallengeID)
|
||||
}
|
||||
@@ -4630,6 +4629,22 @@ func actionTokenCacheID(uid types.Uid, tokenID, action, target string) string {
|
||||
return uid.String() + ":" + strings.TrimSpace(tokenID) + ":" + strings.TrimSpace(action) + ":" + strings.TrimSpace(target)
|
||||
}
|
||||
|
||||
func actionTokenHash(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(strconv.FormatInt(int64(tenantID), 10)))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(strconv.FormatInt(DecodeUid(uid), 10)))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(action))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(target))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(tokenID))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(secret))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// LoginLockoutPersistenceInterface stores per-account login failure counters
|
||||
// and temporary lock state in each tenant business database.
|
||||
type LoginLockoutPersistenceInterface interface {
|
||||
@@ -4665,43 +4680,71 @@ func tenantLoginLockoutAdapter(tenantID types.TenantID) (adapter.TenantLoginLock
|
||||
}
|
||||
|
||||
func (loginLockoutMapper) Get(tenantID types.TenantID, accountHash string) (*types.LoginLockout, error) {
|
||||
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
|
||||
var lockout types.LoginLockout
|
||||
ok, err := GetCachedObject(tenantID, "login_lockout", accountHash, &lockout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lockAdp.TenantLoginLockoutGet(tenantID, accountHash)
|
||||
if !ok {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
return &lockout, nil
|
||||
}
|
||||
|
||||
func (loginLockoutMapper) RecordFailure(tenantID types.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*types.LoginLockout, error) {
|
||||
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
|
||||
if err != nil {
|
||||
current, err := LoginLockouts.Get(tenantID, accountHash)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return lockAdp.TenantLoginLockoutRecordFailure(tenantID, accountHash, userID, windowCutoff, now)
|
||||
if current == nil {
|
||||
current = &types.LoginLockout{TenantID: tenantID, AccountHash: accountHash}
|
||||
}
|
||||
if current.WindowStartAt == nil || current.WindowStartAt.Before(windowCutoff) {
|
||||
current.FailedAttempts = 0
|
||||
start := now
|
||||
current.WindowStartAt = &start
|
||||
}
|
||||
current.FailedAttempts++
|
||||
if userID != nil {
|
||||
current.UserID = *userID
|
||||
}
|
||||
current.LastAttemptAt = &now
|
||||
current.UpdatedAt = &now
|
||||
return current, CacheObject(tenantID, "login_lockout", accountHash, current)
|
||||
}
|
||||
|
||||
func (loginLockoutMapper) Lock(tenantID types.TenantID, accountHash string, lockedUntil, now time.Time) error {
|
||||
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
|
||||
if err != nil {
|
||||
current, err := LoginLockouts.Get(tenantID, accountHash)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
return lockAdp.TenantLoginLockoutLock(tenantID, accountHash, lockedUntil, now)
|
||||
if current == nil {
|
||||
current = &types.LoginLockout{TenantID: tenantID, AccountHash: accountHash}
|
||||
}
|
||||
if current.LockedUntil == nil || current.LockedUntil.Before(lockedUntil) {
|
||||
current.LockedUntil = &lockedUntil
|
||||
}
|
||||
current.UpdatedAt = &now
|
||||
return CacheObject(tenantID, "login_lockout", accountHash, current)
|
||||
}
|
||||
|
||||
func (loginLockoutMapper) Reset(tenantID types.TenantID, accountHash string) error {
|
||||
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return lockAdp.TenantLoginLockoutReset(tenantID, accountHash)
|
||||
return DeleteCachedObject(tenantID, "login_lockout", accountHash)
|
||||
}
|
||||
|
||||
func (loginLockoutMapper) Release(tenantID types.TenantID, accountHash string, now time.Time) error {
|
||||
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
|
||||
if err != nil {
|
||||
current, err := LoginLockouts.Get(tenantID, accountHash)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
return lockAdp.TenantLoginLockoutRelease(tenantID, accountHash, now)
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
current.FailedAttempts = 0
|
||||
current.WindowStartAt = nil
|
||||
current.LockedUntil = nil
|
||||
current.UpdatedAt = &now
|
||||
return CacheObject(tenantID, "login_lockout", accountHash, current)
|
||||
}
|
||||
|
||||
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
HTTPIdempotencyProcessing = "processing"
|
||||
HTTPIdempotencyCompleted = "completed"
|
||||
|
||||
KafkaConsumeProcessing = "processing"
|
||||
KafkaConsumeSucceeded = "succeeded"
|
||||
KafkaConsumeFailed = "failed"
|
||||
@@ -22,21 +19,6 @@ const (
|
||||
KafkaDeadStatusResolved = "resolved"
|
||||
)
|
||||
|
||||
// HTTPIdempotencyRecord stores the first response for one authenticated write.
|
||||
type HTTPIdempotencyRecord struct {
|
||||
UserID Uid
|
||||
Method string
|
||||
Route string
|
||||
Key string
|
||||
BodyHash string
|
||||
State string
|
||||
ResponseStatus int
|
||||
ResponseBody []byte
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// KafkaConsumeRecord stores idempotent consumer state for one event and group.
|
||||
type KafkaConsumeRecord struct {
|
||||
EventID string
|
||||
|
||||
@@ -2,8 +2,8 @@ package types
|
||||
|
||||
import "time"
|
||||
|
||||
// LoginLockout is the per-account login failure counter and lock state
|
||||
// stored in each tenant business database (table im_login_lockouts).
|
||||
// LoginLockout is the per-account login failure counter and lock state stored
|
||||
// in Redis.
|
||||
type LoginLockout struct {
|
||||
TenantID TenantID `json:"tenant_id"`
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -461,18 +461,20 @@ func (s *testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(tenantID
|
||||
return count, s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
|
||||
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
|
||||
s.readTenant = tenantID
|
||||
s.readUser = uid
|
||||
s.readNotificationID = eventID
|
||||
for i := range s.notifications {
|
||||
if s.notifications[i].EventID == eventID {
|
||||
s.notifications[i].ReadAt = &readAt
|
||||
return s.err
|
||||
if s.notifications[i].ReadAt == nil {
|
||||
s.notifications[i].ReadAt = &readAt
|
||||
}
|
||||
return &s.notifications[i], s.err
|
||||
}
|
||||
}
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
return nil, s.err
|
||||
}
|
||||
return types.ErrNotFound
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user