Files
im_server/server/db/mysql/tenant_adapter.go
T
Austin dbc7c6a3e9 fix(avatar): 租户路径文件 ACL 补齐头像放行条
fileMapper.UserHasAccess 恒经 TenantFileUserHasAccess,共享库多租户
模式走 tenant_adapter 的 SQL;此前第 9 条(头像链接行对登录用户放行)
只加在了基础路径 FileUserHasAccess,导致共享库环境下他人下载头像仍
403。同步补齐,与 FileUserHasAccess 保持一致。
2026-10-15 13:38:39 +08:00

7645 lines
244 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build mysql
// +build mysql
package mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"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)
}
save := func() error {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, saveErr := 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 saveErr == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
return saveErr
}
if msg.ClientMsgID == "" {
err = save()
} else {
err = retryInvalidConnection("tenant_message_save", save)
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
// schemaColumnContract describes the required shape of one column.
type schemaColumnContract struct {
dataType string
notNull bool
autoInc bool
}
func (c schemaColumnContract) describe() string {
out := strings.ToUpper(c.dataType)
if c.notNull {
out += " NOT NULL"
}
if c.autoInc {
out += " AUTO_INCREMENT"
}
return out
}
// TenantMessageSchemaValidate verifies the storage contracts asynchronous
// persistence depends on: message ids and topic sequence counters are written
// as explicit snowflake int64 values, and file-message links reference them.
// A column that is still INT overflows on the first snowflake write and, under
// Kafka persistence, stalls the consumer partition instead of failing fast.
func (a *adapter) TenantMessageSchemaValidate(tenantIDs []t.TenantID) error {
if len(tenantIDs) == 0 {
return errors.New("mysql: no active tenants available for message schema validation")
}
// Message ids are explicit snowflake values stored in the auto-increment
// id column; sequence counters and file ids are snowflake-derived too.
var (
bigintPK = schemaColumnContract{dataType: "bigint", notNull: true, autoInc: true}
bigintReq = schemaColumnContract{dataType: "bigint", notNull: true}
bigintOpt = schemaColumnContract{dataType: "bigint"}
)
type scopedCheck struct {
table string
column string
want schemaColumnContract
}
perTenant := func(tenantID t.TenantID, scopedDB bool) []scopedCheck {
msgTable, topicsTable, subsTable := tenantMessagesTable(tenantID), tenantTopicsTable(tenantID), tenantSubscriptionsTable(tenantID)
if scopedDB {
msgTable, topicsTable, subsTable = "im_messages", "im_topics", "im_subscriptions"
}
return []scopedCheck{
{msgTable, "id", bigintPK},
{topicsTable, "seqid", bigintReq},
{subsTable, "recvseqid", bigintOpt},
{subsTable, "readseqid", bigintOpt},
// Global file tables exist once per database and are shared by all tenants.
{"im_filemsglinks", "fileid", bigintReq},
{"im_filemsglinks", "msgid", bigintOpt},
{"im_fileuploads", "id", bigintReq},
}
}
validated := make(map[*adapter]map[string]struct{})
run := func(tenantAdp *adapter, chk scopedCheck) error {
key := chk.table + "." + chk.column
if _, ok := validated[tenantAdp][key]; ok {
return nil
}
if err := tenantAdp.validateColumnContract(chk.table, chk.column, chk.want); err != nil {
return err
}
if validated[tenantAdp] == nil {
validated[tenantAdp] = make(map[string]struct{})
}
validated[tenantAdp][key] = struct{}{}
return nil
}
for _, tenantID := range tenantIDs {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return fmt.Errorf("mysql: resolve tenant %d for message schema validation: %w", tenantID, err)
}
for _, chk := range perTenant(tenantID, tenantAdp.tenantScopedDB) {
if err = run(tenantAdp, chk); err != nil {
return err
}
}
}
if !a.tenantScopedDB {
for _, chk := range perTenant(0, true) {
if err := run(a, chk); err != nil {
return err
}
}
}
return nil
}
// validateColumnContract fails when the live column shape does not match the
// contract. The caller turns the error into a fatal startup failure so a
// drifted schema is caught before the first write instead of as a stuck
// persistence partition.
func (a *adapter) validateColumnContract(table, column string, want schemaColumnContract) error {
if !regexp.MustCompile(`^im_(?:[0-9]{3}_)?[a-z_]+$`).MatchString(table) {
return fmt.Errorf("mysql: unsafe table name %q for schema validation", table)
}
var dataType, nullable, extra string
err := a.db.QueryRow(`SELECT DATA_TYPE,IS_NULLABLE,EXTRA FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?`, table, column).
Scan(&dataType, &nullable, &extra)
if err == sql.ErrNoRows {
return fmt.Errorf("mysql: required table %s is missing", table)
}
if err != nil {
return fmt.Errorf("mysql: inspect %s.%s: %w", table, column, err)
}
if !strings.EqualFold(dataType, want.dataType) || want.notNull != strings.EqualFold(nullable, "NO") ||
(want.autoInc && !strings.Contains(strings.ToLower(extra), "auto_increment")) {
return fmt.Errorf("mysql: incompatible %s.%s schema: type=%s nullable=%s extra=%s; want %s "+
"(apply scripts/IM-租户业务库初始化.sql equivalents or the matching BIGINT migration before starting)",
table, column, dataType, nullable, extra, want.describe())
}
return nil
}
func (a *adapter) TenantMessageSaveBatch(id t.TenantID, messages []*t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if len(messages) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageSaveBatch(id, messages)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
values := make([]string, 0, len(messages))
args := make([]any, 0, len(messages)*13)
for _, msg := range messages {
if msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 || msg.Uid().IsZero() {
return t.ErrMalformed
}
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?)")
args = append(args, id, store.DecodeUid(msg.Uid()), msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
decodeUID(t.ParseUid(msg.From)), sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""},
messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
}
query := fmt.Sprintf("INSERT INTO %s(tenant_id,id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES %s",
tenantMessagesTable(id), strings.Join(values, ","))
_, err = a.db.ExecContext(ctx, query, args...)
if isDupe(err) {
return t.ErrDuplicate
}
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)
}
err = retryInvalidConnection("tenant_group_message_save", func() error {
return a.tenantGroupMessageSaveOnce(id, msg)
})
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) tenantGroupMessageSaveOnce(id t.TenantID, msg *t.Message) error {
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 err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupMessageSaveBatch(id t.TenantID, messages []*t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if len(messages) == 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageSaveBatch(id, messages)
}
if err = a.ensureCurrentGroupMessageShardWindow(); err != nil {
return err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
month := messages[0].ShardMonth
table, ok := groupMessageShardTable(id, month)
if !ok {
return t.ErrMalformed
}
groupIDs := make(map[string]int64)
for _, msg := range messages {
if msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 || msg.Uid().IsZero() || msg.ShardMonth != month {
return t.ErrMalformed
}
if _, found := groupIDs[msg.Topic]; !found {
groupIDs[msg.Topic], err = a.groupIDByTopic(ctx, id, msg.Topic)
if 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, month, table).Scan(&shardStatus); err != nil {
return err
}
if shardStatus != "writable" && shardStatus != "sealing" {
return fmt.Errorf("mysql: group message shard %s is %s", month, shardStatus)
}
values := make([]string, 0, len(messages))
args := make([]any, 0, len(messages)*20)
for _, msg := range messages {
contentJSON := common.ToJSON(msg.Content)
values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
args = append(args, id, store.DecodeUid(msg.Uid()), msg.Topic, groupIDs[msg.Topic], msg.SeqId,
decodeUID(t.ParseUid(msg.From)), sql.NullString{String: msg.ClientMsgID, Valid: msg.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)
}
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 %s`, table, strings.Join(values, ","))
_, err = a.db.ExecContext(ctx, query, args...)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
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) "+
"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.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,original_name) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location, strings.TrimSpace(fd.OriginalName))
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=?,original_name=? WHERE tenant_id=? AND id=?",
now, t.UploadCompleted, size, fd.ETag, fd.Location, strings.TrimSpace(fd.OriginalName), 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,IFNULL(original_name,'') AS original_name "+
"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,IFNULL(original_name,'') AS original_name "+
"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
)
OR EXISTS (
-- 用户头像/资料文件:设置头像时写入的链接行有两种形态——
-- userid 形态(topic/msgid 为 NULL,userid=头像主人)与
-- 纯数字 uid topic 形态(store 层已把 usr 前缀 topic 归一为 userid 形态)。
-- 头像属于公开资料,对任何登录用户放行;与 FileUserHasAccess 的第 9 条保持一致。
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.msgid IS NULL AND (
(fml.topic IS NULL AND fml.userid IS NOT NULL)
OR fml.topic REGEXP '^[0-9]+$'
)
)
) 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,
"好友申请已处理", "对方已同意你的好友申请", 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 {
return nil, err
}
if req == nil {
// 好友申请不存在必须返回 ErrNotFound,否则上层拿到 (nil,nil) 会解引用 panic(网关 502)。
return nil, t.ErrNotFound
}
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())
summary := "对方已婉拒你的好友申请"
if status == t.FriendRequestAccepted {
summary = "对方已同意你的好友申请"
}
if err = insertSystemNotificationTx(tx, id, req.ApplicantID, t.StableEventID(eventID, "notification"), eventType,
"好友申请已处理", summary, 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))
}