fix: 统一用户资料投影并放开头像访问

This commit is contained in:
richjohnrambo
2026-09-08 15:26:05 +08:00
parent 62e989487b
commit 06945a4050
8 changed files with 250 additions and 57 deletions
+35 -4
View File
@@ -165,6 +165,21 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
return db, dbName, nil
}
func (a *adapter) HealthCheck(ctx context.Context) error {
if a.db == nil {
return errors.New("mysql database is not open")
}
if err := a.db.PingContext(ctx); err != nil {
return err
}
if a.registryDB != nil {
if err := a.registryDB.PingContext(ctx); err != nil {
return errors.New("tenant registry mysql: " + err.Error())
}
}
return nil
}
// Open initializes database session
func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.db != nil {
@@ -2490,7 +2505,8 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
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
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil {
@@ -2523,7 +2539,8 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
}
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
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.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...)
@@ -3259,7 +3276,8 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
// Fetch p2p users and join to p2p subscriptions.
if len(usrq) > 0 {
q = `SELECT 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
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?)`
q, args, _ = sqlx.In(q, usrq)
if !keepDeleted {
@@ -5165,6 +5183,12 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.fileid=fu.id AND fml.userid=?
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_friendships AS fs
ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?
WHERE fml.fileid=fu.id AND fml.userid IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s
@@ -5172,6 +5196,12 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_group_members AS gm
ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?
WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m
@@ -5211,7 +5241,8 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
)
)
) LIMIT 1`,
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid),
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend,
store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid),
store.DecodeUid(uid), fid, fid,
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
if err != nil {
@@ -54,3 +54,21 @@ func TestMessageSearchDoesNotClearScannedHead(t *testing.T) {
t.Fatal("message search must not clear Head after sqlx StructScan has already decoded it")
}
}
func TestTenantFileAccessIncludesAvatarAudienceRules(t *testing.T) {
source, err := os.ReadFile("tenant_adapter.go")
if err != nil {
t.Fatal(err)
}
body := string(source)
for _, want := range []string{
"INNER JOIN im_friendships AS fs",
"fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?",
"INNER JOIN im_group_members AS gm",
"gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?",
} {
if !strings.Contains(body, want) {
t.Fatalf("file ACL must include avatar audience rule %q", want)
}
}
}
+83 -38
View File
@@ -48,22 +48,26 @@ 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"`
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"`
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"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}
func (row profileUserRow) effectiveUpdatedAt() time.Time {
@@ -81,7 +85,7 @@ func (row profileUserRow) lastSeen() *time.Time {
}
func (row profileUserRow) profilePublic() any {
return profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID)
return profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
}
func profileUserFromRow(row profileUserRow) t.User {
@@ -106,7 +110,7 @@ func profileUserFromRow(row profileUserRow) t.User {
return user
}
func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) any {
func profilePublicFromFields(base any, nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any {
public := publicMap(base)
clearProfilePublicFields(public)
if nickname.Valid {
@@ -123,11 +127,31 @@ func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) an
public["avatar_file_id"] = value
}
}
if signature.Valid {
if value := strings.TrimSpace(signature.String); value != "" {
public["signature"] = value
}
}
if gender.Valid {
if value := strings.TrimSpace(gender.String); value != "" {
public["gender"] = value
}
}
if region.Valid {
if value := strings.TrimSpace(region.String); value != "" {
public["region"] = value
}
}
if publicAccount.Valid {
if value := strings.TrimSpace(publicAccount.String); value != "" {
public["public_account"] = value
}
}
return public
}
func clearProfilePublicFields(public map[string]any) {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar"} {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
delete(public, key)
}
}
@@ -335,7 +359,8 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
var row profileUserRow
err = a.db.GetContext(ctx, &row, `SELECT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`,
id, decodeUID(uid), t.StateDeleted)
@@ -995,7 +1020,8 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
}
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
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)`
q, args, _ = sqlx.In(q, id, usrq)
if !keepDeleted {
@@ -7821,6 +7847,12 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
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_friendships AS fs
ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s
@@ -7828,6 +7860,12 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_group_members AS gm
ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m
@@ -7851,7 +7889,8 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
) LIMIT 1`,
id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid),
id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), t.RelationshipFriend,
decodeUID(uid), decodeUID(uid), t.GroupMemberStatusActive, decodeUID(uid), decodeUID(uid),
decodeUID(uid), fid, fid)
if err != nil {
return false, err
@@ -9397,7 +9436,8 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
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
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_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
@@ -9411,19 +9451,23 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
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"`
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"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
@@ -9447,7 +9491,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID),
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
Trusted: common.FromJSON(row.Trusted),
},
})
@@ -9799,7 +9843,8 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.updated_at AS profile_updated_at
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.signature AS profile_signature,
profile.gender AS profile_gender,profile.region AS profile_region,profile.public_account AS profile_public_account,profile.updated_at AS profile_updated_at
FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
+12 -7
View File
@@ -34,16 +34,22 @@ func TestProfilePublicFromFields(t *testing.T) {
"nickname": "旧昵称",
"display_name": "旧昵称",
"avatar_file_id": "old_avatar",
"signature": "旧签名",
"gender": "old_gender",
"region": "old_region",
"photo": "old_photo",
"public_account": "usrAccount",
}, sql.NullString{String: "新昵称", Valid: true}, sql.NullString{String: "file_avatar_1", Valid: true}).(map[string]any)
}, sql.NullString{String: "新昵称", Valid: true}, sql.NullString{String: "file_avatar_1", Valid: true},
sql.NullString{String: "新签名", Valid: true}, sql.NullString{String: "male", Valid: true},
sql.NullString{String: "上海", Valid: true}, sql.NullString{String: "usrProfile", Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name"} {
if public[key] != "新昵称" {
t.Fatalf("nickname field %s not overlaid: %#v", key, public)
}
}
if public["avatar_file_id"] != "file_avatar_1" || public["public_account"] != "usrAccount" {
if public["avatar_file_id"] != "file_avatar_1" || public["signature"] != "新签名" || public["gender"] != "male" ||
public["region"] != "上海" || public["public_account"] != "usrProfile" {
t.Fatalf("profile public not overlaid: %#v", public)
}
@@ -51,15 +57,14 @@ func TestProfilePublicFromFields(t *testing.T) {
"fn": "旧昵称",
"avatar_file_id": "old_avatar",
"public_account": "usrAccount",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar"} {
"signature": "旧签名",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true},
sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
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) {