real back

This commit is contained in:
mixa
2026-06-22 22:39:08 +03:00
parent 27600872a8
commit c78212263b
38 changed files with 6884 additions and 479 deletions

View File

@@ -1,6 +1,9 @@
POSTGRES_DB=fable POSTGRES_DB=fable
POSTGRES_USER=fable POSTGRES_USER=fable
POSTGRES_PASSWORD=change_me_for_real_environment POSTGRES_PASSWORD=change_me_for_real_environment
JWT_SECRET=change_me_before_production DATABASE_URL=postgres://fable:change_me_for_real_environment@127.0.0.1:5432/fable?sslmode=disable
TOKEN_SECRET=change_me_before_production
TOKEN_TTL=24h
VITE_API_URL=http://127.0.0.1:3000/api
GATEWAY_PORT=3000 GATEWAY_PORT=3000
WEB_PORT=5173 WEB_PORT=5173

View File

@@ -16,14 +16,14 @@
Все данные в интерфейсе и API являются явно демонстрационными заглушками. Реальные люди, подразделения, интеграции, юридические сведения и брендовые материалы не добавлялись. Все данные в интерфейсе и API являются явно демонстрационными заглушками. Реальные люди, подразделения, интеграции, юридические сведения и брендовые материалы не добавлялись.
Backend сейчас использует in-memory demo store внутри Go-сервисов. PostgreSQL schema и Docker PostgreSQL подготовлены, но постоянное хранение через БД остается следующим этапом. Backend теперь использует PostgreSQL через GORM в Go-сервисах. Демо-данные остаются только как seed-набор для локального запуска и проверки сценариев.
## Локальный запуск ## Локальный запуск
Установить зависимости: Установить зависимости:
```bash ```bash
npm install pnpm install
``` ```
Запустить весь проект без Docker: Запустить весь проект без Docker:
@@ -40,13 +40,15 @@ npm run dev
npm run dev:backend npm run dev:backend
``` ```
Для этого локально должен быть доступен PostgreSQL на `127.0.0.1:5432` или должен быть задан `DATABASE_URL`.
Запустить только gateway с fallback demo API: Запустить только gateway с fallback demo API:
```bash ```bash
npm run dev:gateway npm run dev:gateway
``` ```
В обычном локальном режиме gateway работает с fallback demo API, если Go-сервисы не запущены и `*_SERVICE_URL` не заданы. `npm run dev:gateway` оставляет только gateway. Для рабочего backend-контура используйте `npm run dev:backend`, потому что Go-сервисы теперь требуют реальную БД и не должны подменяться скрытым demo-store.
Запустить frontend: Запустить frontend:
@@ -60,7 +62,13 @@ npm run dev:web
npm run test:go npm run test:go
``` ```
Docker сейчас необязателен. Для текущей разработки используйте `npm run dev:backend` и `npm run dev:web`. Docker сейчас необязателен, но PostgreSQL обязателен для Go-сервисов. Для текущей разработки используйте `npm run dev:backend` и `npm run dev:web`.
Проверить сборку всего проекта:
```bash
npm run check
```
Запуск всего окружения через Docker, если он понадобится позже: Запуск всего окружения через Docker, если он понадобится позже:
@@ -82,7 +90,7 @@ Internal services health through gateway: `http://localhost:3000/api/services/he
Пароль: `demo_password` Пароль: `demo_password`
Auth service принимает демо-учетную запись и возвращает demo token. Это не production-аутентификация; для промышленного контура нужен подписанный JWT или другой проверяемый token format. Auth service принимает демо-учетную запись из seed-данных, проверяет хэш пароля и выдает подписанный token для локального backend-контура.
## Backend API ## Backend API

View File

@@ -4,7 +4,7 @@ import { Elysia } from 'elysia';
import { WebStandardAdapter } from 'elysia/adapter/web-standard'; import { WebStandardAdapter } from 'elysia/adapter/web-standard';
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event'; type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив'; type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Возвращен' | 'Архив';
type Visibility = 'Публично' | 'После входа' | 'По роли'; type Visibility = 'Публично' | 'После входа' | 'По роли';
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь'; type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
@@ -28,6 +28,11 @@ type ContentItem = {
mimeType?: string; mimeType?: string;
fileName?: string; fileName?: string;
fileSize?: number; fileSize?: number;
moderatorComment?: string;
reviewComment?: string;
ratingAverage?: number;
ratingCount?: number;
myRating?: number;
}; };
type MediaKind = NonNullable<ContentItem['mediaKind']>; type MediaKind = NonNullable<ContentItem['mediaKind']>;
@@ -40,6 +45,18 @@ type StoredUpload = {
data: Buffer; data: Buffer;
}; };
type NormalizedContentPatch = Partial<ContentItem> & {
title?: string;
category?: string;
lead?: string;
body?: string;
type?: ContentType;
status?: ContentStatus;
tags?: string[];
moderatorComment?: string;
reviewComment?: string;
};
type UserProfile = { type UserProfile = {
id: string; id: string;
name: string; name: string;
@@ -76,6 +93,28 @@ const serviceUrls: Record<string, string | undefined> = {
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия']; const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив']; const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event']; const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event'];
const loginAliases: Record<string, string> = {
'admin@dstu.ru': 'demo_admin',
'editor@dstu.ru': 'demo_editor',
'moderator@dstu.ru': 'demo_moderator',
'manager@dstu.ru': 'demo_moderator',
'user@dstu.ru': 'demo_user'
};
const statusAliases: Record<string, ContentStatus> = {
draft: 'Черновик',
moderation: 'На модерации',
pending: 'На модерации',
review: 'На проверке',
published: 'Опубликовано',
returned: 'Возвращен',
archived: 'Архив',
черновик: 'Черновик',
'на модерации': 'На модерации',
'на проверке': 'На проверке',
опубликовано: 'Опубликовано',
возвращен: 'Возвращен',
архив: 'Архив'
};
const demoUser: UserProfile = { const demoUser: UserProfile = {
id: 'demo-user-1', id: 'demo-user-1',
@@ -88,6 +127,7 @@ const demoUser: UserProfile = {
const users: UserProfile[] = [ const users: UserProfile[] = [
demoUser, demoUser,
{ id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] }, { id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] },
{ id: 'demo-user-4', name: 'Демо-модератор', login: 'demo_moderator', roles: ['менеджер'], subscriptions: ['Мероприятия'] },
{ id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] } { id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] }
]; ];
@@ -298,6 +338,97 @@ function searchContent(query: Record<string, string | undefined>) {
return sorted; return sorted;
} }
function normalizeLogin(value = '') {
const login = value.trim().toLowerCase();
return loginAliases[login] ?? login;
}
function normalizeStatus(value: unknown): ContentStatus | undefined {
if (typeof value !== 'string') {
return undefined;
}
return statusAliases[value.trim().toLowerCase()] ?? (value as ContentStatus);
}
function normalizeContentType(value: unknown): ContentType | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'research') return 'article';
if (normalized === 'announcement') return 'news';
if (contentTypes.includes(normalized as ContentType)) return normalized as ContentType;
return undefined;
}
function normalizeTags(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (typeof value === 'string') {
return parseTags(value);
}
return [];
}
function normalizeContentPatch(payload: Record<string, unknown>): NormalizedContentPatch {
const nextType = normalizeContentType(payload.type);
const nextStatus = normalizeStatus(payload.status);
const nextTags = payload.tags === undefined ? undefined : normalizeTags(payload.tags);
return {
title: typeof payload.title === 'string' ? payload.title.trim() : undefined,
category: typeof payload.category === 'string' ? payload.category.trim() : undefined,
lead: typeof payload.lead === 'string' ? payload.lead : typeof payload.excerpt === 'string' ? payload.excerpt : undefined,
body: typeof payload.body === 'string' ? payload.body : typeof payload.content === 'string' ? payload.content : undefined,
type: nextType,
status: nextStatus,
tags: nextTags,
visibility: payload.visibility as Visibility | undefined,
imageTone: typeof payload.imageTone === 'string' ? payload.imageTone : undefined,
mediaUrl: typeof payload.mediaUrl === 'string' ? payload.mediaUrl : undefined,
mediaKind: payload.mediaKind as ContentItem['mediaKind'] | undefined,
mimeType: typeof payload.mimeType === 'string' ? payload.mimeType : undefined,
fileName: typeof payload.fileName === 'string' ? payload.fileName : undefined,
fileSize: typeof payload.fileSize === 'number' ? payload.fileSize : undefined,
ratingAverage: typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined,
ratingCount: typeof payload.ratingCount === 'number' ? payload.ratingCount : undefined,
myRating: typeof payload.myRating === 'number' ? payload.myRating : undefined,
moderatorComment:
typeof payload.moderatorComment === 'string'
? payload.moderatorComment.trim()
: typeof payload.reviewComment === 'string'
? payload.reviewComment.trim()
: undefined
};
}
function listContent(query: Record<string, string | undefined>, user: UserProfile | null) {
const requestedType = normalizeContentType(query.type);
const requestedStatus = normalizeStatus(query.status);
const requestedAuthor = query.authorId;
const mine = query.mine === 'true';
const limit = Number(query.limit ?? 0);
const exclude = query.exclude;
const term = (query.q ?? '').trim().toLowerCase();
const items = content
.filter((item) => !exclude || item.id !== exclude)
.filter((item) => !requestedType || item.type === requestedType)
.filter((item) => !query.category || item.category === query.category)
.filter((item) => !requestedStatus || item.status === requestedStatus)
.filter((item) => !requestedAuthor || item.author === users.find((entry) => entry.id === requestedAuthor)?.name)
.filter((item) => !mine || (user ? item.author === user.name : false))
.filter((item) => !term || [item.title, item.lead, item.body, item.author, item.category, item.status, ...item.tags].join(' ').toLowerCase().includes(term))
.sort((a, b) => {
if (query.sort === 'popular') return b.views - a.views;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
});
return limit > 0 ? items.slice(0, limit) : items;
}
function isBlobLike(value: unknown): value is Blob & { name?: string; type?: string; size: number } { function isBlobLike(value: unknown): value is Blob & { name?: string; type?: string; size: number } {
return typeof value === 'object' && value !== null && 'arrayBuffer' in value && typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'; return typeof value === 'object' && value !== null && 'arrayBuffer' in value && typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function';
} }
@@ -533,14 +664,15 @@ const app = new Elysia({ adapter: WebStandardAdapter })
.get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] })) .get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
.get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) })) .get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
.post('/api/auth/register', ({ body, set }) => { .post('/api/auth/register', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string; name: string }>; const payload = body as Partial<{ login: string; email: string; password: string; name: string }>;
if (!payload.login || !payload.password || payload.password.length < 8) { const login = normalizeLogin(payload.login ?? payload.email ?? '');
if (!login || !payload.password || payload.password.length < 8) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов'); return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
} }
const user: UserProfile = { const user: UserProfile = {
id: `demo-user-${Date.now()}`, id: `demo-user-${Date.now()}`,
name: payload.name?.trim() || 'Демо-пользователь', name: payload.name?.trim() || 'Демо-пользователь',
login: payload.login, login,
roles: ['пользователь'], roles: ['пользователь'],
subscriptions: [] subscriptions: []
}; };
@@ -551,11 +683,12 @@ const app = new Elysia({ adapter: WebStandardAdapter })
return { token, user }; return { token, user };
}) })
.post('/api/auth/login', ({ body, set }) => { .post('/api/auth/login', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string }>; const payload = body as Partial<{ login: string; email: string; password: string }>;
if (!payload.login || !payload.password) { const login = normalizeLogin(payload.login ?? payload.email ?? '');
if (!login || !payload.password) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль'); return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль');
} }
const user = users.find((item) => item.login === payload.login) ?? demoUser; const user = users.find((item) => item.login === login) ?? demoUser;
const token = `demo-token-${crypto.randomUUID()}`; const token = `demo-token-${crypto.randomUUID()}`;
tokens.set(token, user); tokens.set(token, user);
return { token, user }; return { token, user };
@@ -585,7 +718,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
} }
return { ok: true }; return { ok: true };
}) })
.get('/api/content', () => ({ items: content })) .get('/api/content', ({ query, request }) => ({ items: listContent(query as Record<string, string | undefined>, readUser(request)) }))
.get('/api/content/:id', ({ params, set }) => { .get('/api/content/:id', ({ params, set }) => {
const item = content.find((entry) => entry.id === params.id); const item = content.find((entry) => entry.id === params.id);
if (!item) { if (!item) {
@@ -602,7 +735,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) { if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора'); return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
} }
const payload = body as Partial<ContentItem>; const payload = normalizeContentPatch(body as Record<string, unknown>);
if (!payload.title || !payload.category || !payload.type) { if (!payload.title || !payload.category || !payload.type) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала'); return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
} }
@@ -617,14 +750,19 @@ const app = new Elysia({ adapter: WebStandardAdapter })
author: auth.user.name, author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10), publishedAt: new Date().toISOString().slice(0, 10),
visibility: payload.visibility ?? 'После входа', visibility: payload.visibility ?? 'После входа',
status: 'Черновик', status: payload.status ?? 'Черновик',
views: 0, views: 0,
imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300', imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300',
mediaUrl: payload.mediaUrl, mediaUrl: payload.mediaUrl,
mediaKind: payload.mediaKind, mediaKind: payload.mediaKind,
mimeType: payload.mimeType, mimeType: payload.mimeType,
fileName: payload.fileName, fileName: payload.fileName,
fileSize: payload.fileSize fileSize: payload.fileSize,
moderatorComment: payload.moderatorComment,
reviewComment: payload.reviewComment,
ratingAverage: 0,
ratingCount: 0,
myRating: 0
}; };
content = [item, ...content]; content = [item, ...content];
set.status = 201; set.status = 201;
@@ -639,7 +777,24 @@ const app = new Elysia({ adapter: WebStandardAdapter })
if (index === -1) { if (index === -1) {
return fail(set, 404, 'NOT_FOUND', 'Материал не найден'); return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
} }
content[index] = { ...content[index], ...(body as Partial<ContentItem>) }; const payload = normalizeContentPatch(body as Record<string, unknown>);
const nextRating = typeof payload.ratingAverage === 'number' ? payload.ratingAverage : undefined;
const nextUserRating = typeof body === 'object' && body !== null && typeof (body as Record<string, unknown>).rating === 'number'
? Number((body as Record<string, unknown>).rating)
: undefined;
content[index] = {
...content[index],
...payload,
lead: payload.lead ?? content[index].lead,
body: payload.body ?? content[index].body,
tags: payload.tags ?? content[index].tags,
status: payload.status ?? content[index].status,
ratingAverage: nextUserRating ?? nextRating ?? content[index].ratingAverage,
ratingCount: nextUserRating ? Math.max(Number(content[index].ratingCount ?? 0) + 1, 1) : content[index].ratingCount,
myRating: nextUserRating ?? content[index].myRating,
reviewComment: payload.moderatorComment ?? payload.reviewComment ?? content[index].reviewComment
};
return { item: content[index] }; return { item: content[index] };
}) })
.delete('/api/content/:id', ({ request, params, set }) => { .delete('/api/content/:id', ({ request, params, set }) => {
@@ -770,11 +925,13 @@ const app = new Elysia({ adapter: WebStandardAdapter })
return { item }; return { item };
}) })
.get('/api/analytics/summary', ({ request, set }) => { .get('/api/analytics/summary', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор'); const auth = requireUser(request, set);
if (!auth.user) { if (!auth.user) {
return auth.response; return auth.response;
} }
return { return {
materials: content.filter((item) => item.author === auth.user?.name).length,
comments: comments.length,
totalViews: content.reduce((sum, item) => sum + item.views, 0), totalViews: content.reduce((sum, item) => sum + item.views, 0),
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0), subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
activeUsers: users.length, activeUsers: users.length,
@@ -788,8 +945,18 @@ const app = new Elysia({ adapter: WebStandardAdapter })
} }
return { return {
users: users.length, users: users.length,
content: content.length, materials: content.length,
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length, views: content.reduce((sum, item) => sum + item.views, 0),
pending: content.filter((item) => item.status !== 'Опубликовано').length,
viewsByDay: [
{ label: 'Пн', value: 120 },
{ label: 'Вт', value: 180 },
{ label: 'Ср', value: 160 },
{ label: 'Чт', value: 220 },
{ label: 'Пт', value: 260 },
{ label: 'Сб', value: 140 },
{ label: 'Вс', value: 110 }
],
roles: ['администратор', 'редактор', 'менеджер', 'пользователь'] roles: ['администратор', 'редактор', 'менеджер', 'пользователь']
}; };
}) })

View File

@@ -5,22 +5,29 @@ Frontend MVP информационной системы управления м
## Запуск ## Запуск
Требуются Node.js 22+ и Corepack. Требуются Node.js 22+ и npm.
Из корня монорепозитория:
```bash ```bash
corepack prepare pnpm@11.0.0 --activate npm install
corepack pnpm install npm run dev
corepack pnpm dev
``` ```
Откройте `http://localhost:5173`. Для запуска только frontend:
```bash
npm run dev:web
```
Откройте `http://localhost:5173`. В dev-режиме Vite проксирует `/api` на `http://localhost:3000`.
## Проверки ## Проверки
```bash ```bash
corepack pnpm lint npm --workspace @fable/web run lint
corepack pnpm test npm --workspace @fable/web run test
corepack pnpm build npm --workspace @fable/web run build
``` ```
## Структура ## Структура
@@ -56,7 +63,7 @@ src/
## Тестовые роли ## Тестовые роли
Пароль может быть любым от 6 символов. Основной demo-вход: `demo_admin` / `demo_password`.
| Роль | Учётная запись | | Роль | Учётная запись |
| --- | --- | | --- | --- |
@@ -77,7 +84,7 @@ src/
- `/admin/users`, `/admin/stats`, `/admin/audit-log`; - `/admin/users`, `/admin/stats`, `/admin/audit-log`;
- `POST /uploads`. - `POST /uploads`.
Адрес API задаётся через `VITE_API_URL`. Пока интерфейс использует локальные mock-данные. Адрес API можно переопределить через `VITE_API_URL`. По умолчанию интерфейс работает с `/api` и ожидает gateway на `localhost:3000`.
## Безопасность зависимостей ## Безопасность зависимостей
@@ -92,7 +99,6 @@ Lockfile необходимо хранить в репозитории.
## Ограничения MVP ## Ограничения MVP
- данные не сохраняются после обновления страницы; - данные backend по-прежнему демонстрационные и в основном живут в in-memory store;
- загрузка файлов и комментарии представлены интерфейсом без backend;
- внешние изображения загружаются с Unsplash; - внешние изображения загружаются с Unsplash;
- график администратора демонстрационный. - график администратора демонстрационный.

View File

@@ -2,16 +2,56 @@ import { create } from "zustand";
import { authApi } from "../../shared/api/endpoints"; import { authApi } from "../../shared/api/endpoints";
import { tokenStorage } from "../../shared/api/client"; import { tokenStorage } from "../../shared/api/client";
const roleMap = {
admin: "admin",
administrator: "admin",
user: "user",
editor: "editor",
moderator: "moderator",
manager: "moderator",
администратор: "admin",
редактор: "editor",
менеджер: "moderator",
пользователь: "user",
};
function pickRole(user = {}) {
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
const normalizedRoles = roles.map((role) => roleMap[String(role).toLowerCase()] ?? role);
if (normalizedRoles.includes("admin")) return "admin";
if (normalizedRoles.includes("moderator")) return "moderator";
if (normalizedRoles.includes("editor")) return "editor";
return "user";
}
function normalizeUser(user) {
if (!user) return null;
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
const role = pickRole(user);
const login = user.login ?? user.username ?? "";
return {
...user,
login,
email: user.email ?? (login && login.includes("@") ? login : ""),
roles,
role,
};
}
function readStoredUser() { function readStoredUser() {
try { try {
return JSON.parse(sessionStorage.getItem("user") ?? "null"); return normalizeUser(JSON.parse(sessionStorage.getItem("user") ?? "null"));
} catch { } catch {
return null; return null;
} }
} }
function persistUser(user) { function persistUser(user) {
if (user) sessionStorage.setItem("user", JSON.stringify(user)); const normalizedUser = normalizeUser(user);
if (normalizedUser) sessionStorage.setItem("user", JSON.stringify(normalizedUser));
else sessionStorage.removeItem("user"); else sessionStorage.removeItem("user");
} }
@@ -20,7 +60,7 @@ function getToken(payload) {
} }
function getUser(payload) { function getUser(payload) {
return payload?.user ?? payload?.profile ?? payload; return normalizeUser(payload?.user ?? payload?.profile ?? payload);
} }
export const useSession = create((set, get) => ({ export const useSession = create((set, get) => ({
@@ -37,12 +77,13 @@ export const useSession = create((set, get) => ({
return user; return user;
}, },
login: async ({ email, password }) => { login: async ({ identifier, email, login, password }) => {
const response = await authApi.login({ email, password }); const credential = identifier ?? login ?? email;
const response = await authApi.login({ login: credential, email: credential, password });
const token = getToken(response); const token = getToken(response);
tokenStorage.set(token); tokenStorage.set(token);
const user = response.user ?? (await authApi.me()); const user = normalizeUser(response.user ?? (await authApi.me()));
set({ user }); set({ user });
persistUser(user); persistUser(user);
return user; return user;
@@ -53,7 +94,7 @@ export const useSession = create((set, get) => ({
set({ initializing: true }); set({ initializing: true });
try { try {
const user = await authApi.me(); const user = normalizeUser(await authApi.me());
set({ user, initializing: false }); set({ user, initializing: false });
persistUser(user); persistUser(user);
return user; return user;
@@ -87,7 +128,15 @@ export const useSession = create((set, get) => ({
return user; return user;
}, },
switchRole: () => get().user, switchRole: (nextRole) => {
const current = get().user;
if (!current) return null;
const user = normalizeUser({ ...current, role: nextRole });
set({ user });
persistUser(user);
return user;
},
})); }));
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

View File

@@ -7,12 +7,32 @@ import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button"; import { Button } from "../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States"; import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
function normalizeEvent(event) {
const rawDate = event.date ?? event.startsAt ?? event.publishedAt ?? "";
const parsedDate = rawDate ? new Date(rawDate) : null;
const day = parsedDate && !Number.isNaN(parsedDate.getTime())
? parsedDate.toLocaleDateString("ru-RU", { day: "numeric" })
: String(rawDate).split(" ")[0] ?? "--";
const month = parsedDate && !Number.isNaN(parsedDate.getTime())
? parsedDate.toLocaleDateString("ru-RU", { month: "short" })
: String(rawDate).split(" ")[1] ?? "";
return {
...event,
day,
month,
description: event.description ?? event.lead ?? event.body ?? "Описание события появится после загрузки данных.",
time: event.time ?? event.startsAt ?? "Время уточняется",
place: event.place ?? event.location ?? "Место уточняется",
};
}
export function EventsPage() { export function EventsPage() {
const { data: eventsPayload, isLoading, isError, refetch } = useQuery({ const { data: eventsPayload, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.events(), queryKey: queryKeys.events(),
queryFn: directoriesApi.events, queryFn: directoriesApi.events,
}); });
const pageEvents = toList(eventsPayload); const pageEvents = toList(eventsPayload).map(normalizeEvent);
return ( return (
<div className="page-shell py-10"> <div className="page-shell py-10">
@@ -51,10 +71,10 @@ export function EventsPage() {
className="group grid gap-5 py-8 md:grid-cols-[minmax(7rem,1fr)_minmax(0,5fr)_auto] md:items-center" className="group grid gap-5 py-8 md:grid-cols-[minmax(7rem,1fr)_minmax(0,5fr)_auto] md:items-center"
> >
<div className="flex items-baseline gap-2 md:block"> <div className="flex items-baseline gap-2 md:block">
<span className="font-serif text-5xl text-primary">{event.date.split(" ")[0]}</span> <span className="font-serif text-5xl text-primary">{event.day}</span>
<span className="text-sm uppercase tracking-widest text-muted"> <span className="text-sm uppercase tracking-widest text-muted">
{event.date.split(" ")[1]} {event.month}
</span> </span>
</div> </div>
<div> <div>
<Badge tone={index === 0 ? "accent" : "neutral"}>{event.category}</Badge> <Badge tone={index === 0 ? "accent" : "neutral"}>{event.category}</Badge>

View File

@@ -179,8 +179,9 @@ export function HomePage() {
<p className="text-xs font-bold uppercase tracking-widest text-accent">Навигация по темам</p> <p className="text-xs font-bold uppercase tracking-widest text-accent">Навигация по темам</p>
<div className="mt-5 grid border-l border-t border-line sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-5 grid border-l border-t border-line sm:grid-cols-2 lg:grid-cols-4">
{pageCategories.map((category, index) => { {pageCategories.map((category, index) => {
const name = category.name ?? category.title ?? category.label; const name = typeof category === "string" ? category : category.name ?? category.title ?? category.label;
const value = category.slug ?? category.id ?? name; const value = typeof category === "string" ? category : category.slug ?? category.id ?? name;
const description = typeof category === "string" ? "Материалы этой категории доступны в едином каталоге." : category.description;
return ( return (
<Link <Link
key={category.id ?? value} key={category.id ?? value}
@@ -192,7 +193,7 @@ export function HomePage() {
</span> </span>
<h3 className="mt-12 font-serif text-2xl">{name}</h3> <h3 className="mt-12 font-serif text-2xl">{name}</h3>
<p className="mt-3 text-sm leading-6 text-muted transition group-hover:text-white/65"> <p className="mt-3 text-sm leading-6 text-muted transition group-hover:text-white/65">
{category.description} {description}
</p> </p>
</Link> </Link>
); );

View File

@@ -8,7 +8,7 @@ import { Button } from "../shared/ui/Button";
import { Input } from "../shared/ui/Field"; import { Input } from "../shared/ui/Field";
const schema = z.object({ const schema = z.object({
email: z.email("Введите корректный адрес"), identifier: z.string().min(3, "Введите логин или почту"),
password: z.string().min(6, "Минимум 6 символов"), password: z.string().min(6, "Минимум 6 символов"),
}); });
@@ -22,7 +22,7 @@ export function LoginPage() {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { email: "editor@dstu.ru", password: "password" }, defaultValues: { identifier: "demo_admin", password: "demo_password" },
}); });
const onSubmit = async (data) => { const onSubmit = async (data) => {
@@ -52,14 +52,13 @@ export function LoginPage() {
</div> </div>
<h1 className="mt-6 font-serif text-4xl">Вход в систему</h1> <h1 className="mt-6 font-serif text-4xl">Вход в систему</h1>
<p className="mt-3 leading-7 text-muted"> <p className="mt-3 leading-7 text-muted">
Используйте тестовый адрес роли или учетную запись backend. Используйте демо-логин backend или тестовую почту роли.
</p> </p>
<form className="mt-8 grid gap-5" noValidate onSubmit={handleSubmit(onSubmit)}> <form className="mt-8 grid gap-5" noValidate onSubmit={handleSubmit(onSubmit)}>
<Input <Input
label="Корпоративная почта" label="Логин или почта"
type="email" error={errors.identifier?.message}
error={errors.email?.message} {...register("identifier")}
{...register("email")}
/> />
<Input <Input
label="Пароль" label="Пароль"
@@ -72,8 +71,8 @@ export function LoginPage() {
</Button> </Button>
</form> </form>
<div className="mt-7 rounded-lg border border-line bg-surface p-4 text-xs leading-6 text-muted"> <div className="mt-7 rounded-lg border border-line bg-surface p-4 text-xs leading-6 text-muted">
<b className="text-ink">Тестовые роли:</b> user@dstu.ru, editor@dstu.ru, <b className="text-ink">Демо-вход:</b> demo_admin / demo_password. Также поддерживаются
moderator@dstu.ru, admin@dstu.ru user@dstu.ru, editor@dstu.ru, moderator@dstu.ru и admin@dstu.ru.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,15 +12,15 @@ describe("LoginPage", () => {
</MemoryRouter>, </MemoryRouter>,
); );
const email = screen.getByLabelText("Корпоративная почта"); const email = screen.getByLabelText("Логин или почта");
const password = screen.getByLabelText("Пароль"); const password = screen.getByLabelText("Пароль");
await user.clear(email); await user.clear(email);
await user.type(email, "wrong"); await user.type(email, "ab");
await user.clear(password); await user.clear(password);
await user.type(password, "123"); await user.type(password, "123");
await user.click(screen.getByRole("button", { name: "Войти" })); await user.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText("Введите корректный адрес")).toBeInTheDocument(); expect(await screen.findByText("Введите логин или почту")).toBeInTheDocument();
expect(screen.getByText("Минимум 6 символов")).toBeInTheDocument(); expect(screen.getByText("Минимум 6 символов")).toBeInTheDocument();
}); });
}); });

View File

@@ -18,11 +18,17 @@ const materialTypes = [
]; ];
function normalizeCategories(payload) { function normalizeCategories(payload) {
return toList(payload).map((item) => ({ return toList(payload).map((item) => {
id: item.id ?? item.slug ?? item.name, if (typeof item === "string") {
name: item.name ?? item.title ?? item.label, return { id: item, name: item, value: item };
value: item.slug ?? item.id ?? item.name, }
}));
return {
id: item.id ?? item.slug ?? item.name,
name: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
};
});
} }
export function MaterialsPage() { export function MaterialsPage() {

View File

@@ -13,6 +13,19 @@ function number(value) {
return Number(value ?? 0).toLocaleString("ru-RU"); return Number(value ?? 0).toLocaleString("ru-RU");
} }
function normalizeUsers(payload) {
return toList(payload).map((user) => {
const roles = Array.isArray(user.roles) ? user.roles : [user.role].filter(Boolean);
const primaryRole = roles[0] ?? "user";
return {
...user,
email: user.email ?? user.login ?? "-",
role: user.role ?? primaryRole,
};
});
}
function pickChart(summary) { function pickChart(summary) {
const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? []; const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? [];
if (!Array.isArray(values)) return []; if (!Array.isArray(values)) return [];
@@ -34,7 +47,7 @@ export function AdminPage() {
}); });
const summary = toEntity(dashboardQuery.data) ?? {}; const summary = toEntity(dashboardQuery.data) ?? {};
const users = toList(usersQuery.data); const users = normalizeUsers(usersQuery.data);
const popular = normalizeContentList(popularQuery.data); const popular = normalizeContentList(popularQuery.data);
const chart = pickChart(summary); const chart = pickChart(summary);
const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1); const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1);

View File

@@ -89,7 +89,7 @@ export function CabinetHomePage() {
<span className={`mt-1.5 size-2 rounded-full ${item.read || item.isRead ? "bg-line" : "bg-accent"}`} /> <span className={`mt-1.5 size-2 rounded-full ${item.read || item.isRead ? "bg-line" : "bg-accent"}`} />
<div> <div>
<h3 className="font-semibold">{item.title ?? "Уведомление"}</h3> <h3 className="font-semibold">{item.title ?? "Уведомление"}</h3>
<p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.body}</p> <p className="mt-1 text-sm text-muted">{item.text ?? item.message ?? item.description ?? item.body}</p>
<p className="mt-2 text-xs text-muted">{formatDate(item.createdAt ?? item.date)}</p> <p className="mt-2 text-xs text-muted">{formatDate(item.createdAt ?? item.date)}</p>
</div> </div>
</div> </div>

View File

@@ -36,11 +36,17 @@ function formatFileSize(size) {
} }
function normalizeCategories(payload) { function normalizeCategories(payload) {
return toList(payload).map((item) => ({ return toList(payload).map((item) => {
id: item.id ?? item.slug ?? item.name, if (typeof item === "string") {
label: item.name ?? item.title ?? item.label, return { id: item, label: item, value: item };
value: item.slug ?? item.id ?? item.name, }
}));
return {
id: item.id ?? item.slug ?? item.name,
label: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
};
});
} }
function makePayload(data, photos, status) { function makePayload(data, photos, status) {

View File

@@ -112,7 +112,7 @@ export function ProfilePage() {
<div className="mt-7 grid gap-5 sm:grid-cols-2"> <div className="mt-7 grid gap-5 sm:grid-cols-2">
<Input name="name" label="Имя и фамилия" defaultValue={user.name ?? ""} disabled /> <Input name="name" label="Имя и фамилия" defaultValue={user.name ?? ""} disabled />
<Input label="Электронная почта" defaultValue={user.email ?? ""} disabled /> <Input label="Электронная почта" defaultValue={user.email ?? user.login ?? ""} disabled />
<Input name="department" label="Подразделение" defaultValue={user.department ?? ""} disabled /> <Input name="department" label="Подразделение" defaultValue={user.department ?? ""} disabled />
<Input name="phone" label="Телефон" defaultValue={user.phone ?? ""} disabled /> <Input name="phone" label="Телефон" defaultValue={user.phone ?? ""} disabled />
<Textarea <Textarea

View File

@@ -1,6 +1,6 @@
import axios from "axios"; import axios from "axios";
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000/api"; export const API_BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
export const tokenStorage = { export const tokenStorage = {
get: () => sessionStorage.getItem("accessToken"), get: () => sessionStorage.getItem("accessToken"),
@@ -32,8 +32,8 @@ api.interceptors.response.use(
return Promise.reject({ return Promise.reject({
status: error.response?.status ?? 0, status: error.response?.status ?? 0,
message: error.response?.data?.message ?? "Не удалось выполнить запрос", message: error.response?.data?.error?.message ?? error.response?.data?.message ?? "Не удалось выполнить запрос",
fieldErrors: error.response?.data?.errors ?? error.response?.data?.fieldErrors, fieldErrors: error.response?.data?.error?.errors ?? error.response?.data?.errors ?? error.response?.data?.fieldErrors,
}); });
}, },
); );

View File

@@ -8,6 +8,9 @@ const labels = {
published: "Опубликован", published: "Опубликован",
returned: "Возвращен", returned: "Возвращен",
archived: "В архиве", archived: "В архиве",
Опубликовано: "Опубликовано",
Возвращен: "Возвращен",
Архив: "В архиве",
}; };
const tones = { const tones = {
@@ -22,7 +25,9 @@ const tones = {
"На модерации": "warning", "На модерации": "warning",
Одобрен: "success", Одобрен: "success",
Опубликован: "success", Опубликован: "success",
Опубликовано: "success",
Возвращен: "danger", Возвращен: "danger",
Архив: "neutral",
"В архиве": "neutral", "В архиве": "neutral",
}; };

View File

@@ -6,6 +6,16 @@ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
port: 5173, port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/health": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
}, },
test: { test: {
globals: true, globals: true,

View File

@@ -2,13 +2,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$ DO $$
BEGIN BEGIN
CREATE TYPE content_type AS ENUM ('news', 'article', 'video', 'audio', 'graphic', 'event_announcement'); CREATE TYPE content_type AS ENUM ('news', 'article', 'video', 'audio', 'graphic', 'event');
EXCEPTION WHEN duplicate_object THEN NULL; EXCEPTION WHEN duplicate_object THEN NULL;
END $$; END $$;
DO $$ DO $$
BEGIN BEGIN
CREATE TYPE content_status AS ENUM ('draft', 'moderation', 'review', 'published', 'archived'); CREATE TYPE content_status AS ENUM ('draft', 'moderation', 'review', 'published', 'returned', 'archived');
EXCEPTION WHEN duplicate_object THEN NULL; EXCEPTION WHEN duplicate_object THEN NULL;
END $$; END $$;
@@ -76,6 +76,9 @@ CREATE TABLE IF NOT EXISTS speakers (
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
role_description TEXT, role_description TEXT,
biography TEXT, biography TEXT,
topics_csv TEXT NOT NULL DEFAULT '',
materials_count INTEGER NOT NULL DEFAULT 0,
subscribers_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@@ -107,6 +110,12 @@ CREATE TABLE IF NOT EXISTS content_items (
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL, author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
author_label TEXT, author_label TEXT,
speaker_id UUID REFERENCES speakers(id) ON DELETE SET NULL, speaker_id UUID REFERENCES speakers(id) ON DELETE SET NULL,
duration TEXT,
image_tone TEXT NOT NULL DEFAULT '',
moderator_comment TEXT,
review_comment TEXT,
rating_average DOUBLE PRECISION NOT NULL DEFAULT 0,
rating_count INTEGER NOT NULL DEFAULT 0,
published_at TIMESTAMPTZ, published_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ, archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -132,6 +141,15 @@ CREATE TABLE IF NOT EXISTS media_files (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS stored_file_blobs (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS subscriptions ( CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -1,3 +1,9 @@
x-go-service-env: &go-service-env
PORT: 8080
DATABASE_URL: postgres://${POSTGRES_USER:-fable}:${POSTGRES_PASSWORD:-fable_dev_password}@postgres:5432/${POSTGRES_DB:-fable}?sslmode=disable
TOKEN_SECRET: ${TOKEN_SECRET:-local-dev-secret-change-me}
TOKEN_TTL: ${TOKEN_TTL:-24h}
services: services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
@@ -61,8 +67,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: auth SERVICE: auth
environment: environment: *go-service-env
PORT: 8080
user-service: user-service:
build: build:
@@ -70,8 +75,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: user SERVICE: user
environment: environment: *go-service-env
PORT: 8080
content-service: content-service:
build: build:
@@ -79,8 +83,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: content SERVICE: content
environment: environment: *go-service-env
PORT: 8080
taxonomy-service: taxonomy-service:
build: build:
@@ -88,8 +91,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: taxonomy SERVICE: taxonomy
environment: environment: *go-service-env
PORT: 8080
speaker-service: speaker-service:
build: build:
@@ -97,8 +99,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: speaker SERVICE: speaker
environment: environment: *go-service-env
PORT: 8080
subscription-service: subscription-service:
build: build:
@@ -106,8 +107,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: subscription SERVICE: subscription
environment: environment: *go-service-env
PORT: 8080
notification-service: notification-service:
build: build:
@@ -115,8 +115,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: notification SERVICE: notification
environment: environment: *go-service-env
PORT: 8080
comment-service: comment-service:
build: build:
@@ -124,8 +123,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: comment SERVICE: comment
environment: environment: *go-service-env
PORT: 8080
search-service: search-service:
build: build:
@@ -133,8 +131,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: search SERVICE: search
environment: environment: *go-service-env
PORT: 8080
analytics-service: analytics-service:
build: build:
@@ -142,8 +139,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: analytics SERVICE: analytics
environment: environment: *go-service-env
PORT: 8080
audit-service: audit-service:
build: build:
@@ -151,8 +147,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: audit SERVICE: audit
environment: environment: *go-service-env
PORT: 8080
media-service: media-service:
build: build:
@@ -160,8 +155,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile dockerfile: infra/docker/go-service.Dockerfile
args: args:
SERVICE: media SERVICE: media
environment: environment: *go-service-env
PORT: 8080
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -0,0 +1,497 @@
# Backend Stabilization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the current scaffold into a repeatable backend-first almost-production demo: real PostgreSQL persistence, real auth, real gateway-backed API responses, and passing root verification.
**Architecture:** Keep the current three-layer shape (`apps/web` -> `apps/gateway` -> `services/cmd/*`) and replace the shared Go `demoStore` with a PostgreSQL-backed store built on `GORM`. Preserve existing HTTP contracts where possible, add only the minimal new plumbing needed for config, auth, health checks, and smoke verification.
**Tech Stack:** npm workspaces, React 19, Vite, Axios, Zustand, Node.js + Elysia gateway, Go HTTP services, GORM, PostgreSQL 17, SQL migrations, Docker Compose.
---
## File Map
- `package.json`: root verification commands and workspace entrypoints.
- `README.md`: authoritative local run and acceptance instructions.
- `.env.example`: example backend DSN, token secret, token TTL and API base URL.
- `docker-compose.yml`: consistent local runtime for postgres, gateway, web and services.
- `scripts/dev-backend.sh`: local backend boot with PostgreSQL-aware env.
- `scripts/smoke-backend.sh`: curl-based critical-path smoke verification.
- `apps/gateway/src/index.ts`: gateway proxying, auth fallback removal, unified error/health behavior.
- `apps/web/src/shared/api/client.js`: correct default API base URL and error normalization.
- `apps/web/src/shared/api/endpoints.js`: API contract used by the UI.
- `apps/web/src/app/store/session.js`: login payload compatibility, `me`, logout, session reset.
- `apps/web/src/pages/*.jsx`: key pages that currently assume mock-only data shapes.
- `apps/web/src/**/*.test.*`: frontend regression tests for login and key data pages.
- `services/go.mod`: PostgreSQL driver and password-hash dependency declarations.
- `services/internal/service/types.go`: backend DTOs and enum/value mapping.
- `services/internal/service/service.go`: service bootstrap, DB-aware health/readiness.
- `services/internal/service/api.go`: HTTP handlers for auth, content, media, search, comments, subscriptions, notifications, admin and analytics.
- `services/internal/service/store.go`: store interface and bootstrap hook.
- `services/internal/service/db.go`: PostgreSQL connection, GORM bootstrap and migration helpers.
- `services/internal/service/store_gorm.go`: GORM-backed implementation of the store.
- `services/internal/service/auth.go`: password hashing and signed token helpers.
- `services/internal/service/seed.go`: idempotent bootstrap data for roles, demo users and demo content.
- `services/internal/service/*_test.go`: Go unit/integration coverage for critical backend flows.
- `database/migrations/001_init.sql`: schema source of truth; must match current Go enum values and required tables.
### Task 1: Reproduce and lock the runtime baseline
**Covers:** [S1, S2, S7]
**Files:**
- Modify: `README.md`
- Modify: `.env.example`
- Modify: `package.json`
- Modify: `scripts/dev-backend.sh`
- Create: `scripts/smoke-backend.sh`
- [ ] **Step 1: Capture the failing baseline in notes and a smoke script stub**
```bash
npm install
npm run check
```
Expected: failure is reproduced before code changes. Right now the first expected failure is the missing frontend toolchain (`vite: command not found`) or another dependency/runtime error from a clean install.
- [ ] **Step 2: Write the failing smoke script skeleton**
```bash
#!/usr/bin/env bash
set -euo pipefail
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:3000/api}"
echo "[1/4] login"
echo "[2/4] fetch current user"
echo "[3/4] create draft content"
echo "[4/4] read admin dashboard"
exit 1
```
Save as `scripts/smoke-backend.sh` and mark it executable after the rest of the plan fills the real requests.
- [ ] **Step 3: Make the local runtime explicit and consistent**
Add backend env examples to `.env.example`:
```dotenv
DATABASE_URL=postgres://fable:fable_dev_password@127.0.0.1:5432/fable?sslmode=disable
TOKEN_SECRET=local-dev-secret-change-me
TOKEN_TTL=24h
VITE_API_URL=http://127.0.0.1:3000/api
```
Update `scripts/dev-backend.sh` so every Go service receives `DATABASE_URL`, `TOKEN_SECRET` and `TOKEN_TTL` instead of relying on demo-only state.
- [ ] **Step 4: Update root docs and verification contract**
Document the intended acceptance flow in `README.md`:
```markdown
1. `npm install`
2. `docker compose up -d postgres`
3. `npm run dev:backend`
4. `npm run dev:web`
5. `npm run check`
6. `bash scripts/smoke-backend.sh`
```
- [ ] **Step 5: Re-run the root verification command**
```bash
npm run check
```
Expected: it may still fail, but only on real application gaps rather than missing setup instructions.
### Task 2: Replace the shared demo store with PostgreSQL-backed GORM bootstrap
**Covers:** [S2, S4, S5, S6]
**Files:**
- Modify: `services/go.mod`
- Modify: `services/internal/service/store.go`
- Modify: `services/internal/service/service.go`
- Create: `services/internal/service/db.go`
- Create: `services/internal/service/store_gorm.go`
- Create: `services/internal/service/seed.go`
- Create: `services/internal/service/store_gorm_test.go`
- Modify: `database/migrations/001_init.sql`
- [ ] **Step 1: Write the failing DB bootstrap test**
```go
func TestBootstrapStoreLoadsSeededUsersAndContent(t *testing.T) {
store := newTestGORMStore(t)
user, ok := store.UserByLogin(context.Background(), "demo_admin")
if !ok {
t.Fatal("expected seeded admin user")
}
items, err := store.ListContent(context.Background(), ContentFilter{})
if err != nil {
t.Fatalf("list content: %v", err)
}
if len(items) == 0 {
t.Fatal("expected seeded content")
}
}
```
- [ ] **Step 2: Run the failing Go test**
```bash
cd services && go test ./internal/service -run TestBootstrapStoreLoadsSeededUsersAndContent -v
```
Expected: FAIL because there is no GORM bootstrap/store yet.
- [ ] **Step 3: Introduce DB config, connection and store interface**
In `services/internal/service/store.go`, replace the global concrete store with an interface:
```go
type Store interface {
UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
// add the remaining methods currently hanging off demoStore
}
```
In `services/internal/service/db.go`, add a PostgreSQL opener using `DATABASE_URL` and return a shared `*gorm.DB`.
- [ ] **Step 4: Implement GORM store and idempotent seed bootstrap**
Key shape for `services/internal/service/store_gorm.go`:
```go
type gormStore struct {
db *gorm.DB
}
func (s *gormStore) UserByLogin(ctx context.Context, login string) (UserProfile, bool, error) {
var user userRecord
err := s.db.WithContext(ctx).Preload("Roles").Where("login = ?", login).First(&user).Error
// map record -> API DTO
}
```
In `seed.go`, upsert roles, demo users, speakers, categories, tags, content, notifications and comments.
- [ ] **Step 5: Align the migration with Go values before wiring queries**
Fix the enum mismatch in `database/migrations/001_init.sql` so the stored content type matches Go (`event`, not `event_announcement`) or update Go and frontend in one consistent direction. Pick one representation and use it everywhere.
- [ ] **Step 6: Run the new store test and the existing Go service test**
```bash
cd services && go test ./internal/service -run 'TestBootstrapStoreLoadsSeededUsersAndContent|TestHealthEndpoint' -v
```
Expected: PASS.
### Task 3: Implement real auth, session lookup and readiness behavior
**Covers:** [S2, S3, S5, S6, S7]
**Files:**
- Modify: `services/internal/service/api.go`
- Modify: `services/internal/service/types.go`
- Create: `services/internal/service/auth.go`
- Create: `services/internal/service/auth_test.go`
- Modify: `services/internal/service/service.go`
- [ ] **Step 1: Write failing auth tests for register, login and me**
```go
func TestAuthFlowRegisterLoginAndMe(t *testing.T) {
h := newTestHandler(t, Config{Name: "auth", Domain: "auth"})
registerBody := `{"login":"new_user","password":"verysecret","name":"Новый пользователь"}`
registerRec := performJSONRequest(t, h, http.MethodPost, "/api/auth/register", registerBody, "")
if registerRec.Code != http.StatusCreated {
t.Fatalf("register status = %d", registerRec.Code)
}
token := readTokenFromResponse(t, registerRec.Body.Bytes())
meRec := performJSONRequest(t, h, http.MethodGet, "/api/auth/me", "", token)
if meRec.Code != http.StatusOK {
t.Fatalf("me status = %d", meRec.Code)
}
}
```
- [ ] **Step 2: Run the failing auth test**
```bash
cd services && go test ./internal/service -run TestAuthFlowRegisterLoginAndMe -v
```
Expected: FAIL because login currently accepts any password and returns a demo token.
- [ ] **Step 3: Add password hashing and signed tokens**
In `services/internal/service/auth.go` add minimal helpers:
```go
func HashPassword(password string) (string, error) { /* bcrypt */ }
func ComparePassword(hash, password string) error { /* bcrypt */ }
func SignToken(secret string, claims TokenClaims, ttl time.Duration) (string, error) { /* HMAC-signed token */ }
func ParseToken(secret, token string) (TokenClaims, error) { /* validate signature + expiry */ }
```
Keep the token format simple and self-contained; do not preserve `demo-token-*` compatibility.
- [ ] **Step 4: Update auth handlers to use the SQL store**
Replace the login fallback in `api.go` with explicit credential checks:
```go
user, ok, err := backendStore.UserByLogin(ctx, payload.Login)
if err != nil { /* 500 */ }
if !ok || ComparePassword(user.PasswordHash, payload.Password) != nil {
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
return true
}
```
Also make `/auth/change-password` update the stored hash and make `/ready` fail when DB connectivity is broken.
- [ ] **Step 5: Run focused auth/readiness verification**
```bash
cd services && go test ./internal/service -run 'TestAuthFlowRegisterLoginAndMe|TestHealthEndpoint' -v
```
Expected: PASS.
### Task 4: Persist content, taxonomy, subscriptions, comments, admin and analytics endpoints
**Covers:** [S3, S4, S5, S6, S7]
**Files:**
- Modify: `services/internal/service/api.go`
- Modify: `services/internal/service/types.go`
- Modify: `services/internal/service/store_gorm.go`
- Create: `services/internal/service/content_api_test.go`
- Create: `services/internal/service/admin_api_test.go`
- [ ] **Step 1: Write failing tests for the critical domain path**
```go
func TestContentCommentSubscriptionAndAdminFlow(t *testing.T) {
h := newTestHandlerAsAdmin(t)
created := performJSONRequest(t, h, http.MethodPost, "/api/content", `{"title":"Новый материал","type":"article","category":"Статьи"}`, adminToken(t, h))
if created.Code != http.StatusCreated {
t.Fatalf("create content status = %d", created.Code)
}
contentID := readItemID(t, created.Body.Bytes())
comment := performJSONRequest(t, h, http.MethodPost, "/api/comments/"+contentID, `{"text":"Полезный материал"}`, userToken(t, h))
if comment.Code != http.StatusCreated {
t.Fatalf("create comment status = %d", comment.Code)
}
}
```
- [ ] **Step 2: Run the failing domain test**
```bash
cd services && go test ./internal/service -run TestContentCommentSubscriptionAndAdminFlow -v
```
Expected: FAIL because handlers still depend on the in-memory methods and non-persistent side effects.
- [ ] **Step 3: Port each handler branch to GORM-backed methods without changing the external contract**
Implement GORM methods for:
- content list/detail/create/update/delete;
- events/media derived listings;
- category/tag reads;
- speaker reads;
- comment list/create;
- subscription list/create;
- notification list/mark-read;
- admin users/roles/audit/dashboard;
- analytics summary;
- search using PostgreSQL text search.
Represent the filter in Go explicitly:
```go
type ContentFilter struct {
Query string
Category string
Type string
Sort string
Limit int
Exclude string
}
```
- [ ] **Step 4: Keep API payloads stable for the current frontend**
Return the same envelope keys the UI already expects:
```json
{ "user": { ... } }
{ "items": [ ... ] }
{ "item": { ... } }
{ "ok": true }
```
Do not introduce a second incompatible schema while stabilizing the backend.
- [ ] **Step 5: Run the focused Go verification for the critical path**
```bash
cd services && go test ./internal/service -run 'TestContentCommentSubscriptionAndAdminFlow|TestAuthFlowRegisterLoginAndMe' -v
```
Expected: PASS.
### Task 5: Remove gateway/frontend mock-only assumptions and align contracts
**Covers:** [S2, S3, S5, S7, S8]
**Files:**
- Modify: `apps/gateway/src/index.ts`
- Modify: `apps/web/src/shared/api/client.js`
- Modify: `apps/web/src/shared/api/endpoints.js`
- Modify: `apps/web/src/app/store/session.js`
- Modify: `apps/web/src/pages/LoginPage.test.jsx`
- Modify: `apps/web/src/pages/MaterialsPage.test.jsx`
- Create: `apps/web/src/app/store/session.test.js`
- [ ] **Step 1: Write the failing frontend session test**
```js
it("stores token after login and loads current user from /auth/me", async () => {
authApi.login = vi.fn().mockResolvedValue({ accessToken: "real-token" });
authApi.me = vi.fn().mockResolvedValue({ id: "u1", login: "demo_admin", roles: ["администратор"] });
const user = await useSession.getState().login({ email: "demo_admin", password: "secret123" });
expect(tokenStorage.get()).toBe("real-token");
expect(user.login).toBe("demo_admin");
});
```
- [ ] **Step 2: Run the failing frontend tests**
```bash
npm --workspace @fable/web run test -- --runInBand
```
Expected: FAIL because the current store sends `{ email, password }` while the backend expects `login`, and the default API URL points to `:8000/api` instead of the gateway on `:3000/api`.
- [ ] **Step 3: Fix the frontend contract with minimal surface area**
Make these concrete adjustments:
- `client.js`: default `VITE_API_URL` to `http://localhost:3000/api`;
- `client.js`: normalize backend errors from `{ error: { message } }` as well as `{ message }`;
- `session.js`: submit `login` instead of `email`, while still reading the same form field from UI;
- `session.js`: persist the `me` response shape without assuming mock-only fields.
Target login change:
```js
login: async ({ email, password }) => {
const response = await authApi.login({ login: email, password });
const token = getToken(response);
tokenStorage.set(token);
const user = response.user ?? (await authApi.me());
set({ user });
persistUser(user);
return user;
}
```
- [ ] **Step 4: Remove gateway-local demo fallback behavior**
In `apps/gateway/src/index.ts`, delete or bypass the hardcoded `demoUser`, `tokens` and local content arrays once real service URLs are configured. Gateway behavior should become: proxy or fail explicitly, but never silently serve a shadow backend.
- [ ] **Step 5: Re-run frontend verification**
```bash
npm --workspace @fable/web run test
npm --workspace @fable/web run build
npm --workspace @fable/gateway run check
```
Expected: PASS.
### Task 6: Final smoke verification for local and Docker flows
**Covers:** [S2, S6, S7]
**Files:**
- Modify: `docker-compose.yml`
- Modify: `scripts/smoke-backend.sh`
- Modify: `README.md`
- [ ] **Step 1: Fill the smoke script with real requests**
```bash
TOKEN="$({
curl -sS -X POST "$API_BASE_URL/auth/login" \
-H 'Content-Type: application/json' \
-d '{"login":"demo_admin","password":"demo_password"}'
} | jq -r '.token // .accessToken')"
curl -sS "$API_BASE_URL/auth/me" -H "Authorization: Bearer $TOKEN"
curl -sS -X POST "$API_BASE_URL/content" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"title":"Smoke draft","type":"article","category":"Статьи"}'
curl -sS "$API_BASE_URL/admin/users" -H "Authorization: Bearer $TOKEN"
```
- [ ] **Step 2: Verify the non-Docker local path**
Run in separate shells:
```bash
docker compose up -d postgres
npm run dev:backend
npm run dev:web
bash scripts/smoke-backend.sh
```
Expected: PASS.
- [ ] **Step 3: Verify the Docker path**
```bash
docker compose up --build -d
bash scripts/smoke-backend.sh
```
Expected: PASS against the same API base URL.
- [ ] **Step 4: Run the final root verification command**
```bash
npm run check
```
Expected: PASS.
- [ ] **Step 5: Update the README acceptance section with the final proven commands**
```markdown
Проверка готовности:
- `npm run check`
- `bash scripts/smoke-backend.sh`
- `docker compose up --build`
```
## Self-Review
- [x] Every spec section `[S1]`..`[S8]` is covered by at least one task.
- [x] The migration/schema mismatch (`event_announcement` vs `event`) is explicitly addressed.
- [x] No `TODO`/`TBD` placeholders remain in tasks.
- [x] Frontend/gateway/backend contract mismatches are called out explicitly (`VITE_API_URL`, `login` payload, demo fallback removal).

View File

@@ -0,0 +1,95 @@
# Backend Stabilization Design
## [S1] Контекст
Репозиторий уже содержит три основных слоя: `apps/web`, `apps/gateway` и Go-сервисы в `services/`. Сейчас проект не доведен до почти production-состояния по трем причинам: установка и сборка из корня не подтверждены, frontend частично опирается на mock-данные, а backend хранит состояние в `demo`/in-memory store.
Цель этой спецификации: определить первый под-проект, который переведет приложение из состояния scaffold/demo в состояние почти production-демо с реальным backend-контуром.
## [S2] Цель первого под-проекта
Первый под-проект называется `backend stabilization`.
Его результат:
- проект воспроизводимо устанавливается и собирается из корня репозитория;
- gateway и Go-сервисы работают на PostgreSQL, а не на in-memory store;
- frontend перестает зависеть от mock-only ядра в основных сценариях;
- аутентификация, авторизация и сохранение данных становятся реальными и повторяемыми;
- локальный запуск и Docker-окружение дают одинаково рабочий backend-контур.
## [S3] Объем и границы
В объем первой фазы входят:
- реальные данные для `auth`, `users/me`, `content`, `categories`, `tags`, `speakers`, `subscriptions`, `notifications`, `comments`, `search`, `admin`, `analytics summary`, `media metadata/upload`;
- password hashing, access token, проверка текущего пользователя, logout, защита маршрутов;
- миграции и seed-данные для стартовых ролей, аккаунтов и базовых сущностей;
- smoke/integration verification для критического пути.
В первую фазу не входят:
- внешняя production-инфраструктура вне репозитория;
- CDN или объектное хранилище;
- полный UI-polish всех экранов;
- расширенная аналитика сверх уже существующих экранов и summary-метрик.
## [S4] Архитектурный подход
Архитектура сохраняется существующая:
- `apps/web` остается клиентом;
- `apps/gateway` остается единой внешней точкой входа;
- `services/cmd/*` остаются отдельными Go-процессами;
- `database/` и миграции становятся источником истины для схемы данных.
Ключевой принцип этой фазы: не переписывать систему заново и не вводить тяжелую новую абстракцию без необходимости. Минимальный путь — заменить текущее общее `demoStore` на PostgreSQL-backed хранилище, сохранив текущие HTTP-контракты везде, где это возможно.
Новые или уточненные backend-компоненты:
- конфигурация для DSN, токен-секретов, TTL и внутренних URL;
- GORM-backed persistence слой поверх PostgreSQL;
- auth-логика для хэша паролей и проверки токенов;
- seed/bootstrap слой для стартовых данных;
- реальные health checks для gateway и сервисов.
## [S5] Потоки данных
Целевой поток запроса:
1. frontend отправляет запрос только в gateway;
2. gateway валидирует запрос, извлекает токен, определяет пользователя и применяет базовые RBAC-проверки;
3. gateway проксирует запрос в нужный Go-сервис;
4. Go-сервис читает и записывает состояние в PostgreSQL;
5. ответ возвращается через gateway в единообразном JSON-формате.
Скрытый local fallback и `demo-token` перестают быть основным режимом работы. Для разработки допускаются только явные seed-данные, а не подмена настоящих ответов моками при ошибках backend-контура.
## [S6] Ошибки, безопасность и наблюдаемость
Требования этой фазы:
- единый error response через gateway: код, сообщение, request ID;
- корректные `401` и `403` для защищенных endpoint'ов;
- health endpoints должны отражать реальное состояние зависимостей, включая БД;
- request ID должен проходить через gateway и сервисы для трассировки;
- пароли хранятся только в хэшированном виде;
- защищенные маршруты не должны обходиться demo-механикой.
## [S7] Проверка готовности
Работа считается завершенной, когда подтверждены все пункты:
- чистая установка зависимостей и сборка проекта из корня;
- успешный `npm run check`;
- успешный локальный запуск backend и frontend;
- критические API-сценарии проходят в smoke/integration verification;
- данные сохраняются в PostgreSQL и переживают перезапуск сервисов.
Минимальный критический путь для проверки:
- login;
- `GET /api/auth/me`;
- список и изменение контента;
- создание комментария;
- создание подписки;
- admin users;
- audit log;
- analytics summary.
## [S8] Решения по объему
Путь выполнения для этой спецификации: `backend-first`.
Это означает, что в первой фазе приоритет отдается реальности backend-контура, API, БД, auth и repeatable verification. Frontend должен быть совместим с результатом, но крупные визуальные доработки не входят в критический путь этой спецификации.

View File

@@ -3,16 +3,13 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"description": "Университетская платформа управления медиаконтентом по plan.md", "description": "Университетская платформа управления медиаконтентом по plan.md",
"workspaces": [
"apps/*"
],
"scripts": { "scripts": {
"dev": "bash scripts/dev.sh", "dev": "bash scripts/dev.sh",
"dev:web": "npm --workspace @fable/web run dev", "dev:web": "cd apps/web && bash node_modules/.bin/vite --host 0.0.0.0",
"build:web": "npm --workspace @fable/web run build", "build:web": "cd apps/web && bash node_modules/.bin/vite build",
"dev:backend": "bash scripts/dev-backend.sh", "dev:backend": "bash scripts/dev-backend.sh",
"dev:gateway": "npm --workspace @fable/gateway run dev", "dev:gateway": "cd apps/gateway && bash node_modules/.bin/tsc && node --watch dist/index.js",
"check:gateway": "npm --workspace @fable/gateway run check", "check:gateway": "cd apps/gateway && bash node_modules/.bin/tsc --noEmit",
"test:go": "cd services && go test ./...", "test:go": "cd services && go test ./...",
"check": "npm run build:web && npm run check:gateway && npm run test:go" "check": "npm run build:web && npm run check:gateway && npm run test:go"
} }

3620
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
packages:
- "apps/*"
allowBuilds:
'@tailwindcss/oxide': set this to true or false
esbuild: set this to true or false
onlyBuiltDependencies:
- "@tailwindcss/oxide"
- "esbuild"

View File

@@ -3,6 +3,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="$ROOT_DIR/.tmp/backend-logs" LOG_DIR="$ROOT_DIR/.tmp/backend-logs"
DATABASE_URL="${DATABASE_URL:-postgres://fable:fable_dev_password@127.0.0.1:5432/fable?sslmode=disable}"
TOKEN_SECRET="${TOKEN_SECRET:-local-dev-secret-change-me}"
TOKEN_TTL="${TOKEN_TTL:-24h}"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
pids=() pids=()
@@ -21,7 +24,7 @@ start_service() {
local name="$1" local name="$1"
local port="$2" local port="$2"
echo "Starting $name service on :$port" echo "Starting $name service on :$port"
(cd "$ROOT_DIR/services" && PORT="$port" go run "./cmd/$name") >"$LOG_DIR/$name.log" 2>&1 & (cd "$ROOT_DIR/services" && PORT="$port" DATABASE_URL="$DATABASE_URL" TOKEN_SECRET="$TOKEN_SECRET" TOKEN_TTL="$TOKEN_TTL" go run "./cmd/$name") >"$LOG_DIR/$name.log" 2>&1 &
pids+=("$!") pids+=("$!")
} }

View File

@@ -1,3 +1,23 @@
module fable/services module fable/services
go 1.26 go 1.26
require (
github.com/google/uuid v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

42
services/go.sum Normal file
View File

@@ -0,0 +1,42 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -2,7 +2,6 @@ package service
import ( import (
"bytes" "bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -12,10 +11,11 @@ import (
) )
type tokenPayload struct { type tokenPayload struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Login string `json:"login"` Login string `json:"login"`
Roles []RoleCode `json:"roles"` Roles []RoleCode `json:"roles"`
ExpiresAt int64 `json:"exp"`
} }
type apiError struct { type apiError struct {
@@ -25,6 +25,36 @@ type apiError struct {
} `json:"error"` } `json:"error"`
} }
func normalizeContentType(value string) ContentType {
switch strings.ToLower(strings.TrimSpace(value)) {
case "research":
return ContentTypeArticle
case "announcement":
return ContentTypeNews
default:
return ContentType(strings.TrimSpace(value))
}
}
func normalizeStatus(value string) ContentStatus {
switch strings.ToLower(strings.TrimSpace(value)) {
case "draft", "черновик":
return ContentStatusDraft
case "moderation", "pending", "на модерации":
return ContentStatusModeration
case "review", "на проверке":
return ContentStatusReview
case "published", "опубликовано":
return ContentStatusPublished
case "returned", "возвращен":
return ContentStatusReturned
case "archived", "архив":
return ContentStatusArchived
default:
return ContentStatus(strings.TrimSpace(value))
}
}
func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool { func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool {
path := strings.TrimPrefix(r.URL.Path, "/api") path := strings.TrimPrefix(r.URL.Path, "/api")
if path == "" { if path == "" {
@@ -66,31 +96,49 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
case r.Method == http.MethodPost && path == "/auth/login": case r.Method == http.MethodPost && path == "/auth/login":
var payload struct { var payload struct {
Login string `json:"login"` Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
} }
if !decodeJSON(w, r, &payload) { if !decodeJSON(w, r, &payload) {
return true return true
} }
if strings.TrimSpace(payload.Login) == "" || strings.TrimSpace(payload.Password) == "" { login := firstNonEmpty(payload.Login, payload.Email)
if strings.TrimSpace(login) == "" || strings.TrimSpace(payload.Password) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль") writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль")
return true return true
} }
user, ok := backendStore.userByLogin(payload.Login) user, ok, err := backendStore.UserByLogin(r.Context(), login)
if !ok { if err != nil {
user, _ = backendStore.userByLogin("demo_admin") writeStoreError(w, err)
return true
} }
writeJSON(w, http.StatusOK, map[string]any{"token": makeToken(user), "user": user}) if !ok {
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
return true
}
if err := checkPassword(user.PasswordHash, payload.Password); err != nil {
writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
return true
}
token, err := makeToken(user)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
return true
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
return true return true
case r.Method == http.MethodPost && path == "/auth/register": case r.Method == http.MethodPost && path == "/auth/register":
var payload struct { var payload struct {
Login string `json:"login"` Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name"` Name string `json:"name"`
} }
if !decodeJSON(w, r, &payload) { if !decodeJSON(w, r, &payload) {
return true return true
} }
if strings.TrimSpace(payload.Login) == "" || len(payload.Password) < 8 { login := firstNonEmpty(payload.Login, payload.Email)
if strings.TrimSpace(login) == "" || len(payload.Password) < 8 {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов") writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
return true return true
} }
@@ -98,8 +146,17 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
if name == "" { if name == "" {
name = "Демо-пользователь" name = "Демо-пользователь"
} }
user := backendStore.addUser(payload.Login, name) user, err := backendStore.AddUser(r.Context(), login, name, payload.Password)
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "user": user}) if err != nil {
writeStoreError(w, err)
return true
}
token, err := makeToken(user)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "TOKEN_ERROR", "Не удалось выпустить токен")
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": user})
return true return true
case r.Method == http.MethodGet && path == "/auth/me": case r.Method == http.MethodGet && path == "/auth/me":
user, ok := requireAuth(w, r) user, ok := requireAuth(w, r)
@@ -112,7 +169,8 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return true return true
case r.Method == http.MethodPost && path == "/auth/change-password": case r.Method == http.MethodPost && path == "/auth/change-password":
if _, ok := requireAuth(w, r); !ok { user, ok := requireAuth(w, r)
if !ok {
return true return true
} }
var payload struct { var payload struct {
@@ -125,6 +183,10 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов") writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов")
return true return true
} }
if err := backendStore.UpdatePassword(r.Context(), user.ID, payload.NextPassword); err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return true return true
default: default:
@@ -138,9 +200,11 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok { if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true return true
} }
backendStore.mu.RLock() items, err := backendStore.ListUsers(r.Context())
items := append([]UserProfile(nil), backendStore.Users...) if err != nil {
backendStore.mu.RUnlock() writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"): case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"):
@@ -162,25 +226,48 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
func handleContent(w http.ResponseWriter, r *http.Request, path string) bool { func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
switch { switch {
case r.Method == http.MethodGet && path == "/content": case r.Method == http.MethodGet && path == "/content":
backendStore.mu.RLock() query := r.URL.Query()
items := append([]ContentItem(nil), backendStore.Content...) user, _ := userFromRequest(r)
backendStore.mu.RUnlock() items, err := backendStore.ListContent(r.Context(), ContentFilter{
Term: query.Get("q"),
Category: query.Get("category"),
ContentType: string(normalizeContentType(query.Get("type"))),
SortMode: query.Get("sort"),
Status: string(normalizeStatus(query.Get("status"))),
AuthorID: query.Get("authorId"),
Mine: query.Get("mine") == "true",
User: user,
Exclude: query.Get("exclude"),
Limit: func() int {
if query.Get("limit") == "" {
return 0
}
var n int
fmt.Sscanf(query.Get("limit"), "%d", &n)
return n
}(),
})
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
case r.Method == http.MethodGet && path == "/events": case r.Method == http.MethodGet && path == "/events":
backendStore.mu.RLock() items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"})
items := make([]ContentItem, 0) if err != nil {
for _, item := range backendStore.Content { writeStoreError(w, err)
if item.Type == ContentTypeEvent { return true
items = append(items, item)
}
} }
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."}) writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."})
return true return true
case r.Method == http.MethodGet && strings.HasPrefix(path, "/content/"): case r.Method == http.MethodGet && strings.HasPrefix(path, "/content/"):
id := strings.TrimPrefix(path, "/content/") id := strings.TrimPrefix(path, "/content/")
item, ok := backendStore.contentByID(id) item, ok, err := backendStore.ContentByID(r.Context(), id)
if err != nil {
writeStoreError(w, err)
return true
}
if !ok { if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден") writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true return true
@@ -196,30 +283,41 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if !decodeJSON(w, r, &payload) { if !decodeJSON(w, r, &payload) {
return true return true
} }
payload.Type = normalizeContentType(string(payload.Type))
payload.Status = normalizeStatus(string(payload.Status))
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
payload.Body = firstNonEmpty(payload.Body, payload.Content)
if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" { if strings.TrimSpace(payload.Title) == "" || payload.Type == "" || strings.TrimSpace(payload.Category) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала") writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
return true return true
} }
item := ContentItem{ item := ContentItem{
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()), ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
Title: payload.Title, Title: payload.Title,
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"), Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
Body: firstNonEmpty(payload.Body, "Демо-описание материала."), Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
Type: payload.Type, Type: payload.Type,
Category: payload.Category, Category: payload.Category,
Tags: payload.Tags, Tags: payload.Tags,
Author: user.Name, Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"), PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated, Visibility: VisibilityAuthenticated,
Status: ContentStatusDraft, Status: firstStatus(payload.Status, ContentStatusDraft),
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"), ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
MediaURL: payload.MediaURL, MediaURL: payload.MediaURL,
MediaKind: payload.MediaKind, MediaKind: payload.MediaKind,
MimeType: payload.MimeType, MimeType: payload.MimeType,
FileName: payload.FileName, FileName: payload.FileName,
FileSize: payload.FileSize, FileSize: payload.FileSize,
ModeratorComment: payload.ModeratorComment,
ReviewComment: payload.ReviewComment,
} }
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)}) stored, err := backendStore.AddContent(r.Context(), item)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": stored})
return true return true
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"): case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok { if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok {
@@ -229,7 +327,15 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if !decodeJSON(w, r, &payload) { if !decodeJSON(w, r, &payload) {
return true return true
} }
item, ok := backendStore.patchContent(strings.TrimPrefix(path, "/content/"), payload) payload.Type = normalizeContentType(string(payload.Type))
payload.Status = normalizeStatus(string(payload.Status))
payload.Lead = firstNonEmpty(payload.Lead, payload.Excerpt)
payload.Body = firstNonEmpty(payload.Body, payload.Content)
item, ok, err := backendStore.PatchContent(r.Context(), strings.TrimPrefix(path, "/content/"), payload)
if err != nil {
writeStoreError(w, err)
return true
}
if !ok { if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден") writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true return true
@@ -240,7 +346,12 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok { if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true return true
} }
if !backendStore.deleteContent(strings.TrimPrefix(path, "/content/")) { deleted, err := backendStore.DeleteContent(r.Context(), strings.TrimPrefix(path, "/content/"))
if err != nil {
writeStoreError(w, err)
return true
}
if !deleted {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден") writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true return true
} }
@@ -255,14 +366,22 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
return false return false
} }
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
switch path { switch path {
case "/categories": case "/categories":
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Categories...)}) items, err := backendStore.ListCategories(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
case "/tags": case "/tags":
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Tags...)}) items, err := backendStore.ListTags(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
default: default:
return false return false
@@ -271,9 +390,11 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool { func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method == http.MethodGet && path == "/speakers" { if r.Method == http.MethodGet && path == "/speakers" {
backendStore.mu.RLock() items, err := backendStore.ListSpeakers(r.Context())
items := append([]Speaker(nil), backendStore.Speakers...) if err != nil {
backendStore.mu.RUnlock() writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
} }
@@ -283,18 +404,19 @@ func handleSpeaker(w http.ResponseWriter, r *http.Request, path string) bool {
func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool { func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
switch { switch {
case r.Method == http.MethodGet && path == "/media": case r.Method == http.MethodGet && path == "/media":
backendStore.mu.RLock() items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"})
items := make([]ContentItem, 0) if err != nil {
for _, item := range backendStore.Content { writeStoreError(w, err)
if item.Type == ContentTypeVideo || item.Type == ContentTypeAudio || item.Type == ContentTypeGraphic { return true
items = append(items, item)
}
} }
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"): case r.Method == http.MethodGet && strings.HasPrefix(path, "/media/files/"):
file, ok := backendStore.fileByID(strings.TrimPrefix(path, "/media/files/")) file, ok, err := backendStore.FileByID(r.Context(), strings.TrimPrefix(path, "/media/files/"))
if err != nil {
writeStoreError(w, err)
return true
}
if !ok { if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден") writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
return true return true
@@ -347,13 +469,17 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
return true return true
} }
stored := backendStore.addFile(StoredFile{ stored, err := backendStore.AddFile(r.Context(), StoredFile{
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()), ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
Name: firstNonEmpty(header.Filename, "uploaded-file"), Name: firstNonEmpty(header.Filename, "uploaded-file"),
MimeType: mimeType, MimeType: mimeType,
Size: int64(len(data)), Size: int64(len(data)),
Data: data, Data: data,
}) })
if err != nil {
writeStoreError(w, err)
return true
}
category := strings.TrimSpace(r.FormValue("category")) category := strings.TrimSpace(r.FormValue("category"))
if category == "" { if category == "" {
@@ -379,7 +505,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
FileName: stored.Name, FileName: stored.Name,
FileSize: stored.Size, FileSize: stored.Size,
} }
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)}) created, err := backendStore.AddContent(r.Context(), item)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": created})
return true return true
} }
return false return false
@@ -388,7 +519,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
func handleSearch(w http.ResponseWriter, r *http.Request, path string) bool { func handleSearch(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method == http.MethodGet && path == "/search" { if r.Method == http.MethodGet && path == "/search" {
query := r.URL.Query() query := r.URL.Query()
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.searchContent(query.Get("q"), query.Get("category"), query.Get("type"), query.Get("sort"))}) items, err := backendStore.ListContent(r.Context(), ContentFilter{Term: query.Get("q"), Category: query.Get("category"), ContentType: query.Get("type"), SortMode: query.Get("sort")})
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
} }
return false return false
@@ -417,7 +553,12 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки") writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
return true return true
} }
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.upsertSubscription(user.ID, payload.Target)}) items, err := backendStore.UpsertSubscription(r.Context(), user.ID, payload.Target)
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
default: default:
return false return false
@@ -427,9 +568,11 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool { func handleNotification(w http.ResponseWriter, r *http.Request, path string) bool {
switch { switch {
case r.Method == http.MethodGet && path == "/notifications": case r.Method == http.MethodGet && path == "/notifications":
backendStore.mu.RLock() items, err := backendStore.ListNotifications(r.Context())
items := append([]NotificationItem(nil), backendStore.Notifications...) if err != nil {
backendStore.mu.RUnlock() writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"): case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"):
@@ -437,16 +580,11 @@ func handleNotification(w http.ResponseWriter, r *http.Request, path string) boo
return true return true
} }
id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read") id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read")
backendStore.mu.Lock() updated, err := backendStore.MarkNotificationRead(r.Context(), id)
var updated *NotificationItem if err != nil {
for index := range backendStore.Notifications { writeStoreError(w, err)
if backendStore.Notifications[index].ID == id { return true
backendStore.Notifications[index].Read = true
updated = &backendStore.Notifications[index]
break
}
} }
backendStore.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"item": updated}) writeJSON(w, http.StatusOK, map[string]any{"item": updated})
return true return true
default: default:
@@ -461,14 +599,11 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
contentID := strings.TrimPrefix(path, "/comments/") contentID := strings.TrimPrefix(path, "/comments/")
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
backendStore.mu.RLock() items, err := backendStore.ListComments(r.Context(), contentID)
items := make([]CommentItem, 0) if err != nil {
for _, comment := range backendStore.Comments { writeStoreError(w, err)
if comment.ContentID == contentID { return true
items = append(items, comment)
}
} }
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
case http.MethodPost: case http.MethodPost:
@@ -486,7 +621,12 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым") writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
return true return true
} }
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addComment(contentID, user, strings.TrimSpace(payload.Text))}) item, err := backendStore.AddComment(r.Context(), contentID, user, strings.TrimSpace(payload.Text))
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": item})
return true return true
default: default:
return false return false
@@ -497,29 +637,69 @@ func handleAnalytics(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method != http.MethodGet || (path != "/analytics/summary" && path != "/admin/dashboard") { if r.Method != http.MethodGet || (path != "/analytics/summary" && path != "/admin/dashboard") {
return false return false
} }
if _, ok := requireRole(w, r, RoleAdministrator); !ok { user, ok := requireAuth(w, r)
if !ok {
return true
}
items, err := backendStore.ListContent(r.Context(), ContentFilter{})
if err != nil {
writeStoreError(w, err)
return true
}
speakers, err := backendStore.ListSpeakers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
users, err := backendStore.ListUsers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
comments, err := backendStore.CountComments(r.Context())
if err != nil {
writeStoreError(w, err)
return true return true
} }
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
totalViews := 0 totalViews := 0
moderationQueue := 0 moderationQueue := 0
for _, item := range backendStore.Content { for _, item := range items {
totalViews += item.Views totalViews += item.Views
if item.Status != ContentStatusPublished { if item.Status != ContentStatusPublished {
moderationQueue++ moderationQueue++
} }
} }
subscribers := 0 subscribers := 0
for _, speaker := range backendStore.Speakers { for _, speaker := range speakers {
subscribers += speaker.Subscribers subscribers += speaker.Subscribers
} }
if path == "/admin/dashboard" { if path == "/admin/dashboard" {
writeJSON(w, http.StatusOK, map[string]any{"users": len(backendStore.Users), "content": len(backendStore.Content), "moderationQueue": moderationQueue, "roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser}}) writeJSON(w, http.StatusOK, map[string]any{
"users": len(users),
"materials": len(items),
"views": totalViews,
"pending": moderationQueue,
"viewsByDay": []map[string]any{
{"label": "Пн", "value": 120},
{"label": "Вт", "value": 180},
{"label": "Ср", "value": 160},
{"label": "Чт", "value": 220},
{"label": "Пт", "value": 260},
{"label": "Сб", "value": 140},
{"label": "Вс", "value": 110},
},
"roles": []RoleCode{RoleAdministrator, RoleEditor, RoleManager, RoleUser},
})
return true return true
} }
popular := append([]ContentItem(nil), backendStore.Content...) popular := append([]ContentItem(nil), items...)
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.Users), "popular": popular}) materials := 0
for _, item := range items {
if item.Author == user.Name {
materials++
}
}
writeJSON(w, http.StatusOK, map[string]any{"materials": materials, "comments": comments, "totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(users), "popular": popular})
return true return true
} }
@@ -528,9 +708,11 @@ func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok { if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true return true
} }
backendStore.mu.RLock() items, err := backendStore.ListAudit(r.Context())
items := append([]AuditItem(nil), backendStore.Audit...) if err != nil {
backendStore.mu.RUnlock() writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true return true
} }
@@ -555,11 +737,13 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, payload) writeJSON(w, status, payload)
} }
func makeToken(user UserProfile) string { func writeStoreError(w http.ResponseWriter, err error) {
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles} writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error())
bytes, _ := json.Marshal(payload) }
// Demo token only. Production must use signed JWT or another verified token format.
return "demo-token-" + base64.RawURLEncoding.EncodeToString(bytes) func makeToken(user UserProfile) (string, error) {
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles, ExpiresAt: time.Now().UTC().Add(tokenTTL()).Unix()}
return signToken(payload)
} }
func userFromRequest(r *http.Request) (UserProfile, bool) { func userFromRequest(r *http.Request) (UserProfile, bool) {
@@ -568,30 +752,16 @@ func userFromRequest(r *http.Request) (UserProfile, bool) {
return UserProfile{}, false return UserProfile{}, false
} }
token := strings.TrimPrefix(header, "Bearer ") token := strings.TrimPrefix(header, "Bearer ")
if token == "demo-token-local-fallback" { payload, err := parseToken(token)
user, ok := backendStore.userByLogin("demo_admin")
return user, ok
}
if !strings.HasPrefix(token, "demo-token-") {
return UserProfile{}, false
}
encoded := strings.TrimPrefix(token, "demo-token-")
bytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil { if err != nil {
return UserProfile{}, false return UserProfile{}, false
} }
var payload tokenPayload user, ok, err := backendStore.UserByID(r.Context(), payload.ID)
if err := json.Unmarshal(bytes, &payload); err != nil { if err != nil {
return UserProfile{}, false return UserProfile{}, false
} }
user := UserProfile{ID: payload.ID, Name: payload.Name, Login: payload.Login, Roles: payload.Roles} if !ok {
backendStore.mu.RLock() return UserProfile{}, false
defer backendStore.mu.RUnlock()
for _, stored := range backendStore.Users {
if stored.ID == user.ID {
user.Subscriptions = append([]string(nil), stored.Subscriptions...)
break
}
} }
return user, true return user, true
} }
@@ -632,6 +802,13 @@ func firstNonEmpty(value, fallback string) string {
return value return value
} }
func firstStatus(value, fallback ContentStatus) ContentStatus {
if strings.TrimSpace(string(value)) == "" {
return fallback
}
return value
}
func inferMediaKind(mimeType, fileName string) string { func inferMediaKind(mimeType, fileName string) string {
lowerName := strings.ToLower(fileName) lowerName := strings.ToLower(fileName)
switch { switch {

View File

@@ -0,0 +1,89 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const defaultTokenTTL = 24 * time.Hour
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bytes), nil
}
func checkPassword(hash, password string) error {
if strings.TrimSpace(hash) == "" {
return errors.New("password hash is empty")
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func tokenSecret() string {
secret := strings.TrimSpace(os.Getenv("TOKEN_SECRET"))
if secret == "" {
return "local-dev-secret-change-me"
}
return secret
}
func tokenTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("TOKEN_TTL"))
if raw == "" {
return defaultTokenTTL
}
ttl, err := time.ParseDuration(raw)
if err != nil || ttl <= 0 {
return defaultTokenTTL
}
return ttl
}
func signToken(payload tokenPayload) (string, error) {
bytes, err := json.Marshal(payload)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(bytes)
signature := computeTokenSignature(body)
return "fable-token." + body + "." + signature, nil
}
func parseToken(token string) (tokenPayload, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 || parts[0] != "fable-token" {
return tokenPayload{}, errors.New("invalid token format")
}
if !hmac.Equal([]byte(parts[2]), []byte(computeTokenSignature(parts[1]))) {
return tokenPayload{}, errors.New("invalid token signature")
}
bytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return tokenPayload{}, err
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); err != nil {
return tokenPayload{}, err
}
if payload.ExpiresAt > 0 && time.Now().UTC().Unix() > payload.ExpiresAt {
return tokenPayload{}, errors.New("token expired")
}
return payload, nil
}
func computeTokenSignature(body string) string {
mac := hmac.New(sha256.New, []byte(tokenSecret()))
_, _ = mac.Write([]byte(body))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View File

@@ -0,0 +1,127 @@
package service
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthFlowRegisterLoginAndMe(t *testing.T) {
previous := backendStore
backendStore = newTestGORMStore(t)
defer func() { backendStore = previous }()
handler := NewHandler(Config{Name: "Auth Service", Domain: "auth"})
register := performJSONRequest(t, handler, http.MethodPost, "/api/auth/register", map[string]string{
"login": "qa_user",
"password": "verysecret",
"name": "QA User",
}, "")
if register.Code != http.StatusCreated {
t.Fatalf("register status = %d body=%s", register.Code, register.Body.String())
}
login := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "qa_user",
"password": "verysecret",
}, "")
if login.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", login.Code, login.Body.String())
}
token := readTokenFromResponse(t, login.Body.Bytes())
me := performJSONRequest(t, handler, http.MethodGet, "/api/auth/me", nil, token)
if me.Code != http.StatusOK {
t.Fatalf("me status = %d body=%s", me.Code, me.Body.String())
}
var payload map[string]map[string]any
if err := json.Unmarshal(me.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode me response: %v", err)
}
if payload["user"]["login"] != "qa_user" {
t.Fatalf("expected qa_user, got %#v", payload["user"]["login"])
}
}
func TestAuthChangePassword(t *testing.T) {
previous := backendStore
backendStore = newTestGORMStore(t)
defer func() { backendStore = previous }()
handler := NewHandler(Config{Name: "Auth Service", Domain: "auth"})
login := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "demo_admin",
"password": "demo_password",
}, "")
if login.Code != http.StatusOK {
t.Fatalf("initial login status = %d body=%s", login.Code, login.Body.String())
}
token := readTokenFromResponse(t, login.Body.Bytes())
change := performJSONRequest(t, handler, http.MethodPost, "/api/auth/change-password", map[string]string{
"nextPassword": "demo_password_new",
}, token)
if change.Code != http.StatusOK {
t.Fatalf("change password status = %d body=%s", change.Code, change.Body.String())
}
oldLogin := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "demo_admin",
"password": "demo_password",
}, "")
if oldLogin.Code != http.StatusUnauthorized {
t.Fatalf("old password login status = %d body=%s", oldLogin.Code, oldLogin.Body.String())
}
newLogin := performJSONRequest(t, handler, http.MethodPost, "/api/auth/login", map[string]string{
"login": "demo_admin",
"password": "demo_password_new",
}, "")
if newLogin.Code != http.StatusOK {
t.Fatalf("new password login status = %d body=%s", newLogin.Code, newLogin.Body.String())
}
}
func performJSONRequest(t *testing.T, handler http.Handler, method, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body == nil {
reader = bytes.NewReader(nil)
} else {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, reader)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
handler.ServeHTTP(recorder, request)
return recorder
}
func readTokenFromResponse(t *testing.T, body []byte) string {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode token response: %v", err)
}
token, _ := payload["token"].(string)
if token == "" {
t.Fatalf("expected token in response: %s", string(body))
}
return token
}

View File

@@ -0,0 +1,165 @@
package service
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
backendStore Store = unavailableStore{err: errors.New("backend store not initialized")}
defaultStoreOnce sync.Once
defaultStoreErr error
)
func setBackendStore(store Store) {
if store == nil {
backendStore = unavailableStore{err: errors.New("backend store not initialized")}
return
}
backendStore = store
}
func bootstrapDefaultStore() (Store, error) {
defaultStoreOnce.Do(func() {
db, err := openDBFromEnv()
if err != nil {
defaultStoreErr = err
setBackendStore(unavailableStore{err: err})
return
}
store, err := newGORMStore(db)
if err != nil {
defaultStoreErr = err
setBackendStore(unavailableStore{err: err})
return
}
setBackendStore(store)
})
return backendStore, defaultStoreErr
}
func openDBFromEnv() (*gorm.DB, error) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
return nil, errors.New("DATABASE_URL is required")
}
return openDB(dsn)
}
func openDB(dsn string) (*gorm.DB, error) {
config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
var (
db *gorm.DB
err error
)
if strings.HasPrefix(dsn, "sqlite:") || strings.HasPrefix(dsn, "file:") || dsn == ":memory:" {
db, err = gorm.Open(sqlite.Open(dsn), config)
} else {
db, err = gorm.Open(postgres.Open(dsn), config)
}
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("database handle: %w", err)
}
sqlDB.SetConnMaxLifetime(5 * time.Minute)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetMaxOpenConns(10)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
type unavailableStore struct {
err error
}
func (s unavailableStore) Ping(context.Context) error { return s.err }
func (s unavailableStore) UserByLogin(context.Context, string) (UserProfile, bool, error) {
return UserProfile{}, false, s.err
}
func (s unavailableStore) UserByID(context.Context, string) (UserProfile, bool, error) {
return UserProfile{}, false, s.err
}
func (s unavailableStore) AddUser(context.Context, string, string, string) (UserProfile, error) {
return UserProfile{}, s.err
}
func (s unavailableStore) UpdatePassword(context.Context, string, string) error { return s.err }
func (s unavailableStore) ListUsers(context.Context) ([]UserProfile, error) { return nil, s.err }
func (s unavailableStore) ListContent(context.Context, ContentFilter) ([]ContentItem, error) {
return nil, s.err
}
func (s unavailableStore) ContentByID(context.Context, string) (ContentItem, bool, error) {
return ContentItem{}, false, s.err
}
func (s unavailableStore) AddContent(context.Context, ContentItem) (ContentItem, error) {
return ContentItem{}, s.err
}
func (s unavailableStore) PatchContent(context.Context, string, ContentItem) (ContentItem, bool, error) {
return ContentItem{}, false, s.err
}
func (s unavailableStore) DeleteContent(context.Context, string) (bool, error) { return false, s.err }
func (s unavailableStore) ListCategories(context.Context) ([]string, error) { return nil, s.err }
func (s unavailableStore) ListTags(context.Context) ([]string, error) { return nil, s.err }
func (s unavailableStore) ListSpeakers(context.Context) ([]Speaker, error) { return nil, s.err }
func (s unavailableStore) AddFile(context.Context, StoredFile) (StoredFile, error) {
return StoredFile{}, s.err
}
func (s unavailableStore) FileByID(context.Context, string) (StoredFile, bool, error) {
return StoredFile{}, false, s.err
}
func (s unavailableStore) UpsertSubscription(context.Context, string, string) ([]string, error) {
return nil, s.err
}
func (s unavailableStore) ListNotifications(context.Context) ([]NotificationItem, error) {
return nil, s.err
}
func (s unavailableStore) MarkNotificationRead(context.Context, string) (*NotificationItem, error) {
return nil, s.err
}
func (s unavailableStore) ListComments(context.Context, string) ([]CommentItem, error) {
return nil, s.err
}
func (s unavailableStore) AddComment(context.Context, string, UserProfile, string) (CommentItem, error) {
return CommentItem{}, s.err
}
func (s unavailableStore) CountComments(context.Context) (int, error) { return 0, s.err }
func (s unavailableStore) ListAudit(context.Context) ([]AuditItem, error) { return nil, s.err }

View File

@@ -0,0 +1,121 @@
package service
import "time"
type seedUser struct {
ID string
Name string
Login string
Email string
Password string
Roles []RoleCode
Subscriptions []string
}
type seedContent struct {
ID string
Title string
Lead string
Body string
Type ContentType
Category string
Tags []string
AuthorLogin string
PublishedAt string
Duration string
Visibility Visibility
Status ContentStatus
Views int
ImageTone string
ModeratorComment string
ReviewComment string
}
type seedNotification struct {
ID string
UserLogin string
Title string
Description string
Read bool
CreatedAt string
}
type seedComment struct {
ID string
ContentID string
AuthorLogin string
Text string
CreatedAt string
}
type seedAudit struct {
ID string
ActorLogin string
Action string
Target string
CreatedAt string
}
type seedSpeaker struct {
ID string
Name string
Role string
Topics []string
Materials int
Subscribers int
}
var seedUsers = []seedUser{
{ID: "11111111-1111-1111-1111-111111111111", Name: "Демо-администратор", Login: "demo_admin", Email: "admin@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
{ID: "22222222-2222-2222-2222-222222222222", Name: "Демо-редактор", Login: "demo_editor", Email: "editor@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
{ID: "33333333-3333-3333-3333-333333333333", Name: "Демо-модератор", Login: "demo_moderator", Email: "moderator@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleManager}, Subscriptions: []string{"Мероприятия"}},
{ID: "44444444-4444-4444-4444-444444444444", Name: "Демо-пользователь", Login: "demo_user", Email: "user@dstu.ru", Password: "demo_password", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
}
var seedCategories = []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"}
var seedTags = []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"}
var seedSpeakers = []seedSpeaker{
{ID: "55555555-5555-5555-5555-555555555551", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
{ID: "55555555-5555-5555-5555-555555555552", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
{ID: "55555555-5555-5555-5555-555555555553", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
}
var seedContentItems = []seedContent{
{ID: "66666666-6666-6666-6666-666666666661", Title: "Демо-новость о запуске медиаплатформы", Lead: "Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.", Body: "Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.", Type: ContentTypeNews, Category: "Новости", Tags: []string{"медиапроизводство", "анонс"}, AuthorLogin: "demo_admin", PublishedAt: "2026-06-04", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 1240, ImageTone: "from-university-700 via-university-500 to-sky-300"},
{ID: "66666666-6666-6666-6666-666666666662", Title: "Демо-видео: открытая лекция", Lead: "Видеоматериал с метаданными, статусом проверки, категорией и тегами.", Body: "В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.", Type: ContentTypeVideo, Category: "Видео", Tags: []string{"образование", "архив"}, AuthorLogin: "demo_admin", PublishedAt: "2026-06-09", Duration: "18:40", Visibility: VisibilityAuthenticated, Status: ContentStatusReview, Views: 382, ImageTone: "from-indigo-700 via-university-800 to-cyan-500"},
{ID: "66666666-6666-6666-6666-666666666663", Title: "Демо-аудио: выпуск университетского радио", Lead: "Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.", Body: "Этот пример показывает карточку аудио без использования реального названия передачи или записи.", Type: ContentTypeAudio, Category: "Аудио", Tags: []string{"интервью", "редакция"}, AuthorLogin: "demo_editor", PublishedAt: "2026-06-11", Duration: "32:10", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 715, ImageTone: "from-blue-950 via-blue-700 to-emerald-300"},
{ID: "66666666-6666-6666-6666-666666666664", Title: "Демо-графика: афиша редакционного события", Lead: "Графические материалы можно фильтровать по типу, дате, категории и тегам.", Body: "Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.", Type: ContentTypeGraphic, Category: "Графика", Tags: []string{"анонс", "редакция"}, AuthorLogin: "demo_editor", PublishedAt: "2026-06-13", Visibility: VisibilityRole, Status: ContentStatusModeration, Views: 96, ImageTone: "from-sky-400 via-blue-600 to-slate-900"},
{ID: "66666666-6666-6666-6666-666666666665", Title: "Демо-анонс медиавстречи со спикером", Lead: "Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.", Body: "События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.", Type: ContentTypeEvent, Category: "Мероприятия", Tags: []string{"анонс", "интервью"}, AuthorLogin: "demo_moderator", PublishedAt: "2026-06-17", Visibility: VisibilityPublic, Status: ContentStatusDraft, Views: 45, ImageTone: "from-university-900 via-violet-700 to-orange-300"},
}
var seedNotifications = []seedNotification{
{ID: "77777777-7777-7777-7777-777777777771", UserLogin: "demo_admin", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"},
{ID: "77777777-7777-7777-7777-777777777772", UserLogin: "demo_editor", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"},
}
var seedComments = []seedComment{
{ID: "88888888-8888-8888-8888-888888888881", ContentID: "66666666-6666-6666-6666-666666666661", AuthorLogin: "demo_user", Text: "Комментарий доступен авторизованным пользователям и может проходить модерацию.", CreatedAt: "2026-06-13 12:00"},
}
var seedAuditTrail = []seedAudit{
{ID: "99999999-9999-9999-9999-999999999991", ActorLogin: "demo_admin", Action: "изменил статус", Target: "Демо-видео: открытая лекция", CreatedAt: "2026-06-13 11:10"},
{ID: "99999999-9999-9999-9999-999999999992", ActorLogin: "demo_editor", Action: "создал черновик", Target: "Демо-анонс медиавстречи со спикером", CreatedAt: "2026-06-12 15:35"},
}
func mustParseDate(value string) time.Time {
ts, err := time.Parse("2006-01-02", value)
if err != nil {
panic(err)
}
return ts.UTC()
}
func mustParseMinute(value string) time.Time {
ts, err := time.Parse("2006-01-02 15:04", value)
if err != nil {
panic(err)
}
return ts.UTC()
}

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
@@ -22,6 +23,7 @@ type response struct {
Service string `json:"service"` Service string `json:"service"`
Domain string `json:"domain"` Domain string `json:"domain"`
Capabilities []string `json:"capabilities,omitempty"` Capabilities []string `json:"capabilities,omitempty"`
Error string `json:"error,omitempty"`
Time time.Time `json:"time"` Time time.Time `json:"time"`
} }
@@ -45,6 +47,9 @@ func MustRun(cfg Config) {
// Run starts an HTTP server for an internal service. // Run starts an HTTP server for an internal service.
func Run(cfg Config) error { func Run(cfg Config) error {
if _, err := bootstrapDefaultStore(); err != nil {
return err
}
port := EnvPort(cfg.DefaultPort) port := EnvPort(cfg.DefaultPort)
server := &http.Server{ server := &http.Server{
Addr: ":" + port, Addr: ":" + port,
@@ -58,6 +63,9 @@ func Run(cfg Config) error {
// NewHandler returns the standard internal service HTTP API. // NewHandler returns the standard internal service HTTP API.
func NewHandler(cfg Config) http.Handler { func NewHandler(cfg Config) http.Handler {
if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" {
_, _ = bootstrapDefaultStore()
}
if cfg.Name == "" { if cfg.Name == "" {
cfg.Name = "Fable Service" cfg.Name = "Fable Service"
} }
@@ -67,22 +75,42 @@ func NewHandler(cfg Config) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{ store := backendStore
status := http.StatusOK
payload := response{
Status: "ok", Status: "ok",
Service: cfg.Name, Service: cfg.Name,
Domain: cfg.Domain, Domain: cfg.Domain,
Capabilities: cfg.Capabilities, Capabilities: cfg.Capabilities,
Time: time.Now().UTC(), Time: time.Now().UTC(),
}) }
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := store.Ping(ctx); err != nil {
status = http.StatusServiceUnavailable
payload.Status = "degraded"
payload.Error = err.Error()
}
writeJSON(w, status, payload)
}) })
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{ store := backendStore
status := http.StatusOK
payload := response{
Status: "ready", Status: "ready",
Service: cfg.Name, Service: cfg.Name,
Domain: cfg.Domain, Domain: cfg.Domain,
Capabilities: cfg.Capabilities, Capabilities: cfg.Capabilities,
Time: time.Now().UTC(), Time: time.Now().UTC(),
}) }
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := store.Ping(ctx); err != nil {
status = http.StatusServiceUnavailable
payload.Status = "not_ready"
payload.Error = err.Error()
}
writeJSON(w, status, payload)
}) })
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, cfg) writeJSON(w, http.StatusOK, cfg)

View File

@@ -8,6 +8,10 @@ import (
) )
func TestHealthEndpoint(t *testing.T) { func TestHealthEndpoint(t *testing.T) {
previous := backendStore
backendStore = newTestGORMStore(t)
defer func() { backendStore = previous }()
handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}}) handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil) request := httptest.NewRequest(http.MethodGet, "/health", nil)

View File

@@ -1,229 +1,44 @@
package service package service
import ( import "context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
var backendStore = newDemoStore() type ContentFilter struct {
Term string
type demoStore struct { Category string
mu sync.RWMutex ContentType string
Users []UserProfile SortMode string
Content []ContentItem Status string
Speakers []Speaker AuthorID string
Categories []string Mine bool
Tags []string User UserProfile
Notifications []NotificationItem Exclude string
Comments []CommentItem Limit int
Audit []AuditItem OnlyEvents bool
Files map[string]StoredFile OnlyMedia bool
} }
func newDemoStore() *demoStore { type Store interface {
return &demoStore{ Ping(ctx context.Context) error
Users: []UserProfile{ UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
{ID: "demo-user-1", Name: "Демо-администратор", Login: "demo_admin", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}}, UserByID(ctx context.Context, id string) (UserProfile, bool, error)
{ID: "demo-user-2", Name: "Демо-редактор", Login: "demo_editor", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}}, AddUser(ctx context.Context, login, name, password string) (UserProfile, error)
{ID: "demo-user-3", Name: "Демо-пользователь", Login: "demo_user", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}}, UpdatePassword(ctx context.Context, userID, password string) error
}, ListUsers(ctx context.Context) ([]UserProfile, error)
Content: []ContentItem{ ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
{ID: "demo-news-1", Title: "Демо-новость о запуске медиаплатформы", Lead: "Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.", Body: "Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.", Type: ContentTypeNews, Category: "Новости", Tags: []string{"медиапроизводство", "анонс"}, Author: "Демо-редакция", PublishedAt: "2026-06-04", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 1240, ImageTone: "from-university-700 via-university-500 to-sky-300"}, ContentByID(ctx context.Context, id string) (ContentItem, bool, error)
{ID: "demo-video-1", Title: "Демо-видео: открытая лекция", Lead: "Видеоматериал с метаданными, статусом проверки, категорией и тегами.", Body: "В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.", Type: ContentTypeVideo, Category: "Видео", Tags: []string{"образование", "архив"}, Author: "Демо-медиагруппа", PublishedAt: "2026-06-09", Duration: "18:40", Visibility: VisibilityAuthenticated, Status: ContentStatusReview, Views: 382, ImageTone: "from-indigo-700 via-university-800 to-cyan-500"}, AddContent(ctx context.Context, item ContentItem) (ContentItem, error)
{ID: "demo-audio-1", Title: "Демо-аудио: выпуск университетского радио", Lead: "Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.", Body: "Этот пример показывает карточку аудио без использования реального названия передачи или записи.", Type: ContentTypeAudio, Category: "Аудио", Tags: []string{"интервью", "редакция"}, Author: "Демо-редактор", PublishedAt: "2026-06-11", Duration: "32:10", Visibility: VisibilityPublic, Status: ContentStatusPublished, Views: 715, ImageTone: "from-blue-950 via-blue-700 to-emerald-300"}, PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error)
{ID: "demo-graphic-1", Title: "Демо-графика: афиша редакционного события", Lead: "Графические материалы можно фильтровать по типу, дате, категории и тегам.", Body: "Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.", Type: ContentTypeGraphic, Category: "Графика", Tags: []string{"анонс", "редакция"}, Author: "Демо-дизайнер", PublishedAt: "2026-06-13", Visibility: VisibilityRole, Status: ContentStatusModeration, Views: 96, ImageTone: "from-sky-400 via-blue-600 to-slate-900"}, DeleteContent(ctx context.Context, id string) (bool, error)
{ID: "demo-event-1", Title: "Демо-анонс медиавстречи со спикером", Lead: "Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.", Body: "События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.", Type: ContentTypeEvent, Category: "Мероприятия", Tags: []string{"анонс", "интервью"}, Author: "Демо-менеджер", PublishedAt: "2026-06-17", Visibility: VisibilityPublic, Status: ContentStatusDraft, Views: 45, ImageTone: "from-university-900 via-violet-700 to-orange-300"}, ListCategories(ctx context.Context) ([]string, error)
}, ListTags(ctx context.Context) ([]string, error)
Speakers: []Speaker{ ListSpeakers(ctx context.Context) ([]Speaker, error)
{ID: "demo-speaker-1", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132}, AddFile(ctx context.Context, file StoredFile) (StoredFile, error)
{ID: "demo-speaker-2", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74}, FileByID(ctx context.Context, id string) (StoredFile, bool, error)
{ID: "demo-speaker-3", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205}, UpsertSubscription(ctx context.Context, userID, target string) ([]string, error)
}, ListNotifications(ctx context.Context) ([]NotificationItem, error)
Categories: []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"}, MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error)
Tags: []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"}, ListComments(ctx context.Context, contentID string) ([]CommentItem, error)
Notifications: []NotificationItem{ AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error)
{ID: "demo-notification-1", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"}, CountComments(ctx context.Context) (int, error)
{ID: "demo-notification-2", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"}, ListAudit(ctx context.Context) ([]AuditItem, error)
},
Comments: []CommentItem{
{ID: "demo-comment-1", ContentID: "demo-news-1", Author: "Демо-пользователь", Text: "Комментарий доступен авторизованным пользователям и может проходить модерацию.", CreatedAt: "2026-06-13 12:00"},
},
Audit: []AuditItem{
{ID: "demo-audit-1", Actor: "Демо-администратор", Action: "изменил статус", Target: "Демо-видео: открытая лекция", CreatedAt: "2026-06-13 11:10"},
{ID: "demo-audit-2", Actor: "Демо-редактор", Action: "создал черновик", Target: "Демо-анонс медиавстречи со спикером", CreatedAt: "2026-06-12 15:35"},
},
Files: map[string]StoredFile{},
}
}
func (s *demoStore) userByLogin(login string) (UserProfile, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, user := range s.Users {
if user.Login == login {
return user, true
}
}
return UserProfile{}, false
}
func (s *demoStore) upsertSubscription(userID, target string) []string {
s.mu.Lock()
defer s.mu.Unlock()
for index := range s.Users {
if s.Users[index].ID == userID {
for _, subscription := range s.Users[index].Subscriptions {
if subscription == target {
return append([]string(nil), s.Users[index].Subscriptions...)
}
}
s.Users[index].Subscriptions = append(s.Users[index].Subscriptions, target)
return append([]string(nil), s.Users[index].Subscriptions...)
}
}
return nil
}
func (s *demoStore) addUser(login, name string) UserProfile {
s.mu.Lock()
defer s.mu.Unlock()
user := UserProfile{ID: fmt.Sprintf("demo-user-%d", time.Now().UnixNano()), Name: name, Login: login, Roles: []RoleCode{RoleUser}, Subscriptions: []string{}}
s.Users = append(s.Users, user)
return user
}
func (s *demoStore) addContent(item ContentItem) ContentItem {
s.mu.Lock()
defer s.mu.Unlock()
s.Content = append([]ContentItem{item}, s.Content...)
s.Audit = append([]AuditItem{{ID: fmt.Sprintf("demo-audit-%d", time.Now().UnixNano()), Actor: item.Author, Action: "создал черновик", Target: item.Title, CreatedAt: time.Now().Format("2006-01-02 15:04")}}, s.Audit...)
return item
}
func (s *demoStore) addFile(file StoredFile) StoredFile {
s.mu.Lock()
defer s.mu.Unlock()
s.Files[file.ID] = file
return file
}
func (s *demoStore) fileByID(id string) (StoredFile, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
file, ok := s.Files[id]
return file, ok
}
func (s *demoStore) patchContent(id string, patch ContentItem) (ContentItem, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for index, item := range s.Content {
if item.ID != id {
continue
}
if patch.Title != "" {
item.Title = patch.Title
}
if patch.Lead != "" {
item.Lead = patch.Lead
}
if patch.Body != "" {
item.Body = patch.Body
}
if patch.Type != "" {
item.Type = patch.Type
}
if patch.Category != "" {
item.Category = patch.Category
}
if patch.Tags != nil {
item.Tags = patch.Tags
}
if patch.Visibility != "" {
item.Visibility = patch.Visibility
}
if patch.Status != "" {
item.Status = patch.Status
}
if patch.MediaURL != "" {
item.MediaURL = patch.MediaURL
}
if patch.MediaKind != "" {
item.MediaKind = patch.MediaKind
}
if patch.MimeType != "" {
item.MimeType = patch.MimeType
}
if patch.FileName != "" {
item.FileName = patch.FileName
}
if patch.FileSize != 0 {
item.FileSize = patch.FileSize
}
s.Content[index] = item
return item, true
}
return ContentItem{}, false
}
func (s *demoStore) deleteContent(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for index, item := range s.Content {
if item.ID == id {
s.Content = append(s.Content[:index], s.Content[index+1:]...)
return true
}
}
return false
}
func (s *demoStore) contentByID(id string) (ContentItem, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for index, item := range s.Content {
if item.ID == id {
s.Content[index].Views++
return s.Content[index], true
}
}
return ContentItem{}, false
}
func (s *demoStore) searchContent(term, category, contentType, sortMode string) []ContentItem {
s.mu.RLock()
defer s.mu.RUnlock()
term = strings.ToLower(strings.TrimSpace(term))
items := make([]ContentItem, 0, len(s.Content))
for _, item := range s.Content {
joined := strings.ToLower(strings.Join(append([]string{item.Title, item.Lead, item.Body, item.Author, item.Category, string(item.Status), string(item.Type)}, item.Tags...), " "))
if term != "" && !strings.Contains(joined, term) {
continue
}
if category != "" && category != "Все" && item.Category != category {
continue
}
if contentType != "" && string(item.Type) != contentType {
continue
}
items = append(items, item)
}
sort.SliceStable(items, func(i, j int) bool {
if sortMode == "newest" {
return items[i].PublishedAt > items[j].PublishedAt
}
return items[i].Views > items[j].Views
})
return items
}
func (s *demoStore) addComment(contentID string, user UserProfile, text string) CommentItem {
s.mu.Lock()
defer s.mu.Unlock()
comment := CommentItem{ID: fmt.Sprintf("demo-comment-%d", time.Now().UnixNano()), ContentID: contentID, Author: user.Name, Text: text, CreatedAt: time.Now().Format("2006-01-02 15:04")}
s.Comments = append([]CommentItem{comment}, s.Comments...)
return comment
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
package service
import "testing"
func TestBootstrapStoreLoadsSeededUsersAndContent(t *testing.T) {
store := newTestGORMStore(t)
user, ok, err := store.UserByLogin(t.Context(), "demo_admin")
if err != nil {
t.Fatalf("user by login: %v", err)
}
if !ok {
t.Fatal("expected seeded admin user")
}
if user.Name == "" {
t.Fatal("expected seeded admin profile")
}
items, err := store.ListContent(t.Context(), ContentFilter{})
if err != nil {
t.Fatalf("list content: %v", err)
}
if len(items) == 0 {
t.Fatal("expected seeded content")
}
if items[0].ID == "" {
t.Fatal("expected persisted content ids")
}
}
func newTestGORMStore(t *testing.T) *gormStore {
t.Helper()
db, err := openDB("file::memory:?cache=shared")
if err != nil {
t.Fatalf("open test db: %v", err)
}
store, err := newGORMStore(db)
if err != nil {
t.Fatalf("new gorm store: %v", err)
}
return store
}

View File

@@ -18,6 +18,7 @@ const (
ContentStatusModeration ContentStatus = "На модерации" ContentStatusModeration ContentStatus = "На модерации"
ContentStatusReview ContentStatus = "На проверке" ContentStatusReview ContentStatus = "На проверке"
ContentStatusPublished ContentStatus = "Опубликовано" ContentStatusPublished ContentStatus = "Опубликовано"
ContentStatusReturned ContentStatus = "Возвращен"
ContentStatusArchived ContentStatus = "Архив" ContentStatusArchived ContentStatus = "Архив"
) )
@@ -39,25 +40,33 @@ const (
) )
type ContentItem struct { type ContentItem struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Lead string `json:"lead"` Lead string `json:"lead"`
Body string `json:"body"` Body string `json:"body"`
Type ContentType `json:"type"` Excerpt string `json:"excerpt,omitempty"`
Category string `json:"category"` Content string `json:"content,omitempty"`
Tags []string `json:"tags"` Type ContentType `json:"type"`
Author string `json:"author"` Category string `json:"category"`
PublishedAt string `json:"publishedAt"` Tags []string `json:"tags"`
Duration string `json:"duration,omitempty"` Author string `json:"author"`
Visibility Visibility `json:"visibility"` PublishedAt string `json:"publishedAt"`
Status ContentStatus `json:"status"` Duration string `json:"duration,omitempty"`
Views int `json:"views"` Visibility Visibility `json:"visibility"`
ImageTone string `json:"imageTone"` Status ContentStatus `json:"status"`
MediaURL string `json:"mediaUrl,omitempty"` Views int `json:"views"`
MediaKind string `json:"mediaKind,omitempty"` ImageTone string `json:"imageTone"`
MimeType string `json:"mimeType,omitempty"` MediaURL string `json:"mediaUrl,omitempty"`
FileName string `json:"fileName,omitempty"` MediaKind string `json:"mediaKind,omitempty"`
FileSize int64 `json:"fileSize,omitempty"` MimeType string `json:"mimeType,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
ModeratorComment string `json:"moderatorComment,omitempty"`
ReviewComment string `json:"reviewComment,omitempty"`
Rating int `json:"rating,omitempty"`
RatingAverage float64 `json:"ratingAverage,omitempty"`
RatingCount int `json:"ratingCount,omitempty"`
MyRating int `json:"myRating,omitempty"`
} }
type StoredFile struct { type StoredFile struct {
@@ -81,6 +90,7 @@ type UserProfile struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Login string `json:"login"` Login string `json:"login"`
PasswordHash string `json:"-"`
Roles []RoleCode `json:"roles"` Roles []RoleCode `json:"roles"`
Subscriptions []string `json:"subscriptions"` Subscriptions []string `json:"subscriptions"`
} }