Sync profile source and message topic seq

This commit is contained in:
richjohnrambo
2026-09-04 16:06:57 +08:00
parent 47649d33e2
commit 7fb1ea70bc
5 changed files with 216 additions and 23 deletions
+82 -12
View File
@@ -1055,6 +1055,9 @@ func (a *adapter) UpgradeDb() error {
if err := a.ensureMessageTopicSeqIndex(); err != nil {
return err
}
if err := a.ensureTopicSeqMatchesMessages(); err != nil {
return err
}
if a.version == adpVersion {
return nil
@@ -1080,6 +1083,61 @@ func (a *adapter) ensureMessageTopicSeqIndex() error {
return err
}
func (a *adapter) ensureTopicSeqMatchesMessages() error {
ctx := context.Background()
conn, err := a.db.Connx(ctx)
if err != nil {
return err
}
defer conn.Close()
topicsScoped, err := identityColumnExists(ctx, conn, "im_topics", "tenant_id")
if err != nil {
return err
}
messagesScoped, err := identityColumnExists(ctx, conn, "im_messages", "tenant_id")
if err != nil {
return err
}
if topicsScoped && messagesScoped {
if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT tenant_id,topic,MAX(seqid) AS max_seq FROM im_messages GROUP BY tenant_id,topic) AS m
ON m.tenant_id=t.tenant_id AND m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`); err != nil {
return err
}
} else {
if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT topic,MAX(seqid) AS max_seq FROM im_messages GROUP BY topic) AS m ON m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`); err != nil {
return err
}
}
groupMessagesExist, err := identityTableExists(ctx, conn, "im_group_messages")
if err != nil || !groupMessagesExist {
return err
}
groupMessagesScoped, err := identityColumnExists(ctx, conn, "im_group_messages", "tenant_id")
if err != nil {
return err
}
if topicsScoped && groupMessagesScoped {
_, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT tenant_id,topic,MAX(seqid) AS max_seq FROM im_group_messages GROUP BY tenant_id,topic) AS m
ON m.tenant_id=t.tenant_id AND m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`)
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT topic,MAX(seqid) AS max_seq FROM im_group_messages GROUP BY topic) AS m ON m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`)
return err
}
func createIMNotificationTables(tx *sql.Tx) error {
_, err := tx.Exec(
`CREATE TABLE im_system_notifications(
@@ -2019,6 +2077,10 @@ func (a *adapter) UserCreate(user *t.User) error {
if err != nil {
return err
}
profileID, err := nextSnowflakeID()
if err != nil {
return err
}
var phone any
if strings.TrimSpace(user.Phone) != "" {
canonicalPhone, err := normalizeUserPhone(user.Phone)
@@ -2048,6 +2110,11 @@ func (a *adapter) UserCreate(user *t.User) error {
securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
nickname, avatarFileID := profileSeedFromPublic(user.Public)
if _, err = tx.Exec(`INSERT IGNORE INTO im_user_profiles(id,user_id,avatar_file_id,nickname,public_account,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, profileID, decoded_uid, avatarFileID, nickname, user.Uid().UserId(), user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
user.Tags = nil
return tx.Commit()
@@ -2316,13 +2383,15 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
if cancel != nil {
defer cancel()
}
var user t.User
err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
var row profileUserRow
err := a.db.GetContext(ctx, &row, `SELECT 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.user_id=u.id
WHERE u.id=? AND u.state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil {
user := profileUserFromRow(row)
user.SetUid(uid)
user.Public = common.FromJSON(user.Public)
user.Trusted = common.FromJSON(user.Trusted)
return &user, nil
}
@@ -2348,8 +2417,11 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
if cancel != nil {
defer cancel()
}
q, uids, _ := 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!=?`, uids, t.StateDeleted)
q, uids, _ := sqlx.In(`SELECT 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.user_id=u.id
WHERE u.id IN (?) AND u.state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
if err != nil {
return nil, err
@@ -2357,14 +2429,12 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
defer rows.Close()
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
var row profileUserRow
if err = rows.StructScan(&row); err != nil {
users = nil
break
}
user.SetUid(common.EncodeUidString(user.Id))
user.Public = common.FromJSON(user.Public)
user.Trusted = common.FromJSON(user.Trusted)
user := profileUserFromRow(row)
users = append(users, user)
}
+64 -9
View File
@@ -50,9 +50,13 @@ func stampTenantUser(id t.TenantID, user *t.User) *t.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"`
@@ -80,8 +84,31 @@ 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 != "" {
@@ -99,6 +126,29 @@ func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) an
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 != "<nil>" {
nickname = value
break
}
}
for _, key := range []string{"avatar_file_id", "photo", "avatar"} {
if value := strings.TrimSpace(fmt.Sprint(values[key])); value != "" && value != "<nil>" {
avatarFileID = value
break
}
}
return nickname, avatarFileID
}
func publicMap(value any) map[string]any {
switch public := value.(type) {
case map[string]any:
@@ -282,9 +332,12 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
if cancel != nil {
defer cancel()
}
var user t.User
err = a.db.GetContext(ctx, &user, `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=? AND state!=?`,
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
@@ -292,7 +345,7 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
if err != nil {
return nil, err
}
scanTenantUser(&user)
user := profileUserFromRow(row)
return &user, nil
}
@@ -9264,8 +9317,10 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT 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 FROM im_users AS u `+strings.Join(joins, " ")+`
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
@@ -9274,11 +9329,11 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
users := make([]t.User, 0, limit)
nextCursor := ""
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
var row profileUserRow
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
scanTenantUser(&user)
user := profileUserFromRow(row)
if len(users) == limit {
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
continue
+50
View File
@@ -108,6 +108,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
return err
}
if err = backfillProfileDisplay(ctx, conn, usersScoped); err != nil {
return err
}
}
indexName := "users_phone"
@@ -416,6 +419,53 @@ func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped boo
return nil
}
func backfillProfileDisplay(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error {
profilesScoped, err := identityColumnExists(ctx, conn, "im_user_profiles", "tenant_id")
if err != nil {
return err
}
if profilesScoped != usersScoped {
return errors.New("mysql: im_users and im_user_profiles tenant scope mismatch")
}
nicknameExpr := `COALESCE(
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.display_name')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.nickname')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.fn')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.name')), '')
)`
avatarExpr := `COALESCE(
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo')), ''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar')), '')
)`
if usersScoped {
if _, err = conn.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,tenant_id,user_id,nickname,avatar_file_id,public_account)
SELECT u.id,u.tenant_id,u.id,COALESCE(`+nicknameExpr+`,''),COALESCE(`+avatarExpr+`,''),''
FROM im_users AS u
WHERE `+nicknameExpr+` IS NOT NULL OR `+avatarExpr+` IS NOT NULL`); err != nil {
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_user_profiles AS p
JOIN im_users AS u ON u.tenant_id=p.tenant_id AND u.id=p.user_id
SET p.nickname=IF(p.nickname='' AND `+nicknameExpr+` IS NOT NULL, `+nicknameExpr+`, p.nickname),
p.avatar_file_id=IF(p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL, `+avatarExpr+`, p.avatar_file_id)
WHERE (p.nickname='' AND `+nicknameExpr+` IS NOT NULL) OR (p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL)`)
return err
}
if _, err = conn.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,nickname,avatar_file_id,public_account)
SELECT u.id,u.id,COALESCE(`+nicknameExpr+`,''),COALESCE(`+avatarExpr+`,''),''
FROM im_users AS u
WHERE `+nicknameExpr+` IS NOT NULL OR `+avatarExpr+` IS NOT NULL`); err != nil {
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_user_profiles AS p
JOIN im_users AS u ON u.id=p.user_id
SET p.nickname=IF(p.nickname='' AND `+nicknameExpr+` IS NOT NULL, `+nicknameExpr+`, p.nickname),
p.avatar_file_id=IF(p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL, `+avatarExpr+`, p.avatar_file_id)
WHERE (p.nickname='' AND `+nicknameExpr+` IS NOT NULL) OR (p.avatar_file_id='' AND `+avatarExpr+` IS NOT NULL)`)
return err
}
func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
var out []identityValue
appendQuery := func(query string, args ...any) error {
+17 -2
View File
@@ -52,7 +52,22 @@ func TestProfilePublicFromFields(t *testing.T) {
"avatar_file_id": "old_avatar",
"public_account": "usrAccount",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
if public["fn"] != "旧昵称" || public["avatar_file_id"] != "old_avatar" || public["public_account"] != "usrAccount" {
t.Fatalf("empty profile fields should keep existing public values: %#v", public)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar"} {
if _, ok := public[key]; ok {
t.Fatalf("legacy profile field %s should not be read from im_users.public: %#v", key, public)
}
}
if public["public_account"] != "usrAccount" {
t.Fatalf("unrelated public field changed: %#v", public)
}
}
func TestProfileSeedFromPublic(t *testing.T) {
nickname, avatar := profileSeedFromPublic(map[string]any{
"fn": "旧昵称",
"photo": "file_avatar_1",
})
if nickname != "旧昵称" || avatar != "file_avatar_1" {
t.Fatalf("unexpected profile seed nickname=%q avatar=%q", nickname, avatar)
}
}
+3
View File
@@ -3374,6 +3374,9 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
}
return nil, err
}
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return nil, err
}
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
if err := groupMsgRuntime.MarkDone(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId, msg.Uid().String()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark group message unique key done: %v", msg.Topic, err)