feat(invites): приглашения, каталог публичных серверов и публичность сервера

- POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит
  использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD);
  DELETE /invites/{code} — создатель или MANAGE_GUILD;
- GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и
  исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников,
  повторное принятие не расходует использование;
- PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для
  входа без приглашения (главный сервер публиковать нельзя);
- GET /guilds/public — каталог (имя, описание, иконка, число участников);
- аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и
  INVITE_DELETE владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
  публичный каталог и вход в публичный сервер.
This commit is contained in:
2026-09-19 23:33:34 +03:00
parent e4ffbb05a5
commit 61a84bfeae
16 changed files with 1233 additions and 32 deletions
+16
View File
@@ -221,6 +221,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
Body struct {
Name *string `json:"name,omitempty" maxLength:"64"`
Description *string `json:"description,omitempty" maxLength:"500"`
// Публичность сервера: попадает в каталог и открывает вход без
// приглашения (AGENT.md 7.20).
Public *bool `json:"public,omitempty"`
}
},
) (*guildOutput, error) {
@@ -233,6 +236,19 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
return nil, err
}
params := store.UpdateGuildParams{Description: input.Body.Description}
if input.Body.Public != nil {
// Главный сервер публичным не делаем: он и так точка входа.
if *input.Body.Public {
guild, err := s.store.GetGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
if guild.IsMain {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "main guild is always open and cannot be published")
}
}
params.IsDiscoverable = input.Body.Public
}
if input.Body.Name != nil {
trimmed := strings.TrimSpace(*input.Body.Name)
if trimmed == "" {