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
+20 -3
View File
@@ -34,7 +34,11 @@ type Channel struct {
// DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он
// добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL.
DMOwnerID *uint64
CreatedAt time.Time
// IconFileID — иконка групповой беседы (AGENT.md 7.8, D-070): обычная
// загрузка, менять её может только владелец. У 1:1 иконка — аватар
// собеседника, поэтому колонка пустая.
IconFileID *uint64
CreatedAt time.Time
}
type ChannelOverride struct {
@@ -59,7 +63,8 @@ type CreateChannelParams struct {
}
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id, created_at`
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id,
created_at, icon_file_id`
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
if params.ID == 0 {
@@ -105,6 +110,9 @@ type UpdateChannelParams struct {
ClearBackground bool
VoiceStatus *string
UserLimit *int
// IconFileID задаёт иконку групповой беседы, ClearIcon снимает её (D-070).
IconFileID *uint64
ClearIcon bool
}
func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) {
@@ -156,6 +164,13 @@ func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChann
sets = append(sets, "user_limit = ?")
args = append(args, *params.UserLimit)
}
if params.IconFileID != nil {
sets = append(sets, "icon_file_id = ?")
args = append(args, int64(*params.IconFileID))
}
if params.ClearIcon {
sets = append(sets, "icon_file_id = NULL")
}
if len(sets) == 0 {
return s.GetChannel(ctx, id)
}
@@ -274,10 +289,11 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
backgroundID sql.NullInt64
dmOwnerID sql.NullInt64
createdAt string
iconID sql.NullInt64
)
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt)
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt, &iconID)
if err != nil {
return nil, mapError(err)
}
@@ -289,6 +305,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
}
channel.BackgroundFileID = optionalID(backgroundID)
channel.DMOwnerID = optionalID(dmOwnerID)
channel.IconFileID = optionalID(iconID)
channel.CreatedAt = parseTimestamp(createdAt)
return &channel, nil
}
+5 -1
View File
@@ -93,8 +93,12 @@ func (s *Store) RemoveDMParticipant(ctx context.Context, channelID, userID uint6
}
}
case remaining == 2:
// Беседа снова обычная личная: имя, владелец и иконка группы
// сбрасываются (AGENT.md 7.8, D-070). Файл иконки после этого
// становится сиротой и удаляется уборкой (AGENT.md 7.7).
if _, err := tx.ExecContext(ctx,
`UPDATE channels SET name = '', dm_owner_id = NULL WHERE id = ?`, int64(channelID)); err != nil {
`UPDATE channels SET name = '', dm_owner_id = NULL, icon_file_id = NULL WHERE id = ?`,
int64(channelID)); err != nil {
return err
}
default:
+170
View File
@@ -0,0 +1,170 @@
package store
import (
"context"
"path/filepath"
"testing"
"glchat/internal/database"
)
// newDMIconStore открывает временную БД и создаёт трёх пользователей: владельца
// группы и двух участников (AGENT.md 7.8).
func newDMIconStore(t *testing.T) (*Store, []uint64) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("открыть тестовую БД: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("закрыть тестовую БД: %v", err)
}
})
st := New(db)
users := make([]uint64, 0, 3)
for index, name := range []string{"icon_owner", "icon_bob", "icon_carol"} {
user, err := st.CreateUser(ctx, CreateUserParams{
Username: name,
EmailEnc: "enc",
EmailIndex: name + "-idx",
PasswordHash: "hash",
})
if err != nil {
t.Fatalf("создать пользователя %d: %v", index, err)
}
users = append(users, user.ID)
}
return st, users
}
// dmIconFile регистрирует файл иконки так же, как это делает сервер: обычная
// загрузка с назначением dm_icon, привязанная к беседе.
func dmIconFile(ctx context.Context, t *testing.T, st *Store, uploaderID, channelID uint64, name string) *File {
t.Helper()
file, err := st.CreateFile(ctx, CreateFileParams{
UploaderID: uploaderID,
ChannelID: &channelID,
Filename: name,
ContentType: "image/png",
SizeBytes: 128,
StoragePath: filepath.Join(t.TempDir(), name),
SHA256: name,
Purpose: "dm_icon",
})
if err != nil {
t.Fatalf("создать файл иконки: %v", err)
}
return file
}
// groupIconSummary ищет беседу в списке пользователя.
func groupIconSummary(ctx context.Context, t *testing.T, st *Store, userID, channelID uint64) DMChannelSummary {
t.Helper()
channels, err := st.ListDMChannels(ctx, userID)
if err != nil {
t.Fatalf("ListDMChannels: %v", err)
}
for _, summary := range channels {
if summary.Channel.ID == channelID {
return summary
}
}
t.Fatalf("беседа %d не найдена в списке пользователя %d", channelID, userID)
return DMChannelSummary{}
}
// TestDMChannelIconLifecycle проверяет иконку групповой беседы (D-070):
// установку и снятие через UpdateChannel, видимость в списке бесед у всех
// участников и ON DELETE SET NULL при удалении файла.
func TestDMChannelIconLifecycle(t *testing.T) {
ctx := context.Background()
st, users := newDMIconStore(t)
channel, err := st.CreateGroupDMChannel(ctx, users[0], "Игровая", users[1:])
if err != nil {
t.Fatalf("CreateGroupDMChannel: %v", err)
}
file := dmIconFile(ctx, t, st, users[0], channel.ID, "icon.png")
updated, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID})
if err != nil {
t.Fatalf("UpdateChannel(IconFileID): %v", err)
}
if updated.IconFileID == nil || *updated.IconFileID != file.ID {
t.Fatalf("icon_file_id = %v, ожидался %d", updated.IconFileID, file.ID)
}
// Иконка видна каждому участнику: список бесед читает ту же колонку.
for _, userID := range users {
summary := groupIconSummary(ctx, t, st, userID, channel.ID)
if !summary.IsGroup {
t.Fatalf("беседа не групповая для пользователя %d: %+v", userID, summary)
}
if summary.Channel.IconFileID == nil || *summary.Channel.IconFileID != file.ID {
t.Fatalf("иконка в списке пользователя %d = %v", userID, summary.Channel.IconFileID)
}
// У группы нет собеседника: аватар 1:1 остаётся пустым.
if summary.AvatarFileID != nil {
t.Fatalf("у группы появился аватар собеседника: %v", *summary.AvatarFileID)
}
}
// Снятие иконки очищает колонку.
cleared, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{ClearIcon: true})
if err != nil {
t.Fatalf("UpdateChannel(ClearIcon): %v", err)
}
if cleared.IconFileID != nil {
t.Fatalf("после снятия icon_file_id = %v, ожидался NULL", *cleared.IconFileID)
}
// Удаление файла обнуляет ссылку (ON DELETE SET NULL), а не ломает беседу.
if _, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}); err != nil {
t.Fatalf("повторная установка иконки: %v", err)
}
if err := st.DeleteFile(ctx, file.ID); err != nil {
t.Fatalf("DeleteFile: %v", err)
}
after, err := st.GetChannel(ctx, channel.ID)
if err != nil {
t.Fatalf("GetChannel после удаления файла: %v", err)
}
if after.IconFileID != nil {
t.Fatalf("после удаления файла icon_file_id = %v, ожидался NULL", *after.IconFileID)
}
if after.Name != "Игровая" || after.DMOwnerID == nil {
t.Fatalf("беседа пострадала при удалении файла: %+v", after)
}
}
// TestDMChannelIconResetOnDemotion: когда в беседе остаётся два участника, она
// снова обычная личная — иконка группы сбрасывается вместе с именем и
// владельцем (AGENT.md 7.8, D-070).
func TestDMChannelIconResetOnDemotion(t *testing.T) {
ctx := context.Background()
st, users := newDMIconStore(t)
channel, err := st.CreateGroupDMChannel(ctx, users[0], "Игровая", users[1:])
if err != nil {
t.Fatalf("CreateGroupDMChannel: %v", err)
}
file := dmIconFile(ctx, t, st, users[0], channel.ID, "icon.png")
if _, err := st.UpdateChannel(ctx, channel.ID, UpdateChannelParams{IconFileID: &file.ID}); err != nil {
t.Fatalf("UpdateChannel(IconFileID): %v", err)
}
if err := st.RemoveDMParticipant(ctx, channel.ID, users[2]); err != nil {
t.Fatalf("RemoveDMParticipant: %v", err)
}
demoted, err := st.GetChannel(ctx, channel.ID)
if err != nil {
t.Fatalf("GetChannel: %v", err)
}
if demoted.IconFileID != nil || demoted.DMOwnerID != nil || demoted.Name != "" {
t.Fatalf("беседа с двумя участниками не сброшена: %+v", demoted)
}
}
+1 -1
View File
@@ -106,7 +106,7 @@ const orphanFilesQuery = `
AND f.message_id IS NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_file_id = f.id OR u.banner_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guilds g WHERE g.icon_file_id = f.id OR g.banner_file_id = f.id OR g.splash_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id OR c.icon_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM invites i WHERE i.background_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_cosmetics gc WHERE gc.file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM webhooks w WHERE w.avatar_file_id = f.id)
+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)