61a84bfeae
- 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 владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
публичный каталог и вход в публичный сервер.
155 lines
4.6 KiB
Go
155 lines
4.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
)
|
|
|
|
// Invite — приглашение на сервер (AGENT.md 7.9).
|
|
type Invite struct {
|
|
Code string
|
|
GuildID uint64
|
|
ChannelID *uint64
|
|
CreatorID *uint64
|
|
MaxUses int
|
|
Uses int
|
|
MaxAgeSec int
|
|
ExpiresAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// CreateInviteParams — параметры приглашения.
|
|
type CreateInviteParams struct {
|
|
Code string
|
|
GuildID uint64
|
|
ChannelID *uint64
|
|
CreatorID uint64
|
|
MaxUses int
|
|
MaxAgeSec int
|
|
}
|
|
|
|
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
|
|
max_age_sec, expires_at, created_at`
|
|
|
|
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
|
|
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
|
|
var expiresAt any
|
|
if params.MaxAgeSec > 0 {
|
|
expiresAt = s.Timestamp(time.Now().UTC().Add(time.Duration(params.MaxAgeSec) * time.Second))
|
|
}
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO invites (code, guild_id, channel_id, creator_id, max_uses, uses, max_age_sec, expires_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
|
params.Code, int64(params.GuildID), nullableID(params.ChannelID), int64(params.CreatorID),
|
|
params.MaxUses, params.MaxAgeSec, expiresAt, s.Now())
|
|
if err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
return s.GetInvite(ctx, params.Code)
|
|
}
|
|
|
|
func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
|
|
row := s.reader.QueryRowContext(ctx, `SELECT `+inviteColumns+` FROM invites WHERE code = ?`, code)
|
|
return scanInvite(row)
|
|
}
|
|
|
|
// ListGuildInvites возвращает приглашения сервера (для панели управления).
|
|
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
SELECT `+inviteColumns+` FROM invites WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?`,
|
|
int64(guildID), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
invites := make([]Invite, 0, limit)
|
|
for rows.Next() {
|
|
invite, err := scanInvite(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
invites = append(invites, *invite)
|
|
}
|
|
return invites, rows.Err()
|
|
}
|
|
|
|
// UseInvite атомарно увеличивает счётчик использований, проверяя лимит и срок.
|
|
// Возвращает ErrNotFound, если приглашение исчерпано или истекло (AGENT.md 7.9).
|
|
func (s *Store) UseInvite(ctx context.Context, code string) (*Invite, error) {
|
|
result, err := s.writer.ExecContext(ctx, `
|
|
UPDATE invites SET uses = uses + 1
|
|
WHERE code = ?
|
|
AND (max_uses = 0 OR uses < max_uses)
|
|
AND (expires_at IS NULL OR expires_at > ?)`, code, s.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
return s.GetInvite(ctx, code)
|
|
}
|
|
|
|
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
|
|
result, err := s.writer.ExecContext(ctx, `DELETE FROM invites WHERE code = ?`, code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListPublicGuilds возвращает публичные серверы для каталога (AGENT.md 7.20).
|
|
func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
SELECT `+guildColumns+` FROM guilds
|
|
WHERE is_discoverable = 1 ORDER BY id LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
guilds := make([]Guild, 0, limit)
|
|
for rows.Next() {
|
|
guild, err := scanGuild(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
guilds = append(guilds, *guild)
|
|
}
|
|
return guilds, rows.Err()
|
|
}
|
|
|
|
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
|
|
var (
|
|
invite Invite
|
|
channelID sql.NullInt64
|
|
creatorID sql.NullInt64
|
|
expiresAt sql.NullString
|
|
createdAt string
|
|
)
|
|
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
|
|
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt)
|
|
if err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
invite.ChannelID = optionalID(channelID)
|
|
invite.CreatorID = optionalID(creatorID)
|
|
if expiresAt.Valid {
|
|
value := parseTimestamp(expiresAt.String)
|
|
invite.ExpiresAt = &value
|
|
}
|
|
invite.CreatedAt = parseTimestamp(createdAt)
|
|
return &invite, nil
|
|
}
|