feat(guilds): встроенные шаблоны серверов

- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли,
  категории, текстовые и голосовые комнаты без сообщений
- POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит
  фиксирует выбранный шаблон
- GET /guild-templates отдаёт каталог с предпросмотром структуры
- клиент: выбор шаблона в окне создания сервера
- тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
This commit is contained in:
2026-09-21 18:04:52 +03:00
parent a36ee9527b
commit 91f415e36d
9 changed files with 572 additions and 15 deletions
+32 -7
View File
@@ -186,6 +186,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
}, func(ctx context.Context, input *struct {
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
// TemplateID — встроенный шаблон структуры (AGENT.md 7.3);
// пусто — «Пустой».
TemplateID string `json:"template_id,omitempty"`
}
},
) (*guildOutput, error) {
@@ -197,13 +200,32 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
if name == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
}
guild, err := s.createGuild(ctx, user, name)
guild, err := s.createGuild(ctx, user, name, input.Body.TemplateID)
if err != nil {
return nil, err
}
return s.guildOutput(ctx, user, *guild)
})
huma.Register(api, huma.Operation{
OperationID: "listGuildTemplates",
Method: http.MethodGet,
Path: "/guild-templates",
Summary: "Встроенные шаблоны серверов",
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*guildTemplateListOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
output := &guildTemplateListOutput{}
output.Body.Templates = make([]guildTemplatePayload, 0, len(guildTemplates))
for _, template := range guildTemplates {
output.Body.Templates = append(output.Body.Templates, guildTemplateFromCatalog(template))
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getGuild",
Method: http.MethodGet,
@@ -1518,7 +1540,11 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin
}
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
func (s *Server) createGuild(ctx context.Context, owner *store.User, name string) (*store.Guild, error) {
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
template, known := findGuildTemplate(templateID)
if !known {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
@@ -1552,17 +1578,16 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name string
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
return nil, humaError(err)
}
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
}); err != nil {
return nil, humaError(err)
// Структура комнат и ролей — из выбранного шаблона (AGENT.md 7.3).
if err := s.applyGuildTemplate(ctx, guild, template); err != nil {
return nil, err
}
if guild.IsMain {
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
return nil, humaError(err)
}
}
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "")
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
if bypassed {
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
}