feat(profile): системные бейджи рядом с ником

- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2):
  список расширяемый, назначается только системой
- клиент: каталог бейджей (щит администратора инстанса, корона владельца,
  ранний сторонник, проверенный), неизвестные показываются нейтральной меткой
- бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки
- тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
This commit is contained in:
2026-09-21 18:10:39 +03:00
parent 0f0060e8a4
commit b43452aa04
19 changed files with 266 additions and 19 deletions
+6
View File
@@ -62,6 +62,8 @@ type memberPayload struct {
JoinedAt string `json:"joined_at"`
TimeoutUntil string `json:"timeout_until,omitempty"`
RoleIDs []string `json:"role_ids"`
// Системные бейджи (AGENT.md 7.2): назначаются только системой.
Badges []string `json:"badges"`
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
}
@@ -1416,6 +1418,10 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
IsInstanceAdmin: profile.IsInstanceAdmin,
JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339),
RoleIDs: make([]string, 0, len(profile.RoleIDs)),
Badges: profile.Badges,
}
if payload.Badges == nil {
payload.Badges = []string{}
}
if profile.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
+43
View File
@@ -478,3 +478,46 @@ func TestChannelBackgroundUploadAndDelete(t *testing.T) {
t.Fatalf("фон не снят: %s", removed.Body.String())
}
}
func TestMembersCarrySystemBadges(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
ownerID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
// Бейдж назначает система (AGENT.md 7.2): в тесте ставим его напрямую.
id, err := parseID("user_id", ownerID)
if err != nil {
t.Fatalf("parse user id: %v", err)
}
if err := srv.store.SetUserBadges(t.Context(), id, []string{"instance_admin"}); err != nil {
t.Fatalf("set badges: %v", err)
}
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner)
members := decodeResponse[struct {
Members []struct {
UserID string `json:"user_id"`
Badges []string `json:"badges"`
} `json:"members"`
}](t, list)
var found bool
for _, entry := range members.Members {
if entry.Badges == nil {
t.Fatalf("бейджи должны быть списком, а не null")
}
if entry.UserID != ownerID {
continue
}
found = true
if !contains(entry.Badges, "instance_admin") {
t.Fatalf("бейджи владельца = %v, ожидался instance_admin", entry.Badges)
}
}
if !found {
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
}
}
+11 -2
View File
@@ -384,13 +384,17 @@ type GuildMemberProfile struct {
JoinedAt time.Time
TimeoutUntil *time.Time
RoleIDs []uint64
// Badges — системные бейджи пользователя (AGENT.md 7.2): список
// расширяемый, назначается только системой.
Badges []string
}
// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями.
func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id,
u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until
u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until,
u.badges_json
FROM guild_members m
JOIN users u ON u.id = m.user_id
WHERE m.guild_id = ? AND u.deleted_at IS NULL
@@ -409,11 +413,16 @@ func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]
isAdmin int
joinedAt string
timeoutUntil sql.NullString
badges string
)
if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname,
&avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil); err != nil {
&avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil,
&badges); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
profile.Badges = nil
}
if nickname.Valid {
profile.Nickname = nickname.String
}