feat: add im tenant db routing and chapter seven flows

This commit is contained in:
richjohnrambo
2026-08-13 11:30:48 +08:00
parent 774a448c12
commit 31f82d9c41
19 changed files with 5261 additions and 47 deletions
+16
View File
@@ -215,6 +215,12 @@ type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
@@ -281,6 +287,12 @@ type TenantBusinessAdapter interface {
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error)
TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error)
TenantFavoriteGet(t.TenantID, t.Uid, string) (*t.FavoriteItem, error)
TenantFavoriteDelete(t.TenantID, t.Uid, string) error
TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
@@ -291,6 +303,7 @@ type TenantBusinessAdapter interface {
TenantFileGet(t.TenantID, string) (*t.FileDef, error)
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error
TenantFileUserHasAccess(t.TenantID, string, t.Uid) (bool, error)
TenantPCacheGet(t.TenantID, string) (string, error)
TenantPCacheUpsert(t.TenantID, string, string, bool) error
@@ -326,8 +339,11 @@ type TenantBusinessAdapter interface {
TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error
TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error)
TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, error)
TenantFriendshipUpdateRemark(t.TenantID, t.Uid, t.Uid, string, time.Time) (*t.Friendship, error)
TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error
TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, error)
TenantUserBlockList(t.TenantID, t.Uid, string, int) ([]t.BlockListItem, string, error)
TenantUserBlockUpsert(t.TenantID, t.Uid, t.Uid, time.Time) (*t.UserBlock, error)
TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error
TenantUserSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.User, string, error)
}
+261 -24
View File
@@ -13,11 +13,13 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
ms "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/auth"
dbadapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
@@ -25,9 +27,10 @@ import (
// adapter holds MySQL connection data.
type adapter struct {
db *sqlx.DB
dsn string
dbName string
db *sqlx.DB
registryDB *sqlx.DB
dsn string
dbName string
// Maximum number of records to return
maxResults int
// Maximum number of message records to return
@@ -38,6 +41,12 @@ type adapter struct {
sqlTimeout time.Duration
// DB transaction timeout.
txTimeout time.Duration
baseConfig *configType
tenantDBRouting bool
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantMu sync.Mutex
}
const (
@@ -78,6 +87,11 @@ type configType struct {
// DB request timeout (in seconds).
// If 0 (or negative), no timeout is applied.
SqlTimeout int `json:"sql_timeout,omitempty"`
// TenantRegistryDB is the public backend database, e.g. hotgo.
TenantRegistryDB string `json:"tenant_registry_db,omitempty"`
// TenantDBRouting enables "one tenant, one database" routing.
TenantDBRouting bool `json:"tenant_db_routing,omitempty"`
}
func (a *adapter) getContext() (context.Context, context.CancelFunc) {
@@ -94,6 +108,44 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
return context.Background(), nil
}
func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) {
if strings.TrimSpace(dsn) != "" {
cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://"))
if err != nil {
return nil, "", err
}
if dbName != "" {
cfg.DBName = dbName
}
dsn = cfg.FormatDSN()
dbName = cfg.DBName
} else {
cfg := config.Config
if dbName != "" {
cfg.DBName = dbName
}
if cfg.DBName == "" {
cfg.DBName = defaultDatabase
}
dsn = cfg.FormatDSN()
dbName = cfg.DBName
}
db, err := sqlx.Open("mysql", dsn)
if err != nil {
return nil, "", err
}
if config.MaxOpenConns > 0 {
db.SetMaxOpenConns(config.MaxOpenConns)
}
if config.MaxIdleConns > 0 {
db.SetMaxIdleConns(config.MaxIdleConns)
}
if config.ConnMaxLifetime > 0 {
db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
}
return db, dbName, nil
}
// Open initializes database session
func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.db != nil {
@@ -139,6 +191,11 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.dbName == "" {
a.dbName = defaultDatabase
}
config.Config.DBName = a.dbName
a.baseConfig = &config
a.tenantDBRouting = config.TenantDBRouting
a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB)
a.tenantAdapters = make(map[t.TenantID]*adapter)
if a.maxResults <= 0 {
a.maxResults = defaultMaxResults
@@ -149,7 +206,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
}
// This just initializes the driver but does not open the network connection.
a.db, err = sqlx.Open("mysql", a.dsn)
a.db, a.dbName, err = openMySQLDB(config, a.dsn, a.dbName)
if err != nil {
return err
}
@@ -162,20 +219,20 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
err = nil
}
if err == nil {
if config.MaxOpenConns > 0 {
a.db.SetMaxOpenConns(config.MaxOpenConns)
}
if config.MaxIdleConns > 0 {
a.db.SetMaxIdleConns(config.MaxIdleConns)
}
if config.ConnMaxLifetime > 0 {
a.db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
}
if config.SqlTimeout > 0 {
a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second
// We allocate txTimeoutMultiplier times sqlTimeout for transactions.
a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second
}
if a.tenantRegistryDB != "" && a.tenantRegistryDB != a.dbName {
a.registryDB, _, err = openMySQLDB(config, a.dsn, a.tenantRegistryDB)
if err == nil {
err = a.registryDB.Ping()
}
if err != nil {
return errors.New("mysql adapter failed to open tenant registry database: " + err.Error())
}
}
}
return err
}
@@ -183,8 +240,26 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
// Close closes the underlying database connection
func (a *adapter) Close() error {
var err error
a.tenantMu.Lock()
for tenantID, tenantAdp := range a.tenantAdapters {
if tenantAdp != nil && tenantAdp.db != nil {
if closeErr := tenantAdp.db.Close(); err == nil {
err = closeErr
}
}
delete(a.tenantAdapters, tenantID)
}
a.tenantMu.Unlock()
if a.registryDB != nil {
if closeErr := a.registryDB.Close(); err == nil {
err = closeErr
}
a.registryDB = nil
}
if a.db != nil {
err = a.db.Close()
if closeErr := a.db.Close(); err == nil {
err = closeErr
}
a.db = nil
a.version = -1
}
@@ -285,18 +360,112 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) {
}
var tenant t.Tenant
err := a.db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
db := a.db
if a.registryDB != nil {
db = a.registryDB
}
err := db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE code=? LIMIT 1`, code)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if isUnknownColumn(err) {
err = db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE code=? LIMIT 1`, code)
}
if err != nil {
return nil, err
}
return &tenant, nil
}
func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) {
if !a.tenantDBRouting || tenantID == defaultTenantID {
return a, a, nil
}
a.tenantMu.Lock()
if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil {
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
}
a.tenantMu.Unlock()
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
dbName := strings.TrimSpace(tenant.DBName)
dsn := strings.TrimSpace(tenant.DBDSN)
if dbName == "" && dsn == "" {
return a, a, nil
}
if a.baseConfig == nil {
return nil, nil, t.ErrFailed
}
db, resolvedDBName, err := openMySQLDB(*a.baseConfig, mysqlDefaultString(dsn, a.dsn), dbName)
if err != nil {
return nil, nil, err
}
if err = db.Ping(); err != nil {
db.Close()
return nil, nil, err
}
tenantAdp := &adapter{
db: db,
dsn: mysqlDefaultString(dsn, a.dsn),
dbName: resolvedDBName,
maxResults: a.maxResults,
maxMessageResults: a.maxMessageResults,
version: a.version,
sqlTimeout: a.sqlTimeout,
txTimeout: a.txTimeout,
baseConfig: a.baseConfig,
}
a.tenantMu.Lock()
if existing := a.tenantAdapters[tenantID]; existing != nil {
a.tenantMu.Unlock()
db.Close()
return existing, existing, nil
}
a.tenantAdapters[tenantID] = tenantAdp
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
}
func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
db := a.db
if a.registryDB != nil {
db = a.registryDB
}
var tenant t.Tenant
err := db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if isUnknownColumn(err) {
err = db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
}
if err != nil {
return nil, err
}
if !tenant.IsActive() {
return nil, t.ErrPermissionDenied
}
return &tenant, nil
}
// CreateDb initializes the storage.
func (a *adapter) CreateDb(reset bool) error {
var err error
@@ -348,6 +517,8 @@ func (a *adapter) CreateDb(reset bool) error {
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
@@ -407,8 +578,8 @@ func (a *adapter) CreateDb(reset bool) error {
}
if _, err = tx.Exec(
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
return err
}
@@ -997,10 +1168,12 @@ func (a *adapter) UpgradeDb() error {
if _, err := a.db.Exec(
`CREATE TABLE im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
state SMALLINT NOT NULL,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -1030,8 +1203,8 @@ func (a *adapter) UpgradeDb() error {
}
if _, err := a.db.Exec(
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
return err
}
@@ -1162,6 +1335,10 @@ func (a *adapter) UpgradeDb() error {
}
}
if err := ensureTenantRoutingColumns(a); err != nil {
return err
}
if a.version != adpVersion {
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
". DB is still at " + strconv.Itoa(a.version))
@@ -1191,6 +1368,20 @@ func createIMNotificationTables(tx *sql.Tx) error {
return err
}
func ensureTenantRoutingColumns(a *adapter) error {
statements := []string{
"ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"UPDATE im_tenant SET db_name='tinode' WHERE code='default' AND db_name=''",
}
for _, stmt := range statements {
if _, err := a.db.Exec(stmt); err != nil && !isDuplicateColumn(err) {
return err
}
}
return nil
}
func upgradeIMMessageActions(a *adapter) error {
statements := []string{
"ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
@@ -1272,6 +1463,29 @@ func createIMRelationTables(exec sqlExecutor) error {
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id),
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`,
`CREATE TABLE im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`,
}
for _, stmt := range statements {
if _, err := exec.Exec(stmt); err != nil {
@@ -4608,6 +4822,29 @@ func isMissingDb(err error) bool {
return ok && myerr.Number == 1049
}
func isUnknownColumn(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1054
}
func isDuplicateColumn(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1060
}
func mysqlDefaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
// GetTestAdapter returns an adapter object. Useful for running tests.
func GetTestAdapter() *adapter {
return &adapter{}
+29 -2
View File
@@ -19,6 +19,8 @@ CREATE TABLE im_tenant(
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
@@ -72,8 +74,8 @@ CREATE TABLE im_verification_codes(
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,1,0,0);
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0);
CREATE TABLE kvmeta(
@@ -246,6 +248,31 @@ CREATE TABLE im_user_blocks(
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
CREATE TABLE im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
# Indexed user tags.
CREATE TABLE usertags(
id INT NOT NULL AUTO_INCREMENT,
+426
View File
@@ -1549,6 +1549,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
return msgs, rows.Err()
}
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"m.tenant_id=?", "m.topic=?", "m.delid=0"}
args := []any{id, topic}
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.seqid<?")
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
if queryText != "" {
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
if messageType != "" && messageType != "all" {
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.x-im-type'))=?)")
args = append(args, messageType, messageType)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
queryArgs := append([]any{decodeUID(uid)}, args...)
queryArgs = append(queryArgs, limit+1)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM messages AS m LEFT JOIN dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=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...)
if err != nil {
return nil, "", err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
nextCursor := ""
for rows.Next() {
var msg t.Message
if err = rows.StructScan(&msg); err != nil {
return nil, "", err
}
msg.From = common.EncodeUidString(msg.From).String()
msg.Head = kvMapFromJSON(msg.Head)
msg.Content = common.FromJSON(msg.Content)
if len(msgs) == limit {
nextCursor = strconv.Itoa(msg.SeqId)
continue
}
msgs = append(msgs, msg)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return msgs, nextCursor, nil
}
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) {
row := struct {
t.Message
@@ -1577,6 +1640,146 @@ func messageVersion(version int) int {
return version
}
func kvMapFromJSON(value any) t.KVMap {
switch parsed := common.FromJSON(value).(type) {
case map[string]any:
return t.KVMap(parsed)
case t.KVMap:
return parsed
default:
return nil
}
}
func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
if err := validTenant(id); err != nil || fav == nil || fav.TenantID != id || fav.UserID.IsZero() || fav.FavoriteID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_favorites(tenant_id,favorite_id,user_id,item_type,source_topic,source_seq,snapshot,attachment_refs,availability,idempotency_key,collected_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
id, fav.FavoriteID, decodeUID(fav.UserID), fav.ItemType, fav.SourceTopic, fav.SourceSeq, common.ToJSON(fav.Snapshot), common.ToJSON(fav.AttachmentRefs), fav.Availability, fav.IdempotencyKey, fav.CollectedAt, fav.CreatedAt, fav.UpdatedAt)
if err != nil {
return nil, err
}
return a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
}
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"tenant_id=?", "user_id=?"}
args := []any{id, decodeUID(uid)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
if itemType != "" && itemType != "all" {
where = append(where, "item_type=?")
args = append(args, itemType)
}
if queryText = strings.TrimSpace(queryText); queryText != "" {
where = append(where, "CAST(snapshot AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE "+strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FavoriteItem, 0, limit)
nextCursor := ""
for rows.Next() {
item, err := scanFavorite(rows)
if err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(item.ID, 10)
continue
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) (*t.FavoriteItem, error) {
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, t.ErrNotFound
}
item, err := scanFavorite(rows)
if err != nil {
return nil, err
}
return &item, nil
}
func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID string) error {
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "DELETE FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID)
if err != nil {
return err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return t.ErrNotFound
}
return nil
}
func scanFavorite(rows *sqlx.Rows) (t.FavoriteItem, error) {
row := struct {
t.FavoriteItem
UserID int64 `db:"user_id"`
}{}
if err := rows.StructScan(&row); err != nil {
return t.FavoriteItem{}, err
}
item := row.FavoriteItem
item.UserID = store.EncodeUid(row.UserID)
item.Snapshot = common.FromJSON(item.Snapshot)
item.AttachmentRefs = common.FromJSON(item.AttachmentRefs)
return item, nil
}
func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
@@ -1954,6 +2157,57 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID,
return tx.Commit()
}
func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) (bool, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return false, t.ErrMalformed
}
fileID := t.ParseUid(fid)
if fileID.IsZero() {
return false, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var allowed int
err := a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM fileuploads AS fu
WHERE fu.tenant_id=? AND fu.id=? AND fu.status=? AND (
fu.userid=?
OR EXISTS (
SELECT 1 FROM filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=?
)
OR EXISTS (
SELECT 1 FROM filemsglinks AS fml
INNER JOIN subscriptions AS s
ON s.tenant_id=fml.tenant_id AND s.topic=fml.topic AND s.userid=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM filemsglinks AS fml
INNER JOIN messages AS m
ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid
INNER JOIN subscriptions AS s
ON s.tenant_id=m.tenant_id AND s.topic=m.topic AND s.userid=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.msgid IS NOT NULL
AND m.deletedat IS NULL AND m.recalled_at IS NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
AND NOT EXISTS (
SELECT 1 FROM dellog AS d
WHERE d.tenant_id=m.tenant_id AND d.topic=m.topic AND d.deletedfor=?
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
)
)
) LIMIT 1`,
id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid))
if err != nil {
return false, err
}
return allowed > 0, nil
}
func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) {
if err := validTenant(id); err != nil {
return "", err
@@ -2998,6 +3252,26 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F
}, nil
}
func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) {
if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() || len([]rune(remark)) > 64 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
"UPDATE im_friendships SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?",
remark, now, id, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend)
if err != nil {
return nil, err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return nil, t.ErrNotFound
}
return a.TenantFriendshipGet(id, userID, peerID)
}
func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, now time.Time) error {
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return t.ErrMalformed
@@ -3044,6 +3318,79 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid)
}, nil
}
func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) {
if err := validTenant(id); err != nil || blockerID.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"b.tenant_id=?", "b.blocker_id=?"}
args := []any{id, decodeUID(blockerID)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "b.blocked_id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT b.*,u.public,u.trusted
FROM im_user_blocks AS b
LEFT JOIN users AS u ON u.tenant_id=b.tenant_id AND u.id=b.blocked_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY b.blocked_id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.BlockListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
TenantID t.TenantID `db:"tenant_id"`
BlockerID int64 `db:"blocker_id"`
BlockedID int64 `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.BlockedID, 10)
continue
}
blockedID := store.EncodeUid(row.BlockedID)
items = append(items, t.BlockListItem{
Block: t.UserBlock{
TenantID: row.TenantID,
BlockerID: store.EncodeUid(row.BlockerID),
BlockedID: blockedID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
User: &t.User{
ObjHeader: t.ObjHeader{Id: blockedID.String()},
TenantID: row.TenantID,
Public: common.FromJSON(row.Public),
Trusted: common.FromJSON(row.Trusted),
},
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Uid, now time.Time) (*t.UserBlock, error) {
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return nil, t.ErrMalformed
@@ -3075,6 +3422,85 @@ func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Ui
return err
}
func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searchType, cursor string, limit int) ([]t.User, string, error) {
if err := validTenant(id); err != nil || viewer.IsZero() {
return nil, "", t.ErrMalformed
}
queryText = strings.TrimSpace(queryText)
if queryText == "" {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 50 {
limit = 20
}
where := []string{"u.tenant_id=?", "u.id<>?", "u.state=?"}
args := []any{id, decodeUID(viewer), t.StateOK}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "u.id>?")
args = append(args, parsed)
}
join := ""
switch searchType {
case "", "account":
like := "%" + queryText + "%"
join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id"
where = append(where, "(ut.tag LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)")
args = append(args, like, like, userIDNumericArg(queryText))
case "exact_account":
join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id"
where = append(where, "(ut.tag=? OR u.id=?)")
args = append(args, queryText, userIDNumericArg(queryText))
case "phone":
where = append(where, `EXISTS (
SELECT 1 FROM credentials AS c
WHERE c.tenant_id=u.tenant_id AND c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true
)`)
args = append(args, queryText)
default:
return nil, "", t.ErrMalformed
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* FROM users AS u `+join+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
users := make([]t.User, 0, limit)
nextCursor := ""
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
return nil, "", err
}
scanTenantUser(&user)
if len(users) == limit {
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
continue
}
users = append(users, user)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return users, nextCursor, nil
}
func userIDNumericArg(value string) int64 {
if uid := t.ParseUserId(value); !uid.IsZero() {
return decodeUID(uid)
}
return 0
}
func (a *adapter) friendRequestByRequestID(ctx context.Context, tenantID t.TenantID, requestID string) (*t.FriendRequest, error) {
return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID)
}