//go:build mysql // +build mysql package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/json" "fmt" "sort" "strconv" "strings" "time" "github.com/jmoiron/sqlx" "github.com/tinode/chat/server/db/common" "github.com/tinode/chat/server/store" t "github.com/tinode/chat/server/store/types" ) func (*adapter) TenantBusinessReady() bool { return true } func (a *adapter) routedTenantAdapter(id t.TenantID) (*adapter, error) { if err := validTenant(id); err != nil { return nil, err } if !a.tenantDBRouting { return a, nil } business, _, err := a.TenantAdapterFor(id) if err != nil { return nil, err } tenantAdp, ok := business.(*adapter) if !ok { return nil, t.ErrUnsupported } return tenantAdp, nil } func stampTenantUser(id t.TenantID, user *t.User) *t.User { if user != nil { user.TenantID = id } return user } type profileUserRow struct { TenantID t.TenantID `db:"tenant_id"` ID string `db:"id"` Username string CreatedAt time.Time `db:"createdat"` UpdatedAt time.Time `db:"updatedat"` State t.ObjState `db:"state"` StateAt *time.Time `db:"stateat"` Access t.DefaultAccess Phone string `db:"phone"` LastSeen sql.NullTime `db:"lastseen"` UserAgent string `db:"useragent"` Public any `db:"public"` Trusted any `db:"trusted"` ProfileNickname sql.NullString `db:"profile_nickname"` ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"` ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"` } 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 profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID) } 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(base any, nickname, avatarFileID sql.NullString) any { public := publicMap(base) clearProfilePublicFields(public) 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 } } return public } func clearProfilePublicFields(public map[string]any) { for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar"} { delete(public, key) } } 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 != "" { nickname = value break } } for _, key := range []string{"avatar_file_id", "photo", "avatar"} { if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "" { 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 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 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.tenantScopedDB { return tenantAdp.UserCreate(user) } return tenantAdp.UserCreate(user) } 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 { user, err := tenantAdp.UserGet(uid) return stampTenantUser(id, user), err } 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.public,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,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 || a.tenantScopedDB { ctx, cancel := tenantAdp.getContext() if cancel != nil { defer cancel() } var decoded int64 err = tenantAdp.db.GetContext(ctx, &decoded, "SELECT id FROM im_users WHERE phone=? AND state!=?", phone, t.StateDeleted) if err == sql.ErrNoRows { return t.ZeroUid, nil } if err != nil { return t.ZeroUid, err } return store.EncodeUid(decoded), nil } 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 { users, err := tenantAdp.UserGetAll(uids...) return stampTenantUsers(id, users), err } 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 id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted) } else { q, params, _ = sqlx.In(`SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND 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 user t.User if err = rows.StructScan(&user); err != nil { return nil, err } scanTenantUser(&user) users = append(users, user) } 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.UserDelete(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 := "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("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("DELETE m FROM im_messages 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=?", id, decoded); err != nil { return err } sqlq, params, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, ownAll) if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil { return err } if _, err = tx.Exec("DELETE tt FROM im_topictags AS tt JOIN im_topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { return err } if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND owner=?", id, decoded); err != nil { return err } } if _, err = tx.Exec("DELETE FROM im_auth WHERE userid=?", decoded); err != nil { return err } if _, err = tx.Exec("DELETE FROM im_credentials WHERE userid=?", 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("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, ownAll) if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil { return err } } if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?", now, now, t.StateDeleted, now, id, decoded); err != nil { return err } if _, err = tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ "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("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("UPDATE im_topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?", objState, now, tenantID, decodedUID, t.StateDeleted); err != nil { return err } _, err := tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ "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.UserUpdate(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 = withoutUserTags(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.UserUpdateTags(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.UserUnreadCount(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("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.UserGetUnvalidated(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 u.id=c.userid "+ "WHERE u.lastseen IS NULL AND u.updatedat 0 && opts.Limit < limit { limit = opts.Limit } } else { ims = *opts.IfModifiedSince } } else { limit = a.maxResults } 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{} 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() switch t.GetTopicCat(tname) { case t.TopicCatMe, t.TopicCatFnd: continue case t.TopicCatP2P: uid1, uid2, _ := t.ParseP2P(tname) if uid1 == uid { usrq = append(usrq, decodeUID(uid2)) sub.SetWith(uid2.UserId()) } else { usrq = append(usrq, decodeUID(uid1)) sub.SetWith(uid1.UserId()) } case t.TopicCatGrp: tname = t.ChnToGrp(tname) } topq = append(topq, tname) sub.Private = common.FromJSON(sub.Private) join[tname] = sub } err = rows.Err() rows.Close() if err != nil || len(join) == 0 { return nil, err } if len(topq) > 0 { q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,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) 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 = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)` q, args, _ = sqlx.In(q, 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) join[joinOn] = sub } } err = rows.Err() rows.Close() if err != nil { return nil, err } } subs := make([]t.Subscription, 0, len(join)) for _, sub := range join { 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 { subs, err := tenantAdp.UsersForTopic(topic, keepDeleted, opts) return stampTenantSubs(id, subs), err } 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.public,u.trusted,u.lastseen,u.useragent,s.private FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id WHERE s.tenant_id=? AND s.topic=?` 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 public, trusted any 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, &public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil { return nil, err } sub.User = common.EncodeUidString(sub.User).String() sub.Private = common.FromJSON(sub.Private) sub.SetPublic(common.FromJSON(public)) 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.OwnTopics(uid) } if a.tenantScopedDB { return a.OwnTopics(uid) } return a.tenantTopicNames("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.ChannelsForUser(uid) } if a.tenantScopedDB { return a.ChannelsForUser(uid) } return a.tenantTopicNames("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", id, decodeUID(uid)) } 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.TopicShare(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 = createSubscription(tx, sub, true); err != nil { return err } } if topic != "" { if _, err = tx.Exec("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 { 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("DELETE FROM im_messages WHERE tenant_id=? AND topic=?", 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, "SELECT seqid FROM im_messages AS m WHERE "+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("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN im_messages AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE "+where, args...); err != nil { return err } if _, err := tx.Exec("UPDATE im_messages AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+ 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, topic string, toDel *t.DelMessage) error { var err error if toDel == nil { if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil { return err } _, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic) return err } delRanges := toDel.SeqIdRanges if toDel.DeletedFor == "" { where := "m.topic=?" args := []any{topic} if len(delRanges) > 0 { rSQL, rArgs := common.RangesToSql(delRanges) where += " AND m.seqid " + rSQL args = append(args, rArgs...) } where += " AND m.deleted_at IS NULL" if newerThan := toDel.GetNewerThan(); newerThan != nil { where += " AND m.created_at>?" args = append(args, newerThan) } var seqIDs []int64 if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil { return err } 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.topic=? AND m.seqid " + rSQL args = append([]any{topic}, rArgs...) if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+ where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil { return err } } stmt, err := tx.Prepare("INSERT INTO im_dellog(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(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.TopicDelete(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("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, 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("DELETE FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil { return err } } else { now := t.TimeNow() q, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, topics) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?", 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.TopicUpdateOnMessage(topic, msg) } if a.tenantScopedDB { return a.TopicUpdateOnMessage(topic, msg) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err = a.db.ExecContext(ctx, "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.TopicUpdateSubCnt(topic) } if a.tenantScopedDB { return a.TopicUpdateSubCnt(topic) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err = a.db.ExecContext(ctx, "UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?", 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.TopicUpdate(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("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) 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.TopicOwnerChange(topic, owner) } if a.tenantScopedDB { return a.TopicOwnerChange(topic, owner) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err = a.db.ExecContext(ctx, "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 { sub, err := tenantAdp.SubscriptionGet(topic, uid, keepDeleted) if sub != nil { sub.TenantID = id } return sub, err } 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=?` 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 { subs, err := tenantAdp.SubsForUser(uid) return stampTenantSubs(id, subs), err } if a.tenantScopedDB { subs, err := a.SubsForUser(uid) return stampTenantSubs(id, subs), err } rows, err := a.db.Queryx(`SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`, 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 { subs, err := tenantAdp.SubsForTopic(topic, keepDeleted, opts) return stampTenantSubs(id, subs), err } 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=?` args := []any{id, topic} if !keepDeleted { q += " AND deletedat IS NULL" } limit := a.maxResults if opts != nil { if !opts.User.IsZero() { q += " AND userid=?" args = append(args, decodeUID(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 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.SubsUpdate(topic, uid, update) } if a.tenantScopedDB { return a.SubsUpdate(topic, uid, update) } cols, args := common.UpdateByMap(update) q := "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.SubsDelete(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("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("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("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("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)", tenantID, topics) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } } if hard { _, err = tx.Exec("DELETE FROM im_subscriptions WHERE tenant_id=? AND userid=?", tenantID, decodedUID) } else { now := t.TimeNow() _, err = tx.Exec("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 { subs, err := tenantAdp.Find(caller, promoPrefix, req, opt, activeOnly) return stampTenantSubs(id, subs), err } 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 " 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.FindOne(tag) } if a.tenantScopedDB { return a.FindOne(tag) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, "SELECT tp.name AS topic FROM im_topics AS tp LEFT JOIN im_topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+ "WHERE tp.tenant_id=? AND tt.tag=? LIMIT 1", id, tag) if err != nil { return "", err } defer rows.Close() var found string if rows.Next() { if err = rows.Scan(&found); err != nil { return "", err } if parsed, err := strconv.ParseInt(found, 10, 64); err == nil { found = store.EncodeUid(parsed).UserId() } } return found, rows.Err() } func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || msg == nil || msg.TenantID != id { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantMessageSave(id, msg) } if a.tenantScopedDB { return a.MessageSave(msg) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} res, err := a.db.ExecContext(ctx, "INSERT INTO im_messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)", id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content)) if isDupe(err) { return t.ErrDuplicate } if err == nil { lastID, _ := res.LastInsertId() msg.SetUid(t.Uid(lastID)) } return err } func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantGroupMessageSave(id, msg) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } groupID, err := a.groupIDByTopic(ctx, msg.Topic) if err != nil { return err } clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} contentJSON := common.ToJSON(msg.Content) res, err := a.db.ExecContext(ctx, `INSERT INTO im_group_messages (topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all, file_count,content_size,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal", contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"), 0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt) if err == nil { lastID, _ := res.LastInsertId() msg.SetUid(t.Uid(lastID)) } if isDupe(err) { return t.ErrDuplicate } return err } 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 "+ "FROM im_messages WHERE `from`=? AND client_msg_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() } row := struct { t.Message RecalledBy sql.NullInt64 `db:"recalled_by"` }{} err = a.db.QueryRowxContext(ctx, "SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+ "FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?", topic, decodeUID(from), clientMsgID).StructScan(&row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } msg := normalizeGroupMessage(id, row.Message, row.RecalledBy) return &msg, 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, "UPDATE im_messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion) if err != nil { return nil, err } if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"), "topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return a.TenantMessageGetOne(id, topic, seq) } 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, err := tenantGroupMessageGetOne(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, "UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion) if err != nil { return nil, err } if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"), "topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return a.TenantGroupMessageGetOne(id, topic, seq) } 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(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)", editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil { return nil, err } _, err = tx.ExecContext(ctx, "UPDATE im_messages SET updatedat=?,version=version+1,head=?,content=? WHERE topic=? AND seqid=? AND version=?", editedAt, head, common.ToJSON(content), topic, seq, expectedVersion) if err != nil { return nil, err } if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"), "topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return a.TenantMessageGetOne(id, topic, seq) } 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, err := tenantGroupMessageGetOne(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(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)", editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil { return nil, err } contentJSON := common.ToJSON(content) _, err = tx.ExecContext(ctx, "UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?", editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion) if err != nil { return nil, err } if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"), "topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return a.TenantGroupMessageGetOne(id, topic, seq) } 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 { msgs, err := tenantAdp.MessageGetAll(topic, uid, opts) return stampTenantMessages(id, msgs), err } 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 "+ "FROM im_messages AS m 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=? 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 args := []any{store.DecodeUid(uid), topic} 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.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ "FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+ "ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+ "WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", args...) 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, normalizeGroupMessage(id, row.Message, row.RecalledBy)) } return msgs, rows.Err() } func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) { if !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.topic=?", "m.delid=0"} args := []any{topic} if cursor != "" { parsed, err := strconv.Atoi(cursor) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "m.seqid 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 layoutVersion { layoutVersion = int(module.UpdatedAt.Unix()) } modules = append(modules, module) } if err = rows.Err(); err != nil { return nil, 0, err } if layoutVersion == 0 { layoutVersion = 1 } return modules, layoutVersion, nil } func (a *adapter) TenantOrganizationDepartments(id t.TenantID, uid t.Uid, parentID, cursor string, limit int) ([]t.OrganizationDepartment, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantOrganizationDepartments(id, uid, parentID, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } parentID = strings.TrimSpace(parentID) if parentID == "" { parentID = "root" } if limit <= 0 || limit > 100 { limit = 50 } where := []string{"d.parent_id=?", "d.state='active'"} args := []any{parentID} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "d.id>?") args = append(args, parsed) } args = append(args, limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, "", err } cte, cteArgs := visibility.departmentCTE() memberCountSQL, memberCountArgs := visibility.memberCountSQL("m") if visibility.Scope != orgVisibilityAll { where = append(where, "d.department_id IN (SELECT department_id FROM visible_depts)") } queryArgs := append([]any{}, cteArgs...) queryArgs = append(queryArgs, memberCountArgs...) queryArgs = append(queryArgs, args...) childCountSQL := `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active')` if visibility.Scope != orgVisibilityAll { childCountSQL = `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active' AND child.department_id IN (SELECT department_id FROM visible_depts))` } rows, err := a.db.QueryxContext(ctx, cte+`SELECT d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, `+childCountSQL+` AS child_count, (SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id AND m.employment_state='active'`+memberCountSQL+`) AS member_count FROM im_org_departments AS d WHERE `+strings.Join(where, " AND ")+` ORDER BY d.sort_order ASC,d.id ASC LIMIT ?`, queryArgs...) if err != nil { return nil, "", err } defer rows.Close() departments := make([]t.OrganizationDepartment, 0, limit) nextCursor := "" for rows.Next() { var department t.OrganizationDepartment if err = rows.StructScan(&department); err != nil { return nil, "", err } if len(departments) == limit { nextCursor = strconv.FormatInt(department.ID, 10) continue } departments = append(departments, department) } if err = rows.Err(); err != nil { return nil, "", err } return departments, nextCursor, nil } func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, departmentID, cursor string, limit int) ([]t.OrganizationMember, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantOrganizationMembers(id, uid, departmentID, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(departmentID) == "" { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } where := []string{"m.department_id=?", "m.employment_state='active'"} args := []any{strings.TrimSpace(departmentID)} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "m.id>?") args = append(args, parsed) } args = append(args, limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, "", err } visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") if visibilitySQL != "" { where = append(where, visibilitySQL) args = append(args, visibilityArgs...) } rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at FROM im_org_members AS m WHERE `+strings.Join(where, " AND ")+` ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...) if err != nil { return nil, "", err } defer rows.Close() members := make([]t.OrganizationMember, 0, limit) nextCursor := "" for rows.Next() { member, err := scanOrganizationMember(rows) if err != nil { return nil, "", err } if len(members) == limit { nextCursor = strconv.FormatInt(member.ID, 10) continue } members = append(members, *member) } if err = rows.Err(); err != nil { return nil, "", err } return members, nextCursor, nil } func (a *adapter) TenantOrganizationMembersAll(id t.TenantID, uid t.Uid, departmentID, queryText, cursor string, limit int) ([]t.OrganizationMember, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantOrganizationMembersAll(id, uid, departmentID, queryText, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } where := []string{"m.employment_state='active'"} args := []any{} if strings.TrimSpace(departmentID) != "" { where = append(where, "m.department_id=?") args = append(args, strings.TrimSpace(departmentID)) } if q := strings.TrimSpace(queryText); q != "" { like := "%" + q + "%" where = append(where, "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?)") args = append(args, like, like, like, like) } if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "m.id>?") args = append(args, parsed) } args = append(args, limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, "", err } visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") if visibilitySQL != "" { where = append(where, visibilitySQL) args = append(args, visibilityArgs...) } rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at FROM im_org_members AS m WHERE `+strings.Join(where, " AND ")+` ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...) if err != nil { return nil, "", err } defer rows.Close() members := make([]t.OrganizationMember, 0, limit) nextCursor := "" for rows.Next() { member, err := scanOrganizationMember(rows) if err != nil { return nil, "", err } if len(members) == limit { nextCursor = strconv.FormatInt(member.ID, 10) continue } members = append(members, *member) } if err = rows.Err(); err != nil { return nil, "", err } return members, nextCursor, nil } func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText, resultTypes, cursor string, limit int) ([]t.OrganizationSearchItem, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantOrganizationSearch(id, uid, queryText, resultTypes, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(queryText) == "" { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } offset := 0 if cursor != "" { parsed, err := strconv.Atoi(cursor) if err != nil || parsed < 0 { return nil, "", t.ErrMalformed } offset = parsed } includeDepartments, includeUsers := organizationSearchIncludes(resultTypes) if !includeDepartments && !includeUsers { return nil, "", t.ErrMalformed } like := "%" + strings.TrimSpace(queryText) + "%" ctx, cancel := a.getContext() if cancel != nil { defer cancel() } visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, "", err } cte, cteArgs := visibility.departmentCTE() parts := make([]string, 0, 2) args := make([]any, 0, 10) args = append(args, cteArgs...) if includeDepartments { departmentWhere := "d.state='active' AND d.name LIKE ?" if visibility.Scope != orgVisibilityAll { departmentWhere += " AND d.department_id IN (SELECT department_id FROM visible_depts)" } parts = append(parts, `SELECT 'department' AS result_type,d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, (SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count, (SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count, 0 AS user_id,'' AS display_name,'' AS position,'' AS avatar_file_id,'' AS mobile,'' AS email,'' AS employment_state,0 AS contactable FROM im_org_departments AS d WHERE `+departmentWhere) args = append(args, like) } if includeUsers { userWhere := "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?) AND m.employment_state='active'" visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") if visibilitySQL != "" { userWhere += " AND " + visibilitySQL } parts = append(parts, `SELECT 'user' AS result_type,m.id,'' AS department_id,'' AS name,m.department_id AS parent_id,m.sort_order,m.version,'' AS state,m.created_at,m.updated_at, 0 AS child_count,0 AS member_count,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable FROM im_org_members AS m WHERE `+userWhere) args = append(args, like, like, like, like) args = append(args, visibilityArgs...) } args = append(args, offset, limit+1) rows, err := a.db.QueryxContext(ctx, cte+`SELECT * FROM (`+strings.Join(parts, " UNION ALL ")+`) AS results ORDER BY result_type ASC,id ASC LIMIT ?,?`, args...) if err != nil { return nil, "", err } defer rows.Close() items := make([]t.OrganizationSearchItem, 0, limit) nextCursor := "" for rows.Next() { item, err := scanOrganizationSearchItem(rows) if err != nil { return nil, "", err } if len(items) == limit { nextCursor = strconv.Itoa(offset + limit) continue } items = append(items, *item) } if err = rows.Err(); err != nil { return nil, "", err } return items, nextCursor, nil } const ( orgVisibilityAll = "all" orgVisibilityFriends = "friends" orgVisibilitySelf = "self" ) type organizationVisibility struct { Scope string UserID int64 } func (a *adapter) organizationVisibility(ctx context.Context, uid t.Uid) (organizationVisibility, error) { userID := decodeUID(uid) visibility := organizationVisibility{Scope: orgVisibilitySelf, UserID: userID} if userID <= 0 { return visibility, t.ErrMalformed } var override string err := a.db.QueryRowxContext(ctx, `SELECT visibility_scope FROM im_org_visibility_overrides WHERE user_id=? AND state='active' LIMIT 1`, userID).Scan(&override) if err != nil && err != sql.ErrNoRows { return visibility, err } if err == nil { switch strings.TrimSpace(override) { case orgVisibilityAll, orgVisibilityFriends, orgVisibilitySelf: visibility.Scope = strings.TrimSpace(override) return visibility, nil default: return visibility, t.ErrMalformed } } var phone sql.NullString err = a.db.QueryRowxContext(ctx, `SELECT phone FROM im_users WHERE id=? AND state<>? LIMIT 1`, userID, t.StateDeleted).Scan(&phone) if err == sql.ErrNoRows { return visibility, t.ErrNotFound } if err != nil { return visibility, err } var inviteUsed int err = a.db.QueryRowxContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_registration_invite_uses WHERE user_id=?)`, userID).Scan(&inviteUsed) if err != nil { return visibility, err } if inviteUsed > 0 { visibility.Scope = orgVisibilitySelf return visibility, nil } if phone.Valid && strings.TrimSpace(phone.String) != "" { visibility.Scope = orgVisibilityFriends } return visibility, nil } func (v organizationVisibility) memberWhereSQL(alias string) (string, []any) { alias = strings.TrimSpace(alias) if alias == "" { alias = "m" } switch v.Scope { case orgVisibilityAll: return "", nil case orgVisibilityFriends: return "(" + alias + ".user_id=? OR EXISTS (SELECT 1 FROM im_friendships AS fs WHERE fs.user_id=? AND fs.peer_id=" + alias + ".user_id AND fs.status=?))", []any{v.UserID, v.UserID, t.RelationshipFriend} default: return alias + ".user_id=?", []any{v.UserID} } } func (v organizationVisibility) memberCountSQL(alias string) (string, []any) { where, args := v.memberWhereSQL(alias) if where == "" { return "", nil } return " AND " + where, args } func (v organizationVisibility) departmentCTE() (string, []any) { if v.Scope == orgVisibilityAll { return "", nil } memberWhere, args := v.memberWhereSQL("m") return `WITH RECURSIVE visible_member_depts AS ( SELECT DISTINCT m.department_id FROM im_org_members AS m WHERE m.employment_state='active' AND ` + memberWhere + ` ), visible_depts AS ( SELECT d.department_id,d.parent_id FROM im_org_departments AS d JOIN visible_member_depts AS vmd ON vmd.department_id=d.department_id WHERE d.state='active' UNION DISTINCT SELECT parent.department_id,parent.parent_id FROM im_org_departments AS parent JOIN visible_depts AS child ON child.parent_id=parent.department_id WHERE parent.state='active' ) `, args } func organizationSearchIncludes(resultTypes string) (bool, bool) { if strings.TrimSpace(resultTypes) == "" { return true, true } includeDepartments, includeUsers := false, false for _, item := range strings.Split(resultTypes, ",") { switch strings.TrimSpace(item) { case "department": includeDepartments = true case "user": includeUsers = true } } return includeDepartments, includeUsers } func scanOrganizationMember(rows *sqlx.Rows) (*t.OrganizationMember, error) { row := struct { ID int64 DepartmentID string `db:"department_id"` UserID int64 `db:"user_id"` DisplayName string `db:"display_name"` Position string AvatarFileID string `db:"avatar_file_id"` Mobile string Email string EmploymentState string `db:"employment_state"` Contactable bool SortOrder int `db:"sort_order"` Version int CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := rows.StructScan(&row); err != nil { return nil, err } return &t.OrganizationMember{ ID: row.ID, DepartmentID: row.DepartmentID, UserID: store.EncodeUid(row.UserID), DisplayName: row.DisplayName, Position: row.Position, AvatarFileID: row.AvatarFileID, Mobile: row.Mobile, Email: row.Email, Status: row.EmploymentState, Contactable: row.Contactable && row.EmploymentState == "active", SortOrder: row.SortOrder, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }, nil } func scanOrganizationSearchItem(rows *sqlx.Rows) (*t.OrganizationSearchItem, error) { row := struct { ResultType string `db:"result_type"` ID int64 DepartmentID string `db:"department_id"` Name string ParentID string `db:"parent_id"` SortOrder int `db:"sort_order"` Version int State string CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` ChildCount int `db:"child_count"` MemberCount int `db:"member_count"` UserID int64 `db:"user_id"` DisplayName string `db:"display_name"` Position string AvatarFileID string `db:"avatar_file_id"` Mobile string Email string EmploymentState string `db:"employment_state"` Contactable bool }{} if err := rows.StructScan(&row); err != nil { return nil, err } item := &t.OrganizationSearchItem{Type: row.ResultType} if row.ResultType == "department" { item.Department = &t.OrganizationDepartment{ ID: row.ID, DepartmentID: row.DepartmentID, Name: row.Name, ParentID: row.ParentID, SortOrder: row.SortOrder, ChildCount: row.ChildCount, MemberCount: row.MemberCount, Version: row.Version, State: row.State, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } return item, nil } item.Member = &t.OrganizationMember{ ID: row.ID, DepartmentID: row.ParentID, UserID: store.EncodeUid(row.UserID), DisplayName: row.DisplayName, Position: row.Position, AvatarFileID: row.AvatarFileID, Mobile: row.Mobile, Email: row.Email, Status: row.EmploymentState, Contactable: row.Contactable && row.EmploymentState == "active", SortOrder: row.SortOrder, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } return item, nil } func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeting, attendeeIDs []t.Uid) (*t.Meeting, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMeetingCreate(id, uid, meeting, attendeeIDs) } } if err := validTenant(id); err != nil || uid.IsZero() || meeting == nil || strings.TrimSpace(meeting.MeetingID) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } if meeting.IdempotencyKey != "" { existing, err := a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey) if err == nil { return existing, nil } if err != t.ErrNotFound { return nil, err } } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if tx != nil { tx.Rollback() } }() reminderJSON, err := json.Marshal(meeting.ReminderMinutes) if err != nil { return nil, err } var idempotencyKey any if meeting.IdempotencyKey != "" { idempotencyKey = meeting.IdempotencyKey } _, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version) VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location), t.MeetingStateScheduled, string(reminderJSON), idempotencyKey) if err != nil { if isDupe(err) && meeting.IdempotencyKey != "" { tx.Rollback() tx = nil return a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey) } return nil, err } participants := uniqueMeetingParticipants(uid, attendeeIDs) now := time.Now().UTC().Round(time.Millisecond) for _, participant := range participants { response := t.MeetingResponsePending var responseAt any if participant == uid { response = t.MeetingResponseAccepted responseAt = now } if _, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(meeting_id,user_id,response,response_at) VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(participant), response, responseAt); err != nil { return nil, err } } if err = insertMeetingEventsTx(tx, principalMeetingEvent{ Actor: uid, MeetingID: meeting.MeetingID, Title: strings.TrimSpace(meeting.Title), State: t.MeetingStateScheduled, Version: 1, EventType: "meeting.created", }, participants); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.Meeting, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantMeetingList(id, uid, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 20 } where := []string{`(m.organizer_id=? OR EXISTS ( SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=? ))`} args := []any{decodeUID(uid), decodeUID(uid)} if strings.TrimSpace(cursor) != "" { parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "m.id 0 { _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) } if len(meetings) == limit { nextCursor = strconv.FormatInt(row.ID, 10) continue } participants, err := a.meetingParticipants(ctx, row.MeetingID) if err != nil { return nil, "", err } meeting.Participants = participants meetings = append(meetings, meeting) } if err = rows.Err(); err != nil { return nil, "", err } return meetings, nextCursor, nil } func (a *adapter) TenantMeetingGet(id t.TenantID, uid t.Uid, meetingID string) (*t.Meeting, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMeetingGet(id, uid, meetingID) } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) if err != nil { return nil, err } participants, err := a.meetingParticipants(ctx, meeting.MeetingID) if err != nil { return nil, err } meeting.Participants = participants return meeting, nil } func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string, patch t.MeetingPatch, _ string) (*t.Meeting, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMeetingUpdate(id, uid, meetingID, patch, "") } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || patch.Version <= 0 { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) if err != nil { return nil, err } if meeting.OrganizerID != uid { return nil, t.ErrPermissionDenied } if meeting.State != t.MeetingStateScheduled { return nil, t.ErrPermissionDenied } if meeting.Version != patch.Version { return nil, t.ErrInvalidResponse } effectiveStart := meeting.StartAt effectiveEnd := meeting.EndAt if patch.StartAt != nil { effectiveStart = *patch.StartAt } if patch.EndAt != nil { effectiveEnd = *patch.EndAt } if !effectiveEnd.After(effectiveStart) { return nil, t.ErrMalformed } sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} args := make([]any, 0, 8) if patch.Title != nil { sets = append(sets, "title=?") args = append(args, strings.TrimSpace(*patch.Title)) } if patch.StartAt != nil { sets = append(sets, "start_at=?") args = append(args, patch.StartAt.UTC()) } if patch.EndAt != nil { sets = append(sets, "end_at=?") args = append(args, patch.EndAt.UTC()) } if patch.Location != nil { sets = append(sets, "location=?") args = append(args, strings.TrimSpace(*patch.Location)) } if patch.ReminderMinutes != nil { encoded, err := json.Marshal(patch.ReminderMinutes) if err != nil { return nil, err } sets = append(sets, "reminder_minutes=?") args = append(args, string(encoded)) } args = append(args, meeting.MeetingID, decodeUID(uid), patch.Version) tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if tx != nil { tx.Rollback() } }() res, err := tx.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=? AND organizer_id=? AND version=?`, args...) if err != nil { return nil, err } affected, err := res.RowsAffected() if err != nil { return nil, err } if affected == 0 { return nil, t.ErrInvalidResponse } participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") if err != nil { return nil, err } if err = insertMeetingEventsTx(tx, principalMeetingEvent{ Actor: uid, MeetingID: meeting.MeetingID, Title: effectiveMeetingTitle(meeting.Title, patch.Title), State: meeting.State, Version: meeting.Version + 1, EventType: "meeting.updated", }, participantIDs); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } func (a *adapter) TenantMeetingCancel(id t.TenantID, uid t.Uid, meetingID, _ string) (*t.Meeting, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMeetingCancel(id, uid, meetingID, "") } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) if err != nil { return nil, err } if meeting.OrganizerID != uid { return nil, t.ErrPermissionDenied } if meeting.State == t.MeetingStateCanceled { return a.TenantMeetingGet(id, uid, meeting.MeetingID) } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if tx != nil { tx.Rollback() } }() _, err = tx.ExecContext(ctx, `UPDATE im_meetings SET state=?,version=version+1,canceled_at=?,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND organizer_id=?`, t.MeetingStateCanceled, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) if err != nil { return nil, err } participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") if err != nil { return nil, err } if err = insertMeetingEventsTx(tx, principalMeetingEvent{ Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: t.MeetingStateCanceled, Version: meeting.Version + 1, EventType: "meeting.canceled", }, participantIDs); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } func (a *adapter) TenantMeetingRemind(id t.TenantID, uid t.Uid, meetingID, _ string) ([]t.MeetingParticipant, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMeetingRemind(id, uid, meetingID, "") } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) if err != nil { return nil, err } if meeting.OrganizerID != uid { return nil, t.ErrPermissionDenied } participants, err := a.meetingParticipantsByResponse(ctx, meeting.MeetingID, t.MeetingResponsePending) if err != nil { return nil, err } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if tx != nil { tx.Rollback() } }() participantIDs := make([]t.Uid, 0, len(participants)) for _, participant := range participants { participantIDs = append(participantIDs, participant.UserID) } if err = insertMeetingEventsTx(tx, principalMeetingEvent{ Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State, Version: meeting.Version, EventType: "meeting.reminded", }, participantIDs); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } tx = nil return participants, nil } func (a *adapter) TenantMeetingRespond(id t.TenantID, uid t.Uid, meetingID, response string, version int) (*t.Meeting, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMeetingRespond(id, uid, meetingID, response, version) } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) if err != nil { return nil, err } if meeting.State != t.MeetingStateScheduled || time.Now().UTC().After(meeting.EndAt) { return nil, t.ErrPermissionDenied } if meeting.Version != version { return nil, t.ErrInvalidResponse } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if tx != nil { tx.Rollback() } }() res, err := tx.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=?,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND user_id=?`, response, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) if err != nil { return nil, err } affected, err := res.RowsAffected() if err != nil { return nil, err } if affected == 0 { return nil, t.ErrPermissionDenied } res, err = tx.ExecContext(ctx, `UPDATE im_meetings SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND version=?`, meeting.MeetingID, version) if err != nil { return nil, err } if affected, err = res.RowsAffected(); err != nil { return nil, err } if affected == 0 { return nil, t.ErrInvalidResponse } participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") if err != nil { return nil, err } if err = insertMeetingEventsTx(tx, principalMeetingEvent{ Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State, Version: meeting.Version + 1, EventType: "meeting.response.updated", }, participantIDs); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } type principalMeetingEvent struct { Actor t.Uid MeetingID string Title string State string Version int EventType string } func effectiveMeetingTitle(current string, patch *string) string { if patch == nil { return current } return strings.TrimSpace(*patch) } func meetingParticipantIDsTx(ctx context.Context, tx *sqlx.Tx, meetingID, response string) ([]t.Uid, error) { query := `SELECT user_id FROM im_meeting_participants WHERE meeting_id=?` args := []any{meetingID} if response != "" { query += ` AND response=?` args = append(args, response) } rows, err := tx.QueryxContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() participants := make([]t.Uid, 0, 8) for rows.Next() { var userID int64 if err = rows.Scan(&userID); err != nil { return nil, err } participants = append(participants, store.EncodeUid(userID)) } return participants, rows.Err() } func insertMeetingEventsTx(tx *sqlx.Tx, event principalMeetingEvent, participants []t.Uid) error { for _, participant := range participants { if participant.IsZero() { continue } eventID := t.StableEventID("meeting", event.MeetingID, event.Version, event.EventType, participant.UserId()) if participant != event.Actor { if err := insertSystemNotificationTx(tx, participant, t.StableEventID(eventID, "notification"), event.EventType, "会议通知", event.Title, t.KVMap{"meeting_id": event.MeetingID}); err != nil { return err } } if err := insertBusinessEventTx(tx, eventID, "user", participant.UserId(), event.Actor, event.EventType, map[string]any{"meeting_id": event.MeetingID, "version": event.Version, "state": event.State}); err != nil { return err } } return nil } func (a *adapter) meetingByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.Meeting, error) { row := struct { MeetingID string `db:"meeting_id"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT meeting_id FROM im_meetings WHERE organizer_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } return nil, err } return a.meetingByID(ctx, row.MeetingID, uid) } func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Uid) (*t.Meeting, error) { row := struct { ID int64 MeetingID string `db:"meeting_id"` OrganizerID int64 `db:"organizer_id"` Title string StartAt time.Time `db:"start_at"` EndAt time.Time `db:"end_at"` Location string State string ReminderMinutes []byte `db:"reminder_minutes"` IdempotencyKey sql.NullString `db:"idempotency_key"` Version int CanceledAt *time.Time `db:"canceled_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m WHERE m.meeting_id=? AND (m.organizer_id=? OR EXISTS ( SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=? ))`, meetingID, decodeUID(viewer), decodeUID(viewer)); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } return nil, err } meeting := &t.Meeting{ ID: row.ID, MeetingID: row.MeetingID, OrganizerID: store.EncodeUid(row.OrganizerID), Title: row.Title, StartAt: row.StartAt, EndAt: row.EndAt, Location: row.Location, State: row.State, Version: row.Version, CanceledAt: row.CanceledAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if row.IdempotencyKey.Valid { meeting.IdempotencyKey = row.IdempotencyKey.String } if len(row.ReminderMinutes) > 0 { _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) } return meeting, nil } func (a *adapter) meetingParticipants(ctx context.Context, meetingID string) ([]t.MeetingParticipant, error) { return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? ORDER BY id ASC`, meetingID) } func (a *adapter) meetingParticipantsByResponse(ctx context.Context, meetingID, response string) ([]t.MeetingParticipant, error) { return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? AND response=? ORDER BY id ASC`, meetingID, response) } func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string, args ...any) ([]t.MeetingParticipant, error) { rows, err := a.db.QueryxContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() participants := make([]t.MeetingParticipant, 0, 8) for rows.Next() { row := struct { ID int64 MeetingID string `db:"meeting_id"` UserID int64 `db:"user_id"` Response string ResponseAt *time.Time `db:"response_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err = rows.StructScan(&row); err != nil { return nil, err } participants = append(participants, t.MeetingParticipant{ ID: row.ID, MeetingID: row.MeetingID, UserID: store.EncodeUid(row.UserID), Response: row.Response, ResponseAt: row.ResponseAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }) } if err = rows.Err(); err != nil { return nil, err } return participants, nil } func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid { seen := map[t.Uid]bool{organizer: true} participants := []t.Uid{organizer} for _, uid := range attendeeIDs { if uid.IsZero() || seen[uid] { continue } seen[uid] = true participants = append(participants, uid) } return participants } func (a *adapter) TenantAttendanceConfig(id t.TenantID, uid t.Uid, date time.Time) (*t.AttendanceConfig, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantAttendanceConfig(id, uid, date) } } if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rule, err := a.activeAttendanceRule(ctx) if err != nil { return nil, err } shifts, err := a.attendanceShifts(ctx, rule.RuleID) if err != nil { return nil, err } record, err := a.attendanceRecordByDate(ctx, uid, date, "") if err != nil && err != sql.ErrNoRows { return nil, err } if err == sql.ErrNoRows { record = nil } return &t.AttendanceConfig{ Date: date, Timezone: "Asia/Shanghai", Rule: *rule, Shifts: shifts, AllowedActions: []string{t.AttendanceActionCheckIn, t.AttendanceActionCheckOut}, TodayRecord: record, CanViewManaged: true, }, nil } func (a *adapter) TenantAttendanceRecordList(id t.TenantID, uid t.Uid, opts t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", nil, err } if tenantAdp != a { return tenantAdp.TenantAttendanceRecordList(id, uid, opts) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", nil, t.ErrMalformed } where := []string{"1=1"} args := []any{} if opts.Scope == "" || opts.Scope == "mine" { where = append(where, "user_id=?") args = append(args, decodeUID(uid)) } else if opts.Scope == "managed" { if raw := strings.TrimSpace(opts.UserID); raw != "" { target := t.ParseUserId(raw) if target.IsZero() { return nil, "", nil, t.ErrMalformed } where = append(where, "user_id=?") args = append(args, decodeUID(target)) } } else { return nil, "", nil, t.ErrMalformed } if opts.Date != "" { where = append(where, "record_date=?") args = append(args, opts.Date) } if opts.From != "" { where = append(where, "record_date>=?") args = append(args, opts.From) } if opts.To != "" { where = append(where, "record_date<=?") args = append(args, opts.To) } if opts.Status != "" { where = append(where, "status=?") args = append(args, opts.Status) } if opts.Cursor != "" { parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", nil, t.ErrMalformed } where = append(where, "id 100 { limit = 50 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } query := `SELECT * FROM im_attendance_records WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?` rows, err := a.db.QueryxContext(ctx, query, append(args, limit+1)...) if err != nil { return nil, "", nil, err } defer rows.Close() records := make([]t.AttendanceRecord, 0, limit) nextCursor := "" for rows.Next() { record, err := scanAttendanceRecord(rows) if err != nil { return nil, "", nil, err } if len(records) == limit { nextCursor = strconv.FormatInt(record.ID, 10) continue } checkins, err := a.attendanceCheckins(ctx, record.RecordID) if err != nil { return nil, "", nil, err } record.Checkins = checkins records = append(records, *record) } if err = rows.Err(); err != nil { return nil, "", nil, err } stats, err := a.attendanceRecordStats(ctx, opts, uid) if err != nil { return nil, "", nil, err } return records, nextCursor, stats, nil } func (a *adapter) TenantAttendanceCheckin(id t.TenantID, uid t.Uid, checkinID string, req t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, nil, err } if tenantAdp != a { return tenantAdp.TenantAttendanceCheckin(id, uid, checkinID, req) } } if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(checkinID) == "" || strings.TrimSpace(req.IdempotencyKey) == "" { return nil, nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } if existing, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey); err == nil { record, recErr := a.attendanceRecordByID(ctx, existing.RecordID) return record, existing, recErr } else if err != sql.ErrNoRows { return nil, nil, err } rule, err := a.activeAttendanceRule(ctx) if err != nil { return nil, nil, err } shifts, err := a.attendanceShifts(ctx, rule.RuleID) if err != nil { return nil, nil, err } shift := shifts[0] if strings.TrimSpace(req.ShiftID) != "" { for _, candidate := range shifts { if candidate.ShiftID == strings.TrimSpace(req.ShiftID) { shift = candidate break } } } recordID := t.StableEventID("attendance_record", uid.UserId(), req.Date.Format("2006-01-02"), shift.ShiftID) result := attendanceCheckinResult(req.Action, req.ClientTime, req.Date, shift) status := attendanceRecordStatus(req.Action, result, "") exceptions := attendanceExceptions(status) locationJSON, _ := json.Marshal(req.Location) wifiJSON, _ := json.Marshal(req.Wifi) deviceJSON, _ := json.Marshal(req.Device) exceptionsJSON, _ := json.Marshal(exceptions) now := time.Now().UTC().Round(time.Millisecond) tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, nil, err } defer func() { if tx != nil { tx.Rollback() } }() if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version) VALUES(?,?,?,?,?,?,?,?,1) ON DUPLICATE KEY UPDATE record_id=record_id`, recordID, decodeUID(uid), req.Date.Format("2006-01-02"), rule.RuleID, shift.ShiftID, t.AttendanceStatusNotChecked, "[]", ""); err != nil { return nil, nil, err } current := struct { RecordID string `db:"record_id"` Status string Version int }{} if err = sqlx.GetContext(ctx, tx, ¤t, `SELECT record_id,status,version FROM im_attendance_records WHERE user_id=? AND record_date=? AND shift_id=?`, decodeUID(uid), req.Date.Format("2006-01-02"), shift.ShiftID); err != nil { return nil, nil, err } status = attendanceRecordStatus(req.Action, result, current.Status) exceptionsJSON, _ = json.Marshal(attendanceExceptions(status)) if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, checkinID, current.RecordID, decodeUID(uid), req.Date.Format("2006-01-02"), req.Action, rule.RuleID, shift.ShiftID, req.ClientTime.UTC().Round(time.Millisecond), now, result, string(locationJSON), string(wifiJSON), string(deviceJSON), req.IdempotencyKey); err != nil { if isDupe(err) { tx.Rollback() tx = nil existing, lookupErr := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey) if lookupErr != nil { return nil, nil, lookupErr } record, recErr := a.attendanceRecordByID(ctx, existing.RecordID) return record, existing, recErr } return nil, nil, err } if _, err = tx.ExecContext(ctx, `UPDATE im_attendance_records SET status=?,exceptions=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE record_id=?`, status, string(exceptionsJSON), current.RecordID); err != nil { return nil, nil, err } if err = tx.Commit(); err != nil { return nil, nil, err } tx = nil checkin, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey) if err != nil { return nil, nil, err } record, err := a.attendanceRecordByID(ctx, checkin.RecordID) return record, checkin, err } func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal *t.AttendanceAppeal, idempotencyKey string) (*t.AttendanceAppeal, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantAttendanceAppealCreate(id, uid, appeal, idempotencyKey) } } if err := validTenant(id); err != nil || uid.IsZero() || appeal == nil || strings.TrimSpace(idempotencyKey) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } if existing, err := a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey); err == nil { return existing, nil } else if err != sql.ErrNoRows { return nil, err } record, err := a.attendanceRecordByID(ctx, strings.TrimSpace(appeal.RecordID)) if err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } return nil, err } if record.UserID != uid { return nil, t.ErrPermissionDenied } attachmentsJSON, _ := json.Marshal(appeal.AttachmentFileIDs) now := time.Now().UTC().Round(time.Millisecond) if appeal.TargetTime != nil { tm := appeal.TargetTime.UTC().Round(time.Millisecond) appeal.TargetTime = &tm } _, err = a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, strings.TrimSpace(appeal.AppealID), strings.TrimSpace(appeal.RecordID), decodeUID(uid), strings.TrimSpace(appeal.AppealType), strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1, idempotencyKey, now) if err != nil { if isDupe(err) { return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey) } return nil, err } _, _ = a.db.ExecContext(ctx, `UPDATE im_attendance_records SET appeal_status=?,updated_at=CURRENT_TIMESTAMP(3) WHERE record_id=?`, t.AttendanceAppealPending, strings.TrimSpace(appeal.RecordID)) return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey) } func (a *adapter) TenantAttendanceAppealList(id t.TenantID, uid t.Uid, opts t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantAttendanceAppealList(id, uid, opts) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } where := []string{"1=1"} args := []any{} if opts.Scope == "" || opts.Scope == "mine" { where = append(where, "user_id=?") args = append(args, decodeUID(uid)) } else if opts.Scope != "managed" { return nil, "", t.ErrMalformed } if opts.Status != "" { where = append(where, "status=?") args = append(args, opts.Status) } if opts.Cursor != "" { parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "id 100 { limit = 50 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, append(args, limit+1)...) if err != nil { return nil, "", err } defer rows.Close() appeals := make([]t.AttendanceAppeal, 0, limit) nextCursor := "" for rows.Next() { appeal, err := scanAttendanceAppeal(rows) if err != nil { return nil, "", err } if len(appeals) == limit { nextCursor = strconv.FormatInt(appeal.ID, 10) continue } appeals = append(appeals, *appeal) } if err = rows.Err(); err != nil { return nil, "", err } return appeals, nextCursor, nil } func (a *adapter) activeAttendanceRule(ctx context.Context) (*t.AttendanceRule, error) { row := struct { ID int64 RuleID string `db:"rule_id"` Name string Workday bool LocationRequired bool `db:"location_required"` WifiRequired bool `db:"wifi_required"` AllowOutsideCheckin bool `db:"allow_outside_checkin"` AppealAllowed bool `db:"appeal_allowed"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,created_at,updated_at FROM im_attendance_rules WHERE state='active' ORDER BY id ASC LIMIT 1`); err != nil { if err == sql.ErrNoRows { now := time.Now().UTC().Round(time.Millisecond) return &t.AttendanceRule{RuleID: "att_rule_default", Name: "默认考勤规则", Workday: true, AllowOutsideCheckin: true, AppealAllowed: true, CreatedAt: now, UpdatedAt: now}, nil } return nil, err } return &t.AttendanceRule{ ID: row.ID, RuleID: row.RuleID, Name: row.Name, Workday: row.Workday, LocationRequired: row.LocationRequired, WifiRequired: row.WifiRequired, AllowOutsideCheckin: row.AllowOutsideCheckin, AppealAllowed: row.AppealAllowed, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }, nil } func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.AttendanceShift, error) { rows, err := a.db.QueryxContext(ctx, `SELECT id,shift_id,rule_id,name,TIME_FORMAT(work_start,'%H:%i') AS work_start,TIME_FORMAT(work_end,'%H:%i') AS work_end, TIME_FORMAT(checkin_start,'%H:%i') AS checkin_start,TIME_FORMAT(checkout_end,'%H:%i') AS checkout_end,sort_order,created_at,updated_at FROM im_attendance_shifts WHERE rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, ruleID) if err != nil { return nil, err } defer rows.Close() shifts := make([]t.AttendanceShift, 0, 2) for rows.Next() { row := struct { ID int64 ShiftID string `db:"shift_id"` RuleID string `db:"rule_id"` Name string `db:"name"` WorkStart string `db:"work_start"` WorkEnd string `db:"work_end"` CheckinStart string `db:"checkin_start"` CheckoutEnd string `db:"checkout_end"` SortOrder int `db:"sort_order"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err = rows.StructScan(&row); err != nil { return nil, err } shift := t.AttendanceShift{ ID: row.ID, ShiftID: row.ShiftID, RuleID: row.RuleID, Name: row.Name, WorkStart: row.WorkStart, WorkEnd: row.WorkEnd, CheckinStart: row.CheckinStart, CheckoutEnd: row.CheckoutEnd, SortOrder: row.SortOrder, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } shifts = append(shifts, shift) } if err = rows.Err(); err != nil { return nil, err } if len(shifts) == 0 { now := time.Now().UTC().Round(time.Millisecond) shifts = append(shifts, t.AttendanceShift{ShiftID: "shift_default", RuleID: ruleID, Name: "默认班次", WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", SortOrder: 10, CreatedAt: now, UpdatedAt: now}) } return shifts, nil } func (a *adapter) attendanceRecordByDate(ctx context.Context, uid t.Uid, date time.Time, shiftID string) (*t.AttendanceRecord, error) { query := `SELECT * FROM im_attendance_records WHERE user_id=? AND record_date=?` args := []any{decodeUID(uid), date.Format("2006-01-02")} if shiftID != "" { query += ` AND shift_id=?` args = append(args, shiftID) } query += ` ORDER BY id ASC LIMIT 1` rows, err := a.db.QueryxContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() if rows.Next() { record, err := scanAttendanceRecord(rows) if err != nil { return nil, err } record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID) return record, err } if err = rows.Err(); err != nil { return nil, err } return nil, sql.ErrNoRows } func (a *adapter) attendanceRecordByID(ctx context.Context, recordID string) (*t.AttendanceRecord, error) { rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_records WHERE record_id=?`, recordID) if err != nil { return nil, err } defer rows.Close() if rows.Next() { record, err := scanAttendanceRecord(rows) if err != nil { return nil, err } record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID) return record, err } if err = rows.Err(); err != nil { return nil, err } return nil, sql.ErrNoRows } func (a *adapter) attendanceCheckins(ctx context.Context, recordID string) ([]t.AttendanceCheckin, error) { rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE record_id=? ORDER BY id ASC`, recordID) if err != nil { return nil, err } defer rows.Close() items := make([]t.AttendanceCheckin, 0, 2) for rows.Next() { item, err := scanAttendanceCheckin(rows) if err != nil { return nil, err } items = append(items, *item) } return items, rows.Err() } func (a *adapter) attendanceCheckinByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceCheckin, error) { rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key) if err != nil { return nil, err } defer rows.Close() if rows.Next() { return scanAttendanceCheckin(rows) } if err = rows.Err(); err != nil { return nil, err } return nil, sql.ErrNoRows } func (a *adapter) attendanceAppealByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceAppeal, error) { rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key) if err != nil { return nil, err } defer rows.Close() if rows.Next() { return scanAttendanceAppeal(rows) } if err = rows.Err(); err != nil { return nil, err } return nil, sql.ErrNoRows } func (a *adapter) attendanceRecordStats(ctx context.Context, opts t.AttendanceRecordListOptions, viewer t.Uid) (*t.AttendanceRecordStats, error) { where := []string{"1=1"} args := []any{} if opts.Scope == "" || opts.Scope == "mine" { where = append(where, "user_id=?") args = append(args, decodeUID(viewer)) } if opts.Date != "" { where = append(where, "record_date=?") args = append(args, opts.Date) } if opts.From != "" { where = append(where, "record_date>=?") args = append(args, opts.From) } if opts.To != "" { where = append(where, "record_date<=?") args = append(args, opts.To) } stats := &t.AttendanceRecordStats{} if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(where, " AND "), args...).Scan(&stats.CheckedCount); err != nil { return nil, err } exceptionWhere := append([]string{}, where...) exceptionWhere = append(exceptionWhere, "status IN (?,?,?)") exceptionArgs := append(append([]any{}, args...), t.AttendanceStatusLate, t.AttendanceStatusEarlyLeave, t.AttendanceStatusAbnormal) if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(exceptionWhere, " AND "), exceptionArgs...).Scan(&stats.ExceptionCount); err != nil { return nil, err } if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { stats.ExpectedCount = stats.CheckedCount } if opts.Scope == "" || opts.Scope == "mine" { stats.ExpectedCount = 1 } stats.NotCheckedCount = stats.ExpectedCount - stats.CheckedCount if stats.NotCheckedCount < 0 { stats.NotCheckedCount = 0 } return stats, nil } func scanAttendanceRecord(rows *sqlx.Rows) (*t.AttendanceRecord, error) { row := struct { ID int64 RecordID string `db:"record_id"` UserID int64 `db:"user_id"` Date time.Time `db:"record_date"` RuleID string `db:"rule_id"` ShiftID string `db:"shift_id"` Status string Exceptions []byte AppealStatus string `db:"appeal_status"` Version int CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := rows.StructScan(&row); err != nil { return nil, err } record := &t.AttendanceRecord{ ID: row.ID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date, RuleID: row.RuleID, ShiftID: row.ShiftID, Status: row.Status, AppealStatus: row.AppealStatus, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if len(row.Exceptions) > 0 { _ = json.Unmarshal(row.Exceptions, &record.Exceptions) } return record, nil } func scanAttendanceCheckin(rows *sqlx.Rows) (*t.AttendanceCheckin, error) { row := struct { ID int64 CheckinID string `db:"checkin_id"` RecordID string `db:"record_id"` UserID int64 `db:"user_id"` Date time.Time `db:"record_date"` Action string RuleID string `db:"rule_id"` ShiftID string `db:"shift_id"` ClientTime time.Time `db:"client_time"` ServerTime time.Time `db:"server_time"` Result string Location []byte Wifi []byte Device []byte IdempotencyKey string `db:"idempotency_key"` CreatedAt time.Time `db:"created_at"` }{} if err := rows.StructScan(&row); err != nil { return nil, err } item := &t.AttendanceCheckin{ ID: row.ID, CheckinID: row.CheckinID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date, Action: row.Action, RuleID: row.RuleID, ShiftID: row.ShiftID, ClientTime: row.ClientTime, ServerTime: row.ServerTime, Result: row.Result, IdempotencyKey: row.IdempotencyKey, CreatedAt: row.CreatedAt, } _ = json.Unmarshal(row.Location, &item.Location) _ = json.Unmarshal(row.Wifi, &item.Wifi) _ = json.Unmarshal(row.Device, &item.Device) return item, nil } func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) { row := struct { ID int64 AppealID string `db:"appeal_id"` RecordID string `db:"record_id"` UserID int64 `db:"user_id"` AppealType string `db:"appeal_type"` Reason string TargetAction string `db:"target_action"` TargetTime *time.Time `db:"target_time"` AttachmentFileIDs []byte `db:"attachment_file_ids"` Status string Version int IdempotencyKey string `db:"idempotency_key"` SubmittedAt time.Time `db:"submitted_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := rows.StructScan(&row); err != nil { return nil, err } appeal := &t.AttendanceAppeal{ ID: row.ID, AppealID: row.AppealID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), AppealType: row.AppealType, Reason: row.Reason, TargetAction: row.TargetAction, TargetTime: row.TargetTime, Status: row.Status, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if len(row.AttachmentFileIDs) > 0 { _ = json.Unmarshal(row.AttachmentFileIDs, &appeal.AttachmentFileIDs) } return appeal, nil } func attendanceCheckinResult(action string, clientTime, date time.Time, shift t.AttendanceShift) string { switch action { case t.AttendanceActionCheckIn: if clientTime.After(attendanceTimeOnDate(date, shift.WorkStart)) { return t.AttendanceCheckinLate } case t.AttendanceActionCheckOut: if clientTime.Before(attendanceTimeOnDate(date, shift.WorkEnd)) { return t.AttendanceCheckinEarlyLeave } } return t.AttendanceCheckinNormal } func attendanceRecordStatus(action, result, current string) string { if result == t.AttendanceCheckinLate { return t.AttendanceStatusLate } if result == t.AttendanceCheckinEarlyLeave { return t.AttendanceStatusEarlyLeave } if current == t.AttendanceStatusLate || current == t.AttendanceStatusEarlyLeave || current == t.AttendanceStatusAbnormal { return current } if action == t.AttendanceActionCheckOut { return t.AttendanceStatusNormal } return t.AttendanceStatusCheckedIn } func attendanceExceptions(status string) []string { switch status { case t.AttendanceStatusLate: return []string{"late"} case t.AttendanceStatusEarlyLeave: return []string{"early_leave"} case t.AttendanceStatusAbnormal: return []string{"abnormal"} default: return []string{} } } func attendanceTimeOnDate(date time.Time, clock string) time.Time { parts := strings.Split(clock, ":") hour, minute := 0, 0 if len(parts) >= 2 { hour, _ = strconv.Atoi(parts[0]) minute, _ = strconv.Atoi(parts[1]) } loc := time.FixedZone("CST", 8*3600) localDate := date.In(loc) return time.Date(localDate.Year(), localDate.Month(), localDate.Day(), hour, minute, 0, 0, loc).UTC() } func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, to, status, cursor string, limit int) ([]t.DailyReport, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantDailyReportList(id, uid, scope, from, to, status, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } where := []string{"1=1"} args := []any{} if scope == "" || scope == "mine" { where = append(where, "author_id=?") args = append(args, decodeUID(uid)) } else if scope == "managed" { visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, "", err } if !canUseManagedReports(visibility) { return nil, "", t.ErrPermissionDenied } } else { return nil, "", t.ErrMalformed } if from != "" { where = append(where, "report_date>=?") args = append(args, from) } if to != "" { where = append(where, "report_date<=?") args = append(args, to) } if status != "" { where = append(where, "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, "id 0 { submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`) submittedArgs = append(submittedArgs, memberArgs...) } if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil { return nil, err } stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount if stats.MissingCount < 0 { stats.MissingCount = 0 } return stats, nil } func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) { memberWhere := []string{"m.employment_state='active'"} memberArgs := []any{} if strings.TrimSpace(departmentID) != "" { memberWhere = append(memberWhere, "m.department_id=?") memberArgs = append(memberArgs, strings.TrimSpace(departmentID)) } visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") if visibilitySQL != "" { memberWhere = append(memberWhere, visibilitySQL) memberArgs = append(memberArgs, visibilityArgs...) } return memberWhere, memberArgs } func canUseManagedReports(visibility organizationVisibility) bool { return visibility.Scope == orgVisibilityAll } func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey) } } if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, err } if !canUseManagedReports(visibility) { return nil, t.ErrPermissionDenied } targetIDs := make([]int64, 0, len(userIDs)) seen := map[int64]struct{}{} for _, target := range userIDs { if target.IsZero() { return nil, t.ErrMalformed } decoded := decodeUID(target) if decoded <= 0 { return nil, t.ErrMalformed } if _, ok := seen[decoded]; ok { continue } seen[decoded] = struct{}{} targetIDs = append(targetIDs, decoded) } query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS ( SELECT 1 FROM im_daily_reports AS r WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=? )`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted) if err != nil { return nil, err } query = a.db.Rebind(query) rows, err := a.db.QueryxContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() reminded := make([]t.Uid, 0, len(targetIDs)) for rows.Next() { var raw int64 if err = rows.Scan(&raw); err != nil { return nil, err } target := store.EncodeUid(raw) reminded = append(reminded, target) } if err = rows.Err(); err != nil { return nil, err } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } committed := false defer func() { if !committed { _ = tx.Rollback() } }() for _, target := range reminded { eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey)) if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{ "date": date.Format("2006-01-02"), }); err != nil { return nil, err } } if err = tx.Commit(); err != nil { return nil, err } committed = true return reminded, nil } func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) { row := struct { ID int64 ReportID string `db:"report_id"` AuthorID int64 `db:"author_id"` ReportDate time.Time `db:"report_date"` Content string Attachments []byte Status string ReadState string `db:"read_state"` Version int SubmittedAt *time.Time `db:"submitted_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := rows.StructScan(&row); err != nil { return nil, err } report := &t.DailyReport{ ID: row.ID, ReportID: row.ReportID, AuthorID: store.EncodeUid(row.AuthorID), ReportDate: row.ReportDate, Content: row.Content, Status: row.Status, ReadState: row.ReadState, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if len(row.Attachments) > 0 { _ = json.Unmarshal(row.Attachments, &report.Attachments) } return report, nil } func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]t.MonthlyReport, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, "", err } if tenantAdp != a { return tenantAdp.TenantMonthlyReportList(id, uid, scope, fromMonth, toMonth, status, cursor, limit) } } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } where := []string{"1=1"} args := []any{} if scope == "" || scope == "mine" { where = append(where, "author_id=?") args = append(args, decodeUID(uid)) } else if scope == "managed" { visibility, err := a.organizationVisibility(ctx, uid) if err != nil { return nil, "", err } if !canUseManagedReports(visibility) { return nil, "", t.ErrPermissionDenied } } else { return nil, "", t.ErrMalformed } if fromMonth != "" { where = append(where, "report_month>=?") args = append(args, fromMonth) } if toMonth != "" { where = append(where, "report_month<=?") args = append(args, toMonth) } if status != "" { where = append(where, "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, "id 0 { submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`) submittedArgs = append(submittedArgs, memberArgs...) } if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil { return nil, err } stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount if stats.MissingCount < 0 { stats.MissingCount = 0 } return stats, nil } func scanMonthlyReport(rows *sqlx.Rows) (*t.MonthlyReport, error) { row := struct { ID int64 ReportID string `db:"report_id"` AuthorID int64 `db:"author_id"` Month string `db:"report_month"` WorkSummary string `db:"work_summary"` NextPlan string `db:"next_plan"` Attachments []byte Status string ReadState string `db:"read_state"` Version int SubmittedAt *time.Time `db:"submitted_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := rows.StructScan(&row); err != nil { return nil, err } report := &t.MonthlyReport{ ID: row.ID, ReportID: row.ReportID, AuthorID: store.EncodeUid(row.AuthorID), Month: row.Month, WorkSummary: row.WorkSummary, NextPlan: row.NextPlan, Status: row.Status, ReadState: row.ReadState, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if len(row.Attachments) > 0 { _ = json.Unmarshal(row.Attachments, &report.Attachments) } return report, nil } func (a *adapter) TenantMailboxConfigGet(id t.TenantID, uid t.Uid) (*t.MailboxConfig, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMailboxConfigGet(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.mailboxConfig(ctx) } func (a *adapter) TenantMailboxSubmissionCreate(id t.TenantID, uid t.Uid, submission *t.MailboxSubmission, idempotencyKey string) (*t.MailboxSubmission, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantMailboxSubmissionCreate(id, uid, submission, idempotencyKey) } } if err := validTenant(id); err != nil || uid.IsZero() || submission == nil { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } existing, err := a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey) if err == nil { return existing, nil } if err != t.ErrNotFound { return nil, err } config, err := a.mailboxConfig(ctx) if err != nil { return nil, err } if !config.Enabled || (submission.Anonymous && !config.AllowAnonymous) || len([]rune(submission.Content)) > config.MaxLength { return nil, t.ErrPolicy } currentNoticeVersion := config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano) if submission.NoticeVersion != "" && submission.NoticeVersion != currentNoticeVersion { return nil, t.ErrInvalidResponse } attachments, err := json.Marshal(submission.AttachmentFileIDs) if err != nil { return nil, err } now := time.Now().UTC().Round(time.Millisecond) _, err = a.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, decodeUID(uid), strings.TrimSpace(submission.Subject), strings.TrimSpace(submission.Content), submission.Anonymous, string(attachments), currentNoticeVersion, idempotencyKey, t.MailboxSubmissionStatusReceived, now) if err != nil { if isDupe(err) { return a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey) } return nil, err } return a.mailboxSubmissionById(ctx, submission.SubmissionID) } func (a *adapter) mailboxConfig(ctx context.Context) (*t.MailboxConfig, error) { row := struct { Enabled bool AllowAnonymous bool `db:"allow_anonymous"` RequiredFields []byte `db:"required_fields"` MaxLength int `db:"max_length"` AttachmentPolicy []byte `db:"attachment_policy"` NoticeTitle string `db:"notice_title"` NoticeContent string `db:"notice_content"` NoticeUpdatedAt time.Time `db:"notice_updated_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, ` SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy, notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE id=1`); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } return nil, err } config := &t.MailboxConfig{ Enabled: row.Enabled, AllowAnonymous: row.AllowAnonymous, MaxLength: row.MaxLength, Notice: t.MailboxNotice{ Title: row.NoticeTitle, Content: row.NoticeContent, UpdatedAt: row.NoticeUpdatedAt, }, UpdatedAt: row.UpdatedAt, } _ = json.Unmarshal(row.RequiredFields, &config.RequiredFields) _ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy) return config, nil } func (a *adapter) mailboxSubmissionByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.MailboxSubmission, error) { row := struct { SubmissionID string `db:"submission_id"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT submission_id FROM im_mailbox_submissions WHERE sender_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } return nil, err } return a.mailboxSubmissionById(ctx, row.SubmissionID) } func (a *adapter) mailboxSubmissionById(ctx context.Context, submissionID string) (*t.MailboxSubmission, error) { row := struct { ID int64 SubmissionID string `db:"submission_id"` SenderID int64 `db:"sender_id"` Subject string Content string Anonymous bool AttachmentFileIDs []byte `db:"attachment_file_ids"` NoticeVersion string `db:"notice_version"` Status string SubmittedAt time.Time `db:"submitted_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, ` SELECT id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids, notice_version,status,submitted_at,created_at,updated_at FROM im_mailbox_submissions WHERE submission_id=?`, submissionID); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } return nil, err } submission := &t.MailboxSubmission{ ID: row.ID, SubmissionID: row.SubmissionID, SenderID: store.EncodeUid(row.SenderID), Subject: row.Subject, Content: row.Content, Anonymous: row.Anonymous, NoticeVersion: row.NoticeVersion, Status: row.Status, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } _ = json.Unmarshal(row.AttachmentFileIDs, &submission.AttachmentFileIDs) return submission, nil } 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() } profileID, err := nextSnowflakeID() if err != nil { return nil, err } if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil { return nil, err } return a.userProfileByUID(ctx, uid) } 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() } profileID, err := nextSnowflakeID() if err != nil { return nil, err } if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); 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, decodeUID(uid), patch.Version) res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE 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, 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() } privacyID, err := nextSnowflakeID() if err != nil { return nil, err } if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil { return nil, err } return a.userPrivacyByUID(ctx, uid) } 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() } privacyID, err := nextSnowflakeID() if err != nil { return nil, err } if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(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) } args = append(args, decodeUID(uid), patch.Version) res, err := a.db.ExecContext(ctx, `UPDATE im_user_privacy_settings SET `+strings.Join(sets, ",")+` WHERE 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, uid) } func (a *adapter) TenantAppReleaseLatest(id t.TenantID, platform, channel string) (*t.AppRelease, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantAppReleaseLatest(id, platform, channel) } } platform = strings.ToLower(strings.TrimSpace(platform)) channel = strings.ToLower(strings.TrimSpace(channel)) if err := validTenant(id); err != nil || platform == "" || channel == "" { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } row := struct { ID int64 `db:"id"` Platform string `db:"platform"` Channel string `db:"channel"` LatestVersion string `db:"latest_version"` MinSupportedVersion string `db:"min_supported_version"` UpdateType string `db:"update_type"` ReleaseNotes string `db:"release_notes"` StoreURL string `db:"store_url"` PackageURL string `db:"package_url"` Checksum string `db:"checksum"` RemindAfterSeconds int `db:"remind_after_seconds"` PublishedAt time.Time `db:"published_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at FROM im_app_releases WHERE platform=? AND channel=? LIMIT 1`, platform, channel) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } return &t.AppRelease{ ID: row.ID, Platform: row.Platform, Channel: row.Channel, LatestVersion: row.LatestVersion, MinSupportedVersion: row.MinSupportedVersion, UpdateType: row.UpdateType, ReleaseNotes: row.ReleaseNotes, StoreURL: row.StoreURL, PackageURL: row.PackageURL, Checksum: row.Checksum, RemindAfterSeconds: row.RemindAfterSeconds, PublishedAt: row.PublishedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }, nil } 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)) tokenID, err := nextSnowflakeID() if err != nil { return nil, err } _, err = a.db.ExecContext(ctx, `INSERT INTO im_device_push_tokens(id,device_id,user_id,provider,token,app_version,platform,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),updated_at=VALUES(updated_at)`, tokenID, token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now) if err != nil { return nil, err } token.CreatedAt = now token.UpdatedAt = now return token, nil } func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, 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.TenantDevicePushTokenGetAll(id, uids...) } } 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 provider IN ('fcm','firebase') AND user_id IN (?)`, 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(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`, 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, 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, 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 challenge_id=? AND state=?`, nextState, decision, strings.TrimSpace(actionTokenID), now, challengeID, t.RiskChallengeStatePending) if err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } tx = nil return a.riskChallengeByID(ctx, 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(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`, 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, 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(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`, 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, feedback.UserID, idempotencyKey) } func (a *adapter) riskChallengeByID(ctx context.Context, challengeID string) (*t.RiskChallenge, error) { return a.riskChallengeByIDTx(ctx, a.db, challengeID, false) } func (a *adapter) riskChallengeByIDTx(ctx context.Context, q sqlx.QueryerContext, 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 challenge_id=?` if forUpdate { query += " FOR UPDATE" } if err := sqlx.GetContext(ctx, q, &row, query, 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, 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 reporter_id=? AND idempotency_key=?`, 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, 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 user_id=? AND idempotency_key=?`, 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, 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"` 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,version,created_at,updated_at FROM im_user_profiles WHERE user_id=?`, 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, 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, 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"` 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,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, 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, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } _ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods) return settings, nil } func isAllowedWorkbenchRoute(route string) bool { switch route { case "/organization", "/attendance", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": return true default: return false } } 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 "+ "FROM im_messages WHERE topic=? AND seqid=?"+suffix, 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, topic string) (int64, error) { var groupID int64 err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE topic=? LIMIT 1", 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) { row := struct { t.Message RecalledBy sql.NullInt64 `db:"recalled_by"` }{} err := sqlx.GetContext(ctx, q, &row, "SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+ "FROM im_group_messages WHERE topic=? AND seqid=?"+suffix, topic, seq) if err != nil { return nil, err } msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy) return &msg, nil } func messageVersion(version int) int { if version <= 0 { return 1 } return version } func kvMapFromJSON(value any) t.KVMap { switch parsed := common.FromJSON(value).(type) { case map[string]any: return t.KVMap(parsed) case t.KVMap: return parsed default: return nil } } func (a *adapter) 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, 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 (id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, settingsID, decodeUID(uid), strings.TrimSpace(topic), false, false, "", "default", 1, now, now) if err != nil { return nil, err } return a.conversationSettingsByKey(ctx, 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, 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 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, 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, 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() _, err = a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts (id,user_id,topic,content,entities_json,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE content=VALUES(content),entities_json=VALUES(entities_json), version=version+1,updated_at=VALUES(updated_at)`, draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now) if err != nil { return nil, err } return a.topicDraftByKey(ctx, 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 user_id=? AND topic=? AND version=?`, content, entities, 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, uid, strings.TrimSpace(topic)) } func (a *adapter) conversationSettingsByKey(ctx context.Context, 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 user_id=? AND topic=?`, 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, 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 user_id=? AND topic=?`, 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(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`, 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{decodeUID(fav.UserID)}, args...) rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE 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{"user_id=?"} args := []any{decodeUID(uid)} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "id 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.DeviceUpsert(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 { devs, count, err := tenantAdp.DeviceGetAll(uids...) for uid := range devs { for i := range devs[uid] { devs[uid][i].TenantID = id } } return devs, count, err } 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.DeviceDelete(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.FileStartUpload(fd) } if a.tenantScopedDB { return a.FileStartUpload(fd) } var user any = 0 if fd.User != "" { user = decodeUID(t.ParseUid(fd.User)) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err = a.db.ExecContext(ctx, "INSERT INTO im_fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)", id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) return err } func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success bool, size int64) (*t.FileDef, error) { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || fd == nil || fd.TenantID != id { return nil, t.ErrMalformed } if tenantAdp != a { file, err := tenantAdp.FileFinishUpload(fd, success, size) return stampTenantFile(id, file), err } if a.tenantScopedDB { file, err := a.FileFinishUpload(fd, success, size) return stampTenantFile(id, file), err } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if err != nil { tx.Rollback() } }() now := t.TimeNow() if success { _, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?", now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid())) if err == nil { fd.UpdatedAt = now fd.Size = size err = insertFileReviewTasks(ctx, tx, fd) } fd.Status = t.UploadCompleted } else { _, err = tx.Exec("DELETE FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid())) fd.Status = t.UploadFailed fd.Size = 0 } if err != nil { return nil, err } fd.UpdatedAt = now return fd, tx.Commit() } func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { file, err := tenantAdp.FileGet(fid) return stampTenantFile(id, file), err } if a.tenantScopedDB { file, err := a.FileGet(fid) return stampTenantFile(id, file), err } fileID := t.ParseUid(fid) if fileID.IsZero() { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var fd t.FileDef err = a.db.GetContext(ctx, &fd, "SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ "FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID)) if err == sql.ErrNoRows { 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.FileDeleteUnused(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 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.FileLinkAttachments(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 for _, fid := range fids { fileID := t.ParseUid(fid) if fileID.IsZero() { return t.ErrMalformed } args = append(args, id, now, decodeUID(fileID), linkID) } 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 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.FileUserHasAccess(fid, uid) } if a.tenantScopedDB { return a.FileUserHasAccess(fid, uid) } fileID := t.ParseUid(fid) if fileID.IsZero() { return false, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var allowed int err = a.db.GetContext(ctx, &allowed, ` SELECT COUNT(*) FROM im_fileuploads AS fu WHERE fu.tenant_id=? AND fu.id=? 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.userid=? ) 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_messages 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) ) ) ) LIMIT 1`, id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid)) if err != nil { return false, err } return allowed > 0, nil } func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) { if err := validTenant(id); err != nil { return "", err } 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.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() _, err = tx.ExecContext(ctx, `UPDATE im_verification_codes SET state=?,updated_at=? WHERE scene=? AND phone_hash=? AND state=?`, t.VerificationStateExpired, now, vc.Scene, vc.PhoneHash, t.VerificationStatePending) if err != nil { return err } vc.CreatedAt = now vc.UpdatedAt = now _, err = tx.ExecContext(ctx, `INSERT INTO im_verification_codes (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(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, 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 tx.Commit() } 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 challenge_id=? LIMIT 1`, 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 scene=? AND phone_hash=? AND state=? AND expires_at>? ORDER BY created_at DESC LIMIT 1`, 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 challenge_id=?`, state, now, 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 challenge_id=? AND state=?`, t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, 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 challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`, t.VerificationStateConsumed, consumedAt, consumedAt, 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) now := t.TimeNow() securityID, err := nextSnowflakeID() if err != nil { return 0, err } _, err = a.db.ExecContext(ctx, `INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE auth_version=auth_version`, securityID, decoded, 1, now, now) if err != nil { return 0, err } var version int err = a.db.GetContext(ctx, &version, `SELECT auth_version FROM im_user_security WHERE user_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(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)`, 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 user_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 user_id=? ORDER BY created_at DESC,id DESC LIMIT 1`, 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 (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(?,?,?,?,?,?,?,?,?,?,?,?,?)`, 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 (id,appeal_id,user_id,reason,attachments_json,status,review_comment,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, 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 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } now := t.TimeNow() token.CreatedAt = now token.UpdatedAt = now _, err := a.db.ExecContext(ctx, `INSERT INTO im_action_tokens (user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State, token.ExpiresAt, token.CreatedAt, token.UpdatedAt) if isDupe(err) { return t.ErrDuplicate } return err } 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 } tokenHash := securityTokenHash(id, uid, action, target, tokenID, secret) ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if err != nil { tx.Rollback() } }() var at t.ActionToken err = tx.GetContext(ctx, &at, `SELECT id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at FROM im_action_tokens WHERE user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`, decodeUID(uid), tokenID, action, target) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } if at.State != 0 || at.TokenHash != tokenHash || !now.Before(at.ExpiresAt) { return nil, t.ErrNotFound } res, err := tx.ExecContext(ctx, `UPDATE im_action_tokens SET state=1,consumed_at=?,updated_at=? WHERE user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`, now, now, decodeUID(uid), tokenID, action, target, now) if err != nil { return nil, err } if rows, _ := res.RowsAffected(); rows == 0 { return nil, t.ErrNotFound } if err = tx.Commit(); err != nil { return nil, err } at.TenantID = id return &at, nil } 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 user_id=? AND purpose=?`, 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 (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)`, 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 user_id=? AND purpose=?`, attempts, lockedUntil, updatedAt, 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, uid) if err == sql.ErrNoRows { return defaultYouthModeSettings(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 user_id=? AND purpose=? AND LENGTH(password_hash)>0`, enabled, policyVersion, enabledAt, updatedAt, decodeUID(uid), "youth_mode") if err != nil { return nil, err } affected, _ := res.RowsAffected() if affected == 0 { return nil, t.ErrMalformed } return a.youthModeByUID(ctx, uid) } func (a *adapter) youthModeByUID(ctx context.Context, 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 user_id=? AND purpose=?`, 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(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, `INSERT INTO im_system_notifications (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)`, 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 user_id=?` args := []any{decodeUID(uid)} if lastID > 0 { query += " AND id 100 { limit = 20 } where := []string{} args := []any{} 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?`, 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 ((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?)) ORDER BY created_at DESC,id DESC LIMIT 1", 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 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 request_id=? FOR UPDATE", 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 request_id=? AND status=?", t.FriendRequestAccepted, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil { return nil, err } row.Status = t.FriendRequestAccepted row.HandledAt = &handledAt row.UpdatedAt = handledAt } for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} { friendshipID, idErr := nextSnowflakeID() if idErr != nil { return nil, idErr } if _, err = tx.ExecContext(ctx, "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", 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 = createSubscription(tx, applicantSub, true); err != nil { return nil, err } if err = createSubscription(tx, 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, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE name=?", handledAt, handledAt, t.StateOK, 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, applicant, t.StableEventID(applicantEventID, "notification"), eventType, "好友申请已处理", t.FriendRequestAccepted, map[string]any{"type": "friend_request", "request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil { return nil, err } if err = insertBusinessEventTx(tx, applicantEventID, "user", applicant.UserId(), actor, eventType, map[string]any{"request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil { return nil, err } if err = insertBusinessEventTx(tx, t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted+":self", eventType, actor.UserId()), "user", actor.UserId(), actor, eventType, map[string]any{"request_id": row.RequestID, "user_id": actor.UserId(), "topic": topic}); err != nil { return nil, err } if err = tx.Commit(); err != nil { return nil, err } return a.friendRequestByRequestID(ctx, id, requestID) } func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, actor t.Uid, status string, handledAt time.Time) (*t.FriendRequest, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantFriendRequestResolve(id, requestID, actor, status, handledAt) } } if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } req, err := a.friendRequestByRequestID(ctx, id, requestID) if err != nil || req == nil { return req, err } if req.TargetID != actor { return nil, t.ErrPermissionDenied } if req.Status != t.FriendRequestPending { return req, nil } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() if _, err = tx.ExecContext(ctx, "UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE request_id=? AND status=?", status, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil { return nil, err } eventType := "friend.request." + status eventID := t.StableEventID("relation", req.RequestID+":"+status, eventType, req.ApplicantID.UserId()) if err = insertSystemNotificationTx(tx, req.ApplicantID, t.StableEventID(eventID, "notification"), eventType, "好友申请已处理", status, map[string]any{"type": "friend_request", "request_id": req.RequestID, "user_id": actor.UserId()}); err != nil { return nil, err } if err = insertBusinessEventTx(tx, eventID, "user", req.ApplicantID.UserId(), actor, eventType, map[string]any{"request_id": req.RequestID, "user_id": actor.UserId()}); err != nil { return nil, err } if err = insertBusinessEventTx(tx, t.StableEventID("relation", req.RequestID+":"+status+":self", eventType, actor.UserId()), "user", actor.UserId(), actor, eventType, map[string]any{"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, "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", 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.user_id=?", "fs.status=?", "fs.peer_id<>?"} args := []any{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 ? OR CAST(u.public AS CHAR) LIKE ?)") args = append(args, like, like, like, like) } if initial = strings.TrimSpace(initial); initial != "" { like := initial + "%" where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR CAST(u.public AS CHAR) LIKE ?)") args = append(args, like, like, like) } args = append(args, limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at FROM im_friendships AS fs JOIN im_users AS u ON u.id=fs.peer_id LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE `+strings.Join(where, " AND ")+` ORDER BY fs.peer_id ASC LIMIT ?`, 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"` Public any Trusted any ProfileNickname sql.NullString `db:"profile_nickname"` ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"` 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(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID), 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, `SELECT user_id,peer_id,status,remark,topic,created_at,updated_at FROM im_friendships WHERE user_id=? AND peer_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, "UPDATE im_friendships SET remark=?,updated_at=? WHERE user_id=? AND peer_id=? AND status=?", remark, now, 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() } _, err := a.db.ExecContext(ctx, "UPDATE im_friendships SET status=?,updated_at=? WHERE (user_id=? AND peer_id=?) OR (user_id=? AND peer_id=?)", t.RelationshipNone, now, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1)) return err } 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 blocker_id=? AND blocked_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.blocker_id=?"} args := []any{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 50 { limit = 20 } where := []string{"u.id<>?", "u.state=?"} args := []any{decodeUID(viewer), t.StateOK} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { return nil, "", t.ErrMalformed } where = append(where, "u.id>?") args = append(args, parsed) } 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", } 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 CAST(u.public AS CHAR) LIKE ? OR u.id=?)") args = append(args, like, like, like, queryText, phoneCandidate, like, 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, `(EXISTS ( SELECT 1 FROM im_friendships 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')`) args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod) args = append(args, limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT 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.public,u.trusted, profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,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 request_id=?", 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 applicant_id=? AND idempotency_key=?", decodeUID(applicantID), key) } func (a *adapter) friendRequestByQuery(ctx context.Context, tenantID t.TenantID, query string, args ...any) (*t.FriendRequest, error) { row := struct { ID int64 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)) }