Перейти к содержанию

Blog

Blog catalog: персонализированная лента постов из blog.* через SQL views, WebView для контента.

Архитектура

Flutter App
  └── BlogEndpoint.getCatalog()  ← Auth Center (Serverpod)
        ├── v_blog_category      ← public.* VIEW → blog.categories
        ├── v_blog_location      ← public.* VIEW → blog.locations
        └── v_blog_post          ← public.* VIEW → blog.post_groups + blog.posts
              └── unsafeQuery (DISTINCT ON + GIN tags)
  └── WebView → one_blog/posts/{slug}  ← детальная страница поста

Blog DB (blog.*) управляется projects/one_blog (Drizzle миграции). Serverpod читает через public.* views — read-only. Views применяются через projects/deploy/seeds/create-views.sql.

Файлы реализации: - onewallet_base_server/lib/src/endpoints/blog_endpoint.dart — endpoint и все вспомогательные методы - onewallet_base_server/lib/src/models/blog_catalog_result.spy.yaml — модель ответа - onewallet_base_server/lib/src/models/blog_item_dto.spy.yaml — DTO поста - onewallet_base_server/lib/src/models/blog_category_option.spy.yaml — опция категории - onewallet_base_server/lib/src/models/blog_location_option.spy.yaml — опция локации - onewallet_base_server/lib/src/models/v_blog_category.spy.yaml — ORM-модель view категорий - onewallet_base_server/lib/src/models/v_blog_location.spy.yaml — ORM-модель view локаций - projects/deploy/seeds/create-views.sql — DDL для всех трёх views

BlogEndpoint.getCatalog()

Auth → buildUserTags → _getCategories (cache 30m) + _getLocations (cache 30m)
     → whitelist slug→UUID → ETag check
     → _queryFeed: DISTINCT ON locale + GIN tags + category/location UUID filter
     → BlogCatalogResult

Graceful degradation: при недоступности views (не применены или ошибка БД) возвращает пустые массивы (items: [], categories: [], locations: []) и логирует LogLevel.warning. Фронтенд получает валидный ответ и показывает пустое состояние.

Сигнатура метода

Future<BlogCatalogResult> getCatalog(
  Session session,
  String locale,
  String? etag,
  String appId,
  String? categorySlug,
  String? locationSlug,
)
Параметр Тип Описание
locale String ISO 639-1 ('th', 'en', 'ru'); fallback → 'en' при отсутствии перевода
etag String? ETag из предыдущего вызова; null = первый запрос; совпадение → modified: false
appId String 'wallet' | 'closeloop' | 'merchant'; сейчас не используется в фильтрации, зарезервирован
categorySlug String? null = Popular (все посты, сортировка по viewCount); slug = конкретная категория
locationSlug String? null = все города; slug = фильтр по городу

Требует аутентификации: если пользователь не аутентифицирован — PaymentException(unauthenticated).

Структура ответа

BlogCatalogResult

Поле Тип Описание
modified bool false = данные не изменились (ETag совпал), остальные поля null
etag String? Новый ETag; null при modified: false
items List<BlogItemDto>? Список постов; null при modified: false
categories List<BlogCategoryOption>? Справочник категорий; null при modified: false
locations List<BlogLocationOption>? Справочник локаций; null при modified: false
locationName String? Локализованное название города (для заголовка "What's on in {city}"); null если locationSlug не задан

BlogItemDto

Поле Тип Описание
slug String Уникальный идентификатор поста (из blog.post_groups)
type String Тип контента: 'article', 'event', 'offer', и т.д.
coverImageThumbUrl String? URL миниатюры обложки
coverImageThumbBlurhash String? Blurhash плейсхолдер для обложки
isFeatured bool Пост в Featured-баннере
viewCount int Счётчик просмотров (для сортировки Popular)
startsAt DateTime? Начало события (для type='event')
endsAt DateTime? Конец события
categoryId String? UUID категории (соответствует BlogCategoryOption.id)
locationId String? UUID локации (соответствует BlogLocationOption.id)
locale String Локаль выбранного перевода
title String Заголовок поста
excerpt String Краткое описание
publishedAt DateTime Дата публикации

BlogCategoryOption

Поле Тип Описание
id String UUID категории (из blog.categories)
slug String Машиночитаемый slug ('outdoor', 'food', ...)
name String Локализованное название
sortOrder int Порядок отображения в табах

BlogLocationOption

Поле Тип Описание
id String UUID локации (из blog.locations)
slug String Машиночитаемый slug ('bangkok', 'chiangmai', ...)
name String Локализованное название

Tag Filtering

Теги пользователя строятся из User + UserProfile (идентично MiniappEndpoint._buildUserTags):

acct:{accountType}   status:{status}   lang:{languageCode}   tier:{accountTier}
has:business         biz:{categoryCode}   has:referral-agent   nfc:enabled   vat:payer
+ profile.tags  ← кастомные метки из Admin Panel

Префиксы тегов:

Префикс Пример Источник
acct: acct:consumer User.accountType
tier: tier:gold UserProfile.accountTier
lang: lang:th UserProfile.languageCode
has: has:business, has:referral-agent наличие данных в профиле
biz: biz:restaurant UserProfile.businessCategoryCode

SQL фильтрация в _queryFeed (через unsafeQuery):

-- tagsInclude: пользователь должен иметь ВСЕ перечисленные теги
-- (или список пустой → пост виден всем)
@tags::jsonb @> "tagsInclude" OR "tagsInclude" = '[]'::jsonb

-- tagsExclude: ни один из запрещённых тегов не должен совпадать с тегами пользователя.
-- Реализовано через EXISTS (не через оператор &&) для совместимости с GIN-индексами:
"tagsExclude" = '[]'::jsonb OR NOT EXISTS (
  SELECT 1 FROM jsonb_array_elements_text("tagsExclude") e(tag)
  WHERE @tags::jsonb ? tag
)

Важно: оператор && (пересечение jsonb-массивов) был заменён на NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(...)) в процессе реализации. EXISTS-форма совместима с GIN-индексами на blog.post_groups.tags_exclude и корректно обрабатывает edge-cases с NULL.

JSONB-теги хранятся в blog.post_groups.tags_include / tags_exclude. GIN-индексы на этих колонках используются через predicate pushdown (view v_blog_post без оконных функций прозрачна для оптимизатора).

Locale Fallback

WHERE ("locale" = @locale OR "locale" = 'en')
ORDER BY "slug",
         CASE "locale" WHEN @locale THEN 1 ELSE 2 END

DISTINCT ON ("slug") выбирает одну строку на slug с приоритетом запрошенной локали над 'en'. Если пост существует только на языке, не совпадающем ни с запрошенным locale, ни с 'en' — он не попадает в выдачу (нечитаемый контент не показывается).

ETag Кэширование

ETag вычисляется как первые 16 символов SHA-256 от строки:

{locale}|{categorySlug}|{locationSlug}|{sortedUserTags}

Пример входной строки: th|outdoor|bangkok|acct:consumer,lang:th,tier:silver

Теги сортируются (..sort()) перед хэшированием — порядок добавления не влияет на ETag.

Когда etag из запроса совпадает с вычисленным — возвращается BlogCatalogResult(modified: false) без выполнения SQL-запросов к постам. Справочники (_getCategories, _getLocations) при этом не вызываются (проверка ETag происходит до них в коде).

ETag инвалидируется при изменении: locale, categorySlug, locationSlug, или любого тега пользователя (изменение профиля, KYC upgrade и т.д.).

Graceful Degradation

При недоступности любой из views (ошибка БД, view не применена):

Метод Поведение при ошибке
_getCategories Возвращает [], логирует warning: v_blog_category unavailable
_getLocations Возвращает [], логирует warning: v_blog_location unavailable
_queryFeed Возвращает [], логирует warning: v_blog_post unavailable

Фронтенд получает BlogCatalogResult(modified: true, items: [], categories: [], locations: []) и показывает пустое состояние. Исключения не выбрасываются.

Кэш справочников (localPrio) не заполняется при ошибке — следующий запрос повторит попытку обращения к view.

SQL Views

View Источник Столбцы Использование в Serverpod
public.v_blog_category blog.categories id (hashtext int), uuid (text), slug, sortOrder, names (jsonb→text) ORM-модель VBlogCategory; кэш 30 мин
public.v_blog_location blog.locations id (hashtext int), uuid (text), slug, names (jsonb→text) ORM-модель VBlogLocation; кэш 30 мин
public.v_blog_post blog.post_groups + blog.posts (JOIN) postId, slug, type, coverImageThumbUrl, coverImageThumbBlurhash, isFeatured, viewCount, startsAt, endsAt, tagsInclude, tagsExclude, categoryId, locationId, locale, title, excerpt, publishedAt только unsafeQuery; ORM-модели нет

Особенности v_blog_post: - JOIN blog.post_groupsblog.posts по p.group_id = g.id и p.status = 'published' - Фильтр expires_at: посты с истёкшим g.expires_at исключаются прямо в view - Нет JOIN к categories/locations — только UUID FK для фильтрации в endpoint

id в VBlogCategory / VBlogLocation — это hashtext(slug)::int (стабильный int для Serverpod ORM PK). Настоящий UUID хранится в поле uuid.

Значение categorySlug Поведение Сортировка
null все посты (Popular) viewCount DESC, publishedAt DESC
'outdoor' посты с categoryId = UUID категории publishedAt DESC

Кэш справочников

v_blog_category и v_blog_location кэшируются как JSON-строки в session.caches.localPrio с TTL 30 минут (константа _refDataCacheTtl). Ключи кэша: - blog:categories:v1 - blog:locations:v1

Локализация (names JSONB → строка) применяется после чтения из кэша, при маппинге в BlogCategoryOption/BlogLocationOption. Это позволяет кэшировать один раз для всех локалей.

Деплой

# 1. Применить one_blog миграции (если blog.* ещё не существует)
cd projects/one_blog && npm run db:migrate

# 2. Применить views (идемпотентно, безопасно повторно запускать)
psql -h <host> -U <role> -d <db> \
  -f projects/deploy/seeds/create-views.sql

После применения views заменить <serverpod_role> на роль из onewallet_base_server/config/passwords.yaml. Views создаются через CREATE OR REPLACE — безопасно повторное применение.

onewallet_base_server/scripts/create_views.sql — копия deploy-файла для локальной разработки, содержимое идентично.