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_USER=fable
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
WEB_PORT=5173

View File

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

View File

@@ -4,7 +4,7 @@ import { Elysia } from 'elysia';
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Возвращен' | 'Архив';
type Visibility = 'Публично' | 'После входа' | 'По роли';
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
@@ -28,6 +28,11 @@ type ContentItem = {
mimeType?: string;
fileName?: string;
fileSize?: number;
moderatorComment?: string;
reviewComment?: string;
ratingAverage?: number;
ratingCount?: number;
myRating?: number;
};
type MediaKind = NonNullable<ContentItem['mediaKind']>;
@@ -40,6 +45,18 @@ type StoredUpload = {
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 = {
id: string;
name: string;
@@ -76,6 +93,28 @@ const serviceUrls: Record<string, string | undefined> = {
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
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 = {
id: 'demo-user-1',
@@ -88,6 +127,7 @@ const demoUser: UserProfile = {
const users: UserProfile[] = [
demoUser,
{ 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: ['Аудио'] }
];
@@ -298,6 +338,97 @@ function searchContent(query: Record<string, string | undefined>) {
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 } {
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/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
.post('/api/auth/register', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string; name: string }>;
if (!payload.login || !payload.password || payload.password.length < 8) {
const payload = body as Partial<{ login: string; email: string; password: string; name: string }>;
const login = normalizeLogin(payload.login ?? payload.email ?? '');
if (!login || !payload.password || payload.password.length < 8) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
}
const user: UserProfile = {
id: `demo-user-${Date.now()}`,
name: payload.name?.trim() || 'Демо-пользователь',
login: payload.login,
login,
roles: ['пользователь'],
subscriptions: []
};
@@ -551,11 +683,12 @@ const app = new Elysia({ adapter: WebStandardAdapter })
return { token, user };
})
.post('/api/auth/login', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string }>;
if (!payload.login || !payload.password) {
const payload = body as Partial<{ login: string; email: string; password: string }>;
const login = normalizeLogin(payload.login ?? payload.email ?? '');
if (!login || !payload.password) {
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()}`;
tokens.set(token, user);
return { token, user };
@@ -585,7 +718,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
}
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 }) => {
const item = content.find((entry) => entry.id === params.id);
if (!item) {
@@ -602,7 +735,7 @@ const app = new Elysia({ adapter: WebStandardAdapter })
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
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) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
}
@@ -617,14 +750,19 @@ const app = new Elysia({ adapter: WebStandardAdapter })
author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10),
visibility: payload.visibility ?? 'После входа',
status: 'Черновик',
status: payload.status ?? 'Черновик',
views: 0,
imageTone: payload.imageTone ?? 'from-university-800 via-slate-700 to-sky-300',
mediaUrl: payload.mediaUrl,
mediaKind: payload.mediaKind,
mimeType: payload.mimeType,
fileName: payload.fileName,
fileSize: payload.fileSize
fileSize: payload.fileSize,
moderatorComment: payload.moderatorComment,
reviewComment: payload.reviewComment,
ratingAverage: 0,
ratingCount: 0,
myRating: 0
};
content = [item, ...content];
set.status = 201;
@@ -639,7 +777,24 @@ const app = new Elysia({ adapter: WebStandardAdapter })
if (index === -1) {
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] };
})
.delete('/api/content/:id', ({ request, params, set }) => {
@@ -770,11 +925,13 @@ const app = new Elysia({ adapter: WebStandardAdapter })
return { item };
})
.get('/api/analytics/summary', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
return {
materials: content.filter((item) => item.author === auth.user?.name).length,
comments: comments.length,
totalViews: content.reduce((sum, item) => sum + item.views, 0),
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
activeUsers: users.length,
@@ -788,8 +945,18 @@ const app = new Elysia({ adapter: WebStandardAdapter })
}
return {
users: users.length,
content: content.length,
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length,
materials: content.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: ['администратор', 'редактор', 'менеджер', 'пользователь']
};
})

View File

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

View File

@@ -2,16 +2,56 @@ import { create } from "zustand";
import { authApi } from "../../shared/api/endpoints";
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() {
try {
return JSON.parse(sessionStorage.getItem("user") ?? "null");
return normalizeUser(JSON.parse(sessionStorage.getItem("user") ?? "null"));
} catch {
return null;
}
}
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");
}
@@ -20,7 +60,7 @@ function getToken(payload) {
}
function getUser(payload) {
return payload?.user ?? payload?.profile ?? payload;
return normalizeUser(payload?.user ?? payload?.profile ?? payload);
}
export const useSession = create((set, get) => ({
@@ -37,12 +77,13 @@ export const useSession = create((set, get) => ({
return user;
},
login: async ({ email, password }) => {
const response = await authApi.login({ email, password });
login: async ({ identifier, email, login, password }) => {
const credential = identifier ?? login ?? email;
const response = await authApi.login({ login: credential, email: credential, password });
const token = getToken(response);
tokenStorage.set(token);
const user = response.user ?? (await authApi.me());
const user = normalizeUser(response.user ?? (await authApi.me()));
set({ user });
persistUser(user);
return user;
@@ -53,7 +94,7 @@ export const useSession = create((set, get) => ({
set({ initializing: true });
try {
const user = await authApi.me();
const user = normalizeUser(await authApi.me());
set({ user, initializing: false });
persistUser(user);
return user;
@@ -87,7 +128,15 @@ export const useSession = create((set, get) => ({
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") {

View File

@@ -7,12 +7,32 @@ import { Badge } from "../shared/ui/Badge";
import { Button } from "../shared/ui/Button";
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() {
const { data: eventsPayload, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.events(),
queryFn: directoriesApi.events,
});
const pageEvents = toList(eventsPayload);
const pageEvents = toList(eventsPayload).map(normalizeEvent);
return (
<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"
>
<div className="flex items-baseline gap-2 md:block">
<span className="font-serif text-5xl text-primary">{event.date.split(" ")[0]}</span>
<span className="text-sm uppercase tracking-widest text-muted">
{event.date.split(" ")[1]}
</span>
<span className="font-serif text-5xl text-primary">{event.day}</span>
<span className="text-sm uppercase tracking-widest text-muted">
{event.month}
</span>
</div>
<div>
<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>
<div className="mt-5 grid border-l border-t border-line sm:grid-cols-2 lg:grid-cols-4">
{pageCategories.map((category, index) => {
const name = category.name ?? category.title ?? category.label;
const value = category.slug ?? category.id ?? name;
const name = typeof category === "string" ? category : category.name ?? category.title ?? category.label;
const value = typeof category === "string" ? category : category.slug ?? category.id ?? name;
const description = typeof category === "string" ? "Материалы этой категории доступны в едином каталоге." : category.description;
return (
<Link
key={category.id ?? value}
@@ -192,7 +193,7 @@ export function HomePage() {
</span>
<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">
{category.description}
{description}
</p>
</Link>
);

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,19 @@ function number(value) {
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) {
const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? [];
if (!Array.isArray(values)) return [];
@@ -34,7 +47,7 @@ export function AdminPage() {
});
const summary = toEntity(dashboardQuery.data) ?? {};
const users = toList(usersQuery.data);
const users = normalizeUsers(usersQuery.data);
const popular = normalizeContentList(popularQuery.data);
const chart = pickChart(summary);
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"}`} />
<div>
<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>
</div>
</div>

View File

@@ -36,11 +36,17 @@ function formatFileSize(size) {
}
function normalizeCategories(payload) {
return toList(payload).map((item) => ({
id: item.id ?? item.slug ?? item.name,
label: item.name ?? item.title ?? item.label,
value: item.slug ?? item.id ?? item.name,
}));
return toList(payload).map((item) => {
if (typeof item === "string") {
return { id: item, label: item, value: item };
}
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) {

View File

@@ -112,7 +112,7 @@ export function ProfilePage() {
<div className="mt-7 grid gap-5 sm:grid-cols-2">
<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="phone" label="Телефон" defaultValue={user.phone ?? ""} disabled />
<Textarea

View File

@@ -1,6 +1,6 @@
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 = {
get: () => sessionStorage.getItem("accessToken"),
@@ -32,8 +32,8 @@ api.interceptors.response.use(
return Promise.reject({
status: error.response?.status ?? 0,
message: error.response?.data?.message ?? "Не удалось выполнить запрос",
fieldErrors: error.response?.data?.errors ?? error.response?.data?.fieldErrors,
message: error.response?.data?.error?.message ?? error.response?.data?.message ?? "Не удалось выполнить запрос",
fieldErrors: error.response?.data?.error?.errors ?? error.response?.data?.errors ?? error.response?.data?.fieldErrors,
});
},
);

View File

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

View File

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

View File

@@ -2,13 +2,13 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
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;
END $$;
DO $$
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;
END $$;
@@ -76,6 +76,9 @@ CREATE TABLE IF NOT EXISTS speakers (
display_name TEXT NOT NULL,
role_description 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(),
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_label TEXT,
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,
archived_at TIMESTAMPTZ,
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()
);
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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:
postgres:
image: postgres:17-alpine
@@ -61,8 +67,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: auth
environment:
PORT: 8080
environment: *go-service-env
user-service:
build:
@@ -70,8 +75,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: user
environment:
PORT: 8080
environment: *go-service-env
content-service:
build:
@@ -79,8 +83,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: content
environment:
PORT: 8080
environment: *go-service-env
taxonomy-service:
build:
@@ -88,8 +91,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: taxonomy
environment:
PORT: 8080
environment: *go-service-env
speaker-service:
build:
@@ -97,8 +99,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: speaker
environment:
PORT: 8080
environment: *go-service-env
subscription-service:
build:
@@ -106,8 +107,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: subscription
environment:
PORT: 8080
environment: *go-service-env
notification-service:
build:
@@ -115,8 +115,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: notification
environment:
PORT: 8080
environment: *go-service-env
comment-service:
build:
@@ -124,8 +123,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: comment
environment:
PORT: 8080
environment: *go-service-env
search-service:
build:
@@ -133,8 +131,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: search
environment:
PORT: 8080
environment: *go-service-env
analytics-service:
build:
@@ -142,8 +139,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: analytics
environment:
PORT: 8080
environment: *go-service-env
audit-service:
build:
@@ -151,8 +147,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: audit
environment:
PORT: 8080
environment: *go-service-env
media-service:
build:
@@ -160,8 +155,7 @@ services:
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: media
environment:
PORT: 8080
environment: *go-service-env
volumes:
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,
"version": "0.1.0",
"description": "Университетская платформа управления медиаконтентом по plan.md",
"workspaces": [
"apps/*"
],
"scripts": {
"dev": "bash scripts/dev.sh",
"dev:web": "npm --workspace @fable/web run dev",
"build:web": "npm --workspace @fable/web run build",
"dev:web": "cd apps/web && bash node_modules/.bin/vite --host 0.0.0.0",
"build:web": "cd apps/web && bash node_modules/.bin/vite build",
"dev:backend": "bash scripts/dev-backend.sh",
"dev:gateway": "npm --workspace @fable/gateway run dev",
"check:gateway": "npm --workspace @fable/gateway run check",
"dev:gateway": "cd apps/gateway && bash node_modules/.bin/tsc && node --watch dist/index.js",
"check:gateway": "cd apps/gateway && bash node_modules/.bin/tsc --noEmit",
"test:go": "cd services && go test ./...",
"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)"
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"
pids=()
@@ -21,7 +24,7 @@ start_service() {
local name="$1"
local port="$2"
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+=("$!")
}

View File

@@ -1,3 +1,23 @@
module fable/services
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 (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -12,10 +11,11 @@ import (
)
type tokenPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
ExpiresAt int64 `json:"exp"`
}
type apiError struct {
@@ -25,6 +25,36 @@ type apiError struct {
} `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 {
path := strings.TrimPrefix(r.URL.Path, "/api")
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":
var payload struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &payload) {
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", "Укажите логин и пароль")
return true
}
user, ok := backendStore.userByLogin(payload.Login)
if !ok {
user, _ = backendStore.userByLogin("demo_admin")
user, ok, err := backendStore.UserByLogin(r.Context(), login)
if err != nil {
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
case r.Method == http.MethodPost && path == "/auth/register":
var payload struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
if !decodeJSON(w, r, &payload) {
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 символов")
return true
}
@@ -98,8 +146,17 @@ func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
if name == "" {
name = "Демо-пользователь"
}
user := backendStore.addUser(payload.Login, name)
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "user": user})
user, err := backendStore.AddUser(r.Context(), login, name, payload.Password)
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
case r.Method == http.MethodGet && path == "/auth/me":
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})
return true
case r.Method == http.MethodPost && path == "/auth/change-password":
if _, ok := requireAuth(w, r); !ok {
user, ok := requireAuth(w, r)
if !ok {
return true
}
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 символов")
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})
return true
default:
@@ -138,9 +200,11 @@ func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]UserProfile(nil), backendStore.Users...)
backendStore.mu.RUnlock()
items, err := backendStore.ListUsers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
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 {
switch {
case r.Method == http.MethodGet && path == "/content":
backendStore.mu.RLock()
items := append([]ContentItem(nil), backendStore.Content...)
backendStore.mu.RUnlock()
query := r.URL.Query()
user, _ := userFromRequest(r)
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})
return true
case r.Method == http.MethodGet && path == "/events":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeEvent {
items = append(items, item)
}
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyEvents: true, SortMode: "newest"})
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items, "note": "Event представлен как тип контента до подтверждения отдельной сущности."})
return true
case r.Method == http.MethodGet && strings.HasPrefix(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 {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
@@ -196,30 +283,41 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if !decodeJSON(w, r, &payload) {
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) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите название, категорию и тип материала")
return true
}
item := ContentItem{
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
Title: payload.Title,
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
Type: payload.Type,
Category: payload.Category,
Tags: payload.Tags,
Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated,
Status: ContentStatusDraft,
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
MediaURL: payload.MediaURL,
MediaKind: payload.MediaKind,
MimeType: payload.MimeType,
FileName: payload.FileName,
FileSize: payload.FileSize,
ID: fmt.Sprintf("demo-content-%d", time.Now().UnixNano()),
Title: payload.Title,
Lead: firstNonEmpty(payload.Lead, "Демонстрационный черновик"),
Body: firstNonEmpty(payload.Body, "Демо-описание материала."),
Type: payload.Type,
Category: payload.Category,
Tags: payload.Tags,
Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated,
Status: firstStatus(payload.Status, ContentStatusDraft),
ImageTone: firstNonEmpty(payload.ImageTone, "from-university-800 via-slate-700 to-sky-300"),
MediaURL: payload.MediaURL,
MediaKind: payload.MediaKind,
MimeType: payload.MimeType,
FileName: payload.FileName,
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
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
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) {
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 {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
@@ -240,7 +346,12 @@ func handleContent(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
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", "Материал не найден")
return true
}
@@ -255,14 +366,22 @@ func handleTaxonomy(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method != http.MethodGet {
return false
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
switch path {
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
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
default:
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 {
if r.Method == http.MethodGet && path == "/speakers" {
backendStore.mu.RLock()
items := append([]Speaker(nil), backendStore.Speakers...)
backendStore.mu.RUnlock()
items, err := backendStore.ListSpeakers(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
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 {
switch {
case r.Method == http.MethodGet && path == "/media":
backendStore.mu.RLock()
items := make([]ContentItem, 0)
for _, item := range backendStore.Content {
if item.Type == ContentTypeVideo || item.Type == ContentTypeAudio || item.Type == ContentTypeGraphic {
items = append(items, item)
}
items, err := backendStore.ListContent(r.Context(), ContentFilter{OnlyMedia: true, SortMode: "newest"})
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
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 {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
return true
@@ -347,13 +469,17 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
return true
}
stored := backendStore.addFile(StoredFile{
stored, err := backendStore.AddFile(r.Context(), StoredFile{
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
Name: firstNonEmpty(header.Filename, "uploaded-file"),
MimeType: mimeType,
Size: int64(len(data)),
Data: data,
})
if err != nil {
writeStoreError(w, err)
return true
}
category := strings.TrimSpace(r.FormValue("category"))
if category == "" {
@@ -379,7 +505,12 @@ func handleMedia(w http.ResponseWriter, r *http.Request, path string) bool {
FileName: stored.Name,
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 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 {
if r.Method == http.MethodGet && path == "/search" {
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 false
@@ -417,7 +553,12 @@ func handleSubscription(w http.ResponseWriter, r *http.Request, path string) boo
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
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
default:
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 {
switch {
case r.Method == http.MethodGet && path == "/notifications":
backendStore.mu.RLock()
items := append([]NotificationItem(nil), backendStore.Notifications...)
backendStore.mu.RUnlock()
items, err := backendStore.ListNotifications(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
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
}
id := strings.TrimSuffix(strings.TrimPrefix(path, "/notifications/"), "/read")
backendStore.mu.Lock()
var updated *NotificationItem
for index := range backendStore.Notifications {
if backendStore.Notifications[index].ID == id {
backendStore.Notifications[index].Read = true
updated = &backendStore.Notifications[index]
break
}
updated, err := backendStore.MarkNotificationRead(r.Context(), id)
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
return true
default:
@@ -461,14 +599,11 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
contentID := strings.TrimPrefix(path, "/comments/")
switch r.Method {
case http.MethodGet:
backendStore.mu.RLock()
items := make([]CommentItem, 0)
for _, comment := range backendStore.Comments {
if comment.ContentID == contentID {
items = append(items, comment)
}
items, err := backendStore.ListComments(r.Context(), contentID)
if err != nil {
writeStoreError(w, err)
return true
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case http.MethodPost:
@@ -486,7 +621,12 @@ func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
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
default:
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") {
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
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
totalViews := 0
moderationQueue := 0
for _, item := range backendStore.Content {
for _, item := range items {
totalViews += item.Views
if item.Status != ContentStatusPublished {
moderationQueue++
}
}
subscribers := 0
for _, speaker := range backendStore.Speakers {
for _, speaker := range speakers {
subscribers += speaker.Subscribers
}
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
}
popular := append([]ContentItem(nil), backendStore.Content...)
writeJSON(w, http.StatusOK, map[string]any{"totalViews": totalViews, "subscribers": subscribers, "activeUsers": len(backendStore.Users), "popular": popular})
popular := append([]ContentItem(nil), items...)
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
}
@@ -528,9 +708,11 @@ func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]AuditItem(nil), backendStore.Audit...)
backendStore.mu.RUnlock()
items, err := backendStore.ListAudit(r.Context())
if err != nil {
writeStoreError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
@@ -555,11 +737,13 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, payload)
}
func makeToken(user UserProfile) string {
payload := tokenPayload{ID: user.ID, Name: user.Name, Login: user.Login, Roles: user.Roles}
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 writeStoreError(w http.ResponseWriter, err error) {
writeAPIError(w, http.StatusServiceUnavailable, "STORE_UNAVAILABLE", err.Error())
}
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) {
@@ -568,30 +752,16 @@ func userFromRequest(r *http.Request) (UserProfile, bool) {
return UserProfile{}, false
}
token := strings.TrimPrefix(header, "Bearer ")
if token == "demo-token-local-fallback" {
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)
payload, err := parseToken(token)
if err != nil {
return UserProfile{}, false
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); err != nil {
user, ok, err := backendStore.UserByID(r.Context(), payload.ID)
if err != nil {
return UserProfile{}, false
}
user := UserProfile{ID: payload.ID, Name: payload.Name, Login: payload.Login, Roles: payload.Roles}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
for _, stored := range backendStore.Users {
if stored.ID == user.ID {
user.Subscriptions = append([]string(nil), stored.Subscriptions...)
break
}
if !ok {
return UserProfile{}, false
}
return user, true
}
@@ -632,6 +802,13 @@ func firstNonEmpty(value, fallback string) string {
return value
}
func firstStatus(value, fallback ContentStatus) ContentStatus {
if strings.TrimSpace(string(value)) == "" {
return fallback
}
return value
}
func inferMediaKind(mimeType, fileName string) string {
lowerName := strings.ToLower(fileName)
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
import (
"context"
"encoding/json"
"log"
"net/http"
@@ -22,6 +23,7 @@ type response struct {
Service string `json:"service"`
Domain string `json:"domain"`
Capabilities []string `json:"capabilities,omitempty"`
Error string `json:"error,omitempty"`
Time time.Time `json:"time"`
}
@@ -45,6 +47,9 @@ func MustRun(cfg Config) {
// Run starts an HTTP server for an internal service.
func Run(cfg Config) error {
if _, err := bootstrapDefaultStore(); err != nil {
return err
}
port := EnvPort(cfg.DefaultPort)
server := &http.Server{
Addr: ":" + port,
@@ -58,6 +63,9 @@ func Run(cfg Config) error {
// NewHandler returns the standard internal service HTTP API.
func NewHandler(cfg Config) http.Handler {
if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" {
_, _ = bootstrapDefaultStore()
}
if cfg.Name == "" {
cfg.Name = "Fable Service"
}
@@ -67,22 +75,42 @@ func NewHandler(cfg Config) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{
store := backendStore
status := http.StatusOK
payload := response{
Status: "ok",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
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) {
writeJSON(w, http.StatusOK, response{
store := backendStore
status := http.StatusOK
payload := response{
Status: "ready",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
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) {
writeJSON(w, http.StatusOK, cfg)

View File

@@ -8,6 +8,10 @@ import (
)
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"}})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)

View File

@@ -1,229 +1,44 @@
package service
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
import "context"
var backendStore = newDemoStore()
type demoStore struct {
mu sync.RWMutex
Users []UserProfile
Content []ContentItem
Speakers []Speaker
Categories []string
Tags []string
Notifications []NotificationItem
Comments []CommentItem
Audit []AuditItem
Files map[string]StoredFile
type ContentFilter struct {
Term string
Category string
ContentType string
SortMode string
Status string
AuthorID string
Mine bool
User UserProfile
Exclude string
Limit int
OnlyEvents bool
OnlyMedia bool
}
func newDemoStore() *demoStore {
return &demoStore{
Users: []UserProfile{
{ID: "demo-user-1", Name: "Демо-администратор", Login: "demo_admin", Roles: []RoleCode{RoleAdministrator, RoleEditor}, Subscriptions: []string{"Новости", "Демо-спикер 01", "медиапроизводство"}},
{ID: "demo-user-2", Name: "Демо-редактор", Login: "demo_editor", Roles: []RoleCode{RoleEditor}, Subscriptions: []string{"Видео"}},
{ID: "demo-user-3", Name: "Демо-пользователь", Login: "demo_user", Roles: []RoleCode{RoleUser}, Subscriptions: []string{"Аудио"}},
},
Content: []ContentItem{
{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"},
{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"},
{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"},
{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"},
{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"},
},
Speakers: []Speaker{
{ID: "demo-speaker-1", Name: "Демо-спикер 01", Role: "Приглашенный эксперт", Topics: []string{"медиапроизводство", "образование"}, Materials: 8, Subscribers: 132},
{ID: "demo-speaker-2", Name: "Демо-спикер 02", Role: "Участник редакционного события", Topics: []string{"интервью", "анонс"}, Materials: 5, Subscribers: 74},
{ID: "demo-speaker-3", Name: "Демо-спикер 03", Role: "Автор образовательных материалов", Topics: []string{"архив", "редакция"}, Materials: 12, Subscribers: 205},
},
Categories: []string{"Новости", "Статьи", "Видео", "Аудио", "Графика", "Мероприятия"},
Tags: []string{"медиапроизводство", "интервью", "анонс", "образование", "редакция", "архив"},
Notifications: []NotificationItem{
{ID: "demo-notification-1", Title: "Новый материал по подписке", Description: "В категории «Новости» появился демонстрационный материал.", Read: false, CreatedAt: "2026-06-13 10:20"},
{ID: "demo-notification-2", Title: "Материал ожидает проверки", Description: "Демо-видео находится на этапе проверки перед публикацией.", Read: true, CreatedAt: "2026-06-12 16:45"},
},
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
type Store interface {
Ping(ctx context.Context) error
UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
UserByID(ctx context.Context, id string) (UserProfile, bool, error)
AddUser(ctx context.Context, login, name, password string) (UserProfile, error)
UpdatePassword(ctx context.Context, userID, password string) error
ListUsers(ctx context.Context) ([]UserProfile, error)
ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
ContentByID(ctx context.Context, id string) (ContentItem, bool, error)
AddContent(ctx context.Context, item ContentItem) (ContentItem, error)
PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error)
DeleteContent(ctx context.Context, id string) (bool, error)
ListCategories(ctx context.Context) ([]string, error)
ListTags(ctx context.Context) ([]string, error)
ListSpeakers(ctx context.Context) ([]Speaker, error)
AddFile(ctx context.Context, file StoredFile) (StoredFile, error)
FileByID(ctx context.Context, id string) (StoredFile, bool, error)
UpsertSubscription(ctx context.Context, userID, target string) ([]string, error)
ListNotifications(ctx context.Context) ([]NotificationItem, error)
MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error)
ListComments(ctx context.Context, contentID string) ([]CommentItem, error)
AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error)
CountComments(ctx context.Context) (int, error)
ListAudit(ctx context.Context) ([]AuditItem, error)
}

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