Files
im_server/server/db/mysql/tenant_adapter.go
T

7393 lines
235 KiB
Go

//go:build mysql
// +build mysql
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (*adapter) TenantBusinessReady() bool { return true }
func (a *adapter) routedTenantAdapter(id t.TenantID) (*adapter, error) {
if err := validTenant(id); err != nil {
return nil, err
}
if !a.tenantDBRouting {
return a, nil
}
business, _, err := a.TenantAdapterFor(id)
if err != nil {
return nil, err
}
tenantAdp, ok := business.(*adapter)
if !ok {
return nil, t.ErrUnsupported
}
return tenantAdp, nil
}
func stampTenantUser(id t.TenantID, user *t.User) *t.User {
if user != nil {
user.TenantID = id
}
return user
}
type profileUserRow struct {
TenantID t.TenantID `db:"tenant_id"`
ID string `db:"id"`
Username string
CreatedAt time.Time `db:"createdat"`
UpdatedAt time.Time `db:"updatedat"`
State t.ObjState `db:"state"`
StateAt *time.Time `db:"stateat"`
Access t.DefaultAccess
Phone string `db:"phone"`
LastSeen sql.NullTime `db:"lastseen"`
UserAgent string `db:"useragent"`
Trusted any `db:"trusted"`
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileAvatarURL sql.NullString `db:"profile_avatar_url"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
FriendshipRemark sql.NullString `db:"friendship_remark"`
}
func (row profileUserRow) effectiveUpdatedAt() time.Time {
if row.ProfileUpdatedAt.Valid {
return common.SelectLatestTime(row.UpdatedAt, row.ProfileUpdatedAt.Time)
}
return row.UpdatedAt
}
func (row profileUserRow) lastSeen() *time.Time {
if row.LastSeen.Valid {
return &row.LastSeen.Time
}
return nil
}
func (row profileUserRow) profilePublic() any {
return profilePublicFromFieldsWithAvatarURL(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileAvatarURL, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
}
func profileUserFromRow(row profileUserRow) t.User {
user := t.User{
ObjHeader: t.ObjHeader{
Id: row.ID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.effectiveUpdatedAt(),
},
TenantID: row.TenantID,
State: row.State,
StateAt: row.StateAt,
Username: row.Username,
Access: row.Access,
LastSeen: row.lastSeen(),
UserAgent: row.UserAgent,
Phone: row.Phone,
Public: row.profilePublic(),
Trusted: common.FromJSON(row.Trusted),
}
user.SetUid(common.EncodeUidString(row.ID))
return user
}
func profilePublicFromFields(nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any {
return profilePublicFromFieldsWithAvatarURL(nickname, avatarFileID, sql.NullString{}, signature, gender, region, publicAccount)
}
func profilePublicFromFieldsWithAvatarURL(nickname, avatarFileID, avatarURL, signature, gender, region, publicAccount sql.NullString) any {
public := map[string]any{}
if nickname.Valid {
value := strings.TrimSpace(nickname.String)
if value != "" {
for _, key := range []string{"display_name", "nickname", "fn", "name"} {
public[key] = value
}
}
}
if avatarFileID.Valid {
value := strings.TrimSpace(avatarFileID.String)
if value != "" {
public["avatar_file_id"] = value
if url := profileAvatarURL(value, avatarURL); url != "" {
public["avatar_url"] = url
}
}
}
if signature.Valid {
if value := strings.TrimSpace(signature.String); value != "" {
public["signature"] = value
}
}
if gender.Valid {
if value := strings.TrimSpace(gender.String); value != "" {
public["gender"] = value
}
}
if region.Valid {
if value := strings.TrimSpace(region.String); value != "" {
public["region"] = value
}
}
if publicAccount.Valid {
if value := strings.TrimSpace(publicAccount.String); value != "" {
public["public_account"] = value
}
}
return public
}
func ensureP2PPrivatePeer(private any, peerID string) any {
return ensureP2PPrivatePeerRemark(private, peerID, "")
}
func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any {
if strings.TrimSpace(peerID) == "" {
return private
}
data, _ := private.(map[string]any)
if data == nil {
data = map[string]any{}
}
if _, ok := data["peer"]; !ok {
data["peer"] = peerID
}
if remark = strings.TrimSpace(remark); remark != "" {
data["remark"] = remark
}
return data
}
func ensurePrivateMutationVersion(private any, version int64) any {
data, _ := private.(map[string]any)
if data == nil {
data = map[string]any{}
}
data["mutation_version"] = version
return data
}
func sanitizeUserUpdate(update map[string]any) map[string]any {
update = withoutUserTags(update)
delete(update, "Public")
delete(update, "public")
return update
}
func profileAvatarURL(avatarFileID string, avatarURL sql.NullString) string {
if avatarURL.Valid {
if value := strings.TrimSpace(avatarURL.String); value != "" {
return value
}
}
value := strings.TrimSpace(avatarFileID)
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") ||
strings.HasPrefix(value, "data:") || strings.HasPrefix(value, "/") {
return value
}
return ""
}
func profileSeedFromPublic(public any) (nickname, avatarFileID string) {
values := publicMap(public)
for _, key := range []string{"display_name", "nickname", "fn", "name"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
nickname = value
break
}
}
for _, key := range []string{"avatar_file_id"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
avatarFileID = value
break
}
}
return nickname, avatarFileID
}
func publicMap(value any) map[string]any {
switch public := value.(type) {
case map[string]any:
out := make(map[string]any, len(public))
for key, val := range public {
out[key] = val
}
return out
case map[string]string:
out := make(map[string]any, len(public))
for key, val := range public {
out[key] = val
}
return out
default:
return map[string]any{}
}
}
func stampTenantUsers(id t.TenantID, users []t.User) []t.User {
for i := range users {
users[i].TenantID = id
}
return users
}
func stampTenantTopic(id t.TenantID, topic *t.Topic) *t.Topic {
if topic != nil {
topic.TenantID = id
}
return topic
}
func stampTenantSubs(id t.TenantID, subs []t.Subscription) []t.Subscription {
for i := range subs {
subs[i].TenantID = id
}
return subs
}
func stampTenantMessages(id t.TenantID, msgs []t.Message) []t.Message {
for i := range msgs {
msgs[i].TenantID = id
}
return msgs
}
func stampTenantMessage(id t.TenantID, msg *t.Message) *t.Message {
if msg != nil {
msg.TenantID = id
}
return msg
}
func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
msg.TenantID = id
msg.From = common.EncodeUidString(msg.From).String()
if recalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return msg
}
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()
}
msg.Content = common.FromJSON(msg.Content)
return msg
}
func messageHeadType(head t.KVMap) string {
if head == nil {
return "text"
}
for _, key := range []string{"x-im-type", "type"} {
if value, ok := head[key].(string); ok {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
}
return "text"
}
func messageHeadJSON(head t.KVMap, key string) []byte {
if head == nil {
return nil
}
if value, ok := head[key]; ok {
return common.ToJSON(value)
}
return nil
}
func messageHeadBool(head t.KVMap, key string) bool {
if head == nil {
return false
}
switch value := head[key].(type) {
case bool:
return value
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
return false
}
}
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, tenantShardSQL(id, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE name=?"),
at, at, topic)
return err
}
_, 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
}
func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef {
if file != nil {
file.TenantID = id
}
return file
}
func validTenant(id t.TenantID) error {
if !id.IsValid() {
return t.ErrMalformed
}
return nil
}
func tenantCacheKey(id t.TenantID, key string) (string, error) {
if key == "" || strings.Contains(key, "%") {
return "", t.ErrMalformed
}
return "tenant:" + strconv.FormatInt(int64(id), 10) + ":" + key, nil
}
func decodeUID(uid t.Uid) int64 {
return store.DecodeUid(uid)
}
func encodeUserID(id int64) string {
return store.EncodeUid(id).String()
}
func scanTenantUser(user *t.User) {
user.SetUid(common.EncodeUidString(user.Id))
user.Public = common.FromJSON(user.Public)
user.Trusted = common.FromJSON(user.Trusted)
}
func scanTenantTopic(topic *t.Topic) {
topic.Owner = common.EncodeUidString(topic.Owner).String()
topic.Public = common.FromJSON(topic.Public)
topic.Trusted = common.FromJSON(topic.Trusted)
}
func (a *adapter) TenantUserCreate(id t.TenantID, user *t.User) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || user == nil || user.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUserCreate(id, user)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
decodedUID := decodeUID(user.Uid())
securityID, err := nextSnowflakeID()
if err != nil {
return err
}
profileID, err := nextSnowflakeID()
if err != nil {
return err
}
var phone any
if strings.TrimSpace(user.Phone) != "" {
canonicalPhone, normalizeErr := normalizeUserPhone(user.Phone)
if normalizeErr != nil {
return normalizeErr
}
user.Phone = canonicalPhone
phone = canonicalPhone
}
accountNo, hasAccountNo, err := nextTenantUserAccountNo(ctx, tx, id)
if err != nil {
return err
}
nickname, avatarFileID := profileSeedFromPublic(user.Public)
insert := "INSERT INTO im_users(tenant_id,id,username,createdat,updatedat,state,access,phone,trusted) VALUES(?,?,?,?,?,?,?,?,?)"
args := []any{id, decodedUID, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone, common.ToJSON(user.Trusted)}
if hasAccountNo {
insert = "INSERT INTO im_users(tenant_id,id,account_no,username,createdat,updatedat,state,access,phone,trusted) VALUES(?,?,?,?,?,?,?,?,?,?)"
args = []any{id, decodedUID, accountNo, user.Username, user.CreatedAt, user.UpdatedAt, user.State, user.Access, phone, common.ToJSON(user.Trusted)}
}
if _, err = tx.Exec(insert, args...); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
if _, err = tx.Exec("INSERT INTO im_user_security(tenant_id,id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?,?)",
id, securityID, decodedUID, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
if _, err = tx.Exec(`INSERT IGNORE INTO im_user_profiles(tenant_id,id,user_id,avatar_file_id,nickname,public_account,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, profileID, decodedUID, avatarFileID, nickname, user.Uid().UserId(), user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
user.Tags = nil
return tx.Commit()
}
func nextTenantUserAccountNo(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID) (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 columns == 0 {
return 0, false, nil
}
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO im_user_account_sequences(tenant_id,id,next_value)
SELECT ?,1,GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE tenant_id=? AND account_no>0`,
tenantID, tenantID); err != nil {
return 0, true, err
}
if _, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
SET next_value=GREATEST(next_value,(SELECT GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE tenant_id=? AND account_no>0))
WHERE tenant_id=? AND id=1`, tenantID, tenantID); err != nil {
return 0, true, err
}
result, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences
SET next_value=LAST_INSERT_ID(next_value+1) WHERE tenant_id=? AND id=1`, tenantID)
if err != nil {
return 0, true, err
}
if affected, affectedErr := result.RowsAffected(); affectedErr != nil {
return 0, true, affectedErr
} else if affected != 1 {
return 0, true, fmt.Errorf("mysql: user account sequence is not initialized for tenant %d", tenantID)
}
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, fmt.Errorf("mysql: user account number exhausted for tenant %d", tenantID)
}
return uint32(next), true, nil
}
func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUserGet(id, uid)
}
if a.tenantScopedDB {
user, err := a.UserGet(uid)
return stampTenantUser(id, user), err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row profileUserRow
err = a.db.GetContext(ctx, &row, `SELECT u.tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,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,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
WHERE u.tenant_id=? AND u.id=? AND u.state!=?`,
id, decodeUID(uid), t.StateDeleted)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user := profileUserFromRow(row)
return &user, nil
}
func (a *adapter) TenantUserGetByPhone(id t.TenantID, phone string) (t.Uid, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || phone == "" {
return t.ZeroUid, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUserGetByPhone(id, phone)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var decoded int64
err = a.db.GetContext(ctx, &decoded,
"SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", id, phone, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decoded), nil
}
func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserGetAll(id, uids...)
}
if a.tenantScopedDB {
users, err := a.UserGetAll(uids...)
return stampTenantUsers(id, users), err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.tenantUserGetAllTx(ctx, nil, id, uids...)
}
func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, uids ...t.Uid) ([]t.User, error) {
if len(uids) == 0 {
return nil, nil
}
args := make([]any, 0, len(uids))
for _, uid := range uids {
if !uid.IsZero() {
args = append(args, decodeUID(uid))
}
}
if len(args) == 0 {
return nil, nil
}
var q string
var params []any
if a.tenantScopedDB {
q, params, _ = sqlx.In(`SELECT u.id AS id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,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,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
WHERE u.tenant_id=? AND u.id IN (?) AND u.state!=?`, id, args, t.StateDeleted)
} else {
q, params, _ = sqlx.In(`SELECT u.tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,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,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
WHERE u.tenant_id=? AND u.id IN (?) AND u.state!=?`, id, args, t.StateDeleted)
}
query := a.db.Rebind(q)
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.QueryxContext(ctx, query, params...)
} else {
rows, err = a.db.QueryxContext(ctx, query, params...)
}
if err != nil {
return nil, err
}
defer rows.Close()
var users []t.User
for rows.Next() {
var row profileUserRow
if err = rows.StructScan(&row); err != nil {
return nil, err
}
users = append(users, profileUserFromRow(row))
}
return users, rows.Err()
}
func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUserDelete(id, uid, hard)
}
if a.tenantScopedDB {
return a.UserDelete(uid, hard)
}
decoded := decodeUID(uid)
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
var ownTopics []string
q := tenantShardSQL(id, "SELECT name FROM im_topics WHERE tenant_id=? AND owner=?")
args := []any{id, decoded}
if !hard {
q += " AND state!=?"
args = append(args, t.StateDeleted)
}
if err = tx.Select(&ownTopics, q, args...); err != nil {
return err
}
var ownAll []any
for _, name := range ownTopics {
ownAll = append(ownAll, name)
if chn := t.GrpToChn(name); chn != "" {
ownAll = append(ownAll, chn)
}
}
now := t.TimeNow()
if hard {
if _, err = tx.Exec("DELETE FROM im_devices WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return err
}
if err = tenantSubsDelForUser(tx, id, decoded, true); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND deletedfor=?", id, decoded); err != nil {
return err
}
if len(ownTopics) > 0 {
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(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(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(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(tenantShardSQL(id, "DELETE FROM im_topics WHERE tenant_id=? AND owner=?"), id, decoded); err != nil {
return err
}
}
if _, err = tx.Exec("DELETE FROM im_auth WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_credentials WHERE tenant_id=? AND userid=?", id, decoded); err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_users WHERE tenant_id=? AND id=?", id, decoded); err != nil {
return err
}
} else {
if err = tenantSubsDelForUser(tx, id, decoded, false); err != nil {
return err
}
if len(ownAll) > 0 {
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(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(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 {
return err
}
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 {
return err
}
if _, err = tx.Exec("UPDATE im_users SET updatedat=?,state=?,stateat=? WHERE tenant_id=? AND id=?",
now, t.StateDeleted, now, id, decoded); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) tenantTopicStateForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, now time.Time, state any) error {
objState, ok := state.(t.ObjState)
if !ok {
return t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
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(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)
return err
}
func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]any) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUserUpdate(id, uid, update)
}
if a.tenantScopedDB {
return a.UserUpdate(uid, update)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded := decodeUID(uid)
update = sanitizeUserUpdate(update)
cols, args := common.UpdateByMap(update)
if len(cols) == 0 {
return tx.Commit()
}
args = append(args, id, decoded)
res, err := tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
if state, ok := update["State"]; ok {
now, _ := update["StateAt"].(time.Time)
if err = a.tenantTopicStateForUser(tx, id, decoded, now, state); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, reset []string) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUserUpdateTags(id, uid, add, remove, reset)
}
if a.tenantScopedDB {
return a.UserUpdateTags(uid, add, remove, reset)
}
return nil, nil
}
func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserUnreadCount(id, uids...)
}
if a.tenantScopedDB {
return a.UserUnreadCount(uids...)
}
counts := make(map[t.Uid]int, len(uids))
var decoded []any
for _, uid := range uids {
counts[uid] = 0
if !uid.IsZero() {
decoded = append(decoded, decodeUID(uid))
}
}
if len(decoded) == 0 {
return counts, nil
}
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)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return counts, err
}
defer rows.Close()
for rows.Next() {
var uid int64
var unread int
if err = rows.Scan(&uid, &unread); err != nil {
return counts, err
}
counts[store.EncodeUid(uid)] = unread
}
return counts, rows.Err()
}
func (a *adapter) TenantUserGetUnvalidated(id t.TenantID, before time.Time, limit int) ([]t.Uid, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserGetUnvalidated(id, before, limit)
}
if a.tenantScopedDB {
return a.UserGetUnvalidated(before, limit)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT u.id,IFNULL(SUM(c.done),0) AS total FROM im_users AS u "+
"LEFT JOIN im_credentials AS c ON c.tenant_id=u.tenant_id AND u.id=c.userid "+
"WHERE u.tenant_id=? AND u.lastseen IS NULL AND u.updatedat<? "+
"GROUP BY u.id,u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?", id, before, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var uids []t.Uid
for rows.Next() {
var userID int64
var unused int
if err = rows.Scan(&userID, &unused); err != nil {
return nil, err
}
uids = append(uids, store.EncodeUid(userID))
}
return uids, rows.Err()
}
func tenantTopicCreate(tx *sqlx.Tx, topic *t.Topic) error {
if topic == nil || topic.TenantID.IsZero() {
return t.ErrMalformed
}
_, 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,
decodeUID(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
topic.Tags, common.ToJSON(topic.Aux))
if err != nil {
return err
}
return addTenantTags(tx, topic.TenantID, "im_topictags", "topic", topic.Id, topic.Tags, false)
}
// createTenantSubscription mirrors createSubscription but keeps the subscription
// bound to the logical tenant in shared-database deployments.
func createTenantSubscription(tx *sqlx.Tx, tenantID t.TenantID, sub *t.Subscription, undelete bool) error {
if sub == nil || tenantID.IsZero() || sub.TenantID != tenantID {
return t.ErrMalformed
}
isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
jpriv := common.ToJSON(sub.Private)
decodedUID := decodeUID(t.ParseUid(sub.User))
existing := struct {
ID int64 `db:"id"`
DeletedAt sql.NullTime `db:"deletedat"`
}{}
err := tx.Get(&existing,
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(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)
} else {
_, 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(tenantShardSQL(tenantID, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?"), decodedUID, tenantID, sub.Topic)
}
return err
}
subID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = tx.Exec(
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(
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(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)
} else {
_, 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)
}
}
if err == nil && isOwner {
_, err = tx.Exec(tenantShardSQL(tenantID, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?"), decodedUID, tenantID, sub.Topic)
}
return err
}
func (a *adapter) TenantTopicCreate(id t.TenantID, topic *t.Topic) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == nil || topic.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicCreate(id, topic)
}
if a.tenantScopedDB {
return a.TopicCreate(topic)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = tenantTopicCreate(tx, topic); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || one == nil || two == nil || one.TenantID != id || two.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicCreateP2P(id, one, two)
}
if a.tenantScopedDB {
return a.TopicCreateP2P(one, two)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = createTenantSubscription(tx, id, one, false); err != nil {
return err
}
if err = createTenantSubscription(tx, id, two, true); err != nil {
return err
}
topic := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: one.Topic}}
topic.ObjHeader.MergeTimes(&one.ObjHeader)
topic.TouchedAt = one.GetTouchedAt()
var existingTopicID int64
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
}
if err == sql.ErrNoRows {
if err = tenantTopicCreate(tx, topic); err != nil {
return err
}
} 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
}
return tx.Commit()
}
func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicGet(id, topic)
}
if a.tenantScopedDB {
top, err := a.TopicGet(topic)
return stampTenantTopic(id, top), err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
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 "+
tenantShardSQL(id, "FROM im_topics WHERE tenant_id=? AND name=?"), id, topic)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
var subCnt int
if err = a.db.GetContext(ctx, &subCnt,
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, tenantShardSQL(id, "UPDATE im_topics SET subcnt=? WHERE tenant_id=? AND name=?"), subCnt, id, topic); err != nil {
return nil, err
}
}
}
scanTenantTopic(&top)
return &top, nil
}
func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicsForUser(id, uid, keepDeleted, opts)
}
if a.tenantScopedDB {
subs, err := a.TopicsForUser(uid, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
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"
}
limit := 0
ims := time.Time{}
if opts != nil {
if opts.Topic != "" {
q += " AND topic=?"
args = append(args, opts.Topic)
}
if opts.IfModifiedSince == nil {
limit = a.maxResults
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
} else {
ims = *opts.IfModifiedSince
}
} else {
limit = a.maxResults
}
q += " ORDER BY topic ASC, deletedat IS NULL DESC, updatedat DESC, id DESC"
if limit > 0 {
q += " LIMIT ?"
args = append(args, limit)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, q, args...)
if err != nil {
return nil, err
}
join := map[string]t.Subscription{}
p2pResolved := map[string]bool{}
var topq, usrq []any
for rows.Next() {
var sub t.Subscription
if err = rows.StructScan(&sub); err != nil {
rows.Close()
return nil, err
}
tname := sub.Topic
sub.User = uid.String()
if !t.ParseUserId(tname).IsZero() {
continue
}
peerID := ""
switch t.GetTopicCat(tname) {
case t.TopicCatMe, t.TopicCatFnd:
continue
case t.TopicCatP2P:
uid1, uid2, _ := t.ParseP2P(tname)
p2pResolved[tname] = false
if uid1 == uid {
usrq = append(usrq, decodeUID(uid2))
peerID = uid2.UserId()
sub.SetWith(peerID)
} else {
usrq = append(usrq, decodeUID(uid1))
peerID = uid1.UserId()
sub.SetWith(peerID)
}
case t.TopicCatGrp:
tname = t.ChnToGrp(tname)
}
if _, exists := join[tname]; exists {
continue
}
topq = append(topq, tname)
sub.Private = common.FromJSON(sub.Private)
if t.GetTopicCat(sub.Topic) == t.TopicCatP2P {
sub.Private = ensureP2PPrivatePeer(sub.Private, peerID)
}
join[tname] = sub
}
err = rows.Err()
rows.Close()
if err != nil || len(join) == 0 {
return nil, err
}
if len(topq) > 0 {
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!=?"
args = append(args, t.StateDeleted)
}
if !ims.IsZero() {
q += " AND touchedat>?"
args = append(args, ims)
if limit > 0 && limit < len(topq) {
q += " ORDER BY touchedat LIMIT ?"
args = append(args, limit)
}
}
rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return nil, err
}
for rows.Next() {
var top t.Topic
if err = rows.StructScan(&top); err != nil {
rows.Close()
return nil, err
}
sub := join[top.Id]
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
sub.SetState(top.State)
sub.SetTouchedAt(top.TouchedAt)
sub.SetSeqId(top.SeqId)
sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion)
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
sub.SetSubCnt(top.SubCnt)
sub.SetPublic(common.FromJSON(top.Public))
sub.SetTrusted(common.FromJSON(top.Trusted))
}
join[top.Id] = sub
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
if len(usrq) > 0 {
q = fmt.Sprintf(`SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,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,p.updated_at AS profile_updated_at,
fs.remark AS friendship_remark
FROM im_users AS u
LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
LEFT JOIN %s AS fs ON fs.tenant_id=u.tenant_id AND fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
WHERE u.tenant_id=? AND u.id IN (?)`, tenantFriendshipsTable(id))
q, args, _ = sqlx.In(q, decodeUID(uid), t.RelationshipFriend, id, usrq)
if !keepDeleted {
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
}
rows, err = a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return nil, err
}
for rows.Next() {
var user profileUserRow
if err = rows.StructScan(&user); err != nil {
rows.Close()
return nil, err
}
joinOn := uid.P2PName(common.EncodeUidString(user.ID))
if sub, ok := join[joinOn]; ok {
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, user.effectiveUpdatedAt())
sub.SetState(user.State)
sub.SetPublic(user.profilePublic())
sub.SetTrusted(common.FromJSON(user.Trusted))
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(user.ID).UserId(), user.FriendshipRemark.String)
join[joinOn] = sub
p2pResolved[joinOn] = true
}
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
subs := make([]t.Subscription, 0, len(join))
for _, sub := range join {
if t.GetTopicCat(sub.Topic) == t.TopicCatP2P && !p2pResolved[sub.Topic] {
continue
}
subs = append(subs, sub)
}
return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
}
func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantUsersForTopic(id, topic, keepDeleted, opts)
}
if a.tenantScopedDB {
subs, err := a.UsersForTopic(topic, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
tcat := t.GetTopicCat(topic)
q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.trusted,u.lastseen,u.useragent,s.private,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,pu.location AS profile_avatar_url,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
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
LEFT JOIN im_public_uploads AS pu ON pu.file_id=p.avatar_file_id
WHERE s.tenant_id=? AND s.topic=?`
q = tenantShardSQL(id, q)
args := []any{id, topic}
if !keepDeleted {
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
if tcat != t.TopicCatP2P {
q += " AND s.deletedat IS NULL"
}
}
limit := a.maxResults
var oneUser t.Uid
if opts != nil {
if !opts.User.IsZero() {
if tcat != t.TopicCatP2P {
q += " AND s.userid=?"
args = append(args, decodeUID(opts.User))
}
oneUser = opts.User
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
q += " LIMIT ?"
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
var userAgent string
for rows.Next() {
var sub t.Subscription
var lastSeen sql.NullTime
var trusted any
var profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileAvatarURL, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil {
return nil, err
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(profilePublicFromFieldsWithAvatarURL(profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub)
}
if err = rows.Err(); err != nil {
return nil, err
}
if tcat == t.TopicCatP2P && len(subs) > 0 {
if len(subs) == 1 {
subs[0].SetPublic(nil)
subs[0].SetTrusted(nil)
subs[0].SetLastSeenAndUA(nil, "")
} else {
pub := subs[0].GetPublic()
subs[0].SetPublic(subs[1].GetPublic())
subs[1].SetPublic(pub)
trusted := subs[0].GetTrusted()
subs[0].SetTrusted(subs[1].GetTrusted())
subs[1].SetTrusted(trusted)
lastSeen := subs[0].GetLastSeen()
ua := subs[0].GetUserAgent()
subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
subs[1].SetLastSeenAndUA(lastSeen, ua)
}
if !keepDeleted || !oneUser.IsZero() {
filtered := subs[:0]
for i := range subs {
if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
continue
}
filtered = append(filtered, subs[i])
}
subs = filtered
}
}
return subs, nil
}
func (a *adapter) tenantTopicNames(query string, args ...any) ([]string, error) {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err = rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, rows.Err()
}
func (a *adapter) TenantOwnTopics(id t.TenantID, uid t.Uid) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantOwnTopics(id, uid)
}
if a.tenantScopedDB {
return a.OwnTopics(uid)
}
return a.tenantTopicNames(tenantShardSQL(id, "SELECT name FROM im_topics WHERE tenant_id=? AND owner=? AND state!=?"),
id, decodeUID(uid), t.StateDeleted)
}
func (a *adapter) TenantChannelsForUser(id t.TenantID, uid t.Uid) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantChannelsForUser(id, uid)
}
if a.tenantScopedDB {
return a.ChannelsForUser(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 {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
for _, sub := range subs {
if sub == nil || sub.TenantID != id {
return t.ErrMalformed
}
}
if tenantAdp != a {
return tenantAdp.TenantTopicShare(id, topic, subs)
}
if a.tenantScopedDB {
return a.TopicShare(topic, subs)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
for _, sub := range subs {
if err = createTenantSubscription(tx, id, sub, true); err != nil {
return err
}
}
if topic != "" {
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
}
}
return tx.Commit()
}
func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDel *t.DelMessage) error {
messageTable := tenantMessagesTable(tenantID)
if toDel == nil {
if _, err := tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
return err
}
_, err := tx.Exec(fmt.Sprintf("DELETE FROM %s WHERE tenant_id=? AND topic=?", messageTable), tenantID, topic)
return err
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
where := "m.tenant_id=? AND m.topic=?"
args := []any{tenantID, topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deletedat IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.createdat>?"
args = append(args, newerThan)
}
var seqIDs []int64
if err := tx.Select(&seqIDs, fmt.Sprintf("SELECT seqid FROM %s AS m WHERE ", messageTable)+where, args...); err != nil {
return err
}
if len(seqIDs) == 0 {
return nil
}
sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] })
delRanges = t.SliceToRanges(seqIDs)
rSQL, rArgs := common.RangesToSql(delRanges)
where = "m.tenant_id=? AND m.topic=? AND m.seqid " + rSQL
args = append([]any{tenantID, topic}, rArgs...)
if _, err := tx.Exec(fmt.Sprintf("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN %s AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE ", messageTable)+where, args...); err != nil {
return err
}
if _, err := tx.Exec(fmt.Sprintf("UPDATE %s AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE ", messageTable)+
where, append([]any{t.TimeNow(), toDel.DelId}, args...)...); err != nil {
return err
}
}
stmt, err := tx.Prepare("INSERT INTO im_dellog(tenant_id,topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range delRanges {
if rng.Hi == 0 {
rng.Hi = rng.Low + 1
}
if _, err = stmt.Exec(tenantID, topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
return err
}
}
return nil
}
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 tenant_id=? AND topic=?", tenantID, topic); err != nil {
return err
}
for _, table := range tables {
if _, err = tx.Exec("DELETE FROM "+table+" WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil {
return err
}
}
return nil
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
seqIDs := make([]int64, 0)
for _, table := range tables {
where := "m.tenant_id=? AND m.topic=?"
args := []any{tenantID, 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] })
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.tenant_id=? AND m.topic=? AND m.seqid " + rSQL
args := append([]any{tenantID, 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(tenant_id,topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range delRanges {
if rng.Hi == 0 {
rng.Hi = rng.Low + 1
}
if _, err = stmt.Exec(tenantID, topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
return err
}
}
return nil
}
func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicDelete(id, topic, isChan, hard)
}
if a.tenantScopedDB {
return a.TopicDelete(topic, isChan, hard)
}
topics := []any{topic}
if isChan {
topics = append(topics, t.GrpToChn(topic))
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if hard {
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
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
err = groupMessageDeleteList(tx, id, topic, nil)
} else {
err = tenantMessageDeleteList(tx, id, topic, nil)
}
if err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
return err
}
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(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(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
}
}
return tx.Commit()
}
func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicUpdateOnMessage(id, topic, msg)
}
if a.tenantScopedDB {
return a.TopicUpdateOnMessage(topic, msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, 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
}
func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicUpdateSubCnt(id, topic)
}
if a.tenantScopedDB {
return a.TopicUpdateSubCnt(topic)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
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
}
func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[string]any) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicUpdate(id, topic, update)
}
if a.tenantScopedDB {
return a.TopicUpdate(topic, update)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if update["TouchedAt"] == nil && update["UpdatedAt"] != nil {
update["TouchedAt"] = update["UpdatedAt"]
}
cols, args := common.UpdateByMap(update)
args = append(args, id, topic)
res, err := tx.Exec(tenantShardSQL(id, "UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?"), args...)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
if tags := common.ExtractTags(update); tags != nil {
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
return err
}
if err = addTenantTags(tx, id, "im_topictags", "topic", topic, tags, false); err != nil {
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantTopicBumpMutationVersion(id t.TenantID, topic string, at time.Time) (int64, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return 0, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicBumpMutationVersion(id, topic, at)
}
if at.IsZero() {
at = time.Now().UTC().Round(time.Millisecond)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, at); err != nil {
return 0, err
}
var version int64
query := "SELECT mutation_version FROM im_topics WHERE name=?"
args := []any{topic}
if !a.tenantScopedDB {
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
}
if err = tx.Commit(); err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantTopicOwnerChange(id, topic, owner)
}
if a.tenantScopedDB {
return a.TopicOwnerChange(topic, owner)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?"), decodeUID(owner), id, topic)
return err
}
func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid, keepDeleted bool) (*t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantSubscriptionGet(id, topic, uid, keepDeleted)
}
if a.tenantScopedDB {
sub, err := a.SubscriptionGet(topic, uid, keepDeleted)
if sub != nil {
sub.TenantID = id
}
return sub, err
}
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"
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var sub t.Subscription
err = a.db.GetContext(ctx, &sub, query, id, topic, decodeUID(uid))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
sub.User = uid.String()
sub.Private = common.FromJSON(sub.Private)
return &sub, nil
}
func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantSubsForUser(id, uid)
}
if a.tenantScopedDB {
subs, err := a.SubsForUser(uid)
return stampTenantSubs(id, subs), err
}
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
}
defer rows.Close()
var subs []t.Subscription
for rows.Next() {
var sub t.Subscription
if err = rows.StructScan(&sub); err != nil {
return nil, err
}
sub.User = uid.String()
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantSubsForTopic(id, topic, keepDeleted, opts)
}
if a.tenantScopedDB {
subs, err := a.SubsForTopic(topic, keepDeleted, opts)
return stampTenantSubs(id, subs), err
}
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"
}
limit := 0
if opts != nil {
limit = a.maxResults
if !opts.User.IsZero() {
q += " AND userid=?"
args = append(args, decodeUID(opts.User))
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
if limit > 0 {
q += " LIMIT ?"
args = append(args, limit)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
for rows.Next() {
var sub t.Subscription
if err = rows.StructScan(&sub); err != nil {
return nil, err
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, update map[string]any) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantSubsUpdate(id, topic, uid, update)
}
if a.tenantScopedDB {
return a.SubsUpdate(topic, uid, update)
}
cols, args := common.UpdateByMap(update)
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=?"
args = append(args, decodeUID(uid))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, q, args...)
return err
}
func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantSubsDelete(id, topic, uid)
}
if a.tenantScopedDB {
return a.SubsDelete(topic, uid)
}
ctx, cancel := a.getContextForTx()
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()
decoded := decodeUID(uid)
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
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
if !t.IsChannel(topic) {
if _, err = tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=? AND deletedfor=?", id, topic, decoded); err != nil {
return err
}
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
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
}
}
return tx.Commit()
}
func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, hard bool) error {
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
}
var topics []any
for rows.Next() {
var name string
if err = rows.Scan(&name); err != nil {
rows.Close()
return err
}
if t.IsChannel(name) {
name = t.ChnToGrp(name)
}
topics = append(topics, name)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if len(topics) > 0 {
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(tenantShardSQL(tenantID, "DELETE FROM im_subscriptions WHERE tenant_id=? AND userid=?"), tenantID, decodedUID)
} else {
now := t.TimeNow()
_, 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
}
func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFind(id, caller, promoPrefix, req, opt, activeOnly)
}
if a.tenantScopedDB {
subs, err := a.Find(caller, promoPrefix, req, opt, activeOnly)
return stampTenantSubs(id, subs), err
}
allReq := t.FlattenDoubleSlice(req)
allTags := append(append([]string{}, allReq...), opt...)
if len(allTags) == 0 {
return nil, nil
}
args := []any{id}
stateConstraint := ""
if activeOnly {
args = append(args, t.StateOK)
stateConstraint = "t.state=? AND "
}
index := make(map[string]struct{})
for _, tag := range allTags {
args = append(args, tag)
index[tag] = struct{}{}
}
matcher := "COUNT(*)"
if promoPrefix != "" {
matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)"
}
query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " +
"FROM im_topics AS t JOIN im_topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " +
"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
args = append(args, addArgs...)
}
query += "ORDER BY matches DESC, subcnt DESC LIMIT ?"
args = append(args, a.maxResults)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
for rows.Next() {
var sub t.Subscription
var public, trusted any
var access t.DefaultAccess
var subcnt, ignored int
var setTags t.StringSlice
var isChan bool
if err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,
&public, &trusted, &setTags, &ignored); err != nil {
return nil, err
}
if parsed, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {
sub.Topic = store.EncodeUid(parsed).UserId()
if sub.Topic == caller {
continue
}
}
if isChan {
sub.Topic = t.GrpToChn(sub.Topic)
}
sub.TenantID = id
sub.SetSubCnt(subcnt)
sub.SetPublic(common.FromJSON(public))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetDefaultAccess(access.Auth, access.Anon)
sub.ModeGiven = t.ModeUnset
sub.ModeWant = t.ModeUnset
sub.Private = common.FilterFoundTags(setTags, index)
subs = append(subs, sub)
}
return subs, rows.Err()
}
func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || tag == "" {
return "", t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantFindOne(id, tag)
}
if a.tenantScopedDB {
return a.FindOne(tag)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
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
}
defer rows.Close()
var found string
if rows.Next() {
if err = rows.Scan(&found); err != nil {
return "", err
}
if parsed, err := strconv.ParseInt(found, 10, 64); err == nil {
found = store.EncodeUid(parsed).UserId()
}
}
return found, rows.Err()
}
func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageSave(id, msg)
}
if a.tenantScopedDB {
return a.MessageSave(msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, err := a.db.ExecContext(ctx,
fmt.Sprintf("INSERT INTO %s(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", tenantMessagesTable(id)),
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
if isDupe(err) {
return t.ErrDuplicate
}
if err == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
return err
}
func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 {
return t.ErrMalformed
}
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()
}
groupID, err := a.groupIDByTopic(ctx, id, msg.Topic)
if err != nil {
return err
}
if msg.ShardMonth == "" {
msg.ShardMonth = msg.CreatedAt.UTC().Format("200601")
}
table, ok := groupMessageShardTable(id, 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 tenant_id=? AND shard_month=? AND status='prepared'", id, msg.ShardMonth); err != nil {
return err
}
}
var shardStatus string
if err = a.db.QueryRowContext(ctx, "SELECT status FROM im_group_message_shards WHERE tenant_id=? AND shard_month=? AND table_name=?", id, 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)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
messageID := store.DecodeUid(msg.Uid())
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)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, table)
_, err = tx.ExecContext(ctx, query,
id, 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, msg.ContentNonce, msg.KeyVersion)
if isDupe(err) {
return t.ErrDuplicate
}
if err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageGetByClientID(id, from, clientMsgID)
}
}
if err := validTenant(id); err != nil || from.IsZero() || clientMsgID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := a.db.QueryRowxContext(ctx,
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,content_nonce,key_version "+
fmt.Sprintf("FROM %s WHERE tenant_id=? AND `from`=? AND client_msg_id=?", tenantMessagesTable(id)),
id, decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
return &msg, nil
}
func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, from t.Uid, clientMsgID string) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetByClientID(id, topic, from, clientMsgID)
}
if topic == "" || from.IsZero() || clientMsgID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil {
return nil, err
}
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,content_nonce,key_version,'%s' AS shard_month FROM %s WHERE tenant_id=? AND topic=? AND from_user=? AND client_msg_id=?", idColumn, month, table)
err = a.db.QueryRowxContext(ctx, query, id, 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) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageGetOne(id, topic, seq)
}
}
if err := validTenant(id); err != nil || topic == "" || seq <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
msg, err := tenantMessageGetOne(a.db, ctx, id, topic, seq, "")
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return msg, err
}
func (a *adapter) TenantGroupMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetOne(id, topic, seq)
}
if topic == "" || seq <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
msg, err := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return msg, err
}
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt)
}
}
if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
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()
}
}()
msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, err = tx.ExecContext(ctx,
fmt.Sprintf("UPDATE %s SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", tenantMessagesTable(id)),
recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
msg.UpdatedAt = recalledAt
msg.Version++
msg.RecalledAt = &recalledAt
msg.RecalledBy = actor.String()
msg.Head = nil
msg.Content = nil
return msg, nil
}
func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
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()
}
}()
msg, messageTable, err := tenantGroupMessageGetOneWithTable(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, 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 tenant_id=? AND topic=? AND seqid=? AND version=?", messageTable),
recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
msg.UpdatedAt = recalledAt
msg.Version++
msg.RecalledAt = &recalledAt
msg.RecalledBy = actor.String()
msg.Head = nil
msg.Content = nil
return msg, nil
}
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
}
if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
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()
}
}()
msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,content_nonce,key_version,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion, strings.TrimSpace(reason)); err != nil {
return nil, err
}
storedContent, nonce, keyVersion := messageStoredContent(content)
_, err = tx.ExecContext(ctx,
fmt.Sprintf("UPDATE %s SET updatedat=?,version=version+1,head=?,content=?,content_nonce=?,key_version=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", tenantMessagesTable(id)),
editedAt, head, common.ToJSON(storedContent), nonce, keyVersion, id, topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
msg.UpdatedAt = editedAt
msg.Version++
msg.Head = head
msg.Content = storedContent
msg.ContentNonce = nonce
msg.KeyVersion = keyVersion
return msg, nil
}
func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
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()
}
}()
msg, messageTable, err := tenantGroupMessageGetOneWithTable(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,content_nonce,key_version,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion, strings.TrimSpace(reason)); err != nil {
return nil, err
}
storedContent, nonce, keyVersion := messageStoredContent(content)
contentJSON := common.ToJSON(storedContent)
_, err = tx.ExecContext(ctx, fmt.Sprintf(
"UPDATE %s SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_nonce=?,key_version=?,content_size=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", messageTable),
editedAt, messageHeadType(head), head, contentJSON, nonce, keyVersion, len(contentJSON), id, topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
msg.UpdatedAt = editedAt
msg.Version++
msg.Head = head
msg.Content = storedContent
msg.ContentNonce = nonce
msg.KeyVersion = keyVersion
return msg, nil
}
func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageGetAll(id, topic, uid, opts)
}
if a.tenantScopedDB {
msgs, err := a.MessageGetAll(topic, uid, opts)
return stampTenantMessages(id, msgs), err
}
limit := a.maxMessageResults
args := []any{id, topic, id, decodeUID(uid)}
seq := ""
if opts != nil {
seq = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seq += constr
args = append(args, addArgs...)
} else {
seq += "BETWEEN ? AND ?"
if opts.Since > 0 {
args = append(args, opts.Since)
} else {
args = append(args, 0)
}
if opts.Before > 1 {
args = append(args, opts.Before-1)
} else {
args = append(args, 1<<31-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.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,m.content_nonce,m.key_version "+
fmt.Sprintf("FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic ", tenantMessagesTable(id))+
"AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.delid=0 AND m.topic=? "+
seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
append([]any{decodeUID(uid), id, topic}, args[4:]...)...)
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 {
return nil, err
}
msgs = append(msgs, finalizeTenantMessage(id, row.Message, row.RecalledBy))
}
return msgs, rows.Err()
}
func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts)
}
limit := a.maxMessageResults
seqClause := ""
seqArgs := make([]any, 0)
if opts != nil {
seqClause = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seqClause += constr
seqArgs = append(seqArgs, addArgs...)
} else {
seqClause += "BETWEEN ? AND ?"
if opts.Since > 0 {
seqArgs = append(seqArgs, opts.Since)
} else {
seqArgs = append(seqArgs, 0)
}
if opts.Before > 1 {
seqArgs = append(seqArgs, opts.Before-1)
} else {
seqArgs = append(seqArgs, int64(^uint64(0)>>1))
}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil {
return nil, err
}
msgs := make([]t.Message, 0, limit)
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{id, store.DecodeUid(uid), id, 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,m.content_nonce,m.key_version,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=? AND d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND 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
}
rows.Close()
}
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) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantMessageSearch(id, topic, uid, queryText, messageType, cursor, limit)
}
}
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
return a.tenantGroupMessageSearch(id, topic, uid, queryText, messageType, cursor, limit)
}
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 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()
}
scanLimit := messageSearchScanLimit(limit, queryText)
queryArgs := append([]any{id, decodeUID(uid)}, args...)
queryArgs = append(queryArgs, scanLimit)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,m.content_nonce,m.key_version "+
fmt.Sprintf("FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=? AND d.topic=m.topic ", tenantMessagesTable(id))+
"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 := ""
lastSeq := int64(0)
scanned := 0
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
scanned++
lastSeq = msg.SeqId
if !messageMatchesSearch(msg, queryText) {
continue
}
if len(msgs) == limit {
lastSeq = msgs[len(msgs)-1].SeqId
nextCursor = strconv.FormatInt(lastSeq, 10)
break
}
msgs = append(msgs, msg)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
if nextCursor == "" && scanned == scanLimit && lastSeq > 0 {
nextCursor = strconv.FormatInt(lastSeq, 10)
}
return msgs, nextCursor, nil
}
func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
where := []string{"m.tenant_id=?", "m.status!='deleted'", "m.topic=?"}
args := []any{id, topic}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.seqid<?")
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
messageType = strings.TrimSpace(messageType)
if messageType != "" && messageType != "all" {
where = append(where, "m.msg_type=?")
args = append(args, messageType)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil {
return nil, "", err
}
scanLimit := messageSearchScanLimit(limit, queryText)
msgs := make([]t.Message, 0, scanLimit)
seen := make(map[int64]struct{}, scanLimit)
for _, table := range tables {
if len(seen) >= scanLimit {
break
}
month := groupMessageMonthFromTable(table)
idColumn := "id"
if month != "" {
idColumn = "message_id"
}
queryArgs := append([]any{id, store.DecodeUid(uid)}, args...)
queryArgs = append(queryArgs, scanLimit-len(seen))
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,m.content_nonce,m.key_version,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.tenant_id=? AND 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{}{}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
if messageMatchesSearch(msg, queryText) {
msgs = append(msgs, msg)
}
}
if err = rows.Err(); err != nil {
rows.Close()
return nil, "", err
}
rows.Close()
}
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)
} else if len(seen) >= scanLimit {
lastScanned := int64(0)
for seq := range seen {
if lastScanned == 0 || seq < lastScanned {
lastScanned = seq
}
}
if lastScanned > 0 {
nextCursor = strconv.FormatInt(lastScanned, 10)
}
}
return msgs, nextCursor, nil
}
func messageSearchScanLimit(limit int, queryText string) int {
if limit <= 0 || limit > 100 {
limit = 50
}
if strings.TrimSpace(queryText) == "" {
return limit + 1
}
scanLimit := limit * 20
if scanLimit < 200 {
scanLimit = 200
}
if scanLimit > 1000 {
scanLimit = 1000
}
return scanLimit
}
func messageMatchesSearch(msg t.Message, queryText string) bool {
if msg.KeyVersion > 0 {
// Store decrypts and filters encrypted messages after the adapter returns.
return true
}
queryText = normalizeMessageSearchText(queryText)
if queryText == "" {
return true
}
text := normalizeMessageSearchText(messageSearchText(msg.Content))
if strings.Contains(text, queryText) {
return true
}
terms := strings.Fields(queryText)
if len(terms) == 0 {
return true
}
for _, term := range terms {
if !strings.Contains(text, term) {
return false
}
}
return true
}
func messageSearchText(content any) string {
switch value := content.(type) {
case nil:
return ""
case string:
return value
case []byte:
var decoded any
if json.Unmarshal(value, &decoded) == nil {
return messageSearchText(decoded)
}
return string(value)
case map[string]any:
return messageSearchMapText(value)
case t.KVMap:
return messageSearchMapText(map[string]any(value))
default:
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprint(value)
}
var decoded any
if json.Unmarshal(raw, &decoded) == nil && fmt.Sprintf("%T", decoded) != fmt.Sprintf("%T", value) {
return messageSearchText(decoded)
}
return string(raw)
}
}
func messageSearchMapText(value map[string]any) string {
parts := make([]string, 0, len(value))
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "file_name", "filename", "url"} {
if text := strings.TrimSpace(fmt.Sprint(value[key])); text != "" && text != "<nil>" {
parts = append(parts, text)
}
}
for _, key := range []string{"file", "attachment", "snapshot", "forward", "link", "card"} {
if nested, ok := value[key]; ok {
if text := strings.TrimSpace(messageSearchText(nested)); text != "" {
parts = append(parts, text)
}
}
}
if len(parts) == 0 {
raw, _ := json.Marshal(value)
return string(raw)
}
return strings.Join(parts, " ")
}
func normalizeMessageSearchText(value string) string {
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
}
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", false, 0, err
}
if tenantAdp != a {
return tenantAdp.TenantMessageMutations(id, topic, uid, sinceVersion, limit)
}
}
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() || sinceVersion < 0 {
return nil, "", false, 0, t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var currentVersion int64
if a.tenantScopedDB {
if err := a.db.GetContext(ctx, &currentVersion, 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, &currentVersion, 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
}
return nil, "", false, 0, err
}
}
if sinceVersion >= currentVersion {
return []t.MessageMutation{}, strconv.FormatInt(currentVersion, 10), false, currentVersion, nil
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
return a.tenantGroupMessageMutations(ctx, id, topic, uid, limit, currentVersion)
}
return a.tenantP2PMessageMutations(ctx, id, topic, uid, limit, currentVersion)
}
func (a *adapter) tenantP2PMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
rows, err := a.db.QueryxContext(ctx,
"SELECT m.seqid,m.version,m.updatedat AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
fmt.Sprintf("FROM %s AS m ", tenantMessagesTable(id))+
"LEFT JOIN im_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=? "+
"LEFT JOIN im_message_revisions AS rev ON rev.tenant_id=m.tenant_id AND rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deletedat IS NOT NULL) "+
"ORDER BY m.updatedat DESC,m.seqid DESC LIMIT ?",
decodeUID(uid), id, topic, limit+1)
if err != nil {
return nil, "", false, 0, err
}
defer rows.Close()
return scanTenantMessageMutations(rows, limit, currentVersion)
}
func (a *adapter) tenantGroupMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
tables, err := groupMessageTablesForTopic(a.db, ctx, id, topic)
if err != nil || len(tables) == 0 {
return nil, "", false, 0, err
}
parts := make([]string, 0, len(tables))
args := make([]any, 0, len(tables)*3+1)
for _, table := range tables {
parts = append(parts, "SELECT m.seqid,m.version,m.updated_at AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
fmt.Sprintf("FROM %s AS m ", table)+
"LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"LEFT JOIN im_message_revisions AS rev ON rev.tenant_id=m.tenant_id AND rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
"WHERE m.tenant_id=? AND m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deleted_at IS NOT NULL OR m.status='recalled')")
args = append(args, decodeUID(uid), id, topic)
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM ("+strings.Join(parts, " UNION ALL ")+") AS mutations ORDER BY mutated_at DESC,seqid DESC LIMIT ?", args...)
if err != nil {
return nil, "", false, 0, err
}
defer rows.Close()
return scanTenantMessageMutations(rows, limit, currentVersion)
}
func scanTenantMessageMutations(rows *sqlx.Rows, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
items := make([]t.MessageMutation, 0, limit)
hasMore := false
for rows.Next() {
row := struct {
SeqId int64 `db:"seqid"`
Version int `db:"version"`
MutatedAt time.Time `db:"mutated_at"`
RecalledAt sql.NullTime `db:"recalled_at"`
RecalledBy sql.NullInt64 `db:"recalled_by"`
Actor sql.NullInt64 `db:"actor"`
}{}
if err := rows.StructScan(&row); err != nil {
return nil, "", false, 0, err
}
if len(items) == limit {
hasMore = true
continue
}
event := "message.edited"
operator := ""
if row.Actor.Valid {
operator = store.EncodeUid(row.Actor.Int64).String()
}
if row.RecalledAt.Valid {
event = "message.recalled"
if row.RecalledBy.Valid {
operator = store.EncodeUid(row.RecalledBy.Int64).String()
}
row.MutatedAt = row.RecalledAt.Time
}
items = append(items, t.MessageMutation{
SeqId: row.SeqId,
Version: row.Version,
Event: event,
Operator: operator,
MutatedAt: row.MutatedAt,
MutationVersion: currentVersion,
})
}
if err := rows.Err(); err != nil {
return nil, "", false, 0, err
}
return items, strconv.FormatInt(currentVersion, 10), hasMore, currentVersion, nil
}
func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantCallCreate(id, record)
}
}
if err := validTenant(id); err != nil || record == nil || record.TenantID != id || record.CallID == "" ||
record.Topic == "" || record.SeqID <= 0 || record.CallerID.IsZero() || record.CalleeID.IsZero() ||
record.CallType == "" {
return t.ErrMalformed
}
now := t.TimeNow()
if record.StartedAt.IsZero() {
record.StartedAt = now
}
record.CreatedAt = now
record.UpdatedAt = now
if record.Status == "" {
record.Status = t.CallStatusStarted
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_call_records
(call_id,topic,seq_id,caller_id,callee_id,call_type,status,end_reason,duration_ms,started_at,accepted_at,finished_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)`,
record.CallID, record.Topic, record.SeqID, decodeUID(record.CallerID), decodeUID(record.CalleeID),
record.CallType, record.Status, record.EndReason, record.DurationMS, record.StartedAt, record.AcceptedAt,
record.FinishedAt, record.CreatedAt, record.UpdatedAt)
return err
}
func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int64, acceptedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantCallAccept(id, callID, seq, acceptedAt)
}
}
if err := validTenant(id); err != nil || callID == "" || seq <= 0 || acceptedAt.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_call_records
SET status=?,accepted_at=?,updated_at=?
WHERE call_id=? AND seq_id=? AND status=?`,
t.CallStatusAccepted, acceptedAt, acceptedAt, callID, seq, t.CallStatusStarted)
return err
}
func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantCallFinish(id, callID, seq, status, durationMS, finishedAt)
}
}
if err := validTenant(id); err != nil || callID == "" || seq <= 0 || finishedAt.IsZero() || durationMS < 0 {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_call_records
SET status=?,end_reason=?,duration_ms=?,finished_at=?,updated_at=?
WHERE call_id=? AND seq_id=? AND finished_at IS NULL
AND NOT (status=? AND ? IN (?,?))`,
status, status, durationMS, finishedAt, finishedAt, callID, seq,
t.CallStatusAccepted, status, t.CallStatusMissed, t.CallStatusDeclined)
return err
}
func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string, limit int) ([]t.CallRecord, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantCallList(id, uid, topic, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"(caller_id=? OR callee_id=?)"}
args := []any{decodeUID(uid), decodeUID(uid)}
if topic != "" {
where = append(where, "topic=?")
args = append(args, topic)
}
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)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err := a.finalizeStaleCalls(ctx); err != nil {
return nil, "", err
}
rows, err := a.db.QueryxContext(ctx,
`SELECT id,call_id,topic,seq_id,caller_id,callee_id,call_type,status,end_reason,duration_ms,
started_at,accepted_at,finished_at,created_at,updated_at
FROM im_call_records WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`,
args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
records := make([]t.CallRecord, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
CallID string `db:"call_id"`
Topic string
SeqID int64 `db:"seq_id"`
CallerID int64 `db:"caller_id"`
CalleeID int64 `db:"callee_id"`
CallType string `db:"call_type"`
Status string
EndReason string `db:"end_reason"`
DurationMS int64 `db:"duration_ms"`
StartedAt time.Time `db:"started_at"`
AcceptedAt *time.Time `db:"accepted_at"`
FinishedAt *time.Time `db:"finished_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(records) == limit {
nextCursor = strconv.FormatInt(row.ID, 10)
continue
}
records = append(records, t.CallRecord{
ID: row.ID,
TenantID: id,
CallID: row.CallID,
Topic: row.Topic,
SeqID: row.SeqID,
CallerID: store.EncodeUid(row.CallerID),
CalleeID: store.EncodeUid(row.CalleeID),
CallType: row.CallType,
Status: row.Status,
EndReason: row.EndReason,
DurationMS: row.DurationMS,
StartedAt: row.StartedAt,
AcceptedAt: row.AcceptedAt,
FinishedAt: row.FinishedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return records, nextCursor, nil
}
func (a *adapter) finalizeStaleCalls(ctx context.Context) error {
now := t.TimeNow()
startedBefore := now.Add(-30 * time.Second)
acceptedBefore := now.Add(-12 * time.Hour)
if _, err := a.db.ExecContext(ctx, `UPDATE im_call_records
SET status=?,end_reason=?,duration_ms=0,finished_at=?,updated_at=?
WHERE status=? AND started_at<? AND finished_at IS NULL`,
t.CallStatusMissed, t.CallStatusMissed, now, now, t.CallStatusStarted, startedBefore); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `UPDATE im_call_records
SET status=?,end_reason=?,duration_ms=GREATEST(TIMESTAMPDIFF(MICROSECOND,accepted_at,?) DIV 1000,0),finished_at=?,updated_at=?
WHERE status=? AND accepted_at IS NOT NULL AND accepted_at<? AND finished_at IS NULL`,
t.CallStatusDisconnected, t.CallStatusDisconnected, now, now, now, t.CallStatusAccepted, acceptedBefore)
return err
}
func (a *adapter) TenantUserProfileGet(id t.TenantID, uid t.Uid) (*t.UserProfile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserProfileGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
profile, err := a.userProfileByUID(ctx, id, uid)
if err == nil {
return profile, nil
}
if err != sql.ErrNoRows {
return nil, err
}
if err := a.ensureUserProfile(ctx, id, uid); err != nil {
return nil, err
}
return a.userProfileByUID(ctx, id, uid)
}
func (a *adapter) ensureUserProfile(ctx context.Context, tenantID t.TenantID, uid t.Uid) error {
profileID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(tenant_id,id,user_id,public_account,friend_add_enabled,friend_add_policy_version) VALUES(?,?,?,?,?,?)`, tenantID, profileID, decodeUID(uid), uid.UserId(), true, 1); err != nil {
return err
}
return nil
}
func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.UserProfilePatch) (*t.UserProfile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserProfileUpdate(id, uid, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || patch.Version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err := a.ensureUserProfile(ctx, id, uid); err != nil {
return nil, err
}
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
args := make([]any, 0, 10)
if patch.AvatarFileID != nil {
sets = append(sets, "avatar_file_id=?")
args = append(args, strings.TrimSpace(*patch.AvatarFileID))
}
if patch.Nickname != nil {
sets = append(sets, "nickname=?")
args = append(args, strings.TrimSpace(*patch.Nickname))
}
if patch.BirthDate != nil {
birth := strings.TrimSpace(*patch.BirthDate)
if birth == "" {
sets = append(sets, "birth_date=NULL")
} else {
sets = append(sets, "birth_date=?")
args = append(args, birth)
}
}
if patch.Signature != nil {
sets = append(sets, "signature=?")
args = append(args, strings.TrimSpace(*patch.Signature))
}
if patch.Gender != nil {
sets = append(sets, "gender=?")
args = append(args, strings.TrimSpace(*patch.Gender))
}
if patch.Region != nil {
sets = append(sets, "region=?")
args = append(args, strings.TrimSpace(*patch.Region))
}
if patch.Email != nil {
sets = append(sets, "email=?")
args = append(args, strings.TrimSpace(*patch.Email))
}
args = append(args, id, decodeUID(uid), patch.Version)
res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND user_id=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.userProfileByUID(ctx, id, uid)
}
func (a *adapter) TenantUserPrivacyGet(id t.TenantID, uid t.Uid) (*t.UserPrivacySettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserPrivacyGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
privacy, err := a.userPrivacyByUID(ctx, id, uid)
if err == nil {
return privacy, nil
}
if err != sql.ErrNoRows {
return nil, err
}
if err := a.ensureUserPrivacy(ctx, id, uid); err != nil {
return nil, err
}
return a.userPrivacyByUID(ctx, id, uid)
}
func (a *adapter) ensureUserPrivacy(ctx context.Context, tenantID t.TenantID, uid t.Uid) error {
privacyID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(tenant_id,id,user_id,add_me_methods) VALUES(?,?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, tenantID, privacyID, decodeUID(uid)); err != nil {
return err
}
return nil
}
func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.UserPrivacyPatch) (*t.UserPrivacySettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserPrivacyUpdate(id, uid, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || patch.Version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err := a.ensureUserPrivacy(ctx, id, uid); err != nil {
return nil, err
}
sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"}
args := make([]any, 0, 8)
if patch.AddMeMethods != nil {
encoded, err := json.Marshal(patch.AddMeMethods)
if err != nil {
return nil, err
}
sets = append(sets, "add_me_methods=?")
args = append(args, string(encoded))
}
if patch.ProfileVisibility != nil {
sets = append(sets, "profile_visibility=?")
args = append(args, strings.TrimSpace(*patch.ProfileVisibility))
}
if patch.LastSeenVisibility != nil {
sets = append(sets, "last_seen_visibility=?")
args = append(args, strings.TrimSpace(*patch.LastSeenVisibility))
}
if patch.ReadReceipts != nil {
sets = append(sets, "read_receipts=?")
args = append(args, *patch.ReadReceipts)
}
if patch.FriendRequestVerification != nil {
sets = append(sets, "friend_request_verification=?")
args = append(args, *patch.FriendRequestVerification)
}
if patch.GroupInviteVerification != nil {
sets = append(sets, "group_invite_verification=?")
args = append(args, *patch.GroupInviteVerification)
}
args = append(args, id, decodeUID(uid), patch.Version)
res, err := a.db.ExecContext(ctx, `UPDATE im_user_privacy_settings SET `+strings.Join(sets, ",")+` WHERE tenant_id=? AND user_id=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.userPrivacyByUID(ctx, id, uid)
}
func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePushToken) (*t.DevicePushToken, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDevicePushTokenUpsert(id, token)
}
}
if err := validTenant(id); err != nil || token == nil || token.UserID.IsZero() ||
strings.TrimSpace(token.DeviceID) == "" || strings.TrimSpace(token.Provider) == "" || strings.TrimSpace(token.Token) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
token.DeviceID = strings.TrimSpace(token.DeviceID)
token.Provider = strings.ToLower(strings.TrimSpace(token.Provider))
token.Token = strings.TrimSpace(token.Token)
token.AppVersion = strings.TrimSpace(token.AppVersion)
token.Platform = strings.ToLower(strings.TrimSpace(token.Platform))
token.SDKVersion = strings.TrimSpace(token.SDKVersion)
token.PushEnv = strings.ToLower(strings.TrimSpace(token.PushEnv))
token.NotificationPermission = strings.ToLower(strings.TrimSpace(token.NotificationPermission))
tokenID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_device_push_tokens(tenant_id,id,device_id,user_id,provider,token,app_version,platform,sdk_version,push_env,notification_permission,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE device_id=VALUES(device_id),user_id=VALUES(user_id),provider=VALUES(provider),token=VALUES(token),
app_version=VALUES(app_version),platform=VALUES(platform),sdk_version=VALUES(sdk_version),push_env=VALUES(push_env),
notification_permission=VALUES(notification_permission),updated_at=VALUES(updated_at)`,
id, tokenID, token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform,
token.SDKVersion, token.PushEnv, token.NotificationPermission, now, now)
if err != nil {
return nil, err
}
token.CreatedAt = now
token.UpdatedAt = now
return token, nil
}
func (a *adapter) TenantDevicePushTokenDelete(id t.TenantID, uid t.Uid, deviceID string) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantDevicePushTokenDelete(id, uid, deviceID)
}
}
deviceID = strings.TrimSpace(deviceID)
if err := validTenant(id); err != nil || uid.IsZero() || deviceID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "DELETE FROM im_device_push_tokens WHERE tenant_id=? AND user_id=? AND device_id=?", id, decodeUID(uid), deviceID)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...)
}
func (a *adapter) TenantDevicePushTokenGetAllByProvider(id t.TenantID, provider string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
return a.tenantDevicePushTokenGetAllByProviders(id, []string{provider}, uids...)
}
func (a *adapter) tenantDevicePushTokenGetAllByProviders(id t.TenantID, providers []string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, 0, err
}
if tenantAdp != a {
return tenantAdp.tenantDevicePushTokenGetAllByProviders(id, providers, uids...)
}
}
normalizedProviders := make([]any, 0, len(providers))
for _, provider := range providers {
if provider = strings.ToLower(strings.TrimSpace(provider)); provider != "" {
normalizedProviders = append(normalizedProviders, provider)
}
}
if len(normalizedProviders) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
decoded := make([]any, 0, len(uids))
for _, uid := range uids {
if !uid.IsZero() {
decoded = append(decoded, decodeUID(uid))
}
}
if len(decoded) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
query, args, err := sqlx.In(`SELECT user_id,token,platform,updated_at FROM im_device_push_tokens
WHERE tenant_id=? AND provider IN (?) AND user_id IN (?)`, id, normalizedProviders, decoded)
if err != nil {
return nil, 0, err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(query), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
result := make(map[t.Uid][]t.DeviceDef)
count := 0
for rows.Next() {
var userID int64
var token, platform string
var updatedAt time.Time
if err = rows.Scan(&userID, &token, &platform, &updatedAt); err != nil {
return nil, 0, err
}
uid := store.EncodeUid(userID)
result[uid] = append(result[uid], t.DeviceDef{
TenantID: id, DeviceId: token, Platform: strings.ToLower(strings.TrimSpace(platform)), LastSeen: updatedAt,
})
count++
}
return result, count, rows.Err()
}
func (a *adapter) TenantRiskChallengeCreate(id t.TenantID, challenge *t.RiskChallenge) (*t.RiskChallenge, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRiskChallengeCreate(id, challenge)
}
}
if err := validTenant(id); err != nil || challenge == nil || challenge.UserID.IsZero() ||
strings.TrimSpace(challenge.ChallengeID) == "" || strings.TrimSpace(challenge.Action) == "" || challenge.ExpiresAt.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
params, err := json.Marshal(challenge.NoticeParams)
if err != nil {
return nil, err
}
now := t.TimeNow()
if challenge.State == "" {
challenge.State = t.RiskChallengeStatePending
}
challenge.Level = strings.ToLower(strings.TrimSpace(challenge.Level))
challenge.NoticeCode = strings.TrimSpace(challenge.NoticeCode)
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_risk_challenges(tenant_id,challenge_id,user_id,action,level,notice_code,notice_params,state,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
id, strings.TrimSpace(challenge.ChallengeID), decodeUID(challenge.UserID), strings.TrimSpace(challenge.Action),
challenge.Level, challenge.NoticeCode, string(params), challenge.State, challenge.ExpiresAt, now, now)
if err != nil {
return nil, err
}
return a.riskChallengeByID(ctx, id, challenge.ChallengeID)
}
func (a *adapter) TenantRiskChallengeConfirm(id t.TenantID, uid t.Uid, challengeID, decision, actionTokenID string, now time.Time) (*t.RiskChallenge, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRiskChallengeConfirm(id, uid, challengeID, decision, actionTokenID, now)
}
}
decision = strings.ToLower(strings.TrimSpace(decision))
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(challengeID) == "" ||
(decision != "continue" && decision != "cancel") {
return nil, t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
challenge, err := a.riskChallengeByIDTx(ctx, tx, id, challengeID, true)
if err != nil {
return nil, err
}
if challenge.UserID != uid || challenge.State != t.RiskChallengeStatePending || !now.Before(challenge.ExpiresAt) {
return nil, t.ErrInvalidResponse
}
nextState := t.RiskChallengeStateCanceled
if decision == "continue" {
nextState = t.RiskChallengeStateContinued
}
_, err = tx.ExecContext(ctx,
`UPDATE im_risk_challenges SET state=?,decision=?,action_token_id=?,updated_at=?
WHERE tenant_id=? AND challenge_id=? AND state=?`,
nextState, decision, strings.TrimSpace(actionTokenID), now, id, challengeID, t.RiskChallengeStatePending)
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.riskChallengeByID(ctx, id, challengeID)
}
func (a *adapter) TenantAbuseReasons(id t.TenantID, targetType string) ([]t.AbuseReason, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAbuseReasons(id, targetType)
}
}
targetType = strings.ToLower(strings.TrimSpace(targetType))
if err := validTenant(id); err != nil || targetType == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var reasons []t.AbuseReason
err := sqlx.SelectContext(ctx, a.db, &reasons,
`SELECT target_type,reason_code,label,requires_description,allows_evidence,sort_order
FROM im_abuse_reasons WHERE target_type=? AND enabled=1 ORDER BY sort_order,id`, targetType)
return reasons, err
}
func (a *adapter) TenantAbuseReportCreate(id t.TenantID, report *t.AbuseReport, idempotencyKey string) (*t.AbuseReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAbuseReportCreate(id, report, idempotencyKey)
}
}
if err := validTenant(id); err != nil || report == nil || report.ReporterID.IsZero() ||
strings.TrimSpace(report.ReportID) == "" || strings.TrimSpace(report.ReasonCode) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
evidence, err := json.Marshal(report.EvidenceFileIDs)
if err != nil {
return nil, err
}
snapshot, err := json.Marshal(report.Snapshot)
if err != nil {
return nil, err
}
now := t.TimeNow()
report.Status = t.AbuseReportStatusReceived
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_abuse_reports(tenant_id,report_id,reporter_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,idempotency_key,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
id, report.ReportID, decodeUID(report.ReporterID), report.TargetType, decodeUID(report.TargetUserID), report.TargetTopic,
report.TargetSeq, report.ReasonCode, report.Description, string(evidence), string(snapshot), strings.TrimSpace(idempotencyKey),
report.Status, now, now)
if err != nil {
return nil, err
}
return a.abuseReportByIdempotency(ctx, id, report.ReporterID, idempotencyKey)
}
func (a *adapter) TenantFeedbackConfigGet(id t.TenantID, uid t.Uid) (*t.FeedbackConfig, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFeedbackConfigGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.feedbackConfig(ctx)
}
func (a *adapter) TenantFeedbackCreate(id t.TenantID, feedback *t.FeedbackSubmission, idempotencyKey string) (*t.FeedbackSubmission, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFeedbackCreate(id, feedback, idempotencyKey)
}
}
if err := validTenant(id); err != nil || feedback == nil || feedback.UserID.IsZero() ||
strings.TrimSpace(feedback.FeedbackID) == "" || strings.TrimSpace(feedback.Category) == "" ||
strings.TrimSpace(feedback.Content) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
attachments, err := json.Marshal(feedback.AttachmentFileIDs)
if err != nil {
return nil, err
}
clientContext, err := json.Marshal(feedback.ClientContext)
if err != nil {
return nil, err
}
now := t.TimeNow()
feedback.Status = t.FeedbackStatusReceived
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_feedback_submissions(tenant_id,feedback_id,user_id,category,content,contact,attachment_file_ids,client_context,include_diagnostics,idempotency_key,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
id, feedback.FeedbackID, decodeUID(feedback.UserID), feedback.Category, feedback.Content, feedback.Contact,
string(attachments), string(clientContext), feedback.IncludeDiagnostics, strings.TrimSpace(idempotencyKey),
feedback.Status, now, now)
if err != nil {
return nil, err
}
return a.feedbackByIdempotency(ctx, id, feedback.UserID, idempotencyKey)
}
func (a *adapter) riskChallengeByID(ctx context.Context, tenantID t.TenantID, challengeID string) (*t.RiskChallenge, error) {
return a.riskChallengeByIDTx(ctx, a.db, tenantID, challengeID, false)
}
func (a *adapter) riskChallengeByIDTx(ctx context.Context, q sqlx.QueryerContext, tenantID t.TenantID, challengeID string, forUpdate bool) (*t.RiskChallenge, error) {
row := struct {
ID int64 `db:"id"`
ChallengeID string `db:"challenge_id"`
UserID int64 `db:"user_id"`
Action string `db:"action"`
Level string `db:"level"`
NoticeCode string `db:"notice_code"`
NoticeParams []byte `db:"notice_params"`
State string `db:"state"`
ExpiresAt time.Time `db:"expires_at"`
Decision string `db:"decision"`
ActionTokenID string `db:"action_token_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
query := `SELECT id,challenge_id,user_id,action,level,notice_code,notice_params,state,expires_at,decision,action_token_id,created_at,updated_at
FROM im_risk_challenges WHERE tenant_id=? AND challenge_id=?`
if forUpdate {
query += " FOR UPDATE"
}
if err := sqlx.GetContext(ctx, q, &row, query, tenantID, challengeID); err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
challenge := &t.RiskChallenge{
ID: row.ID,
ChallengeID: row.ChallengeID,
UserID: store.EncodeUid(row.UserID),
Action: row.Action,
Level: row.Level,
NoticeCode: row.NoticeCode,
NoticeParams: map[string]any{},
State: row.State,
ExpiresAt: row.ExpiresAt,
Decision: row.Decision,
ActionTokenID: row.ActionTokenID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.NoticeParams, &challenge.NoticeParams)
return challenge, nil
}
func (a *adapter) abuseReportByIdempotency(ctx context.Context, tenantID t.TenantID, uid t.Uid, idempotencyKey string) (*t.AbuseReport, error) {
row := struct {
ID int64 `db:"id"`
ReportID string `db:"report_id"`
ReporterID int64 `db:"reporter_id"`
TargetType string `db:"target_type"`
TargetUserID int64 `db:"target_user_id"`
TargetTopic string `db:"target_topic"`
TargetSeq int64 `db:"target_seq"`
ReasonCode string `db:"reason_code"`
Description string `db:"description"`
EvidenceFileIDs []byte `db:"evidence_file_ids"`
Snapshot []byte `db:"snapshot"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,report_id,reporter_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at
FROM im_abuse_reports WHERE tenant_id=? AND reporter_id=? AND idempotency_key=?`,
tenantID, decodeUID(uid), strings.TrimSpace(idempotencyKey)); err != nil {
return nil, err
}
report := &t.AbuseReport{
ID: row.ID,
ReportID: row.ReportID,
ReporterID: store.EncodeUid(row.ReporterID),
TargetType: row.TargetType,
TargetUserID: store.EncodeUid(row.TargetUserID),
TargetTopic: row.TargetTopic,
TargetSeq: row.TargetSeq,
ReasonCode: row.ReasonCode,
Description: row.Description,
EvidenceFileIDs: []string{},
Snapshot: map[string]any{},
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.EvidenceFileIDs, &report.EvidenceFileIDs)
_ = json.Unmarshal(row.Snapshot, &report.Snapshot)
return report, nil
}
func (a *adapter) feedbackConfig(ctx context.Context) (*t.FeedbackConfig, error) {
row := struct {
Categories []byte `db:"categories"`
MaxLength int `db:"max_length"`
AttachmentPolicy []byte `db:"attachment_policy"`
AllowContact bool `db:"allow_contact"`
DiagnosticsNotice string `db:"diagnostics_notice"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT categories,max_length,attachment_policy,allow_contact,diagnostics_notice,updated_at
FROM im_feedback_config ORDER BY id LIMIT 1`); err != nil {
if err == sql.ErrNoRows {
now := t.TimeNow()
return &t.FeedbackConfig{
Categories: []string{"bug", "suggestion", "complaint", "other"},
MaxLength: 1000,
AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"},
AllowContact: true,
DiagnosticsNotice: "仅在你同意时上传脱敏诊断信息。",
UpdatedAt: now,
}, nil
}
return nil, err
}
config := &t.FeedbackConfig{
Categories: []string{"bug", "suggestion", "complaint", "other"},
MaxLength: row.MaxLength,
AttachmentPolicy: map[string]any{},
AllowContact: row.AllowContact,
DiagnosticsNotice: row.DiagnosticsNotice,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.Categories, &config.Categories)
_ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy)
return config, nil
}
func (a *adapter) feedbackByIdempotency(ctx context.Context, tenantID t.TenantID, uid t.Uid, idempotencyKey string) (*t.FeedbackSubmission, error) {
row := struct {
ID int64 `db:"id"`
FeedbackID string `db:"feedback_id"`
UserID int64 `db:"user_id"`
Category string `db:"category"`
Content string `db:"content"`
Contact string `db:"contact"`
AttachmentFileIDs []byte `db:"attachment_file_ids"`
ClientContext []byte `db:"client_context"`
IncludeDiagnostics bool `db:"include_diagnostics"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids,client_context,include_diagnostics,status,created_at,updated_at
FROM im_feedback_submissions WHERE tenant_id=? AND user_id=? AND idempotency_key=?`,
tenantID, decodeUID(uid), strings.TrimSpace(idempotencyKey)); err != nil {
return nil, err
}
feedback := &t.FeedbackSubmission{
ID: row.ID,
FeedbackID: row.FeedbackID,
UserID: store.EncodeUid(row.UserID),
Category: row.Category,
Content: row.Content,
Contact: row.Contact,
AttachmentFileIDs: []string{},
ClientContext: map[string]any{},
IncludeDiagnostics: row.IncludeDiagnostics,
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AttachmentFileIDs, &feedback.AttachmentFileIDs)
_ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
return feedback, nil
}
func (a *adapter) userProfileByUID(ctx context.Context, tenantID t.TenantID, uid t.Uid) (*t.UserProfile, error) {
row := struct {
UserID int64 `db:"user_id"`
AvatarFileID string `db:"avatar_file_id"`
Nickname string
BirthDate sql.NullTime `db:"birth_date"`
Signature string
Gender string
Region string
Email string
PublicAccount string `db:"public_account"`
FriendAddEnabled bool `db:"friend_add_enabled"`
FriendAddDisabledReason string `db:"friend_add_disabled_reason"`
FriendAddPolicyVersion int `db:"friend_add_policy_version"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,avatar_file_id,nickname,birth_date,signature,gender,region,email,
public_account,friend_add_enabled,friend_add_disabled_reason,friend_add_policy_version,version,created_at,updated_at FROM im_user_profiles WHERE tenant_id=? AND user_id=?`, tenantID, decodeUID(uid)); err != nil {
return nil, err
}
profile := &t.UserProfile{
UserID: store.EncodeUid(row.UserID),
AvatarFileID: row.AvatarFileID,
Nickname: row.Nickname,
Signature: row.Signature,
Gender: row.Gender,
Region: row.Region,
Email: row.Email,
PublicAccount: row.PublicAccount,
FriendAddEnabled: row.FriendAddEnabled,
FriendAddDisabledReason: row.FriendAddDisabledReason,
FriendAddPolicyVersion: row.FriendAddPolicyVersion,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.BirthDate.Valid {
profile.BirthDate = row.BirthDate.Time.Format("2006-01-02")
}
return profile, nil
}
func (a *adapter) userPrivacyByUID(ctx context.Context, tenantID t.TenantID, uid t.Uid) (*t.UserPrivacySettings, error) {
row := struct {
UserID int64 `db:"user_id"`
AddMeMethods []byte `db:"add_me_methods"`
ProfileVisibility string `db:"profile_visibility"`
LastSeenVisibility string `db:"last_seen_visibility"`
ReadReceipts bool `db:"read_receipts"`
FriendRequestVerification bool `db:"friend_request_verification"`
GroupInviteVerification bool `db:"group_invite_verification"`
Version int
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility,
read_receipts,friend_request_verification,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE tenant_id=? AND user_id=?`, tenantID, decodeUID(uid)); err != nil {
return nil, err
}
settings := &t.UserPrivacySettings{
UserID: store.EncodeUid(row.UserID),
AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
ProfileVisibility: row.ProfileVisibility,
LastSeenVisibility: row.LastSeenVisibility,
ReadReceipts: row.ReadReceipts,
FriendRequestVerification: row.FriendRequestVerification,
GroupInviteVerification: row.GroupInviteVerification,
Version: row.Version,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods)
return settings, nil
}
func tenantMessageGetOne(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,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,content_nonce,key_version "+
fmt.Sprintf("FROM %s WHERE tenant_id=? AND topic=? AND seqid=?", tenantMessagesTable(tenantID))+suffix,
tenantID, topic, seq)
if err != nil {
return nil, err
}
msg := row.Message
msg.TenantID = tenantID
msg.From = common.EncodeUidString(msg.From).String()
if row.RecalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(row.RecalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return &msg, nil
}
func (a *adapter) groupIDByTopic(ctx context.Context, tenantID t.TenantID, topic string) (int64, error) {
var groupID int64
err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE tenant_id=? AND topic=? LIMIT 1", tenantID, topic).Scan(&groupID)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
return groupID, err
}
func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
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
}
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,content_nonce,key_version,'%s' AS shard_month FROM %s WHERE tenant_id=? AND topic=? AND seqid=?%s", idColumn, month, table, suffix)
err = sqlx.GetContext(ctx, q, &row, query, tenantID, 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 {
if version <= 0 {
return 1
}
return version
}
func messageStoredContent(value any) (content any, nonce []byte, keyVersion int) {
if stored, ok := value.(t.StoredMessageContent); ok {
return stored.Content, stored.Nonce, stored.KeyVersion
}
return value, nil, 0
}
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) TenantConversationSettingsGet(id t.TenantID, uid t.Uid, topic string) (*t.ConversationSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantConversationSettingsGet(id, uid, topic)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
settings, err := a.conversationSettingsByKey(ctx, id, uid, strings.TrimSpace(topic))
if err == sql.ErrNoRows {
settingsID, idErr := nextSnowflakeID()
if idErr != nil {
return nil, idErr
}
now := t.TimeNow()
_, err = a.db.ExecContext(ctx, `INSERT IGNORE INTO im_conversation_settings
(tenant_id,id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
id, settingsID, decodeUID(uid), strings.TrimSpace(topic), false, false, "", "default", 1, now, now)
if err != nil {
return nil, err
}
return a.conversationSettingsByKey(ctx, id, uid, strings.TrimSpace(topic))
}
if err != nil {
return nil, err
}
return settings, nil
}
func (a *adapter) TenantConversationSettingsUpdate(id t.TenantID, uid t.Uid, topic string, patch t.ConversationSettingsPatch) (*t.ConversationSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantConversationSettingsUpdate(id, uid, topic, patch)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 {
return nil, t.ErrMalformed
}
if _, err := a.TenantConversationSettingsGet(id, uid, topic); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
if patch.Pinned != nil {
sets = append(sets, "pinned=?")
args = append(args, *patch.Pinned)
}
if patch.Muted != nil {
sets = append(sets, "muted=?")
args = append(args, *patch.Muted)
}
if patch.Remark != nil {
sets = append(sets, "remark=?")
args = append(args, strings.TrimSpace(*patch.Remark))
}
if patch.MessageRetention != nil {
sets = append(sets, "message_retention=?")
args = append(args, strings.TrimSpace(*patch.MessageRetention))
}
if len(sets) == 0 {
return a.TenantConversationSettingsGet(id, uid, topic)
}
sets = append(sets, "version=version+1", "updated_at=CURRENT_TIMESTAMP(3)")
args = append(args, id, decodeUID(uid), strings.TrimSpace(topic), patch.Version)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, `UPDATE im_conversation_settings SET `+strings.Join(sets, ",")+`
WHERE tenant_id=? AND user_id=? AND topic=? AND version=?`, args...)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.conversationSettingsByKey(ctx, id, uid, strings.TrimSpace(topic))
}
func (a *adapter) TenantTopicDraftGet(id t.TenantID, uid t.Uid, topic string) (*t.TopicDraft, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantTopicDraftGet(id, uid, topic)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
draft, err := a.topicDraftByKey(ctx, id, uid, strings.TrimSpace(topic))
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return draft, err
}
func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content string, entities []byte, version int) (*t.TopicDraft, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantTopicDraftPut(id, uid, topic, content, entities, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if version == 0 {
draftID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
now := t.TimeNow()
res, err := a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts
(tenant_id,id,user_id,topic,content,entities_json,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE id=id`,
id, draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, id, uid, strings.TrimSpace(topic))
}
res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts
SET content=?,entities_json=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=? AND user_id=? AND topic=? AND version=?`,
content, entities, id, decodeUID(uid), strings.TrimSpace(topic), version)
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, id, uid, strings.TrimSpace(topic))
}
func (a *adapter) conversationSettingsByKey(ctx context.Context, tenantID t.TenantID, uid t.Uid, topic string) (*t.ConversationSettings, error) {
row := struct {
ID int64
UserID int64 `db:"user_id"`
Topic string `db:"topic"`
Pinned bool
Muted bool
Remark string
MessageRetention string `db:"message_retention"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at
FROM im_conversation_settings WHERE tenant_id=? AND user_id=? AND topic=?`, tenantID, decodeUID(uid), topic); err != nil {
return nil, err
}
return &t.ConversationSettings{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic,
Pinned: row.Pinned, Muted: row.Muted, Remark: row.Remark, MessageRetention: row.MessageRetention,
Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func (a *adapter) topicDraftByKey(ctx context.Context, tenantID t.TenantID, uid t.Uid, topic string) (*t.TopicDraft, error) {
row := struct {
ID int64
UserID int64 `db:"user_id"`
Topic string `db:"topic"`
Content string
EntitiesJSON []byte `db:"entities_json"`
Version int `db:"version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,content,entities_json,version,created_at,updated_at
FROM im_topic_drafts WHERE tenant_id=? AND user_id=? AND topic=?`, tenantID, decodeUID(uid), topic); err != nil {
return nil, err
}
return &t.TopicDraft{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic,
Content: row.Content, EntitiesJSON: row.EntitiesJSON, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil
}
func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteCreate(id, fav)
}
}
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
}
item, err := a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
if err == nil {
return item, nil
}
if err != t.ErrNotFound {
return nil, err
}
return a.tenantFavoriteFindExisting(ctx, id, fav)
}
func (a *adapter) tenantFavoriteFindExisting(ctx context.Context, id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
where := []string{"favorite_id=?", "(source_topic=? AND source_seq=?)"}
args := []any{fav.FavoriteID, fav.SourceTopic, fav.SourceSeq}
if fav.IdempotencyKey != "" {
where = append(where, "idempotency_key=?")
args = append(args, fav.IdempotencyKey)
}
args = append([]any{id, decodeUID(fav.UserID)}, args...)
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE tenant_id=? AND user_id=? AND ("+strings.Join(where, " OR ")+") ORDER BY id DESC LIMIT 1", args...)
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
}
item.TenantID = id
return &item, nil
}
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteList(id, uid, itemType, queryText, cursor, limit)
}
}
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 := ""
lastID := int64(0)
for rows.Next() {
item, err := scanFavorite(rows)
if err != nil {
return nil, "", err
}
item.TenantID = id
if len(items) == limit {
nextCursor = strconv.FormatInt(lastID, 10)
continue
}
items = append(items, item)
lastID = item.ID
}
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 !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteGet(id, uid, favoriteID)
}
}
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
}
item.TenantID = id
return &item, nil
}
func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID string) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantFavoriteDelete(id, uid, favoriteID)
}
}
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 {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageDeleteList(id, topic, del)
}
if a.tenantScopedDB {
return a.MessageDeleteList(topic, del)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = tenantMessageDeleteList(tx, id, topic, del); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageDeleteList(id, topic, del)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = groupMessageDeleteList(tx, id, topic, del); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageGetDeleted(id, topic, uid, opts)
}
if a.tenantScopedDB {
deleted, err := a.MessageGetDeleted(topic, uid, opts)
for i := range deleted {
deleted[i].TenantID = id
}
return deleted, err
}
limit := a.maxResults
var lower int64 = 0
var upper int64 = 1<<31 - 1
if opts != nil {
if opts.Since > 0 {
lower = opts.Since
}
if opts.Before > 1 {
upper = opts.Before - 1
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT tenant_id,topic,deletedfor,delid,low,hi FROM im_dellog WHERE tenant_id=? AND topic=? AND delid BETWEEN ? AND ? "+
"AND (deletedfor=0 OR deletedfor=?) ORDER BY delid LIMIT ?", id, topic, lower, upper, decodeUID(uid), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var dmsgs []t.DelMessage
var current t.DelMessage
for rows.Next() {
var row struct {
TenantID t.TenantID `db:"tenant_id"`
Topic string
Deletedfor int64
Delid int
Low int64
Hi int64
}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
if row.Delid != current.DelId {
if current.DelId > 0 {
dmsgs = append(dmsgs, current)
}
current = t.DelMessage{TenantID: row.TenantID, Topic: row.Topic, DelId: row.Delid}
if row.Deletedfor > 0 {
current.DeletedFor = store.EncodeUid(row.Deletedfor).String()
}
}
if row.Hi <= row.Low+1 {
row.Hi = 0
}
current.SeqIdRanges = append(current.SeqIdRanges, t.Range{Low: row.Low, Hi: row.Hi})
}
if err = rows.Err(); err != nil {
return nil, err
}
if current.DelId > 0 {
dmsgs = append(dmsgs, current)
}
return dmsgs, nil
}
func (a *adapter) TenantDeviceUpsert(id t.TenantID, uid t.Uid, dev *t.DeviceDef) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || dev == nil || dev.TenantID != id || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantDeviceUpsert(id, uid, dev)
}
if a.tenantScopedDB {
return a.DeviceUpsert(uid, dev)
}
hash := deviceHasher(dev.DeviceId)
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if _, err = tx.Exec("DELETE FROM im_devices WHERE tenant_id=? AND hash=?", id, hash); err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO im_devices(tenant_id,userid,hash,deviceid,platform,lastseen,lang) VALUES(?,?,?,?,?,?,?)",
id, decodeUID(uid), hash, dev.DeviceId, dev.Platform, dev.LastSeen, dev.Lang); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantDeviceGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, 0, err
}
if tenantAdp != a {
return tenantAdp.TenantDeviceGetAll(id, uids...)
}
if a.tenantScopedDB {
devs, count, err := a.DeviceGetAll(uids...)
for uid := range devs {
for i := range devs[uid] {
devs[uid][i].TenantID = id
}
}
return devs, count, err
}
var decoded []any
for _, uid := range uids {
if !uid.IsZero() {
decoded = append(decoded, decodeUID(uid))
}
}
if len(decoded) == 0 {
return map[t.Uid][]t.DeviceDef{}, 0, nil
}
q, args, _ := sqlx.In("SELECT tenant_id,userid,deviceid,platform,lastseen,lang FROM im_devices WHERE tenant_id=? AND userid IN (?)", id, decoded)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
result := make(map[t.Uid][]t.DeviceDef)
count := 0
for rows.Next() {
var row struct {
TenantID t.TenantID `db:"tenant_id"`
Userid int64
Deviceid string
Platform string
Lastseen time.Time
Lang string
}
if err = rows.StructScan(&row); err != nil {
return nil, 0, err
}
uid := store.EncodeUid(row.Userid)
result[uid] = append(result[uid], t.DeviceDef{
TenantID: row.TenantID, DeviceId: row.Deviceid, Platform: row.Platform, LastSeen: row.Lastseen, Lang: row.Lang,
})
count++
}
return result, count, rows.Err()
}
func (a *adapter) TenantDeviceDelete(id t.TenantID, uid t.Uid, deviceID string) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantDeviceDelete(id, uid, deviceID)
}
if a.tenantScopedDB {
return a.DeviceDelete(uid, deviceID)
}
q := "DELETE FROM im_devices WHERE tenant_id=? AND userid=?"
args := []any{id, decodeUID(uid)}
if deviceID != "" {
q += " AND hash=?"
args = append(args, deviceHasher(deviceID))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || fd == nil || fd.TenantID != id {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantFileStartUpload(id, fd)
}
if a.tenantScopedDB {
return a.FileStartUpload(fd)
}
var user any = 0
if fd.User != "" {
user = decodeUID(t.ParseUid(fd.User))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
"INSERT INTO im_fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)",
id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)
return err
}
func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || fd == nil || fd.TenantID != id {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantFileFinishUpload(id, fd, success, size)
}
if a.tenantScopedDB {
file, err := a.FileFinishUpload(fd, success, size)
return stampTenantFile(id, file), err
}
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()
}
}()
now := t.TimeNow()
if success {
_, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?",
now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid()))
if err == nil {
fd.UpdatedAt = now
fd.Size = size
err = insertFileReviewTasks(ctx, tx, fd)
}
fd.Status = t.UploadCompleted
} else {
_, err = tx.Exec("DELETE FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid()))
fd.Status = t.UploadFailed
fd.Size = 0
}
if err != nil {
return nil, err
}
fd.UpdatedAt = now
return fd, tx.Commit()
}
func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFileGet(id, fid)
}
if a.tenantScopedDB {
file, err := a.FileGet(fid)
return stampTenantFile(id, file), err
}
fileID := parseFileUID(fid)
if fileID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var fd t.FileDef
err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID))
if err == sql.ErrNoRows {
storageName := fileID.String32()
err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1",
id, storageName, "%/"+storageName)
}
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
fd.Id = common.EncodeUidString(fd.Id).String()
fd.User = common.EncodeUidString(fd.User).String()
return &fd, nil
}
func (a *adapter) TenantFileDeleteUnused(id t.TenantID, olderThan time.Time, limit int) ([]string, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFileDeleteUnused(id, olderThan, limit)
}
if a.tenantScopedDB {
return a.FileDeleteUnused(olderThan, limit)
}
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()
}
}()
query := "SELECT fu.id,fu.location FROM im_fileuploads AS fu LEFT JOIN im_filemsglinks AS fml ON fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id WHERE fu.tenant_id=? AND fml.id IS NULL"
args := []any{id}
if !olderThan.IsZero() {
query += " AND fu.updatedat<?"
args = append(args, olderThan)
}
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := tx.Query(query, args...)
if err != nil {
return nil, err
}
var locations []string
var ids []any
for rows.Next() {
var fileID int64
var location string
if err = rows.Scan(&fileID, &location); err != nil {
rows.Close()
return nil, err
}
if location != "" {
locations = append(locations, location)
}
ids = append(ids, fileID)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
if len(ids) > 0 {
q, args, _ := sqlx.In("DELETE FROM im_fileuploads WHERE tenant_id=? AND id IN (?)", id, ids)
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return nil, err
}
}
return locations, tx.Commit()
}
func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID, msgID t.Uid, fids []string) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || len(fids) == 0 || (topic == "" && msgID.IsZero() && userID.IsZero()) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantFileLinkAttachments(id, topic, userID, msgID, fids)
}
if a.tenantScopedDB {
return a.FileLinkAttachments(topic, userID, msgID, fids)
}
now := t.TimeNow()
var linkID any
var linkBy string
if !msgID.IsZero() {
linkBy = "msgid"
linkID = int64(msgID)
} else if topic != "" {
linkBy = "topic"
linkID = topic
fids = fids[:1]
} else {
linkBy = "userid"
linkID = decodeUID(userID)
fids = fids[:1]
}
var args []any
var dids []any
for _, fid := range fids {
fileID := t.ParseUid(fid)
if fileID.IsZero() {
return t.ErrMalformed
}
decoded := decodeUID(fileID)
dids = append(dids, decoded)
args = append(args, id, now, decoded, linkID)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
var existing int
query, queryArgs, err := sqlx.In("SELECT COUNT(*) FROM im_fileuploads WHERE tenant_id=? AND id IN (?) AND status=?", id, dids, t.UploadCompleted)
if err != nil {
return err
}
if err = a.db.GetContext(ctx, &existing, a.db.Rebind(query), queryArgs...); err != nil {
return err
}
if existing != len(dids) {
return t.ErrNotFound
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if msgID.IsZero() {
if _, err = tx.Exec("DELETE FROM im_filemsglinks WHERE tenant_id=? AND "+linkBy+"=?", id, linkID); err != nil {
return err
}
}
sqlq := "INSERT INTO im_filemsglinks(tenant_id,createdat,fileid," + linkBy + ") VALUES (?,?,?,?)"
_, err = tx.Exec(sqlq+strings.Repeat(",(?,?,?,?)", len(fids)-1), args...)
if err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) (bool, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || uid.IsZero() {
return false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantFileUserHasAccess(id, fid, uid)
}
if a.tenantScopedDB {
return a.FileUserHasAccess(fid, uid)
}
fileID := parseFileUID(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, 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=?
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic LIKE 'cvp%%'
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=?
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN %s AS fs
ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_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 im_filemsglinks AS fml
INNER JOIN im_group_members AS gm
ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN %s AS m
ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid
INNER JOIN im_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 im_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)
)
)
OR EXISTS (
SELECT 1 FROM im_group_files AS gf
INNER JOIN im_subscriptions AS s
ON s.tenant_id=fu.tenant_id AND s.topic=gf.topic AND s.userid=?
WHERE gf.tenant_id=fu.tenant_id AND (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))),
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)
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
}
scoped, err := tenantCacheKey(id, key)
if err != nil {
return "", err
}
return a.PCacheGet(scoped)
}
func (a *adapter) TenantPCacheUpsert(id t.TenantID, key, value string, fail bool) error {
if err := validTenant(id); err != nil {
return err
}
scoped, err := tenantCacheKey(id, key)
if err != nil {
return err
}
return a.PCacheUpsert(scoped, value, fail)
}
func (a *adapter) TenantPCacheDelete(id t.TenantID, key string) error {
if err := validTenant(id); err != nil {
return err
}
scoped, err := tenantCacheKey(id, key)
if err != nil {
return err
}
return a.PCacheDelete(scoped)
}
func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.Time) error {
if err := validTenant(id); err != nil {
return err
}
scoped, err := tenantCacheKey(id, prefix)
if err != nil {
return err
}
return a.PCacheExpire(scoped, before)
}
func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationCreate(id, vc)
}
}
if err := validTenant(id); err != nil || vc == nil || vc.TenantID != id ||
vc.ChallengeID == "" || vc.Scene == "" || vc.PhoneHash == "" || vc.CodeHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
vc.CreatedAt = now
vc.UpdatedAt = now
_, 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)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, vc.ChallengeID, vc.Scene, vc.Action, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
if err != nil {
return err
}
return nil
}
func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationGet(id, challengeID)
}
}
if err := validTenant(id); err != nil || challengeID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row verificationCodeRow
err := a.db.GetContext(ctx, &row,
`SELECT id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
FROM im_verification_codes
WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return row.toVerificationCode(id), nil
}
func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash string, now time.Time) (*t.VerificationCode, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationGetActive(id, scene, phoneHash, now)
}
}
if err := validTenant(id); err != nil || scene == "" || phoneHash == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row verificationCodeRow
err := a.db.GetContext(ctx, &row,
`SELECT id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
FROM im_verification_codes
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>?
ORDER BY created_at DESC LIMIT 1`, id, scene, phoneHash, t.VerificationStatePending, now)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return row.toVerificationCode(id), nil
}
type verificationCodeRow struct {
ID int64 `db:"id"`
ChallengeID string `db:"challenge_id"`
Scene string `db:"scene"`
Action string `db:"action"`
PhoneHash string `db:"phone_hash"`
PhoneMask string `db:"phone_mask"`
TargetUser int64 `db:"target_user"`
CodeHash string `db:"code_hash"`
VerificationTokenHash *string `db:"verification_token_hash"`
State t.VerificationState `db:"state"`
Attempts int `db:"attempts"`
MaxAttempts int `db:"max_attempts"`
ExpiresAt time.Time `db:"expires_at"`
ResendAfter time.Time `db:"resend_after"`
VerifiedAt *time.Time `db:"verified_at"`
ConsumedAt *time.Time `db:"consumed_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (r verificationCodeRow) toVerificationCode(tenantID t.TenantID) *t.VerificationCode {
vc := &t.VerificationCode{
ID: r.ID,
TenantID: tenantID,
ChallengeID: r.ChallengeID,
Scene: r.Scene,
Action: r.Action,
PhoneHash: r.PhoneHash,
PhoneMask: r.PhoneMask,
CodeHash: r.CodeHash,
VerificationTokenHash: r.VerificationTokenHash,
State: r.State,
Attempts: r.Attempts,
MaxAttempts: r.MaxAttempts,
ExpiresAt: r.ExpiresAt,
ResendAfter: r.ResendAfter,
VerifiedAt: r.VerifiedAt,
ConsumedAt: r.ConsumedAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.TargetUser > 0 {
vc.TargetUser = store.EncodeUid(r.TargetUser)
}
return vc
}
func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, state t.VerificationState) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationFail(id, challengeID, state)
}
}
if err := validTenant(id); err != nil || challengeID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
_, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET attempts=attempts+1,state=?,updated_at=?
WHERE tenant_id=? AND challenge_id=?`,
state, now, id, challengeID)
return err
}
func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationMarkVerified(id, challengeID, tokenHash, verifiedAt)
}
}
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,verification_token_hash=?,verified_at=?,updated_at=?
WHERE tenant_id=? AND challenge_id=? AND state=?`,
t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, id, challengeID, t.VerificationStatePending)
return err
}
func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantVerificationConsume(id, challengeID, tokenHash, consumedAt)
}
}
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,consumed_at=?,updated_at=?
WHERE tenant_id=? AND challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`,
t.VerificationStateConsumed, consumedAt, consumedAt, id, challengeID, tokenHash,
t.VerificationStateVerified, consumedAt)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantUserSecurityGetAuthVersion(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
decoded := decodeUID(uid)
var version int
err := a.db.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
id, decoded)
if err == nil {
return version, nil
}
if err != sql.ErrNoRows {
return 0, err
}
now := t.TimeNow()
securityID, err := nextSnowflakeID()
if err != nil {
return 0, err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_user_security(tenant_id,id,user_id,auth_version,created_at,updated_at)
VALUES(?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE auth_version=auth_version`,
id, securityID, decoded, 1, now, now)
if err != nil {
return 0, err
}
err = a.db.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
id, decoded)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
if err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, updatedAt time.Time) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantUserSecurityBumpAuthVersion(id, uid, updatedAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded := decodeUID(uid)
securityID, err := nextSnowflakeID()
if err != nil {
return 0, err
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO im_user_security(tenant_id,id,user_id,auth_version,password_updated_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
auth_version=auth_version+1,
password_updated_at=VALUES(password_updated_at),
updated_at=VALUES(updated_at)`,
id, securityID, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil {
return 0, err
}
var version int
if err = tx.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
id, decoded); err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantRealNameGet(id t.TenantID, uid t.Uid) (*t.RealNameVerification, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var rv t.RealNameVerification
err := a.db.GetContext(ctx, &rv,
`SELECT id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash,
masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at
FROM im_realname_verifications
WHERE tenant_id=? AND user_id=?
ORDER BY created_at DESC,id DESC LIMIT 1`,
id, decodeUID(uid))
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
rv.TenantID = id
return &rv, nil
}
func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameCreate(id, rv)
}
}
if err := validTenant(id); err != nil || rv == nil || rv.TenantID != id || rv.UserID.IsZero() ||
rv.SubmissionID == "" || rv.Status == "" || rv.NameCipher == "" || rv.IDType == "" ||
rv.IDNumberCipher == "" || rv.IDNumberHash == "" || rv.MaskedName == "" || rv.MaskedIDNumber == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
rv.CreatedAt = now
rv.UpdatedAt = now
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_realname_verifications
(tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash,
masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, decodeUID(rv.UserID), rv.SubmissionID, rv.Status, rv.NameCipher, rv.IDType, rv.IDNumberCipher, rv.IDNumberHash,
rv.MaskedName, rv.MaskedIDNumber, rv.ReviewReason, rv.VerifiedAt, rv.CreatedAt, rv.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantRealNameAppealCreate(id t.TenantID, appeal *t.RealNameAppeal) (*t.RealNameAppeal, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRealNameAppealCreate(id, appeal)
}
}
if err := validTenant(id); err != nil || appeal == nil || appeal.TenantID != id || appeal.UserID.IsZero() ||
strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.Reason) == "" {
return nil, t.ErrMalformed
}
appealID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
now := t.TimeNow()
if appeal.Status == "" {
appeal.Status = "pending"
}
appeal.ID = appealID
appeal.CreatedAt = now
appeal.UpdatedAt = now
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_realname_appeals
(tenant_id,id,appeal_id,user_id,reason,attachments_json,status,review_comment,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
id, appeal.ID, appeal.AppealID, decodeUID(appeal.UserID), appeal.Reason, appeal.AttachmentsJSON,
appeal.Status, appeal.ReviewComment, appeal.CreatedAt, appeal.UpdatedAt)
if isDupe(err) {
return nil, t.ErrDuplicate
}
if err != nil {
return nil, err
}
return appeal, nil
}
func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantActionTokenCreate(id, token)
}
}
if err := validTenant(id); err != nil || token == nil || token.TenantID != id || token.UserID.IsZero() ||
token.TokenID == "" || token.Action == "" || token.TokenHash == "" || token.ExpiresAt.IsZero() {
return t.ErrMalformed
}
now := t.TimeNow()
token.CreatedAt = now
token.UpdatedAt = now
return nil
}
func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, action, target string, now time.Time) (*t.ActionToken, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantActionTokenConsume(id, uid, token, action, target, now)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || token == "" || action == "" {
return nil, t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
tokenID, secret, ok := strings.Cut(token, ".")
if !ok || tokenID == "" || secret == "" {
return nil, t.ErrMalformed
}
return nil, t.ErrUnsupported
}
func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose string) (*t.IndependentPassword, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantIndependentPasswordGet(id, uid, purpose)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var pass t.IndependentPassword
err := a.db.GetContext(ctx, &pass,
`SELECT user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at
FROM im_youth_mode_settings
WHERE tenant_id=? AND user_id=? AND purpose=?`,
id, decodeUID(uid), purpose)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
pass.TenantID = id
return &pass, nil
}
func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.IndependentPassword) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantIndependentPasswordUpsert(id, pass)
}
}
if err := validTenant(id); err != nil || pass == nil || pass.TenantID != id || pass.UserID.IsZero() ||
pass.Purpose == "" || len(pass.PasswordHash) == 0 {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
pass.CreatedAt = now
pass.UpdatedAt = now
settingsID, err := nextSnowflakeID()
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_youth_mode_settings
(tenant_id,id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at)
VALUES(?,?,?,?,?,0,NULL,?,?)
ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`,
id, settingsID, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt)
return err
}
func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantIndependentPasswordUpdateAttempts(id, uid, purpose, attempts, lockedUntil, updatedAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" || attempts < 0 {
return t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_youth_mode_settings SET failed_attempts=?,locked_until=?,updated_at=?
WHERE tenant_id=? AND user_id=? AND purpose=?`,
attempts, lockedUntil, updatedAt, id, decodeUID(uid), purpose)
return err
}
func (a *adapter) TenantYouthModeGet(id t.TenantID, uid t.Uid) (*t.YouthModeSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantYouthModeGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
settings, err := a.youthModeByUID(ctx, id, uid)
if err == sql.ErrNoRows {
return defaultYouthModeSettings(id, uid), nil
}
return settings, err
}
func (a *adapter) TenantYouthModeSet(id t.TenantID, uid t.Uid, enabled bool, policyVersion int, updatedAt time.Time) (*t.YouthModeSettings, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantYouthModeSet(id, uid, enabled, policyVersion, updatedAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || policyVersion <= 0 {
return nil, t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var enabledAt *time.Time
if enabled {
enabledAt = &updatedAt
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_youth_mode_settings
SET enabled=?,policy_version=?,enabled_at=?,restrictions=COALESCE(restrictions,'["night_quiet","content_filter","payment_guard"]'),updated_at=?
WHERE tenant_id=? AND user_id=? AND purpose=? AND LENGTH(password_hash)>0`,
enabled, policyVersion, enabledAt, updatedAt, id, decodeUID(uid), "youth_mode")
if err != nil {
return nil, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil, t.ErrMalformed
}
return a.youthModeByUID(ctx, id, uid)
}
func (a *adapter) youthModeByUID(ctx context.Context, tenantID t.TenantID, uid t.Uid) (*t.YouthModeSettings, error) {
row := struct {
UserID int64 `db:"user_id"`
PasswordHash []byte `db:"password_hash"`
Enabled bool `db:"enabled"`
PolicyVersion int `db:"policy_version"`
Restrictions sql.NullString `db:"restrictions"`
EnabledAt *time.Time `db:"enabled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT user_id,password_hash,enabled,policy_version,restrictions,enabled_at,created_at,updated_at
FROM im_youth_mode_settings WHERE tenant_id=? AND user_id=? AND purpose=?`, tenantID, decodeUID(uid), "youth_mode"); err != nil {
return nil, err
}
settings := &t.YouthModeSettings{
UserID: store.EncodeUid(row.UserID),
Enabled: row.Enabled,
PolicyVersion: row.PolicyVersion,
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
PasswordSet: len(row.PasswordHash) > 0,
EnabledAt: row.EnabledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.Restrictions.Valid && row.Restrictions.String != "" {
_ = json.Unmarshal([]byte(row.Restrictions.String), &settings.Restrictions)
}
settings.CanEnable = settings.PasswordSet && !settings.Enabled
return settings, nil
}
func defaultYouthModeSettings(tenantID t.TenantID, uid t.Uid) *t.YouthModeSettings {
now := t.TimeNow()
return &t.YouthModeSettings{
UserID: uid,
Enabled: false,
PolicyVersion: 1,
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
PasswordSet: false,
CanEnable: false,
CreatedAt: now,
UpdatedAt: now,
}
}
func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.SystemNotification) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationCreate(id, notification)
}
}
if err := validTenant(id); err != nil || notification == nil || notification.TenantID != id ||
notification.UserID.IsZero() || notification.EventID == "" || notification.EventType == "" ||
notification.Title == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
notification.CreatedAt = now
notification.UpdatedAt = now
_, err := a.db.ExecContext(ctx,
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
event_type=VALUES(event_type),
title=VALUES(title),
summary=VALUES(summary),
action=VALUES(action),
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
}
func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.SystemNotification, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationList(id, uid, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
lastID := int64(0)
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed < 0 {
return nil, "", t.ErrMalformed
}
lastID = parsed
}
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<?"
args = append(args, lastID)
}
query += " ORDER BY id DESC LIMIT ?"
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
notifications := make([]t.SystemNotification, 0, limit)
nextCursor := ""
for rows.Next() {
var notification t.SystemNotification
if err = rows.StructScan(&notification); err != nil {
return nil, "", err
}
if action, ok := common.FromJSON(notification.Action).(map[string]any); ok {
notification.Action = t.KVMap(action)
}
notification.TenantID = id
if len(notifications) == limit {
nextCursor = strconv.FormatInt(notification.ID, 10)
continue
}
notifications = append(notifications, notification)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return notifications, nextCursor, nil
}
func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationUnreadCount(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
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
}
return count, nil
}
func (a *adapter) TenantSystemNotificationUnreadCountByEventPrefix(id t.TenantID, uid t.Uid, eventPrefix string) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationUnreadCountByEventPrefix(id, uid, eventPrefix)
}
}
eventPrefix = strings.TrimSpace(eventPrefix)
if err := validTenant(id); err != nil || uid.IsZero() || eventPrefix == "" {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
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) (*t.SystemNotification, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantSystemNotificationMarkRead(id, uid, eventID, readAt)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || eventID == "" {
return nil, t.ErrMalformed
}
if readAt.IsZero() {
readAt = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, 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 nil, err
}
notification, err := a.tenantSystemNotificationGet(ctx, id, uid, eventID)
if err != nil {
return nil, err
}
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(&notification)
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 &notification, nil
}
func (a *adapter) TenantFriendRequestCreate(id t.TenantID, req *t.FriendRequest) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestCreate(id, req)
}
}
if err := validTenant(id); err != nil || req == nil || req.TenantID != id || req.ApplicantID.IsZero() || req.TargetID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
"INSERT INTO im_friend_requests(tenant_id,request_id,applicant_id,target_id,message,source,source_topic,status,idempotency_key,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
id, req.RequestID, decodeUID(req.ApplicantID), decodeUID(req.TargetID), req.Message, req.Source, req.SourceTopic, req.Status, req.IdempotencyKey, req.ExpiresAt, req.CreatedAt, req.UpdatedAt)
if err != nil {
if isDupe(err) && req.IdempotencyKey != "" {
tx.Rollback()
return a.friendRequestByIdempotency(ctx, id, req.ApplicantID, req.IdempotencyKey)
}
return nil, err
}
eventType := "friend.request.created"
eventID := t.StableEventID("relation", req.RequestID, eventType, req.TargetID.UserId())
if err = insertSystemNotificationTx(tx, id, req.TargetID, t.StableEventID(eventID, "notification"), eventType,
"新的好友申请", req.Message, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": req.ApplicantID.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, req.RequestID)
}
func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, status, cursor string, limit int) ([]t.FriendRequestListItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestList(id, uid, direction, status, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 20
}
where := []string{"fr.tenant_id=?"}
args := []any{id}
decodedUID := decodeUID(uid)
switch direction {
case "", "inbound":
where = append(where, "fr.target_id=?")
args = append(args, decodedUID)
case "outbound":
where = append(where, "fr.applicant_id=?")
args = append(args, decodedUID)
case "all":
where = append(where, "(fr.applicant_id=? OR fr.target_id=?)")
args = append(args, decodedUID, decodedUID)
default:
return nil, "", t.ErrMalformed
}
if status != "" && status != "all" {
where = append(where, "fr.status=?")
args = append(args, status)
}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "fr.id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
query := `SELECT fr.*,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_friend_requests AS fr
JOIN im_users AS u ON u.tenant_id=fr.tenant_id AND u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id)
LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY fr.id DESC LIMIT ?`
args = append([]any{decodedUID}, args...)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FriendRequestListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
TenantID t.TenantID `db:"tenant_id"`
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.ID, 10)
continue
}
applicant := store.EncodeUid(row.ApplicantID)
target := store.EncodeUid(row.TargetID)
peerID := target
direction := "outbound"
if target == uid {
peerID = applicant
direction = "inbound"
}
items = append(items, t.FriendRequestListItem{
Request: t.FriendRequest{
ID: row.ID,
TenantID: id,
RequestID: row.RequestID,
ApplicantID: applicant,
TargetID: target,
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: profilePublicFromFields(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
Trusted: common.FromJSON(row.Trusted),
},
Direction: direction,
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFriendRequestPendingCount(id t.TenantID, uid t.Uid) (int, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestPendingCount(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var count int
if err := a.db.GetContext(ctx, &count,
`SELECT COUNT(*) FROM im_friend_requests WHERE tenant_id=? AND target_id=? AND status=? AND expires_at>?`,
id, decodeUID(uid), t.FriendRequestPending, t.TimeNow()); err != nil {
return 0, err
}
return count, nil
}
func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.Uid) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestLatestBetween(id, uid1, uid2)
}
}
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.friendRequestByQuery(ctx,
id,
"SELECT * FROM im_friend_requests WHERE tenant_id=? AND ((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?)) ORDER BY created_at DESC,id DESC LIMIT 1",
id, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1))
}
func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, actor t.Uid, handledAt time.Time) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestAccept(id, requestID, actor, handledAt)
}
}
if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() {
return nil, t.ErrMalformed
}
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()
}
}()
row := struct {
ID int64
TenantID t.TenantID `db:"tenant_id"`
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err = tx.GetContext(ctx, &row, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=? FOR UPDATE", id, requestID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
if store.EncodeUid(row.TargetID) != actor {
return nil, t.ErrPermissionDenied
}
if row.Status != t.FriendRequestPending && row.Status != t.FriendRequestAccepted {
return &t.FriendRequest{
ID: row.ID,
TenantID: id,
RequestID: row.RequestID,
ApplicantID: store.EncodeUid(row.ApplicantID),
TargetID: store.EncodeUid(row.TargetID),
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
applicant := store.EncodeUid(row.ApplicantID)
target := store.EncodeUid(row.TargetID)
topic := applicant.P2PName(target)
if topic == "" {
return nil, t.ErrMalformed
}
if row.Status == t.FriendRequestPending {
if _, err = tx.ExecContext(ctx,
"UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE tenant_id=? AND request_id=? AND status=?",
t.FriendRequestAccepted, handledAt, handledAt, id, requestID, t.FriendRequestPending); err != nil {
return nil, err
}
row.Status = t.FriendRequestAccepted
row.HandledAt = &handledAt
row.UpdatedAt = handledAt
}
if _, err = tx.ExecContext(ctx, `UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=?
WHERE tenant_id=? AND request_id<>? AND status=? AND
((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?))`,
t.FriendRequestExpired, handledAt, handledAt, id, requestID, t.FriendRequestPending,
decodeUID(applicant), decodeUID(target), decodeUID(target), decodeUID(applicant)); err != nil {
return nil, err
}
for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
friendshipID, idErr := nextSnowflakeID()
if idErr != nil {
return nil, idErr
}
if _, err = tx.ExecContext(ctx,
fmt.Sprintf("INSERT INTO %s(tenant_id,id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ", tenantFriendshipsTable(id))+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
id, friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil {
return nil, err
}
}
users, err := a.tenantUserGetAllTx(ctx, tx, id, applicant, target)
if err != nil {
return nil, err
}
if len(users) != 2 {
return nil, t.ErrUserNotFound
}
userByID := map[t.Uid]t.User{}
for _, user := range users {
userByID[user.Uid()] = user
}
applicantUser, ok1 := userByID[applicant]
targetUser, ok2 := userByID[target]
if !ok1 || !ok2 {
return nil, t.ErrUserNotFound
}
applicantSub := &t.Subscription{
ObjHeader: t.ObjHeader{CreatedAt: handledAt, UpdatedAt: handledAt},
TenantID: id,
User: applicant.String(),
Topic: topic,
ModeWant: t.ModeCP2P,
ModeGiven: t.ModeCP2P,
}
applicantSub.SetTouchedAt(handledAt)
applicantSub.SetPublic(targetUser.Public)
applicantSub.SetTrusted(targetUser.Trusted)
targetSub := &t.Subscription{
ObjHeader: t.ObjHeader{CreatedAt: handledAt, UpdatedAt: handledAt},
TenantID: id,
User: target.String(),
Topic: topic,
ModeWant: t.ModeCP2P,
ModeGiven: t.ModeCP2P,
}
targetSub.SetTouchedAt(handledAt)
targetSub.SetPublic(applicantUser.Public)
targetSub.SetTrusted(applicantUser.Trusted)
if err = createTenantSubscription(tx, id, applicantSub, true); err != nil {
return nil, err
}
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}
if a.tenantScopedDB {
err = a.topicCreate(tx, topicRow)
} else {
err = tenantTopicCreate(tx, topicRow)
}
if err != nil {
if !isDupe(err) {
return nil, err
}
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
}
}
eventType := "friend.request." + t.FriendRequestAccepted
applicantEventID := t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted, eventType, applicant.UserId())
if err = insertSystemNotificationTx(tx, id, applicant, t.StableEventID(applicantEventID, "notification"), eventType,
"好友申请已处理", t.FriendRequestAccepted, map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, requestID)
}
func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, actor t.Uid, status string, handledAt time.Time) (*t.FriendRequest, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendRequestResolve(id, requestID, actor, status, handledAt)
}
}
if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
req, err := a.friendRequestByRequestID(ctx, id, requestID)
if err != nil || req == nil {
return req, err
}
if req.TargetID != actor {
return nil, t.ErrPermissionDenied
}
if req.Status != t.FriendRequestPending {
return req, nil
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx,
"UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE tenant_id=? AND request_id=? AND status=?",
status, handledAt, handledAt, id, requestID, t.FriendRequestPending); err != nil {
return nil, err
}
eventType := "friend.request." + status
eventID := t.StableEventID("relation", req.RequestID+":"+status, eventType, req.ApplicantID.UserId())
if err = insertSystemNotificationTx(tx, id, req.ApplicantID, t.StableEventID(eventID, "notification"), eventType,
"好友申请已处理", status, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, requestID)
}
func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, topic string, now time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipUpsertPair(id, uid1, uid2, topic, now)
}
}
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() || uid1 == uid2 {
return t.ErrMalformed
}
if topic == "" {
topic = uid1.P2PName(uid2)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} {
friendshipID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx,
fmt.Sprintf("INSERT INTO %s(tenant_id,id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ", tenantFriendshipsTable(id))+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
id, friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil {
return err
}
}
return nil
}
func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, initial, cursor string, limit int) ([]t.FriendListItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipList(id, uid, queryText, initial, cursor, limit)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
uidID := decodeUID(uid)
where := []string{"fs.tenant_id=?", "fs.user_id=?", "fs.status=?", "fs.peer_id<>?"}
args := []any{id, uidID, t.RelationshipFriend, uidID}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "fs.peer_id>?")
args = append(args, parsed)
}
if q := strings.TrimSpace(queryText); q != "" {
like := "%" + q + "%"
where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ?)")
args = append(args, like, like, like)
}
if initial = strings.TrimSpace(initial); initial != "" {
like := initial + "%"
where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ?)")
args = append(args, like, like)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, fmt.Sprintf(`SELECT fs.id,fs.user_id,fs.peer_id,fs.status,fs.remark,fs.topic,fs.created_at,fs.updated_at,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,p.updated_at AS profile_updated_at
FROM %s AS fs
JOIN im_users AS u ON u.tenant_id=fs.tenant_id AND u.id=fs.peer_id
LEFT JOIN im_user_profiles AS p ON p.tenant_id=u.tenant_id AND p.user_id=u.id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY fs.peer_id ASC LIMIT ?`, tenantFriendshipsTable(id)), args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FriendListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
UserID int64 `db:"user_id"`
PeerID int64 `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.PeerID, 10)
continue
}
peerID := store.EncodeUid(row.PeerID)
items = append(items, t.FriendListItem{
Friendship: t.Friendship{
TenantID: id,
UserID: store.EncodeUid(row.UserID),
PeerID: peerID,
Status: row.Status,
Remark: row.Remark,
Topic: row.Topic,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: profilePublicFromFields(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
Trusted: common.FromJSON(row.Trusted),
},
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.Friendship, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipGet(id, userID, peerID)
}
}
if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
UserID int64 `db:"user_id"`
PeerID int64 `db:"peer_id"`
Status string
Remark string
Topic string
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.GetContext(ctx, &row, fmt.Sprintf(`SELECT user_id,peer_id,status,remark,topic,created_at,updated_at
FROM %s WHERE tenant_id=? AND user_id=? AND peer_id=?`, tenantFriendshipsTable(id)),
id, decodeUID(userID), decodeUID(peerID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t.Friendship{
TenantID: id,
UserID: store.EncodeUid(row.UserID),
PeerID: store.EncodeUid(row.PeerID),
Status: row.Status,
Remark: row.Remark,
Topic: row.Topic,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipUpdateRemark(id, userID, peerID, remark, now)
}
}
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,
fmt.Sprintf("UPDATE %s SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?", tenantFriendshipsTable(id)),
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 !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantFriendshipDeletePair(id, uid1, uid2, now)
}
}
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
fmt.Sprintf("UPDATE %s SET status=?,updated_at=? WHERE tenant_id=? AND ((user_id=? AND peer_id=?) OR (user_id=? AND peer_id=?))", tenantFriendshipsTable(id)),
t.RelationshipNone, now, id, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1))
if err != nil {
return err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) (*t.UserBlock, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockGet(id, blockerID, blockedID)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
BlockerID int64 `db:"blocker_id"`
BlockedID int64 `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.GetContext(ctx, &row, `SELECT blocker_id,blocked_id,created_at,updated_at
FROM im_user_blocks WHERE tenant_id=? AND blocker_id=? AND blocked_id=?`,
id, decodeUID(blockerID), decodeUID(blockedID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t.UserBlock{
TenantID: id,
BlockerID: store.EncodeUid(row.BlockerID),
BlockedID: store.EncodeUid(row.BlockedID),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockList(id, blockerID, cursor, limit)
}
}
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.blocker_id,b.blocked_id,b.created_at,b.updated_at,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_user_blocks AS b
LEFT JOIN im_users AS u ON u.tenant_id=b.tenant_id AND u.id=b.blocked_id
LEFT JOIN im_user_profiles AS p ON p.tenant_id=b.tenant_id AND p.user_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 {
BlockerID int64 `db:"blocker_id"`
BlockedID int64 `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
}{}
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: id,
BlockerID: store.EncodeUid(row.BlockerID),
BlockedID: blockedID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
User: &t.User{
ObjHeader: t.ObjHeader{Id: blockedID.String()},
TenantID: id,
Public: profilePublicFromFields(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
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 !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockUpsert(id, blockerID, blockedID, now)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
blockID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
_, err = a.db.ExecContext(ctx,
"INSERT INTO im_user_blocks(tenant_id,id,blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)",
id, blockID, decodeUID(blockerID), decodeUID(blockedID), now, now)
if err != nil {
return nil, err
}
return a.TenantUserBlockGet(id, blockerID, blockedID)
}
func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Uid) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantUserBlockDelete(id, blockerID, blockedID)
}
}
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, "DELETE FROM im_user_blocks WHERE tenant_id=? AND blocker_id=? AND blocked_id=?",
id, decodeUID(blockerID), decodeUID(blockedID))
return err
}
func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searchType, cursor string, limit int) ([]t.User, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, "", err
}
if tenantAdp != a {
return tenantAdp.TenantUserSearch(id, viewer, queryText, searchType, cursor, limit)
}
}
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.id<>?", "u.state=?"}
args := []any{decodeUID(viewer), t.StateOK}
if !a.tenantScopedDB {
where = append([]string{"u.tenant_id=?"}, where...)
args = append([]any{id}, args...)
}
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)
}
joins := []string{
"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id",
"LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id",
}
if !a.tenantScopedDB {
joins = []string{
"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.tenant_id=u.tenant_id AND privacy.user_id=u.id",
"LEFT JOIN im_user_profiles AS profile ON profile.tenant_id=u.tenant_id AND profile.user_id=u.id",
}
}
addMethod := "account"
switch searchType {
case "", "account":
like := "%" + queryText + "%"
phoneCandidate := queryText
if phone, err := normalizeUserPhone(queryText); err == nil {
phoneCandidate = phone
}
where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR u.username LIKE ? OR u.phone=? OR u.phone=? OR u.id=?)")
args = append(args, like, like, like, queryText, phoneCandidate, userIDNumericArg(queryText))
case "exact_account":
where = append(where, "(profile.public_account=? OR u.username=? OR u.id=?)")
args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText))
case "phone":
addMethod = "phone"
where = append(where, "u.phone=?")
args = append(args, queryText)
default:
return nil, "", t.ErrMalformed
}
where = append(where, fmt.Sprintf(`(EXISTS (
SELECT 1 FROM %s AS privacy_friend
WHERE privacy_friend.user_id=? AND privacy_friend.peer_id=u.id AND privacy_friend.status=?
) OR COALESCE(JSON_UNQUOTE(JSON_EXTRACT(privacy.add_me_methods, ?)), 'true')='true')`, tenantFriendshipsTable(id)))
args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod)
args = append([]any{id}, args...)
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.trusted,
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.signature AS profile_signature,
profile.gender AS profile_gender,profile.region AS profile_region,profile.public_account AS profile_public_account,profile.updated_at AS profile_updated_at
FROM im_users AS u `+strings.Join(joins, " ")+`
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 row profileUserRow
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
user := profileUserFromRow(row)
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, tenantID, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID)
}
func (a *adapter) friendRequestByIdempotency(ctx context.Context, tenantID t.TenantID, applicantID t.Uid, key string) (*t.FriendRequest, error) {
return a.friendRequestByQuery(ctx, tenantID, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND applicant_id=? AND idempotency_key=?", tenantID, decodeUID(applicantID), key)
}
func (a *adapter) friendRequestByQuery(ctx context.Context, tenantID t.TenantID, query string, args ...any) (*t.FriendRequest, error) {
row := struct {
ID int64
TenantID t.TenantID `db:"tenant_id"`
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err := a.db.GetContext(ctx, &row, query, args...)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &t.FriendRequest{
ID: row.ID,
TenantID: tenantID,
RequestID: row.RequestID,
ApplicantID: store.EncodeUid(row.ApplicantID),
TargetID: store.EncodeUid(row.TargetID),
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func nullableUID(uid t.Uid) any {
if uid.IsZero() {
return nil
}
return store.DecodeUid(uid)
}
func securityTokenHash(tenantID t.TenantID, uid t.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(store.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))
}