feat(dm): иконка групповой беседы в хранилище и API (Фаза 7)

D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.

Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.

Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
This commit is contained in:
2026-09-26 16:43:51 +03:00
parent c429cbab30
commit 8696d85514
13 changed files with 982 additions and 10 deletions
+8
View File
@@ -68,6 +68,9 @@ type dmChannelPayload struct {
} `json:"recipient"`
LastMessageID string `json:"last_message_id,omitempty"`
LastMessageAt string `json:"last_message_at,omitempty"`
// IconFileID — иконка беседы: у группы своя загрузка (D-070), у 1:1 —
// аватар собеседника. Совпадает с полем READY-снапшота (AGENT.md 8.3).
IconFileID string `json:"icon_file_id,omitempty"`
// Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1
// is_group = false, а данные собеседника лежат в recipient.
IsGroup bool `json:"is_group"`
@@ -934,6 +937,9 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
payload.IsGroup = true
payload.Name = summary.GroupName
payload.MemberCount = summary.MemberCount
if summary.Channel.IconFileID != nil {
payload.IconFileID = formatSnowflake(*summary.Channel.IconFileID)
}
if summary.OwnerID != nil {
payload.OwnerID = formatSnowflake(*summary.OwnerID)
}
@@ -977,6 +983,8 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
}
if summary.AvatarFileID != nil {
payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID)
// У 1:1 иконка беседы — аватар собеседника (как в READY).
payload.IconFileID = formatSnowflake(*summary.AvatarFileID)
}
if summary.LastSeenAt != nil {
payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout)