feat(cosmetics): оформление профиля — галерея, роли и личные стили
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей» и пер-серверные стили) (AGENT.md 7.2, 7.4) - store: CRUD галереи, личные стили и вычисление оформления поэлементно (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль больше не выдаёт элемент - API: галерея сервера под MANAGE_ROLES, оформление роли в create/update, GET/PUT/DELETE /users/@me/styles с доступными элементами по областям - клиент: редактор личного стиля с предпросмотром, галерея оформления в настройках сервера, оформление роли в разделе «Роли» - рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты ника (CSS, при reduced-motion статично) в ленте и списке участников
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера (рамки и иконки),
|
||||
// личные стили пользователя и вычисленное оформление для контекста сервера.
|
||||
|
||||
// CosmeticKind — вид элемента галереи.
|
||||
type CosmeticKind string
|
||||
|
||||
const (
|
||||
// CosmeticFrame — рамка вокруг аватара и ника.
|
||||
CosmeticFrame CosmeticKind = "frame"
|
||||
// CosmeticBadge — иконка рядом с ником.
|
||||
CosmeticBadge CosmeticKind = "badge"
|
||||
)
|
||||
|
||||
// NickEffect — встроенный эффект ника (AGENT.md 7.4).
|
||||
type NickEffect string
|
||||
|
||||
const (
|
||||
NickEffectNone NickEffect = "none"
|
||||
NickEffectGradient NickEffect = "gradient"
|
||||
NickEffectGlow NickEffect = "glow"
|
||||
NickEffectFire NickEffect = "fire"
|
||||
NickEffectShimmer NickEffect = "shimmer"
|
||||
NickEffectGlitch NickEffect = "glitch"
|
||||
)
|
||||
|
||||
// nickEffects — допустимые эффекты: набор встроенный, загружать их нельзя.
|
||||
var nickEffects = []NickEffect{
|
||||
NickEffectNone, NickEffectGradient, NickEffectGlow,
|
||||
NickEffectFire, NickEffectShimmer, NickEffectGlitch,
|
||||
}
|
||||
|
||||
// ValidNickEffect сообщает, что эффект входит во встроенный набор.
|
||||
func ValidNickEffect(effect string) bool {
|
||||
for _, known := range nickEffects {
|
||||
if string(known) == effect {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NickEffects возвращает встроенный набор эффектов (для палитры в настройках).
|
||||
func NickEffects() []NickEffect {
|
||||
result := make([]NickEffect, len(nickEffects))
|
||||
copy(result, nickEffects)
|
||||
return result
|
||||
}
|
||||
|
||||
// nickPalette — палитра цветов ника: выбирать можно только из неё (AGENT.md 7.4).
|
||||
var nickPalette = []int64{
|
||||
0xf2f3f5, 0xa3a6ad, 0xe74c3c, 0xe67e22, 0xf1c40f, 0x2ecc71,
|
||||
0x1abc9c, 0x3498db, 0x5865f2, 0x9b59b6, 0xe91e63, 0xff7ab8,
|
||||
}
|
||||
|
||||
// NickColorPalette возвращает палитру цветов ника.
|
||||
func NickColorPalette() []int64 {
|
||||
result := make([]int64, len(nickPalette))
|
||||
copy(result, nickPalette)
|
||||
return result
|
||||
}
|
||||
|
||||
// InNickColorPalette сообщает, что цвет входит в палитру.
|
||||
func InNickColorPalette(color int64) bool {
|
||||
for _, known := range nickPalette {
|
||||
if known == color {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GuildCosmetic — элемент галереи оформления сервера.
|
||||
type GuildCosmetic struct {
|
||||
ID uint64
|
||||
GuildID uint64
|
||||
Kind CosmeticKind
|
||||
Name string
|
||||
FileID uint64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGuildCosmeticParams — параметры нового элемента галереи.
|
||||
type CreateGuildCosmeticParams struct {
|
||||
ID uint64
|
||||
GuildID uint64
|
||||
Kind CosmeticKind
|
||||
Name string
|
||||
FileID uint64
|
||||
}
|
||||
|
||||
const cosmeticColumns = `id, guild_id, kind, name, file_id, created_at`
|
||||
|
||||
// CreateGuildCosmetic добавляет рамку или иконку в галерею сервера.
|
||||
func (s *Store) CreateGuildCosmetic(ctx context.Context, params CreateGuildCosmeticParams) (*GuildCosmetic, error) {
|
||||
if params.ID == 0 {
|
||||
params.ID = s.NextID()
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO guild_cosmetics (id, guild_id, kind, name, file_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
int64(params.ID), int64(params.GuildID), string(params.Kind), params.Name,
|
||||
int64(params.FileID), s.Now())
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return s.GetGuildCosmetic(ctx, params.ID)
|
||||
}
|
||||
|
||||
func (s *Store) GetGuildCosmetic(ctx context.Context, id uint64) (*GuildCosmetic, error) {
|
||||
row := s.reader.QueryRowContext(ctx, `SELECT `+cosmeticColumns+` FROM guild_cosmetics WHERE id = ?`, int64(id))
|
||||
return scanCosmetic(row)
|
||||
}
|
||||
|
||||
// ListGuildCosmetics возвращает галерею сервера: сначала рамки, потом иконки.
|
||||
func (s *Store) ListGuildCosmetics(ctx context.Context, guildID uint64) ([]GuildCosmetic, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+cosmeticColumns+` FROM guild_cosmetics WHERE guild_id = ? ORDER BY kind, id`,
|
||||
int64(guildID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cosmetics := make([]GuildCosmetic, 0, 8)
|
||||
for rows.Next() {
|
||||
cosmetic, err := scanCosmetic(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cosmetics = append(cosmetics, *cosmetic)
|
||||
}
|
||||
return cosmetics, rows.Err()
|
||||
}
|
||||
|
||||
// CountGuildCosmetics считает элементы галереи: лимит 50 на сервер (7.4).
|
||||
func (s *Store) CountGuildCosmetics(ctx context.Context, guildID uint64) (int, error) {
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM guild_cosmetics WHERE guild_id = ?`, int64(guildID)).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RenameGuildCosmetic меняет имя элемента галереи.
|
||||
func (s *Store) RenameGuildCosmetic(ctx context.Context, id uint64, name string) (*GuildCosmetic, error) {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE guild_cosmetics SET name = ? WHERE id = ?`, name, int64(id))
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.GetGuildCosmetic(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteGuildCosmetic удаляет элемент галереи. Ссылки из ролей и личных стилей
|
||||
// снимаются базой (ON DELETE SET NULL), поэтому оформление откатывается к
|
||||
// следующему приоритету и не ломается.
|
||||
func (s *Store) DeleteGuildCosmetic(ctx context.Context, id uint64) error {
|
||||
result, err := s.writer.ExecContext(ctx, `DELETE FROM guild_cosmetics WHERE id = ?`, int64(id))
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanCosmetic(scanner interface{ Scan(...any) error }) (*GuildCosmetic, error) {
|
||||
var (
|
||||
cosmetic GuildCosmetic
|
||||
kind string
|
||||
createdAt string
|
||||
)
|
||||
if err := scanner.Scan(&cosmetic.ID, &cosmetic.GuildID, &kind, &cosmetic.Name,
|
||||
&cosmetic.FileID, &createdAt); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
cosmetic.Kind = CosmeticKind(kind)
|
||||
cosmetic.CreatedAt = parseTimestamp(createdAt)
|
||||
return &cosmetic, nil
|
||||
}
|
||||
|
||||
// UserStyle — личный стиль профиля: guild_id == nil — «для друзей».
|
||||
type UserStyle struct {
|
||||
ID uint64
|
||||
UserID uint64
|
||||
GuildID *uint64
|
||||
FrameID *uint64
|
||||
BadgeID *uint64
|
||||
NickColor *int64
|
||||
NickEffect string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertUserStyleParams — новое состояние стиля: элементы передаются целиком,
|
||||
// чтобы снятие было обычной записью пустого значения.
|
||||
type UpsertUserStyleParams struct {
|
||||
UserID uint64
|
||||
GuildID *uint64
|
||||
FrameID *uint64
|
||||
BadgeID *uint64
|
||||
NickColor *int64
|
||||
NickEffect string
|
||||
}
|
||||
|
||||
const userStyleColumns = `id, user_id, guild_id, frame_id, badge_id, nick_color, nick_effect, updated_at`
|
||||
|
||||
// UpsertUserStyle записывает стиль пользователя для области (nil — «для друзей»).
|
||||
func (s *Store) UpsertUserStyle(ctx context.Context, params UpsertUserStyleParams) (*UserStyle, error) {
|
||||
effect := params.NickEffect
|
||||
if effect == "" {
|
||||
effect = string(NickEffectNone)
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO user_styles (user_id, guild_id, frame_id, badge_id, nick_color, nick_effect, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, IFNULL(guild_id, 0)) DO UPDATE SET
|
||||
frame_id = excluded.frame_id,
|
||||
badge_id = excluded.badge_id,
|
||||
nick_color = excluded.nick_color,
|
||||
nick_effect = excluded.nick_effect,
|
||||
updated_at = excluded.updated_at`,
|
||||
int64(params.UserID), nullableID(params.GuildID), nullableID(params.FrameID),
|
||||
nullableID(params.BadgeID), nullableNickColor(params.NickColor), effect, s.Now())
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return s.GetUserStyle(ctx, params.UserID, params.GuildID)
|
||||
}
|
||||
|
||||
// GetUserStyle читает стиль пользователя для области; отсутствие стиля — не
|
||||
// ошибка: возвращается `nil` без стиля.
|
||||
func (s *Store) GetUserStyle(ctx context.Context, userID uint64, guildID *uint64) (*UserStyle, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+userStyleColumns+` FROM user_styles
|
||||
WHERE user_id = ? AND IFNULL(guild_id, 0) = ?`,
|
||||
int64(userID), int64(nullableScope(guildID)))
|
||||
style, err := scanUserStyle(row)
|
||||
if err != nil {
|
||||
// Отсутствие стиля — не ошибка: оформление просто не задано.
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return style, nil
|
||||
}
|
||||
|
||||
// ListUserStyles возвращает все стили пользователя: «для друзей» и пер-серверные.
|
||||
func (s *Store) ListUserStyles(ctx context.Context, userID uint64) ([]UserStyle, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+userStyleColumns+` FROM user_styles WHERE user_id = ? ORDER BY IFNULL(guild_id, 0)`,
|
||||
int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
styles := make([]UserStyle, 0, 4)
|
||||
for rows.Next() {
|
||||
style, err := scanUserStyle(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
styles = append(styles, *style)
|
||||
}
|
||||
return styles, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUserStyle убирает стиль области: оформление откатывается к следующему
|
||||
// приоритету (AGENT.md 7.2).
|
||||
func (s *Store) DeleteUserStyle(ctx context.Context, userID uint64, guildID *uint64) error {
|
||||
if _, err := s.writer.ExecContext(ctx,
|
||||
`DELETE FROM user_styles WHERE user_id = ? AND IFNULL(guild_id, 0) = ?`,
|
||||
int64(userID), int64(nullableScope(guildID))); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUserStyle(scanner interface{ Scan(...any) error }) (*UserStyle, error) {
|
||||
var (
|
||||
style UserStyle
|
||||
guildID sql.NullInt64
|
||||
frameID sql.NullInt64
|
||||
badgeID sql.NullInt64
|
||||
nickColor sql.NullInt64
|
||||
updatedAt string
|
||||
)
|
||||
if err := scanner.Scan(&style.ID, &style.UserID, &guildID, &frameID, &badgeID,
|
||||
&nickColor, &style.NickEffect, &updatedAt); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
style.GuildID = optionalID(guildID)
|
||||
style.FrameID = optionalID(frameID)
|
||||
style.BadgeID = optionalID(badgeID)
|
||||
if nickColor.Valid {
|
||||
value := nickColor.Int64
|
||||
style.NickColor = &value
|
||||
}
|
||||
style.UpdatedAt = parseTimestamp(updatedAt)
|
||||
return &style, nil
|
||||
}
|
||||
|
||||
func nullableScope(guildID *uint64) uint64 {
|
||||
if guildID == nil {
|
||||
return 0
|
||||
}
|
||||
return *guildID
|
||||
}
|
||||
|
||||
func nullableNickColor(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// EffectiveCosmetics — вычисленное оформление участника в контексте сервера
|
||||
// (или «для друзей», если GuildID == nil).
|
||||
type EffectiveCosmetics struct {
|
||||
FrameID *uint64
|
||||
BadgeID *uint64
|
||||
NickColor *int64
|
||||
NickEffect string
|
||||
}
|
||||
|
||||
// IsEmpty сообщает, что элементов оформления нет.
|
||||
func (c EffectiveCosmetics) IsEmpty() bool {
|
||||
return c.FrameID == nil && c.BadgeID == nil && c.NickColor == nil &&
|
||||
(c.NickEffect == "" || c.NickEffect == string(NickEffectNone))
|
||||
}
|
||||
|
||||
// CosmeticGrants — элементы, доступные пользователю: из ролей этого сервера и
|
||||
// из ролей на любом сервере (для стиля «для друзей»).
|
||||
type CosmeticGrants struct {
|
||||
Frames map[uint64]bool
|
||||
Badges map[uint64]bool
|
||||
GuildFrames map[uint64]bool
|
||||
GuildBadges map[uint64]bool
|
||||
}
|
||||
|
||||
// EffectiveCosmeticsForGuild вычисляет оформление всех участников сервера
|
||||
// поэлементно: роль (высшая с заданным элементом) → пер-серверный стиль →
|
||||
// стиль «для друзей» → дефолт (AGENT.md 7.2).
|
||||
func (s *Store) EffectiveCosmeticsForGuild(ctx context.Context, guildID uint64) (map[uint64]EffectiveCosmetics, error) {
|
||||
// 1. Роли сервера: позиция задаёт приоритет, оформление роли — источник
|
||||
// элементов на этом сервере.
|
||||
roles, err := s.ListGuildRoles(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleByID := make(map[uint64]Role, len(roles))
|
||||
for _, role := range roles {
|
||||
roleByID[role.ID] = role
|
||||
}
|
||||
|
||||
// 2. Роли участников сервера.
|
||||
members, err := s.ListGuildMemberProfiles(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleIDsByUser := make(map[uint64][]uint64, len(members))
|
||||
userIDs := make([]uint64, 0, len(members))
|
||||
for _, member := range members {
|
||||
roleIDsByUser[member.UserID] = member.RoleIDs
|
||||
userIDs = append(userIDs, member.UserID)
|
||||
}
|
||||
|
||||
// 3. Личные стили участников: пер-серверные и «для друзей».
|
||||
styles, err := s.listUserStylesFor(ctx, userIDs, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Доступность элементов личных стилей: рамку или иконку можно выбрать,
|
||||
// только если её выдаёт роль (7.2).
|
||||
grants, err := s.CosmeticGrantsForUsers(ctx, userIDs, &guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[uint64]EffectiveCosmetics, len(members))
|
||||
for _, userID := range userIDs {
|
||||
result[userID] = resolveCosmetics(
|
||||
roleIDsByUser[userID], roleByID,
|
||||
styles.guild[userID], styles.global[userID],
|
||||
grants[userID],
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EffectiveCosmeticsForUser вычисляет оформление пользователя в контексте
|
||||
// «для друзей» (DM, список друзей, глобальные места).
|
||||
func (s *Store) EffectiveCosmeticsForUser(ctx context.Context, userID uint64) (EffectiveCosmetics, error) {
|
||||
style, err := s.GetUserStyle(ctx, userID, nil)
|
||||
if err != nil {
|
||||
return EffectiveCosmetics{}, err
|
||||
}
|
||||
grants, err := s.CosmeticGrantsForUsers(ctx, []uint64{userID}, nil)
|
||||
if err != nil {
|
||||
return EffectiveCosmetics{}, err
|
||||
}
|
||||
return resolveCosmetics(nil, nil, nil, style, grants[userID]), nil
|
||||
}
|
||||
|
||||
type userStyles struct {
|
||||
guild map[uint64]*UserStyle
|
||||
global map[uint64]*UserStyle
|
||||
}
|
||||
|
||||
// listUserStylesFor читает стили пачки пользователей одним запросом.
|
||||
func (s *Store) listUserStylesFor(ctx context.Context, userIDs []uint64, guildID uint64) (userStyles, error) {
|
||||
result := userStyles{guild: map[uint64]*UserStyle{}, global: map[uint64]*UserStyle{}}
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
placeholders := make([]byte, 0, len(userIDs)*2)
|
||||
args := make([]any, 0, len(userIDs)+1)
|
||||
args = append(args, int64(guildID))
|
||||
for i, userID := range userIDs {
|
||||
if i > 0 {
|
||||
placeholders = append(placeholders, ',')
|
||||
}
|
||||
placeholders = append(placeholders, '?')
|
||||
args = append(args, int64(userID))
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+userStyleColumns+` FROM user_styles
|
||||
WHERE IFNULL(guild_id, 0) IN (0, ?) AND user_id IN (`+string(placeholders)+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
style, err := scanUserStyle(rows)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if style.GuildID == nil {
|
||||
result.global[style.UserID] = style
|
||||
continue
|
||||
}
|
||||
result.guild[style.UserID] = style
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ListGrantedCosmetics возвращает элементы галереи, выданные ролями
|
||||
// пользователя на любом сервере: из них собирается выбор в настройках (7.2).
|
||||
func (s *Store) ListGrantedCosmetics(ctx context.Context, userID uint64) ([]GuildCosmetic, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT DISTINCT c.`+strings.ReplaceAll(cosmeticColumns, ", ", ", c.")+`
|
||||
FROM guild_cosmetics c
|
||||
JOIN roles r ON r.cosmetic_frame_id = c.id OR r.cosmetic_badge_id = c.id
|
||||
JOIN member_roles mr ON mr.role_id = r.id AND mr.user_id = ?
|
||||
ORDER BY c.kind, c.id`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cosmetics := make([]GuildCosmetic, 0, 8)
|
||||
for rows.Next() {
|
||||
cosmetic, err := scanCosmetic(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cosmetics = append(cosmetics, *cosmetic)
|
||||
}
|
||||
return cosmetics, rows.Err()
|
||||
}
|
||||
|
||||
// CosmeticGrantsForUsers собирает доступные рамки и иконки: из ролей указанного
|
||||
// сервера (guildOnly != nil) и из ролей на любом сервере (для «для друзей»).
|
||||
func (s *Store) CosmeticGrantsForUsers(ctx context.Context, userIDs []uint64, guildID *uint64) (map[uint64]CosmeticGrants, error) {
|
||||
result := make(map[uint64]CosmeticGrants, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
placeholders := make([]byte, 0, len(userIDs)*2)
|
||||
args := make([]any, 0, len(userIDs))
|
||||
for i, userID := range userIDs {
|
||||
if i > 0 {
|
||||
placeholders = append(placeholders, ',')
|
||||
}
|
||||
placeholders = append(placeholders, '?')
|
||||
args = append(args, int64(userID))
|
||||
}
|
||||
// Права на оформление дают роли, где задана рамка или иконка.
|
||||
query := `SELECT mr.user_id, r.guild_id, r.cosmetic_frame_id, r.cosmetic_badge_id
|
||||
FROM member_roles mr
|
||||
JOIN roles r ON r.id = mr.role_id
|
||||
WHERE mr.user_id IN (` + string(placeholders) + `)
|
||||
AND (r.cosmetic_frame_id IS NOT NULL OR r.cosmetic_badge_id IS NOT NULL)`
|
||||
rows, err := s.reader.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
userID uint64
|
||||
roleGuild uint64
|
||||
frameID sql.NullInt64
|
||||
badgeID sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&userID, &roleGuild, &frameID, &badgeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, ok := result[userID]
|
||||
if !ok {
|
||||
entry = CosmeticGrants{
|
||||
Frames: map[uint64]bool{},
|
||||
Badges: map[uint64]bool{},
|
||||
GuildFrames: map[uint64]bool{},
|
||||
GuildBadges: map[uint64]bool{},
|
||||
}
|
||||
}
|
||||
sameGuild := guildID != nil && roleGuild == *guildID
|
||||
if frameID.Valid {
|
||||
entry.Frames[uint64(frameID.Int64)] = true
|
||||
if sameGuild {
|
||||
entry.GuildFrames[uint64(frameID.Int64)] = true
|
||||
}
|
||||
}
|
||||
if badgeID.Valid {
|
||||
entry.Badges[uint64(badgeID.Int64)] = true
|
||||
if sameGuild {
|
||||
entry.GuildBadges[uint64(badgeID.Int64)] = true
|
||||
}
|
||||
}
|
||||
result[userID] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Пользователи без ролей с оформлением всё равно должны быть в ответе.
|
||||
for _, userID := range userIDs {
|
||||
if _, ok := result[userID]; !ok {
|
||||
result[userID] = CosmeticGrants{
|
||||
Frames: map[uint64]bool{},
|
||||
Badges: map[uint64]bool{},
|
||||
GuildFrames: map[uint64]bool{},
|
||||
GuildBadges: map[uint64]bool{},
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveCosmetics применяет поэлементный приоритет оформления.
|
||||
func resolveCosmetics(
|
||||
roleIDs []uint64,
|
||||
roleByID map[uint64]Role,
|
||||
guildStyle *UserStyle,
|
||||
globalStyle *UserStyle,
|
||||
grants CosmeticGrants,
|
||||
) EffectiveCosmetics {
|
||||
// Роли идут от высшей к низшей: первая с заданным элементом побеждает.
|
||||
roles := make([]Role, 0, len(roleIDs))
|
||||
for _, roleID := range roleIDs {
|
||||
if role, ok := roleByID[roleID]; ok {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
}
|
||||
// RoleIDs приходят по возрастанию id; порядок по позиции восстанавливаем сами.
|
||||
sort.SliceStable(roles, func(i, j int) bool { return roles[i].Position > roles[j].Position })
|
||||
|
||||
resolved := EffectiveCosmetics{NickEffect: string(NickEffectNone)}
|
||||
for _, role := range roles {
|
||||
if resolved.FrameID == nil && role.CosmeticFrameID != nil {
|
||||
resolved.FrameID = role.CosmeticFrameID
|
||||
}
|
||||
if resolved.BadgeID == nil && role.CosmeticBadgeID != nil {
|
||||
resolved.BadgeID = role.CosmeticBadgeID
|
||||
}
|
||||
if resolved.NickColor == nil && role.NickColor != nil {
|
||||
resolved.NickColor = role.NickColor
|
||||
}
|
||||
if resolved.NickEffect == string(NickEffectNone) && role.NickEffect != "" &&
|
||||
role.NickEffect != string(NickEffectNone) {
|
||||
effect := role.NickEffect
|
||||
resolved.NickEffect = effect
|
||||
}
|
||||
}
|
||||
// Личные стили: пер-серверный важнее «для друзей», элементы проверяются на
|
||||
// доступность — потеря роли откатывает их к следующему приоритету (7.2).
|
||||
if guildStyle != nil {
|
||||
if resolved.FrameID == nil && allowed(guildStyle.FrameID, grants.GuildFrames) {
|
||||
resolved.FrameID = guildStyle.FrameID
|
||||
}
|
||||
if resolved.BadgeID == nil && allowed(guildStyle.BadgeID, grants.GuildBadges) {
|
||||
resolved.BadgeID = guildStyle.BadgeID
|
||||
}
|
||||
if resolved.NickColor == nil {
|
||||
resolved.NickColor = guildStyle.NickColor
|
||||
}
|
||||
if resolved.NickEffect == string(NickEffectNone) && guildStyle.NickEffect != "" {
|
||||
resolved.NickEffect = guildStyle.NickEffect
|
||||
}
|
||||
}
|
||||
if globalStyle != nil {
|
||||
if resolved.FrameID == nil && allowed(globalStyle.FrameID, grants.Frames) {
|
||||
resolved.FrameID = globalStyle.FrameID
|
||||
}
|
||||
if resolved.BadgeID == nil && allowed(globalStyle.BadgeID, grants.Badges) {
|
||||
resolved.BadgeID = globalStyle.BadgeID
|
||||
}
|
||||
if resolved.NickColor == nil {
|
||||
resolved.NickColor = globalStyle.NickColor
|
||||
}
|
||||
if resolved.NickEffect == string(NickEffectNone) && globalStyle.NickEffect != "" {
|
||||
resolved.NickEffect = globalStyle.NickEffect
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func allowed(id *uint64, granted map[uint64]bool) bool {
|
||||
if id == nil {
|
||||
return false
|
||||
}
|
||||
return granted[*id]
|
||||
}
|
||||
Reference in New Issue
Block a user