feat: shard group messages by month
This commit is contained in:
+35
-41
@@ -9,7 +9,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -49,6 +48,7 @@ type adapter struct {
|
||||
tenantRegistryDB string
|
||||
tenantAdapters map[t.TenantID]*adapter
|
||||
tenantMu sync.Mutex
|
||||
groupShardWindow string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -375,7 +375,7 @@ func (a *adapter) CheckDbVersion() error {
|
||||
}
|
||||
|
||||
// Version returns adapter version.
|
||||
func (adapter) Version() int {
|
||||
func (*adapter) Version() int {
|
||||
return adpVersion
|
||||
}
|
||||
|
||||
@@ -1819,26 +1819,19 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
user.Phone = canonicalPhone
|
||||
phone = canonicalPhone
|
||||
}
|
||||
releaseAccountNoLock, err := lockUserAccountNoSequence(ctx, tx)
|
||||
accountNo, hasAccountNo, err := nextUserAccountNo(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseAccountNoLock()
|
||||
accountNo, err := nextUserAccountNo(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
insert := "INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)"
|
||||
args := []any{decoded_uid, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone,
|
||||
common.ToJSON(user.Public), common.ToJSON(user.Trusted)}
|
||||
if hasAccountNo {
|
||||
insert = "INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)"
|
||||
args = []any{decoded_uid, accountNo, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone,
|
||||
common.ToJSON(user.Public), common.ToJSON(user.Trusted)}
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO im_users(id,account_no,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
decoded_uid,
|
||||
accountNo,
|
||||
user.Username,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
user.State,
|
||||
user.Access,
|
||||
phone,
|
||||
common.ToJSON(user.Public),
|
||||
common.ToJSON(user.Trusted)); err != nil {
|
||||
if _, err = tx.Exec(insert, args...); err != nil {
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
@@ -1859,35 +1852,36 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func lockUserAccountNoSequence(ctx context.Context, tx *sqlx.Tx) (func(), error) {
|
||||
var locked sql.NullInt64
|
||||
if err := tx.QueryRowContext(ctx, "SELECT GET_LOCK(CONCAT(DATABASE(), ':im_users_account_no'), 10)").Scan(&locked); err != nil {
|
||||
if isPXCStrictModeNamedLockError(err) {
|
||||
return func() {}, nil
|
||||
}
|
||||
return nil, err
|
||||
// nextUserAccountNo allocates a tenant-local, human-facing account number.
|
||||
// The sequence row serializes concurrent registrations across server nodes.
|
||||
func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (uint32, bool, error) {
|
||||
var columns int
|
||||
if err := tx.GetContext(ctx, &columns, `SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='im_users' AND COLUMN_NAME='account_no'`); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !locked.Valid || locked.Int64 != 1 {
|
||||
return nil, fmt.Errorf("lock user account number sequence timeout")
|
||||
if columns == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return func() {
|
||||
_, _ = tx.ExecContext(ctx, "SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':im_users_account_no'))")
|
||||
}, nil
|
||||
}
|
||||
|
||||
func nextUserAccountNo(ctx context.Context, tx *sqlx.Tx) (int64, error) {
|
||||
var current sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx, "SELECT account_no FROM im_users ORDER BY account_no DESC LIMIT 1 FOR UPDATE").Scan(¤t)
|
||||
if err == sql.ErrNoRows {
|
||||
return 1000, nil
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
|
||||
SET next_value=LAST_INSERT_ID(next_value+1) WHERE id=1`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, true, err
|
||||
}
|
||||
if !current.Valid || current.Int64 < 999 {
|
||||
return 1000, nil
|
||||
if affected, affectedErr := result.RowsAffected(); affectedErr != nil {
|
||||
return 0, true, affectedErr
|
||||
} else if affected != 1 {
|
||||
return 0, true, errors.New("mysql: user account sequence is not initialized")
|
||||
}
|
||||
return current.Int64 + 1, nil
|
||||
var next uint64
|
||||
if err = tx.GetContext(ctx, &next, "SELECT LAST_INSERT_ID()"); err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
if next > uint64(^uint32(0)) {
|
||||
return 0, true, errors.New("mysql: user account number exhausted")
|
||||
}
|
||||
return uint32(next), true, nil
|
||||
}
|
||||
|
||||
// TenantAuthAddRecord adds the user's Basic authentication record.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//go:build mysql
|
||||
// +build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/tinode/chat/server/store"
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
const legacyGroupMessageTable = "im_group_messages"
|
||||
|
||||
func ensureGroupMessageShardInfrastructure(exec sqlExecutor) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS im_group_message_shards (
|
||||
shard_month CHAR(6) NOT NULL, table_name VARCHAR(64) NOT NULL,
|
||||
shard_start DATETIME(3) NOT NULL, shard_end DATETIME(3) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'prepared', row_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3), max_created_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(shard_month), UNIQUE INDEX uk_im_group_message_shards_table(table_name),
|
||||
INDEX idx_im_group_message_shards_status(status, shard_month)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
|
||||
topic VARCHAR(64) NOT NULL, shard_month CHAR(6) NOT NULL,
|
||||
min_seq BIGINT NOT NULL, max_seq BIGINT NOT NULL, message_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NOT NULL, max_created_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(topic, shard_month), INDEX idx_im_group_message_topic_shards_seq(topic, max_seq),
|
||||
INDEX idx_im_group_message_topic_shards_month(shard_month, topic)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_group_message_dedup (
|
||||
topic VARCHAR(64) NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64) NOT NULL,
|
||||
seqid BIGINT NOT NULL, shard_month CHAR(6) NOT NULL, message_id BIGINT,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', expires_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(topic, from_user, client_msg_id), INDEX idx_im_group_message_dedup_expires(expires_at),
|
||||
INDEX idx_im_group_message_dedup_topic_seq(topic, seqid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := exec.Exec(statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return ensureGroupMessageShardWindow(exec, time.Now().UTC())
|
||||
}
|
||||
|
||||
func ensureGroupMessageShardWindow(exec sqlExecutor, now time.Time) error {
|
||||
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, offset := range []int{-1, 0, 1, 2} {
|
||||
start := first.AddDate(0, offset, 0)
|
||||
status := "prepared"
|
||||
if offset == -1 {
|
||||
status = "sealing"
|
||||
} else if offset == 0 {
|
||||
status = "writable"
|
||||
}
|
||||
if err := ensureGroupMessageShard(exec, start, status); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := exec.Exec("UPDATE im_group_message_shards SET status='sealed' WHERE status='sealing' AND shard_end<?", first.AddDate(0, 0, -7))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) ensureCurrentGroupMessageShardWindow() error {
|
||||
month := time.Now().UTC().Format("200601")
|
||||
a.tenantMu.Lock()
|
||||
defer a.tenantMu.Unlock()
|
||||
if a.groupShardWindow == month {
|
||||
return nil
|
||||
}
|
||||
if err := ensureGroupMessageShardWindow(a.db, time.Now().UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
a.groupShardWindow = month
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureGroupMessageShard(exec sqlExecutor, start time.Time, status string) error {
|
||||
month := start.UTC().Format("200601")
|
||||
table, ok := groupMessageShardTable(month)
|
||||
if !ok {
|
||||
return fmt.Errorf("mysql: invalid group message shard month %q", month)
|
||||
}
|
||||
ddl := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
message_id BIGINT NOT NULL, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
|
||||
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
|
||||
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
|
||||
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY(message_id), UNIQUE INDEX uk_%s_group_seq(group_id, seqid),
|
||||
UNIQUE INDEX uk_%s_topic_seq(topic, seqid),
|
||||
UNIQUE INDEX uk_%s_sender_client(topic, from_user, client_msg_id),
|
||||
INDEX idx_%s_group_status_seq(group_id, status, seqid),
|
||||
INDEX idx_%s_topic_created(topic, created_at), INDEX idx_%s_topic_from_seq(topic, from_user, seqid),
|
||||
INDEX idx_%s_audit_created(audit_status, created_at), INDEX idx_%s_expires(expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
table, month, month, month, month, month, month, month, month)
|
||||
if _, err := exec.Exec(ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
end := start.AddDate(0, 1, 0)
|
||||
_, err := exec.Exec(`INSERT INTO im_group_message_shards
|
||||
(shard_month,table_name,shard_start,shard_end,status)
|
||||
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE table_name=VALUES(table_name),
|
||||
shard_start=VALUES(shard_start),shard_end=VALUES(shard_end),
|
||||
status=IF(status IN ('sealed','archived'),status,VALUES(status))`,
|
||||
month, table, start, end, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func groupMessageShardTable(month string) (string, bool) {
|
||||
if len(month) != 6 {
|
||||
return "", false
|
||||
}
|
||||
for i := range month {
|
||||
if month[i] < '0' || month[i] > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
parsed, err := time.Parse("200601", month)
|
||||
if err != nil || parsed.Format("200601") != month {
|
||||
return "", false
|
||||
}
|
||||
return "im_group_messages_" + month, true
|
||||
}
|
||||
|
||||
func groupMessageTablesForTopic(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string) ([]string, error) {
|
||||
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
|
||||
redisAvailable := err == nil
|
||||
if err != nil {
|
||||
cached = false
|
||||
}
|
||||
if !cached {
|
||||
routes = nil
|
||||
if err = sqlx.SelectContext(ctx, q, &routes,
|
||||
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
|
||||
FROM im_group_message_topic_shards WHERE topic=? ORDER BY shard_month DESC`, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if redisAvailable {
|
||||
for _, route := range routes {
|
||||
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := make([]string, 0, len(routes)+1)
|
||||
for _, route := range routes {
|
||||
if table, ok := groupMessageShardTable(route.Month); ok {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
}
|
||||
return append(tables, legacyGroupMessageTable), nil
|
||||
}
|
||||
|
||||
func groupMessageTablesForSeq(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64) ([]string, error) {
|
||||
routes, cached, err := store.GetGroupMessageShardRoutes(tenantID, topic)
|
||||
redisAvailable := err == nil
|
||||
if err != nil {
|
||||
cached = false
|
||||
}
|
||||
if !cached {
|
||||
routes = nil
|
||||
if err = sqlx.SelectContext(ctx, q, &routes,
|
||||
`SELECT shard_month AS month,min_seq,max_seq,message_count AS count
|
||||
FROM im_group_message_topic_shards WHERE topic=? AND min_seq<=? AND max_seq>=?
|
||||
ORDER BY shard_month DESC`, topic, seq, seq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if redisAvailable {
|
||||
for _, route := range routes {
|
||||
_ = store.PutGroupMessageShardRoute(tenantID, topic, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := make([]string, 0, 2)
|
||||
for _, route := range routes {
|
||||
if route.MinSeq <= seq && route.MaxSeq >= seq {
|
||||
if table, ok := groupMessageShardTable(route.Month); ok {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(tables, legacyGroupMessageTable), nil
|
||||
}
|
||||
|
||||
func groupMessageMonthFromTable(table string) string {
|
||||
const prefix = "im_group_messages_"
|
||||
if len(table) == len(prefix)+6 && table[:len(prefix)] == prefix {
|
||||
return table[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -167,5 +167,5 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ensureGroupMessageShardInfrastructure(exec)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
|
||||
if err := ensureIMGroupBusinessTables(exec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(exec.statements) != 13 {
|
||||
t.Fatalf("executed %d group schema statements, want 13", len(exec.statements))
|
||||
if len(exec.statements) != 25 {
|
||||
t.Fatalf("executed %d group schema statements, want 25", len(exec.statements))
|
||||
}
|
||||
for _, statement := range exec.statements {
|
||||
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
|
||||
@@ -46,4 +46,20 @@ 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)"} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("group shard schema is missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMessageShardTableRejectsUnsafeMonth(t *testing.T) {
|
||||
if table, ok := groupMessageShardTable("202609"); !ok || table != "im_group_messages_202609" {
|
||||
t.Fatalf("unexpected valid shard: table=%q ok=%v", table, ok)
|
||||
}
|
||||
for _, month := range []string{"", "20269", "202613", "2026_09", "202609;DROP TABLE im_users"} {
|
||||
if table, ok := groupMessageShardTable(month); ok || table != "" {
|
||||
t.Fatalf("unsafe shard month accepted: month=%q table=%q", month, table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+284
-118
@@ -270,6 +270,9 @@ func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt6
|
||||
|
||||
func normalizeGroupMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
|
||||
msg.TenantID = id
|
||||
if rawID, err := strconv.ParseInt(msg.Id, 10, 64); err == nil && rawID > 0 {
|
||||
msg.SetUid(store.EncodeUid(rawID))
|
||||
}
|
||||
msg.From = common.EncodeUidString(msg.From).String()
|
||||
if recalledBy.Valid {
|
||||
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
|
||||
@@ -1390,44 +1393,70 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupMessageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
|
||||
func groupMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDel *t.DelMessage) error {
|
||||
var err error
|
||||
ctx := context.Background()
|
||||
tables, err := groupMessageTablesForTopic(tx, ctx, tenantID, topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if toDel == nil {
|
||||
if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic)
|
||||
for _, table := range tables {
|
||||
if _, err = tx.Exec("DELETE FROM "+table+" WHERE topic=?", topic); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec("DELETE FROM im_group_message_topic_shards WHERE topic=?", topic); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec("DELETE FROM im_group_message_dedup WHERE topic=?", topic)
|
||||
return err
|
||||
}
|
||||
delRanges := toDel.SeqIdRanges
|
||||
if toDel.DeletedFor == "" {
|
||||
where := "m.topic=?"
|
||||
args := []any{topic}
|
||||
if len(delRanges) > 0 {
|
||||
rSQL, rArgs := common.RangesToSql(delRanges)
|
||||
where += " AND m.seqid " + rSQL
|
||||
args = append(args, rArgs...)
|
||||
}
|
||||
where += " AND m.deleted_at IS NULL"
|
||||
if newerThan := toDel.GetNewerThan(); newerThan != nil {
|
||||
where += " AND m.created_at>?"
|
||||
args = append(args, newerThan)
|
||||
}
|
||||
var seqIDs []int64
|
||||
if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil {
|
||||
return err
|
||||
seqIDs := make([]int64, 0)
|
||||
for _, table := range tables {
|
||||
where := "m.topic=?"
|
||||
args := []any{topic}
|
||||
if len(delRanges) > 0 {
|
||||
rSQL, rArgs := common.RangesToSql(delRanges)
|
||||
where += " AND m.seqid " + rSQL
|
||||
args = append(args, rArgs...)
|
||||
}
|
||||
where += " AND m.deleted_at IS NULL"
|
||||
if newerThan := toDel.GetNewerThan(); newerThan != nil {
|
||||
where += " AND m.created_at>?"
|
||||
args = append(args, newerThan)
|
||||
}
|
||||
var tableSeqIDs []int64
|
||||
if err = tx.Select(&tableSeqIDs, "SELECT seqid FROM "+table+" AS m WHERE "+where, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
seqIDs = append(seqIDs, tableSeqIDs...)
|
||||
}
|
||||
if len(seqIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
|
||||
delRanges = t.SliceToRanges(seqIDs)
|
||||
uniqueSeqIDs := seqIDs[:0]
|
||||
for _, seqID := range seqIDs {
|
||||
if len(uniqueSeqIDs) == 0 || uniqueSeqIDs[len(uniqueSeqIDs)-1] != seqID {
|
||||
uniqueSeqIDs = append(uniqueSeqIDs, seqID)
|
||||
}
|
||||
}
|
||||
delRanges = t.SliceToRanges(uniqueSeqIDs)
|
||||
rSQL, rArgs := common.RangesToSql(delRanges)
|
||||
where = "m.topic=? AND m.seqid " + rSQL
|
||||
args = append([]any{topic}, rArgs...)
|
||||
if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
|
||||
where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil {
|
||||
return err
|
||||
where := "m.topic=? AND m.seqid " + rSQL
|
||||
args := append([]any{topic}, rArgs...)
|
||||
now := t.TimeNow()
|
||||
for _, table := range tables {
|
||||
if _, err = tx.Exec("UPDATE "+table+" AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
|
||||
where, append([]any{now, now}, args...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
stmt, err := tx.Prepare("INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)")
|
||||
@@ -1481,7 +1510,7 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo
|
||||
return err
|
||||
}
|
||||
if t.GetTopicCat(topic) == t.TopicCatGrp {
|
||||
err = groupMessageDeleteList(tx, topic, nil)
|
||||
err = groupMessageDeleteList(tx, id, topic, nil)
|
||||
} else {
|
||||
err = tenantMessageDeleteList(tx, id, topic, nil)
|
||||
}
|
||||
@@ -2053,6 +2082,9 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantGroupMessageSave(id, msg)
|
||||
}
|
||||
if err = a.ensureCurrentGroupMessageShardWindow(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
@@ -2061,24 +2093,77 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.ShardMonth == "" {
|
||||
msg.ShardMonth = msg.CreatedAt.UTC().Format("200601")
|
||||
}
|
||||
table, ok := groupMessageShardTable(msg.ShardMonth)
|
||||
if !ok || msg.Uid().IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if msg.ShardMonth == time.Now().UTC().Format("200601") {
|
||||
if _, err = a.db.ExecContext(ctx, "UPDATE im_group_message_shards SET status='writable' WHERE shard_month=? AND status='prepared'", msg.ShardMonth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var shardStatus string
|
||||
if err = a.db.QueryRowContext(ctx, "SELECT status FROM im_group_message_shards WHERE shard_month=? AND table_name=?", msg.ShardMonth, table).Scan(&shardStatus); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("mysql: group message shard %s is not registered", msg.ShardMonth)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if shardStatus != "writable" && shardStatus != "sealing" {
|
||||
return fmt.Errorf("mysql: group message shard %s is %s", msg.ShardMonth, shardStatus)
|
||||
}
|
||||
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
|
||||
contentJSON := common.ToJSON(msg.Content)
|
||||
res, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_group_messages
|
||||
(topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
messageID := store.DecodeUid(msg.Uid())
|
||||
if clientMsgID.Valid {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_dedup
|
||||
(topic,from_user,client_msg_id,seqid,shard_month,message_id,status,expires_at)
|
||||
VALUES(?,?,?,?,?,?,'done',?)`, 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
|
||||
(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)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, table)
|
||||
_, err = tx.ExecContext(ctx, query,
|
||||
messageID, msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
|
||||
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
|
||||
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt)
|
||||
if err == nil {
|
||||
lastID, _ := res.LastInsertId()
|
||||
msg.SetUid(t.Uid(lastID))
|
||||
}
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_message_topic_shards
|
||||
(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))`,
|
||||
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 shard_month=?`,
|
||||
msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, msg.CreatedAt, msg.ShardMonth); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
|
||||
@@ -2131,22 +2216,44 @@ func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, f
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
err = a.db.QueryRowxContext(ctx,
|
||||
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
|
||||
"FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?",
|
||||
topic, decodeUID(from), clientMsgID).StructScan(&row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
var seq int64
|
||||
if err = a.db.QueryRowContext(ctx, `SELECT seqid FROM im_group_message_dedup
|
||||
WHERE topic=? AND from_user=? AND client_msg_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
|
||||
}
|
||||
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
|
||||
return &msg, nil
|
||||
for _, table := range tables {
|
||||
month := groupMessageMonthFromTable(table)
|
||||
idColumn := "id"
|
||||
if month != "" {
|
||||
idColumn = "message_id"
|
||||
}
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,'%s' AS shard_month FROM %s WHERE topic=? AND from_user=? AND client_msg_id=?", idColumn, month, table)
|
||||
err = a.db.QueryRowxContext(ctx, query, topic, decodeUID(from), clientMsgID).StructScan(&row)
|
||||
if err == nil {
|
||||
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
|
||||
return &msg, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
|
||||
@@ -2270,7 +2377,7 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
|
||||
msg, messageTable, err := tenantGroupMessageGetOneWithTable(tx, ctx, id, topic, seq, " FOR UPDATE")
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
@@ -2280,8 +2387,8 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
|
||||
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
|
||||
return nil, t.ErrDuplicate
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?",
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(
|
||||
"UPDATE %s SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", messageTable),
|
||||
recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2375,7 +2482,7 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
|
||||
msg, messageTable, err := tenantGroupMessageGetOneWithTable(tx, ctx, id, topic, seq, " FOR UPDATE")
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
@@ -2391,8 +2498,8 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
|
||||
return nil, err
|
||||
}
|
||||
contentJSON := common.ToJSON(content)
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?",
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(
|
||||
"UPDATE %s SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?", messageTable),
|
||||
editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2483,58 +2590,84 @@ func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Ui
|
||||
return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts)
|
||||
}
|
||||
limit := a.maxMessageResults
|
||||
args := []any{store.DecodeUid(uid), topic}
|
||||
seq := ""
|
||||
seqClause := ""
|
||||
seqArgs := make([]any, 0)
|
||||
if opts != nil {
|
||||
seq = "AND m.seqid "
|
||||
seqClause = "AND m.seqid "
|
||||
if len(opts.IdRanges) > 0 {
|
||||
constr, addArgs := common.RangesToSql(opts.IdRanges)
|
||||
seq += constr
|
||||
args = append(args, addArgs...)
|
||||
seqClause += constr
|
||||
seqArgs = append(seqArgs, addArgs...)
|
||||
} else {
|
||||
seq += "BETWEEN ? AND ?"
|
||||
seqClause += "BETWEEN ? AND ?"
|
||||
if opts.Since > 0 {
|
||||
args = append(args, opts.Since)
|
||||
seqArgs = append(seqArgs, opts.Since)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
seqArgs = append(seqArgs, 0)
|
||||
}
|
||||
if opts.Before > 1 {
|
||||
args = append(args, opts.Before-1)
|
||||
seqArgs = append(seqArgs, opts.Before-1)
|
||||
} else {
|
||||
args = append(args, 1<<31-1)
|
||||
seqArgs = append(seqArgs, int64(^uint64(0)>>1))
|
||||
}
|
||||
}
|
||||
if opts.Limit > 0 && opts.Limit < limit {
|
||||
limit = opts.Limit
|
||||
}
|
||||
}
|
||||
args = append(args, limit)
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx,
|
||||
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
|
||||
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
|
||||
"ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
|
||||
"WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
|
||||
args...)
|
||||
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
msgs := make([]t.Message, 0, limit)
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
if err = rows.StructScan(&row); err != nil {
|
||||
seen := make(map[int64]struct{}, limit)
|
||||
for _, table := range tables {
|
||||
if len(msgs) >= limit {
|
||||
break
|
||||
}
|
||||
month := groupMessageMonthFromTable(table)
|
||||
idColumn := "id"
|
||||
if month != "" {
|
||||
idColumn = "message_id"
|
||||
}
|
||||
args := []any{store.DecodeUid(uid), topic}
|
||||
args = append(args, seqArgs...)
|
||||
args = append(args, limit-len(msgs))
|
||||
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.status!='deleted' AND m.topic=? %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, seqClause)
|
||||
rows, queryErr := a.db.QueryxContext(ctx, query, args...)
|
||||
if queryErr != nil {
|
||||
return nil, queryErr
|
||||
}
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
if err = rows.StructScan(&row); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := seen[row.SeqId]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.SeqId] = struct{}{}
|
||||
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
|
||||
rows.Close()
|
||||
}
|
||||
return msgs, rows.Err()
|
||||
sort.Slice(msgs, func(i, j int) bool { return msgs[i].SeqId > msgs[j].SeqId })
|
||||
if len(msgs) > limit {
|
||||
msgs = msgs[:limit]
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
|
||||
@@ -2619,7 +2752,7 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
||||
where := []string{"m.status!='deleted'", "m.topic=?"}
|
||||
args := []any{topic}
|
||||
if cursor != "" {
|
||||
parsed, err := strconv.Atoi(cursor)
|
||||
parsed, err := strconv.ParseInt(cursor, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
@@ -2640,39 +2773,55 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
|
||||
queryArgs = append(queryArgs, limit+1)
|
||||
rows, err := a.db.QueryxContext(ctx,
|
||||
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
|
||||
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
|
||||
"ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
|
||||
"WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
|
||||
queryArgs...)
|
||||
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
msgs := make([]t.Message, 0, limit)
|
||||
nextCursor := ""
|
||||
lastSeq := int64(0)
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
if err = rows.StructScan(&row); err != nil {
|
||||
msgs := make([]t.Message, 0, limit+1)
|
||||
seen := make(map[int64]struct{}, limit+1)
|
||||
for _, table := range tables {
|
||||
if len(msgs) > limit {
|
||||
break
|
||||
}
|
||||
month := groupMessageMonthFromTable(table)
|
||||
idColumn := "id"
|
||||
if month != "" {
|
||||
idColumn = "message_id"
|
||||
}
|
||||
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
|
||||
queryArgs = append(queryArgs, limit+1-len(msgs))
|
||||
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
|
||||
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
|
||||
if queryErr != nil {
|
||||
return nil, "", queryErr
|
||||
}
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
if err = rows.StructScan(&row); err != nil {
|
||||
rows.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
if _, exists := seen[row.SeqId]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.SeqId] = struct{}{}
|
||||
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
|
||||
if len(msgs) == limit {
|
||||
nextCursor = strconv.FormatInt(lastSeq, 10)
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
lastSeq = msg.SeqId
|
||||
rows.Close()
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
sort.Slice(msgs, func(i, j int) bool { return msgs[i].SeqId > msgs[j].SeqId })
|
||||
nextCursor := ""
|
||||
if len(msgs) > limit {
|
||||
msgs = msgs[:limit]
|
||||
lastSeq := msgs[len(msgs)-1].SeqId
|
||||
nextCursor = strconv.FormatInt(lastSeq, 10)
|
||||
}
|
||||
return msgs, nextCursor, nil
|
||||
}
|
||||
@@ -7101,19 +7250,36 @@ func (a *adapter) groupIDByTopic(ctx context.Context, topic string) (int64, erro
|
||||
}
|
||||
|
||||
func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
err := sqlx.GetContext(ctx, q, &row,
|
||||
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
|
||||
"FROM im_group_messages WHERE topic=? AND seqid=?"+suffix,
|
||||
topic, seq)
|
||||
msg, _, err := tenantGroupMessageGetOneWithTable(q, ctx, tenantID, topic, seq, suffix)
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func tenantGroupMessageGetOneWithTable(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, string, error) {
|
||||
tables, err := groupMessageTablesForSeq(q, ctx, tenantID, topic, seq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
|
||||
return &msg, nil
|
||||
for _, table := range tables {
|
||||
month := groupMessageMonthFromTable(table)
|
||||
idColumn := "id"
|
||||
if month != "" {
|
||||
idColumn = "message_id"
|
||||
}
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,'%s' AS shard_month FROM %s WHERE topic=? AND seqid=?%s", idColumn, month, table, suffix)
|
||||
err = sqlx.GetContext(ctx, q, &row, query, topic, seq)
|
||||
if err == nil {
|
||||
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
|
||||
return &msg, table, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
return nil, "", sql.ErrNoRows
|
||||
}
|
||||
|
||||
func messageVersion(version int) int {
|
||||
@@ -7594,7 +7760,7 @@ func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if err = groupMessageDeleteList(tx, topic, del); err != nil {
|
||||
if err = groupMessageDeleteList(tx, id, topic, del); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
|
||||
@@ -98,7 +98,7 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
sender, beforeStatus, err := lockAdminMessage(ctx, tx, action.ConversationType, action.Topic, action.SeqID)
|
||||
sender, beforeStatus, messageTable, err := lockAdminMessage(ctx, tx, id, action.ConversationType, action.Topic, action.SeqID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
|
||||
afterStatus = "deleted"
|
||||
}
|
||||
if action.ConversationType == "group" {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE im_group_messages SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
|
||||
_, err = tx.ExecContext(ctx, "UPDATE "+messageTable+" SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE im_messages SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
|
||||
}
|
||||
@@ -127,17 +127,26 @@ func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminA
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic string, seqID int64) (int64, string, error) {
|
||||
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID, conversationType, topic string, seqID int64) (int64, string, string, error) {
|
||||
if conversationType == "group" {
|
||||
tables, err := groupMessageTablesForSeq(tx, ctx, tenantID, topic, seqID)
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
var row struct {
|
||||
Sender int64 `db:"from_user"`
|
||||
Status string `db:"status"`
|
||||
}
|
||||
err := tx.GetContext(ctx, &row, "SELECT from_user,status FROM im_group_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, "", t.ErrNotFound
|
||||
for _, table := range tables {
|
||||
err = tx.GetContext(ctx, &row, "SELECT from_user,status FROM "+table+" WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
|
||||
if err == nil {
|
||||
return row.Sender, row.Status, table, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, "", "", err
|
||||
}
|
||||
}
|
||||
return row.Sender, row.Status, err
|
||||
return 0, "", "", t.ErrNotFound
|
||||
}
|
||||
var row struct {
|
||||
Sender int64 `db:"from"`
|
||||
@@ -146,7 +155,7 @@ func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic
|
||||
}
|
||||
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM im_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, "", t.ErrNotFound
|
||||
return 0, "", "", t.ErrNotFound
|
||||
}
|
||||
status := "normal"
|
||||
if row.RecalledAt.Valid {
|
||||
@@ -155,7 +164,7 @@ func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic
|
||||
if row.DeletedAt.Valid {
|
||||
status = "deleted"
|
||||
}
|
||||
return row.Sender, status, err
|
||||
return row.Sender, status, "im_messages", err
|
||||
}
|
||||
|
||||
func nullableJSON(raw []byte) any {
|
||||
|
||||
@@ -150,6 +150,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ensureUserAccountSequence(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -171,6 +174,23 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureUserAccountSequence(ctx context.Context, conn *sqlx.Conn) error {
|
||||
exists, err := identityColumnExists(ctx, conn, "im_users", "account_no")
|
||||
if err != nil || !exists {
|
||||
return err
|
||||
}
|
||||
if _, err = conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS im_user_account_sequences(
|
||||
id TINYINT UNSIGNED NOT NULL,next_value INT UNSIGNED NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, `INSERT INTO im_user_account_sequences(id,next_value)
|
||||
SELECT 1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users
|
||||
ON DUPLICATE KEY UPDATE next_value=GREATEST(next_value,VALUES(next_value))`)
|
||||
return err
|
||||
}
|
||||
|
||||
func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
|
||||
prefix := "0 AS tenant_id,u.id AS user_id,"
|
||||
join := "a.userid=u.id"
|
||||
|
||||
Reference in New Issue
Block a user