feat(social): блокировка пользователей

- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
  стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
  заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
  «Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
  блокировка из меню)
This commit is contained in:
2026-09-21 19:21:00 +03:00
parent afcdf9c1c4
commit ed98c78c9e
11 changed files with 461 additions and 5 deletions
+10
View File
@@ -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 {
+71
View File
@@ -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)
+81
View File
@@ -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
}