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
+6 -2
View File
@@ -284,7 +284,7 @@ func (s *Store) listDirectChannels(ctx context.Context, userID uint64) ([]DMChan
// пользователя немного, а список нужен интерфейсу для карточки беседы.
func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT c.id, c.name, c.dm_owner_id, c.created_at,
SELECT c.id, c.name, c.dm_owner_id, c.created_at, c.icon_file_id,
(SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id),
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
@@ -305,11 +305,12 @@ func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChann
summary DMChannelSummary
ownerID sql.NullInt64
createdAt string
iconID sql.NullInt64
lastMessageID sql.NullInt64
lastMessageAt sql.NullString
lastAuthorID sql.NullInt64
)
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt,
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt, &iconID,
&summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
return nil, err
}
@@ -318,6 +319,9 @@ func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChann
summary.Channel.Type = ChannelDM
summary.Channel.Name = summary.GroupName
summary.Channel.DMOwnerID = summary.OwnerID
// Иконка группы — своя (D-070), в отличие от 1:1, где показывается
// аватар собеседника (AvatarFileID).
summary.Channel.IconFileID = optionalID(iconID)
summary.Channel.CreatedAt = parseTimestamp(createdAt)
if lastMessageID.Valid {
summary.LastMessageID = uint64(lastMessageID.Int64)