Sync profile source and message topic seq
This commit is contained in:
+82
-12
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user