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 от строки:
Пример входной строки: 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_groups ↔ blog.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.
Popular Tab vs Category Tabs¶
Значение 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-файла для локальной разработки, содержимое идентично.