feat(social): блокировка пользователей
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
«Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
блокировка из меню)
This commit is contained in:
@@ -107,6 +107,16 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Личная беседа: блокировка запрещает переписку в обе стороны (AGENT.md 7.2).
|
||||
if channel.GuildID == nil {
|
||||
blocked, err := s.store.DMBlocked(ctx, channelID, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
||||
}
|
||||
}
|
||||
// Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя,
|
||||
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
|
||||
if !user.IsInstanceAdmin {
|
||||
|
||||
@@ -168,6 +168,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Блокировка сильнее заявки: переписка и дружба с ней невозможны.
|
||||
if blocked, err := s.store.IsBlocked(ctx, user.ID, target.ID); err != nil {
|
||||
return nil, humaError(err)
|
||||
} else if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "relationship.blocked", "user is blocked")
|
||||
}
|
||||
// Если встречная заявка уже есть — сразу становимся друзьями.
|
||||
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
|
||||
switch {
|
||||
@@ -252,6 +258,65 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "blockUser",
|
||||
Method: http.MethodPut,
|
||||
Path: "/users/@me/blocks/{user_id}",
|
||||
Summary: "Заблокировать пользователя",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetID == user.ID {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot block yourself")
|
||||
}
|
||||
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Блокировка снимает дружбу и заявки в обе стороны (AGENT.md 7.2).
|
||||
if err := s.store.BlockUser(ctx, user.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(user.ID, targetID)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "unblockUser",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/users/@me/blocks/{user_id}",
|
||||
Summary: "Снять блокировку",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.UnblockUser(ctx, user.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(user.ID, targetID)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "openDirectChannel",
|
||||
Method: http.MethodPost,
|
||||
@@ -280,6 +345,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// С заблокированным беседа не открывается (AGENT.md 7.2).
|
||||
if blocked, err := s.store.IsBlocked(ctx, user.ID, recipientID); err != nil {
|
||||
return nil, humaError(err)
|
||||
} else if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
||||
}
|
||||
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
||||
|
||||
@@ -521,3 +521,84 @@ func TestMembersCarrySystemBadges(t *testing.T) {
|
||||
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockUserStopsDMsAndRequests(t *testing.T) {
|
||||
srv, owner, member, _, memberID := 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
|
||||
|
||||
// Заводим дружбу, затем блокируем: связь снимается, остаётся блокировка.
|
||||
request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
|
||||
`{"user_id":"`+memberID+`"}`, owner)
|
||||
if request.Code != http.StatusOK {
|
||||
t.Fatalf("заявка = %d (%s)", request.Code, request.Body.String())
|
||||
}
|
||||
if accept := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member); accept.Code != http.StatusOK {
|
||||
t.Fatalf("принятие = %d", accept.Code)
|
||||
}
|
||||
|
||||
blocked := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner)
|
||||
if blocked.Code != http.StatusOK {
|
||||
t.Fatalf("блокировка = %d (%s)", blocked.Code, blocked.Body.String())
|
||||
}
|
||||
relations := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner)
|
||||
list := decodeResponse[struct {
|
||||
Friends []struct {
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"friends"`
|
||||
Blocked []struct {
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"blocked"`
|
||||
}](t, relations)
|
||||
if len(list.Friends) != 0 || len(list.Blocked) != 1 || list.Blocked[0].UserID != memberID {
|
||||
t.Fatalf("после блокировки: друзей %d, заблокированных %d", len(list.Friends), len(list.Blocked))
|
||||
}
|
||||
|
||||
// Заявка в друзья от заблокированного отклоняется.
|
||||
again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
|
||||
`{"user_id":"`+ownerID+`"}`, member)
|
||||
if again.Code != http.StatusForbidden {
|
||||
t.Fatalf("заявка от заблокированного = %d, ожидался 403 (%s)", again.Code, again.Body.String())
|
||||
}
|
||||
|
||||
// Личная беседа не открывается и сообщения в неё не уходят.
|
||||
open := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+memberID+`"}`, owner)
|
||||
if open.Code != http.StatusForbidden {
|
||||
t.Fatalf("беседа с заблокированным = %d, ожидался 403 (%s)", open.Code, open.Body.String())
|
||||
}
|
||||
|
||||
// Снятие блокировки возвращает возможность писать.
|
||||
if unblock := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/blocks/"+memberID, "", owner); unblock.Code != http.StatusOK {
|
||||
t.Fatalf("снятие блокировки = %d", unblock.Code)
|
||||
}
|
||||
reopened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+memberID+`"}`, owner)
|
||||
if reopened.Code != http.StatusOK {
|
||||
t.Fatalf("беседа после снятия блокировки = %d (%s)", reopened.Code, reopened.Body.String())
|
||||
}
|
||||
channelID := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, reopened).Channel.ID
|
||||
|
||||
// Блокируем снова уже при существующей беседе: сообщение не отправляется.
|
||||
if rec := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner); rec.Code != http.StatusOK {
|
||||
t.Fatalf("повторная блокировка = %d", rec.Code)
|
||||
}
|
||||
post := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
||||
`{"content":"привет"}`, owner)
|
||||
if post.Code != http.StatusForbidden {
|
||||
t.Fatalf("сообщение заблокированному = %d, ожидался 403 (%s)", post.Code, post.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, post); code != "dm.blocked" {
|
||||
t.Fatalf("код ошибки = %q", code)
|
||||
}
|
||||
_ = ownerID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Блокировка пользователя (AGENT.md 7.2, 8.2): запрещает личные сообщения и
|
||||
// заявки в друзья. Хранится той же таблицей связей, что друзья: тип `blocked`
|
||||
// сильнее остальных, поэтому при блокировке встречные связи снимаются.
|
||||
|
||||
// BlockUser блокирует пользователя: дружба и заявки в обе стороны снимаются,
|
||||
// у блокирующего остаётся связь `blocked`.
|
||||
func (s *Store) BlockUser(ctx context.Context, userID, targetID uint64) error {
|
||||
if userID == targetID {
|
||||
return ErrConflict
|
||||
}
|
||||
tx, err := s.writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Снимаем все встречные связи: и дружбу, и заявки.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM relationships WHERE (user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?)`,
|
||||
int64(userID), int64(targetID), int64(targetID), int64(userID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO relationships (user_id, target_id, type, created_at, updated_at)
|
||||
VALUES (?, ?, 'blocked', ?, ?)`,
|
||||
int64(userID), int64(targetID), s.Now(), s.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UnblockUser снимает блокировку; дружбу после неё нужно заводить заново.
|
||||
func (s *Store) UnblockUser(ctx context.Context, userID, targetID uint64) error {
|
||||
relation, err := s.GetRelationship(ctx, userID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relation.Type != RelationshipBlocked {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.RemoveRelationship(ctx, userID, targetID)
|
||||
}
|
||||
|
||||
// IsBlocked сообщает, блокирует ли пользователь другого (в любую сторону).
|
||||
func (s *Store) IsBlocked(ctx context.Context, userID, targetID uint64) (bool, error) {
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM relationships
|
||||
WHERE type = 'blocked'
|
||||
AND ((user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?))`,
|
||||
int64(userID), int64(targetID), int64(targetID), int64(userID)).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ListBlocked возвращает заблокированных пользователей (для списка в клиенте).
|
||||
func (s *Store) ListBlocked(ctx context.Context, userID uint64) ([]RelationshipProfile, error) {
|
||||
return s.ListRelationships(ctx, userID, RelationshipBlocked)
|
||||
}
|
||||
|
||||
// BlockedIDs возвращает множество пользователей, которые заблокированы
|
||||
// пользователем: нужно для фильтрации поиска и списков.
|
||||
func (s *Store) BlockedIDs(ctx context.Context, userID uint64) (map[uint64]bool, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT target_id FROM relationships WHERE user_id = ? AND type = 'blocked'`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
ids := map[uint64]bool{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// DMBlocked проверяет, есть ли среди участников личной беседы тот, кто
|
||||
// блокирует пользователя или кого блокирует он сам: переписка запрещена
|
||||
// (AGENT.md 7.2).
|
||||
func (s *Store) DMBlocked(ctx context.Context, channelID, userID uint64) (bool, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT user_id FROM dm_participants WHERE channel_id = ?`, int64(channelID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
participants := make([]uint64, 0, 2)
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
participants = append(participants, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, participant := range participants {
|
||||
if participant == userID {
|
||||
continue
|
||||
}
|
||||
blocked, err := s.IsBlocked(ctx, userID, participant)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if blocked {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
Reference in New Issue
Block a user