feat(moderation): баны сервера, тайм-ауты и фильтры журнала аудита
Фаза 4 (AGENT.md 7.17, 7.18): модерация участников.
- миграция 00011: таблица `guild_bans` (бан переживает исключение участника);
- `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`:
исключает участника, отключает его от голосовой комнаты, пишет системное
сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`;
- `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список
с автором и причиной (только модераторам);
- вход по приглашению для забаненного отклоняется с `guild.banned`;
- тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при
изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ
кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его;
- тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие
тайм-ауты не показываются в профиле участника;
- журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Баны сервера (AGENT.md 7.17): бан переживает исключение участника и
|
||||
// запрещает повторный вход по приглашению до снятия.
|
||||
|
||||
// GuildBan — запись о бане.
|
||||
type GuildBan struct {
|
||||
UserID uint64
|
||||
Username string
|
||||
DisplayName string
|
||||
ActorID *uint64
|
||||
ActorName string
|
||||
Reason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// BanGuildMember добавляет бан: повторный бан обновляет причину и автора.
|
||||
func (s *Store) BanGuildMember(ctx context.Context, guildID, userID, actorID uint64, reason string) error {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO guild_bans (guild_id, user_id, actor_id, reason, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (guild_id, user_id) DO UPDATE SET
|
||||
actor_id = excluded.actor_id,
|
||||
reason = excluded.reason,
|
||||
created_at = excluded.created_at`,
|
||||
int64(guildID), int64(userID), int64(actorID), reason, s.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnbanGuildMember снимает бан: отсутствие бана — не ошибка (идемпотентность).
|
||||
func (s *Store) UnbanGuildMember(ctx context.Context, guildID, userID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
`DELETE FROM guild_bans WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
// IsGuildBanned сообщает, забанен ли пользователь на сервере.
|
||||
func (s *Store) IsGuildBanned(ctx context.Context, guildID, userID uint64) (bool, error) {
|
||||
var exists int
|
||||
err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM guild_bans WHERE guild_id = ? AND user_id = ?`,
|
||||
int64(guildID), int64(userID)).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ListGuildBans перечисляет баны сервера: сначала свежие.
|
||||
func (s *Store) ListGuildBans(ctx context.Context, guildID uint64, limit int) ([]GuildBan, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT b.user_id, u.username, COALESCE(u.display_name, ''), b.actor_id,
|
||||
COALESCE(a.username, ''), b.reason, b.created_at
|
||||
FROM guild_bans b
|
||||
JOIN users u ON u.id = b.user_id
|
||||
LEFT JOIN users a ON a.id = b.actor_id
|
||||
WHERE b.guild_id = ?
|
||||
ORDER BY b.created_at DESC, b.user_id DESC
|
||||
LIMIT ?`, int64(guildID), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bans := make([]GuildBan, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ban GuildBan
|
||||
userID int64
|
||||
actorID sql.NullInt64
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&userID, &ban.Username, &ban.DisplayName, &actorID,
|
||||
&ban.ActorName, &ban.Reason, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ban.UserID = uint64(userID)
|
||||
if actorID.Valid {
|
||||
value := uint64(actorID.Int64)
|
||||
ban.ActorID = &value
|
||||
}
|
||||
ban.CreatedAt = parseTimestamp(createdAt)
|
||||
bans = append(bans, ban)
|
||||
}
|
||||
return bans, rows.Err()
|
||||
}
|
||||
|
||||
// AuditFilter — фильтры журнала аудита (AGENT.md 7.18).
|
||||
type AuditFilter struct {
|
||||
Action string
|
||||
ActorID uint64
|
||||
TargetID uint64
|
||||
BeforeID uint64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListAuditLog отдаёт журнал сервера с фильтрами по действию, автору и цели.
|
||||
func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]AuditEntry, error) {
|
||||
return s.ListAuditLogFiltered(ctx, guildID, AuditFilter{Limit: limit})
|
||||
}
|
||||
|
||||
// ListAuditLogFiltered отдаёт журнал сервера с учётом фильтров: пустые
|
||||
// значения означают «без ограничения», BeforeID — постраничный просмотр.
|
||||
func (s *Store) ListAuditLogFiltered(ctx context.Context, guildID uint64, filter AuditFilter) ([]AuditEntry, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
query := strings.Builder{}
|
||||
query.WriteString(`
|
||||
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
||||
FROM audit_log WHERE guild_id = ?`)
|
||||
args := []any{int64(guildID)}
|
||||
if filter.Action != "" {
|
||||
query.WriteString(" AND action = ?")
|
||||
args = append(args, filter.Action)
|
||||
}
|
||||
if filter.ActorID != 0 {
|
||||
query.WriteString(" AND actor_id = ?")
|
||||
args = append(args, int64(filter.ActorID))
|
||||
}
|
||||
if filter.TargetID != 0 {
|
||||
query.WriteString(" AND target_id = ?")
|
||||
args = append(args, int64(filter.TargetID))
|
||||
}
|
||||
if filter.BeforeID != 0 {
|
||||
query.WriteString(" AND id < ?")
|
||||
args = append(args, int64(filter.BeforeID))
|
||||
}
|
||||
query.WriteString(" ORDER BY id DESC LIMIT ?")
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.reader.QueryContext(ctx, query.String(), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAuditEntries(rows, limit)
|
||||
}
|
||||
@@ -117,19 +117,6 @@ func (s *Store) RecordAudit(ctx context.Context, params AuditParams) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
||||
FROM audit_log WHERE guild_id = ? ORDER BY id DESC LIMIT ?`, int64(guildID), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAuditEntries(rows, limit)
|
||||
}
|
||||
|
||||
func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) {
|
||||
defer rows.Close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user