diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 7f910b9..3cc4fb7 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2439,6 +2439,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 +- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 ### 10.3 设置昵称.png diff --git a/docs/IM-系统设计文档.md b/docs/IM-系统设计文档.md index e885405..cfba600 100644 --- a/docs/IM-系统设计文档.md +++ b/docs/IM-系统设计文档.md @@ -508,11 +508,11 @@ end_reason, created_at, updated_at ### 14.1 个人资料 -基础展示资料可以同步到 User Public,但可索引、可审计或敏感字段应进入独立表。 +个人资料以 `im_user_profiles` 为唯一业务真源;User Public 与 `im_subscriptions.public` 仅作为 MeetingIM 协议展示投影/缓存,由服务端在资料变更后同步刷新,业务接口不得把这些投影当作可写真源。 | 字段 | 存储建议 | | --- | --- | -| 头像、昵称、签名 | User Public + `im_user_profiles` | +| 头像、昵称、签名 | `im_user_profiles`;User Public / Subscription Public 仅保留服务端生成的展示投影 | | 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 | | 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 | | 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 | diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 4d4f9d3..402a39c 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -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 { diff --git a/server/db/mysql/report_group_regression_test.go b/server/db/mysql/report_group_regression_test.go index 5735b70..6d1b22b 100644 --- a/server/db/mysql/report_group_regression_test.go +++ b/server/db/mysql/report_group_regression_test.go @@ -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) + } + } +} diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 44afedb..3f537d0 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -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 { diff --git a/server/db/mysql/user_identity_test.go b/server/db/mysql/user_identity_test.go index b756fe3..6a43159 100644 --- a/server/db/mysql/user_identity_test.go +++ b/server/db/mysql/user_identity_test.go @@ -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) { diff --git a/server/im_http.go b/server/im_http.go index 8f1adde..900e608 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -3109,6 +3109,14 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { return } } + if body.AvatarFileID != nil { + if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" { + if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + } + } profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{ AvatarFileID: body.AvatarFileID, Nickname: body.Nickname, @@ -4603,6 +4611,11 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if settings.AvatarFileID != "" { + if err := store.Files.LinkAttachments(principal.TenantID, topicName, types.ZeroUid, []string{settings.AvatarFileID}); err != nil { + logs.Warn.Printf("group[%s]: failed to link avatar attachment: %v", topicName, err) + } + } dispatchIMGroupEvents(principal.TenantID) created, err := store.Topics.Get(principal.TenantID, topicName) if err != nil { @@ -4897,6 +4910,13 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string return } } + if body.AvatarFileID != nil { + if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" { + if err := store.Files.LinkAttachments(principal.TenantID, topic, types.ZeroUid, []string{avatar}); err != nil { + logs.Warn.Printf("group[%s]: failed to link avatar attachment: %v", topic, err) + } + } + } dispatchIMGroupEvents(principal.TenantID) updated, err := store.Topics.Get(principal.TenantID, topic) if err != nil { diff --git a/server/store/store.go b/server/store/store.go index 65b9b96..12a7876 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2834,7 +2834,66 @@ func (profileMapper) Update(tenantID types.TenantID, uid types.Uid, patch types. if err != nil { return nil, err } - return tenantAdp.TenantUserProfileUpdate(tenantID, uid, patch) + profile, err := tenantAdp.TenantUserProfileUpdate(tenantID, uid, patch) + if err != nil { + return nil, err + } + refreshUserProfileProjections(tenantID, uid, profile) + return profile, nil +} + +func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profile *types.UserProfile) { + _ = DeleteCachedObject(tenantID, cacheNamespaceUser, uid.String()) + if profile == nil { + return + } + public := userProfilePublicProjection(profile) + cursor := "" + for { + items, nextCursor, err := Relations.ListFriends(tenantID, uid, "", "", cursor, 100) + if err != nil { + logs.Warn.Printf("profile %s: refresh friend subscription projections failed: %v", uid, err) + return + } + for _, item := range items { + if item.Friendship.Topic == "" || item.Friendship.PeerID.IsZero() { + continue + } + if err := Subs.Update(tenantID, item.Friendship.Topic, item.Friendship.PeerID, map[string]any{"Public": public}); err != nil { + logs.Warn.Printf("profile %s: refresh subscription %s/%s public failed: %v", uid, item.Friendship.Topic, item.Friendship.PeerID, err) + } + } + if nextCursor == "" { + return + } + cursor = nextCursor + } +} + +func userProfilePublicProjection(profile *types.UserProfile) map[string]any { + public := make(map[string]any) + nickname := strings.TrimSpace(profile.Nickname) + if nickname != "" { + for _, key := range []string{"display_name", "nickname", "fn", "name"} { + public[key] = nickname + } + } + if value := strings.TrimSpace(profile.AvatarFileID); value != "" { + public["avatar_file_id"] = value + } + if value := strings.TrimSpace(profile.Signature); value != "" { + public["signature"] = value + } + if value := strings.TrimSpace(profile.Gender); value != "" { + public["gender"] = value + } + if value := strings.TrimSpace(profile.Region); value != "" { + public["region"] = value + } + if value := strings.TrimSpace(profile.PublicAccount); value != "" { + public["public_account"] = value + } + return public } func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) { @@ -4155,19 +4214,33 @@ func (fileMapper) DeleteUnused(tenantID types.TenantID, olderThan time.Time, lim // LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it // from being garbage collected. func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error { - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return err - } // Convert attachment URLs to file IDs. var fids []string for _, url := range attachments { - if fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() { + raw := strings.TrimSpace(url) + if raw == "" { + continue + } + if mediaHandler != nil { + if fid := mediaHandler.GetIdFromUrl(raw); !fid.IsZero() { + fids = append(fids, fid.String()) + continue + } + } + if fid := media.GetIdFromUrl(raw, ""); !fid.IsZero() { + fids = append(fids, fid.String()) + continue + } + if fid := types.ParseUid(raw); !fid.IsZero() { fids = append(fids, fid.String()) } } if len(fids) > 0 { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } userId := types.ZeroUid if topic != "" && types.GetTopicCat(topic) == types.TopicCatMe { userId = types.ParseUserId(topic)