1
0
forked from mixa/67

first commit 2

This commit is contained in:
mixa
2026-06-15 00:20:48 +03:00
parent 17bfa26689
commit e885e3e6fd
52 changed files with 9107 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
build
.git
.env
*.log
coverage
services/bin

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
POSTGRES_DB=fable
POSTGRES_USER=fable
POSTGRES_PASSWORD=change_me_for_real_environment
JWT_SECRET=change_me_before_production
GATEWAY_PORT=3000
WEB_PORT=5173

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
dist/
build/
.env
.env.*
!.env.example
*.log
coverage/
.DS_Store
tmp/
*.tmp
services/bin/
.tmp/

View File

@@ -0,0 +1,14 @@
{
"owner": "hud",
"pid": 1593489,
"cwd": "/home/mixa/Videos/67",
"heartbeat_at": "2026-06-09T12:05:35.612Z",
"cooldown_ms": 5000,
"jitter_ms": 171,
"skip_count": 260352,
"last_status": "skipped",
"last_reason": "rate_limited",
"last_spawn_at": "2026-06-09T12:05:32.610Z",
"last_skip_at": "2026-06-09T12:05:35.612Z",
"next_allowed_at": "2026-06-09T12:05:37.781Z"
}

View File

@@ -0,0 +1,14 @@
{
"owner": "hud",
"pid": 1593489,
"cwd": "/home/mixa/Videos/67",
"heartbeat_at": "2026-06-09T12:05:35.612Z",
"cooldown_ms": 5000,
"jitter_ms": 171,
"skip_count": 260352,
"last_status": "skipped",
"last_reason": "rate_limited",
"last_spawn_at": "2026-06-09T12:05:32.610Z",
"last_skip_at": "2026-06-09T12:05:35.612Z",
"next_allowed_at": "2026-06-09T12:05:37.781Z"
}

View File

@@ -0,0 +1,4 @@
{
"last_checked_at": "2026-06-05T21:10:52.691Z",
"last_seen_latest": "0.18.9"
}

31
apps/gateway/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:26-alpine AS build
WORKDIR /app
ENV NPM_CONFIG_FETCH_RETRIES=5 \
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false
COPY package.json package-lock.json ./
COPY apps/gateway/package.json apps/gateway/tsconfig.json apps/gateway/
RUN npm ci --workspace @fable/gateway
COPY apps/gateway/src apps/gateway/src
RUN npm --workspace @fable/gateway run build
FROM node:26-alpine
WORKDIR /app
ENV NODE_ENV=production \
NPM_CONFIG_FETCH_RETRIES=5 \
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false
COPY package.json package-lock.json ./
COPY apps/gateway/package.json apps/gateway/package.json
RUN npm ci --workspace @fable/gateway --omit=dev
COPY --from=build /app/apps/gateway/dist apps/gateway/dist
EXPOSE 3000
CMD ["node", "apps/gateway/dist/index.js"]

19
apps/gateway/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "@fable/gateway",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsc && node --watch dist/index.js",
"build": "tsc",
"start": "node dist/index.js",
"check": "tsc --noEmit"
},
"dependencies": {
"elysia": "^1.3.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}

841
apps/gateway/src/index.ts Normal file
View File

@@ -0,0 +1,841 @@
import { Buffer } from 'node:buffer';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { Elysia } from 'elysia';
import { WebStandardAdapter } from 'elysia/adapter/web-standard';
type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
type Visibility = 'Публично' | 'После входа' | 'По роли';
type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
type ContentItem = {
id: string;
title: string;
lead: string;
body: string;
type: ContentType;
category: string;
tags: string[];
author: string;
publishedAt: string;
duration?: string;
visibility: Visibility;
status: ContentStatus;
views: number;
imageTone: string;
mediaUrl?: string;
mediaKind?: 'image' | 'video' | 'audio' | 'document' | 'other';
mimeType?: string;
fileName?: string;
fileSize?: number;
};
type MediaKind = NonNullable<ContentItem['mediaKind']>;
type StoredUpload = {
id: string;
name: string;
mimeType: string;
size: number;
data: Buffer;
};
type UserProfile = {
id: string;
name: string;
login: string;
roles: RoleCode[];
subscriptions: string[];
};
type SetContext = {
status?: number | string;
headers: Record<string, string | number | undefined>;
};
const port = Number(process.env.GATEWAY_PORT ?? process.env.PORT ?? 3000);
const corsOrigin = process.env.CORS_ORIGIN ?? '*';
const rateLimitWindowMs = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000);
const rateLimitMax = Number(process.env.RATE_LIMIT_MAX ?? 180);
const serviceUrls: Record<string, string | undefined> = {
auth: process.env.AUTH_SERVICE_URL,
user: process.env.USER_SERVICE_URL,
content: process.env.CONTENT_SERVICE_URL,
taxonomy: process.env.TAXONOMY_SERVICE_URL,
speaker: process.env.SPEAKER_SERVICE_URL,
subscription: process.env.SUBSCRIPTION_SERVICE_URL,
notification: process.env.NOTIFICATION_SERVICE_URL,
comment: process.env.COMMENT_SERVICE_URL,
search: process.env.SEARCH_SERVICE_URL,
analytics: process.env.ANALYTICS_SERVICE_URL,
audit: process.env.AUDIT_SERVICE_URL,
media: process.env.MEDIA_SERVICE_URL
};
const categories = ['Новости', 'Статьи', 'Видео', 'Аудио', 'Графика', 'Мероприятия'];
const tags = ['медиапроизводство', 'интервью', 'анонс', 'образование', 'редакция', 'архив'];
const contentTypes: ContentType[] = ['news', 'article', 'video', 'audio', 'graphic', 'event'];
const demoUser: UserProfile = {
id: 'demo-user-1',
name: 'Демо-администратор',
login: 'demo_admin',
roles: ['администратор', 'редактор'],
subscriptions: ['Новости', 'Демо-спикер 01', 'медиапроизводство']
};
const users: UserProfile[] = [
demoUser,
{ id: 'demo-user-2', name: 'Демо-редактор', login: 'demo_editor', roles: ['редактор'], subscriptions: ['Видео'] },
{ id: 'demo-user-3', name: 'Демо-пользователь', login: 'demo_user', roles: ['пользователь'], subscriptions: ['Аудио'] }
];
let content: ContentItem[] = [
{
id: 'demo-news-1',
title: 'Демо-новость о запуске медиаплатформы',
lead: 'Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.',
body: 'Демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов.',
type: 'news',
category: 'Новости',
tags: ['медиапроизводство', 'анонс'],
author: 'Демо-редакция',
publishedAt: '2026-06-04',
visibility: 'Публично',
status: 'Опубликовано',
views: 1240,
imageTone: 'from-university-700 via-university-500 to-sky-300'
},
{
id: 'demo-video-1',
title: 'Демо-видео: открытая лекция',
lead: 'Видеоматериал с метаданными, статусом проверки, категорией и тегами.',
body: 'В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров.',
type: 'video',
category: 'Видео',
tags: ['образование', 'архив'],
author: 'Демо-медиагруппа',
publishedAt: '2026-06-09',
duration: '18:40',
visibility: 'После входа',
status: 'На проверке',
views: 382,
imageTone: 'from-indigo-700 via-university-800 to-cyan-500'
},
{
id: 'demo-audio-1',
title: 'Демо-аудио: выпуск университетского радио',
lead: 'Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.',
body: 'Этот пример показывает карточку аудио без использования реального названия передачи или записи.',
type: 'audio',
category: 'Аудио',
tags: ['интервью', 'редакция'],
author: 'Демо-редактор',
publishedAt: '2026-06-11',
duration: '32:10',
visibility: 'Публично',
status: 'Опубликовано',
views: 715,
imageTone: 'from-blue-950 via-blue-700 to-emerald-300'
},
{
id: 'demo-graphic-1',
title: 'Демо-графика: афиша редакционного события',
lead: 'Графические материалы можно фильтровать по типу, дате, категории и тегам.',
body: 'Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов.',
type: 'graphic',
category: 'Графика',
tags: ['анонс', 'редакция'],
author: 'Демо-дизайнер',
publishedAt: '2026-06-13',
visibility: 'По роли',
status: 'На модерации',
views: 96,
imageTone: 'from-sky-400 via-blue-600 to-slate-900'
},
{
id: 'demo-event-1',
title: 'Демо-анонс медиавстречи со спикером',
lead: 'Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.',
body: 'События представлены как анонсы контента, потому что отдельная сущность Event требует подтверждения заказчиком.',
type: 'event',
category: 'Мероприятия',
tags: ['анонс', 'интервью'],
author: 'Демо-менеджер',
publishedAt: '2026-06-17',
visibility: 'Публично',
status: 'Черновик',
views: 45,
imageTone: 'from-university-900 via-violet-700 to-orange-300'
}
];
const speakers = [
{ id: 'demo-speaker-1', name: 'Демо-спикер 01', role: 'Приглашенный эксперт', topics: ['медиапроизводство', 'образование'], materials: 8, subscribers: 132 },
{ id: 'demo-speaker-2', name: 'Демо-спикер 02', role: 'Участник редакционного события', topics: ['интервью', 'анонс'], materials: 5, subscribers: 74 },
{ id: 'demo-speaker-3', name: 'Демо-спикер 03', role: 'Автор образовательных материалов', topics: ['архив', 'редакция'], materials: 12, subscribers: 205 }
];
const notifications = [
{ 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' }
];
const comments = [
{ id: 'demo-comment-1', contentId: 'demo-news-1', author: 'Демо-пользователь', text: 'Комментарий доступен авторизованным пользователям и может проходить модерацию.', createdAt: '2026-06-13 12:00' }
];
const audit = [
{ 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' }
];
const tokens = new Map<string, UserProfile>();
const rateBuckets = new Map<string, { count: number; resetAt: number }>();
const uploadedFiles = new Map<string, StoredUpload>();
function readServiceToken(token: string): UserProfile | null {
if (!token.startsWith('demo-token-')) {
return null;
}
try {
const encoded = token.slice('demo-token-'.length).replace(/-/g, '+').replace(/_/g, '/');
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=');
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Pick<UserProfile, 'id' | 'name' | 'login' | 'roles'>;
const stored = users.find((item) => item.id === parsed.id);
return {
id: parsed.id,
name: parsed.name,
login: parsed.login,
roles: parsed.roles,
subscriptions: stored?.subscriptions ?? []
};
} catch {
return null;
}
}
function fail(set: SetContext, status: number, code: string, message: string) {
set.status = status;
return {
error: {
code,
message,
requestId: set.headers['x-request-id'] ? String(set.headers['x-request-id']) : null
}
};
}
function readToken(request: Request) {
const header = request.headers.get('authorization');
if (!header?.startsWith('Bearer ')) {
return null;
}
return header.slice('Bearer '.length);
}
function readUser(request: Request) {
const token = readToken(request);
if (!token) {
return null;
}
if (token === 'demo-token-local-fallback') {
return demoUser;
}
return tokens.get(token) ?? readServiceToken(token);
}
function requireUser(request: Request, set: SetContext) {
const user = readUser(request);
if (!user) {
return { user: null, response: fail(set, 401, 'UNAUTHORIZED', 'Требуется аутентификация') };
}
return { user, response: null };
}
function requireRole(request: Request, set: SetContext, role: RoleCode) {
const auth = requireUser(request, set);
if (!auth.user) {
return auth;
}
if (!auth.user.roles.includes(role)) {
return { user: null, response: fail(set, 403, 'FORBIDDEN', 'Недостаточно прав доступа') };
}
return auth;
}
function applyRateLimit(request: Request, set: SetContext) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'local';
const now = Date.now();
const current = rateBuckets.get(ip);
if (!current || current.resetAt <= now) {
rateBuckets.set(ip, { count: 1, resetAt: now + rateLimitWindowMs });
return undefined;
}
current.count += 1;
if (current.count > rateLimitMax) {
return fail(set, 429, 'RATE_LIMITED', 'Слишком много запросов');
}
return undefined;
}
function searchContent(query: Record<string, string | undefined>) {
const term = (query.q ?? query.query ?? '').trim().toLowerCase();
const category = query.category;
const type = query.type as ContentType | undefined;
const sorted = [...content]
.filter((item) => !term || [item.title, item.lead, item.body, item.author, item.category, item.status, ...item.tags].join(' ').toLowerCase().includes(term))
.filter((item) => !category || category === 'Все' || item.category === category)
.filter((item) => !type || item.type === type)
.sort((a, b) => (query.sort === 'newest' ? new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() : b.views - a.views));
return sorted;
}
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';
}
function readStringField(source: FormData | Record<string, unknown>, key: string) {
const rawValue = source instanceof FormData ? source.get(key) : source[key];
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return '';
}
function readFileField(source: FormData | Record<string, unknown>, key: string) {
const rawValue = source instanceof FormData ? source.get(key) : source[key];
const value = Array.isArray(rawValue) ? rawValue.find(isBlobLike) : rawValue;
return isBlobLike(value) ? value : null;
}
async function readUploadSource(request: Request, body: unknown): Promise<FormData | Record<string, unknown>> {
if (body instanceof FormData) {
return body;
}
if (body && typeof body === 'object') {
return body as Record<string, unknown>;
}
return request.formData();
}
function inferMediaKind(mimeType = '', fileName = ''): MediaKind {
const lowerName = fileName.toLowerCase();
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType === 'application/pdf' || /\.(pdf|doc|docx|ppt|pptx|xls|xlsx|txt|rtf)$/i.test(lowerName)) return 'document';
return 'other';
}
function defaultContentTypeForMedia(kind: MediaKind): ContentType {
if (kind === 'video') return 'video';
if (kind === 'audio') return 'audio';
if (kind === 'image') return 'graphic';
return 'article';
}
function defaultCategoryForMedia(kind: MediaKind) {
if (kind === 'video') return 'Видео';
if (kind === 'audio') return 'Аудио';
if (kind === 'image') return 'Графика';
return 'Статьи';
}
function parseTags(value: string) {
const tags = value
.split(/[,;\n]/)
.map((tag) => tag.trim())
.filter(Boolean);
return tags.length ? [...new Set(tags)] : ['демо', 'файл'];
}
async function readServiceHealth(name: string, url: string | undefined) {
if (!url) {
return { name, status: 'not_configured' };
}
try {
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1500) });
return { name, status: response.ok ? 'ok' : 'error', url };
} catch {
return { name, status: 'unreachable', url };
}
}
function serviceForAPIPath(pathname: string) {
if (!pathname.startsWith('/api/') || pathname === '/api/health' || pathname === '/api/services/health') {
return null;
}
const path = pathname.slice('/api'.length);
if (path.startsWith('/auth/')) return 'auth';
if (path === '/users' || path.startsWith('/users/') || path === '/admin/users' || path === '/roles' || path === '/admin/roles') return 'user';
if (path === '/content' || path.startsWith('/content/') || path === '/events') return 'content';
if (path === '/media' || path.startsWith('/media/')) return 'media';
if (path === '/categories' || path === '/tags') return 'taxonomy';
if (path === '/speakers' || path.startsWith('/speakers/')) return 'speaker';
if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return 'subscription';
if (path === '/notifications' || path.startsWith('/notifications/')) return 'notification';
if (path.startsWith('/comments/')) return 'comment';
if (path === '/search' || path.startsWith('/search/')) return 'search';
if (path === '/analytics/summary' || path === '/admin/dashboard') return 'analytics';
if (path === '/admin/audit' || path.startsWith('/audit')) return 'audit';
return null;
}
function encodeProxyBody(body: unknown, headers: Headers) {
if (body === undefined || body === null) {
return undefined;
}
if (typeof body === 'string' || body instanceof Blob || body instanceof ArrayBuffer || body instanceof FormData || body instanceof URLSearchParams) {
return body;
}
if (headers.get('content-type')?.includes('multipart/form-data') && typeof body === 'object') {
const form = new FormData();
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(value)) {
for (const entry of value) {
if (isBlobLike(entry)) form.append(key, entry, entry.name ?? 'uploaded-file');
else if (entry !== undefined && entry !== null) form.append(key, String(entry));
}
} else if (isBlobLike(value)) {
form.append(key, value, value.name ?? 'uploaded-file');
} else if (value !== undefined && value !== null) {
form.append(key, String(value));
}
}
headers.delete('content-type');
return form;
}
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
return JSON.stringify(body);
}
async function proxyAPIRequest(request: Request, set: SetContext, body: unknown) {
const source = new URL(request.url);
const serviceName = serviceForAPIPath(source.pathname);
if (!serviceName) {
return undefined;
}
const serviceURL = serviceUrls[serviceName];
if (!serviceURL) {
return undefined;
}
try {
const target = new URL(serviceURL);
target.pathname = source.pathname.replace(/^\/api/, '') || '/';
target.search = source.search;
const headers = new Headers(request.headers);
const requestId = set.headers['x-request-id'];
if (requestId) {
headers.set('x-request-id', String(requestId));
}
headers.set('x-forwarded-by', 'fable-gateway');
const init: RequestInit = {
method: request.method,
headers,
signal: AbortSignal.timeout(1800)
};
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = encodeProxyBody(body, headers);
}
const response = await fetch(target, init);
const responseHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(set.headers)) {
if (value !== undefined && !responseHeaders.has(key)) {
responseHeaders.set(key, String(value));
}
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders
});
} catch {
return undefined;
}
}
function createNodeRequest(req: IncomingMessage) {
const host = req.headers.host ?? `localhost:${port}`;
const url = new URL(req.url ?? '/', `http://${host}`);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const entry of value) headers.append(key, entry);
} else if (value !== undefined) {
headers.set(key, value);
}
}
const init: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers
};
if (req.method !== 'GET' && req.method !== 'HEAD') {
init.body = req as unknown as BodyInit;
init.duplex = 'half';
}
return new Request(url, init);
}
async function writeNodeResponse(res: ServerResponse, response: Response) {
res.statusCode = response.status;
res.statusMessage = response.statusText;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const body = Buffer.from(await response.arrayBuffer());
res.end(body);
}
const app = new Elysia({ adapter: WebStandardAdapter })
.onRequest(({ request, set }) => {
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID();
set.headers['x-request-id'] = requestId;
set.headers['access-control-allow-origin'] = corsOrigin;
set.headers['access-control-allow-methods'] = 'GET,POST,PATCH,DELETE,OPTIONS';
set.headers['access-control-allow-headers'] = 'authorization,content-type,x-request-id';
set.headers['access-control-expose-headers'] = 'x-request-id';
})
.onBeforeHandle(async ({ request, set, body }) => applyRateLimit(request, set) ?? (await proxyAPIRequest(request, set, body)))
.onError(({ error, set }) => fail(set, 500, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Неизвестная ошибка'))
.options('*', () => new Response(null, { status: 204 }))
.get('/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
.get('/api/health', ({ set }) => ({ status: 'ok', service: 'gateway', requestId: set.headers['x-request-id'] }))
.get('/api/services/health', async () => ({ items: await Promise.all(Object.entries(serviceUrls).map(([name, url]) => readServiceHealth(name, url))) }))
.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) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль не короче 8 символов');
}
const user: UserProfile = {
id: `demo-user-${Date.now()}`,
name: payload.name?.trim() || 'Демо-пользователь',
login: payload.login,
roles: ['пользователь'],
subscriptions: []
};
users.push(user);
const token = `demo-token-${crypto.randomUUID()}`;
tokens.set(token, user);
set.status = 201;
return { token, user };
})
.post('/api/auth/login', ({ body, set }) => {
const payload = body as Partial<{ login: string; password: string }>;
if (!payload.login || !payload.password) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите логин и пароль');
}
const user = users.find((item) => item.login === payload.login) ?? demoUser;
const token = `demo-token-${crypto.randomUUID()}`;
tokens.set(token, user);
return { token, user };
})
.get('/api/auth/me', ({ request, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
return { user: auth.user };
})
.post('/api/auth/logout', ({ request }) => {
const token = readToken(request);
if (token) {
tokens.delete(token);
}
return { ok: true };
})
.post('/api/auth/change-password', ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const payload = body as Partial<{ nextPassword: string }>;
if (!payload.nextPassword || payload.nextPassword.length < 8) {
return fail(set, 400, 'VALIDATION_ERROR', 'Новый пароль должен быть не короче 8 символов');
}
return { ok: true };
})
.get('/api/content', () => ({ items: content }))
.get('/api/content/:id', ({ params, set }) => {
const item = content.find((entry) => entry.id === params.id);
if (!item) {
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
}
item.views += 1;
return { item };
})
.post('/api/content', ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
}
const payload = body as Partial<ContentItem>;
if (!payload.title || !payload.category || !payload.type) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите название, категорию и тип материала');
}
const item: ContentItem = {
id: `demo-content-${Date.now()}`,
title: payload.title,
lead: payload.lead ?? 'Демонстрационный черновик',
body: payload.body ?? 'Демо-описание материала.',
type: payload.type,
category: payload.category,
tags: payload.tags ?? [],
author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10),
visibility: payload.visibility ?? 'После входа',
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
};
content = [item, ...content];
set.status = 201;
return { item };
})
.patch('/api/content/:id', ({ request, params, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const index = content.findIndex((item) => item.id === params.id);
if (index === -1) {
return fail(set, 404, 'NOT_FOUND', 'Материал не найден');
}
content[index] = { ...content[index], ...(body as Partial<ContentItem>) };
return { item: content[index] };
})
.delete('/api/content/:id', ({ request, params, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
content = content.filter((item) => item.id !== params.id);
return { ok: true };
})
.get('/api/media/files/:id', ({ params, set }) => {
const file = uploadedFiles.get(params.id);
if (!file) {
return fail(set, 404, 'NOT_FOUND', 'Файл не найден');
}
const data = new Uint8Array(file.data.byteLength);
data.set(file.data);
return new Response(data, {
headers: {
'content-type': file.mimeType || 'application/octet-stream',
'content-length': String(file.size),
'content-disposition': `inline; filename="${file.name.replace(/"/g, '')}"`
}
});
})
.get('/api/media', () => ({ items: content.filter((item) => item.type === 'video' || item.type === 'audio' || item.type === 'graphic') }))
.post('/api/media', async ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
if (!auth.user.roles.some((role) => role === 'администратор' || role === 'редактор' || role === 'менеджер')) {
return fail(set, 403, 'FORBIDDEN', 'Создание материалов требует роли редактора, менеджера или администратора');
}
const source = await readUploadSource(request, body);
const file = readFileField(source, 'file');
if (!file) {
return fail(set, 400, 'VALIDATION_ERROR', 'Прикрепите файл');
}
const data = Buffer.from(await file.arrayBuffer());
if (!data.length) {
return fail(set, 400, 'INVALID_FILE', 'Файл пустой или поврежден');
}
const fileName = file.name || 'uploaded-file';
const mimeType = file.type || 'application/octet-stream';
const mediaKind = inferMediaKind(mimeType, fileName);
const id = `demo-file-${Date.now()}`;
uploadedFiles.set(id, { id, name: fileName, mimeType, size: data.byteLength, data });
const requestedType = readStringField(source, 'type') as ContentType;
const item: ContentItem = {
id: `demo-media-${Date.now()}`,
title: readStringField(source, 'title') || fileName,
lead: readStringField(source, 'lead') || 'Демонстрационный медиаматериал с загруженным файлом.',
body: readStringField(source, 'body') || 'Файл загружен в in-memory demo-хранилище gateway и доступен только до перезапуска процесса.',
type: contentTypes.includes(requestedType) ? requestedType : defaultContentTypeForMedia(mediaKind),
category: readStringField(source, 'category') || defaultCategoryForMedia(mediaKind),
tags: parseTags(readStringField(source, 'tags')),
author: auth.user.name,
publishedAt: new Date().toISOString().slice(0, 10),
visibility: 'После входа',
status: 'Черновик',
views: 0,
imageTone: 'from-university-800 via-slate-700 to-sky-300',
mediaUrl: `/api/media/files/${id}`,
mediaKind,
mimeType,
fileName,
fileSize: data.byteLength
};
content = [item, ...content];
set.status = 201;
return { item };
})
.get('/api/events', () => ({ items: content.filter((item) => item.type === 'event'), note: 'Event представлен как тип контента до подтверждения отдельной сущности.' }))
.get('/api/categories', () => ({ items: categories }))
.get('/api/tags', () => ({ items: tags }))
.get('/api/speakers', () => ({ items: speakers }))
.get('/api/search', ({ query }) => ({ items: searchContent(query as Record<string, string | undefined>) }))
.get('/api/subscriptions', ({ request, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
return { items: auth.user.subscriptions };
})
.post('/api/subscriptions', ({ request, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const payload = body as Partial<{ target: string }>;
if (!payload.target) {
return fail(set, 400, 'VALIDATION_ERROR', 'Укажите объект подписки');
}
auth.user.subscriptions = [...new Set([...auth.user.subscriptions, payload.target])];
return { items: auth.user.subscriptions };
})
.get('/api/notifications', () => ({ items: notifications }))
.patch('/api/notifications/:id/read', ({ request, params, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const item = notifications.find((entry) => entry.id === params.id);
if (item) {
item.read = true;
}
return { item };
})
.get('/api/comments/:contentId', ({ params }) => ({ items: comments.filter((item) => item.contentId === params.contentId) }))
.post('/api/comments/:contentId', ({ request, params, body, set }) => {
const auth = requireUser(request, set);
if (!auth.user) {
return auth.response;
}
const payload = body as Partial<{ text: string }>;
if (!payload.text?.trim()) {
return fail(set, 400, 'VALIDATION_ERROR', 'Комментарий не может быть пустым');
}
const item = { id: `demo-comment-${Date.now()}`, contentId: params.contentId, author: auth.user.name, text: payload.text.trim(), createdAt: new Date().toISOString() };
comments.unshift(item);
set.status = 201;
return { item };
})
.get('/api/analytics/summary', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return {
totalViews: content.reduce((sum, item) => sum + item.views, 0),
subscribers: speakers.reduce((sum, item) => sum + item.subscribers, 0),
activeUsers: users.length,
popular: [...content].sort((a, b) => b.views - a.views).slice(0, 3)
};
})
.get('/api/admin/dashboard', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return {
users: users.length,
content: content.length,
moderationQueue: content.filter((item) => item.status !== 'Опубликовано').length,
roles: ['администратор', 'редактор', 'менеджер', 'пользователь']
};
})
.get('/api/admin/users', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return { items: users };
})
.get('/api/admin/audit', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return { items: audit };
})
.get('/api/admin/roles', ({ request, set }) => {
const auth = requireRole(request, set, 'администратор');
if (!auth.user) {
return auth.response;
}
return {
items: [
{ code: 'администратор', permissions: ['*'] },
{ code: 'редактор', permissions: ['content:create', 'content:update', 'comments:moderate'] },
{ code: 'менеджер', permissions: ['content:create', 'content:publish', 'subscriptions:manage'] },
{ code: 'пользователь', permissions: ['content:read', 'comments:create', 'subscriptions:create'] }
]
};
})
.all('*', ({ set }) => fail(set, 404, 'NOT_FOUND', 'Маршрут не найден'));
const server = createServer(async (req, res) => {
try {
const request = createNodeRequest(req);
const response = await app.fetch(request);
await writeNodeResponse(res, response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown gateway error';
res.statusCode = 500;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message } }));
}
});
server.listen(port, () => {
console.log(`Fable Elysia gateway is running on Node at http://localhost:${port}`);
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

17
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json ./
COPY apps/web/package.json apps/web/package.json
COPY apps/gateway/package.json apps/gateway/package.json
RUN npm install
COPY apps/web apps/web
WORKDIR /app/apps/web
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
apps/web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Демо-прототип университетской платформы управления медиаконтентом Fable"
/>
<title>Fable | Медиаплатформа университета</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/web/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://gateway:3000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://gateway:3000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@fable/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.0",
"vite": "^8.0.16"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

1891
apps/web/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
import { useEffect } from 'react';
import type { UserProfile } from '../types';
export type MenuView = 'home' | 'catalog' | 'search' | 'detail' | 'profile' | 'admin';
type MenuItem = {
label: string;
view: MenuView;
description: string;
requiresAuth?: boolean;
};
const menuItems: MenuItem[] = [
{ label: 'Новости и статьи', view: 'catalog', description: 'Публичные публикации, анонсы и материалы редакций' },
{ label: 'Медиа', view: 'catalog', description: 'Видео, аудио, текстовые и графические материалы' },
{ label: 'Мероприятия', view: 'catalog', description: 'Демо-анонсы до подтверждения отдельной сущности Event' },
{ label: 'Спикеры', view: 'home', description: 'Поиск и подписки на демонстрационных спикеров' },
{ label: 'Категории и теги', view: 'search', description: 'Фильтры, быстрые теги и полнотекстовый поиск' },
{ label: 'Личный кабинет', view: 'profile', description: 'Профиль, подписки, уведомления и роли', requiresAuth: true },
{ label: 'Администрирование', view: 'admin', description: 'Модерация, пользователи, роли, журнал действий', requiresAuth: true }
];
export function AppMenu({
open,
user,
onNavigate,
onClose,
onOpenAuth
}: {
open: boolean;
user: UserProfile | null;
onNavigate: (view: MenuView) => void;
onClose: () => void;
onOpenAuth: () => void;
}) {
useEffect(() => {
if (!open) {
return;
}
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onClose]);
if (!open) {
return null;
}
const handleItemClick = (item: MenuItem) => {
if (item.requiresAuth && !user) {
onClose();
onOpenAuth();
return;
}
onClose();
onNavigate(item.view);
};
return (
<div
id="fullscreen-menu"
className="fixed inset-0 overflow-y-auto bg-[#f6f9fd] text-[#071426]"
role="dialog"
aria-modal="true"
aria-labelledby="app-menu-title"
style={{ zIndex: 2147483647 }}
>
<div className="mx-auto grid min-h-screen w-[min(1180px,calc(100vw-32px))] gap-8 py-6 lg:grid-cols-[360px_1fr] lg:py-10">
<aside className="flex flex-col rounded-[2rem] bg-[#11519c] p-6 text-white shadow-[0_24px_80px_rgba(17,81,156,0.24)] lg:sticky lg:top-10 lg:h-[calc(100vh-5rem)]">
<div>
<p className="text-sm font-black uppercase tracking-[0.28em] text-white/70">Меню платформы</p>
<h2 id="app-menu-title" className="mt-5 text-5xl font-black leading-none tracking-tight">
Fable
</h2>
<p className="mt-5 text-base leading-7 text-white/80">
Навигация только по разделам медиаплатформы: публичный контур, поиск, профиль и административные сценарии.
</p>
</div>
<div className="mt-8 rounded-[1.5rem] bg-white/15 p-4 text-sm leading-6 text-white/80">
{user ? `Вы вошли как ${user.name}. Роли: ${user.roles.join(', ')}.` : 'Сейчас открыт публичный доступ. Для профиля и администрирования нужен вход.'}
</div>
<div className="mt-auto flex flex-col gap-3 pt-8">
{!user ? (
<button className="min-h-12 rounded-full bg-white px-5 py-3 text-sm font-black text-[#11519c] transition hover:bg-[#eef7ff]" type="button" onClick={onOpenAuth}>
Войти в личный кабинет
</button>
) : null}
<button className="min-h-12 rounded-full border border-white/30 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10" type="button" onClick={onClose}>
Закрыть меню
</button>
</div>
</aside>
<main className="pb-10" aria-label="Разделы меню">
<div className="mb-5 flex flex-col justify-between gap-3 rounded-[1.5rem] border border-[#d7e3f1] bg-white p-5 shadow-sm md:flex-row md:items-center">
<div>
<p className="text-sm font-black uppercase tracking-[0.22em] text-[#11519c]">Быстрый переход</p>
<p className="mt-2 text-sm leading-6 text-slate-600">Esc закрывает меню. Выберите раздел или вернитесь на страницу.</p>
</div>
<button className="min-h-11 rounded-full bg-[#071426] px-5 py-2.5 text-sm font-black text-white transition hover:bg-[#11519c]" type="button" onClick={onClose}>
На страницу
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{menuItems.map((item, index) => {
const locked = item.requiresAuth && !user;
return (
<button
key={`${item.label}-${index}`}
className="group min-h-44 rounded-[2rem] border border-[#d7e3f1] bg-white p-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-[#11519c] hover:shadow-[0_18px_50px_rgba(17,81,156,0.16)]"
type="button"
onClick={() => handleItemClick(item)}
>
<span className="text-sm font-black text-[#11519c]">{String(index + 1).padStart(2, '0')}</span>
<span className="mt-5 block text-2xl font-black leading-tight text-[#071426]">{item.label}</span>
<span className="mt-3 block text-sm leading-6 text-slate-600">{item.description}</span>
<span className="mt-5 inline-flex min-h-10 items-center rounded-full bg-[#eef7ff] px-4 text-sm font-black text-[#11519c] group-hover:bg-[#11519c] group-hover:text-white">
{locked ? 'Войти для доступа' : 'Открыть раздел'}
</span>
</button>
);
})}
</div>
</main>
</div>
</div>
);
}

216
apps/web/src/demo-data.ts Normal file
View File

@@ -0,0 +1,216 @@
import type { AuditItem, CommentItem, ContentItem, NotificationItem, Speaker, UserProfile } from './types';
export const demoCategories = [
'Новости',
'Статьи',
'Видео',
'Аудио',
'Графика',
'Мероприятия'
];
export const demoTags = [
'медиапроизводство',
'интервью',
'анонс',
'образование',
'редакция',
'архив'
];
export const demoContent: ContentItem[] = [
{
id: 'demo-news-1',
title: 'Демо-новость о запуске медиаплатформы',
lead: 'Публичная карточка показывает, как новости и статьи будут выглядеть в едином каталоге.',
body:
'Это демонстрационный материал без реальных персональных данных, подразделений и брендовых материалов. Он нужен только для проверки интерфейса, поиска, фильтров и жизненного цикла публикации.',
type: 'news',
category: 'Новости',
tags: ['медиапроизводство', 'анонс'],
author: 'Демо-редакция',
publishedAt: '2026-06-04',
visibility: 'Публично',
status: 'Опубликовано',
views: 1240,
imageTone: 'from-university-700 via-university-500 to-sky-300'
},
{
id: 'demo-video-1',
title: 'Демо-видео: открытая лекция',
lead: 'Видеоматериал с метаданными, статусом проверки, категорией и тегами.',
body:
'В реальной системе здесь будет предпросмотр видео, CDN-ссылка, история модерации и аналитика просмотров. В прототипе используется безопасная заглушка.',
type: 'video',
category: 'Видео',
tags: ['образование', 'архив'],
author: 'Демо-медиагруппа',
publishedAt: '2026-06-09',
duration: '18:40',
visibility: 'После входа',
status: 'На проверке',
views: 382,
imageTone: 'from-indigo-700 via-university-800 to-cyan-500'
},
{
id: 'demo-audio-1',
title: 'Демо-аудио: выпуск университетского радио',
lead: 'Аудиоконтент хранится в медиатеке и связывается с публикациями, авторами и тегами.',
body:
'Этот пример показывает карточку аудио без использования реального названия передачи или записи. Права доступа и публикация управляются через роли.',
type: 'audio',
category: 'Аудио',
tags: ['интервью', 'редакция'],
author: 'Демо-редактор',
publishedAt: '2026-06-11',
duration: '32:10',
visibility: 'Публично',
status: 'Опубликовано',
views: 715,
imageTone: 'from-blue-950 via-blue-700 to-emerald-300'
},
{
id: 'demo-graphic-1',
title: 'Демо-графика: афиша редакционного события',
lead: 'Графические материалы можно фильтровать по типу, дате, категории и тегам.',
body:
'Заглушка демонстрирует графический материал без копирования фотографий, логотипов или брендовых элементов сторонних сайтов.',
type: 'graphic',
category: 'Графика',
tags: ['анонс', 'редакция'],
author: 'Демо-дизайнер',
publishedAt: '2026-06-13',
visibility: 'По роли',
status: 'На модерации',
views: 96,
imageTone: 'from-sky-400 via-blue-600 to-slate-900'
},
{
id: 'demo-event-1',
title: 'Демо-анонс медиавстречи со спикером',
lead: 'Мероприятия показаны как тип медиаконтента до подтверждения отдельной сущности Event.',
body:
'В ТЗ мероприятия указаны в пользовательских функциях, но не выделены в таблице информационных объектов. Поэтому в первом прототипе они представлены как анонсы контента.',
type: 'event',
category: 'Мероприятия',
tags: ['анонс', 'интервью'],
author: 'Демо-менеджер',
publishedAt: '2026-06-17',
visibility: 'Публично',
status: 'Черновик',
views: 45,
imageTone: 'from-university-900 via-violet-700 to-orange-300'
},
{
id: 'demo-article-1',
title: 'Демо-статья о жизненном цикле материала',
lead: 'Публикация проходит этапы: черновик, модерация, проверка, публикация, архив.',
body:
'Материал показывает администраторские сценарии, очереди проверки, журнал действий и ограничения видимости.',
type: 'article',
category: 'Статьи',
tags: ['медиапроизводство', 'архив'],
author: 'Демо-автор',
publishedAt: '2026-06-20',
visibility: 'После входа',
status: 'Архив',
views: 531,
imageTone: 'from-slate-900 via-university-800 to-sky-200'
}
];
export const demoSpeakers: Speaker[] = [
{
id: 'demo-speaker-1',
name: 'Демо-спикер 01',
role: 'Приглашенный эксперт',
topics: ['медиапроизводство', 'образование'],
materials: 8,
subscribers: 132
},
{
id: 'demo-speaker-2',
name: 'Демо-спикер 02',
role: 'Участник редакционного события',
topics: ['интервью', 'анонс'],
materials: 5,
subscribers: 74
},
{
id: 'demo-speaker-3',
name: 'Демо-спикер 03',
role: 'Автор образовательных материалов',
topics: ['архив', 'редакция'],
materials: 12,
subscribers: 205
}
];
export const demoUser: UserProfile = {
id: 'demo-user-1',
name: 'Демо-администратор',
login: 'demo_admin',
roles: ['администратор', 'редактор'],
subscriptions: ['Новости', 'Демо-спикер 01', 'медиапроизводство']
};
export const demoNotifications: 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'
}
];
export const demoComments: CommentItem[] = [
{
id: 'demo-comment-1',
author: 'Демо-пользователь',
text: 'Комментарий доступен только авторизованным пользователям и может проходить модерацию.',
createdAt: '2026-06-13 12:00'
}
];
export const demoAudit: 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'
}
];
export const demoUsers: UserProfile[] = [
demoUser,
{
id: 'demo-user-2',
name: 'Демо-редактор',
login: 'demo_editor',
roles: ['редактор'],
subscriptions: ['Видео']
},
{
id: 'demo-user-3',
name: 'Демо-пользователь',
login: 'demo_user',
roles: ['пользователь'],
subscriptions: ['Аудио', 'Демо-спикер 03']
}
];

140
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,140 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
font-family: 'Golos Text', Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f8fc;
color: #071426;
}
.dark {
color-scheme: dark;
background: #06101f;
color: #f6f9ff;
}
html[data-font-scale='large'] {
font-size: 18px;
}
html[data-font-scale='xlarge'] {
font-size: 20px;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, rgba(17, 81, 156, 0.13), transparent 34rem),
linear-gradient(180deg, #f7fbff 0%, #eef4fb 54%, #f8fafc 100%);
}
.dark body {
background:
radial-gradient(circle at top left, rgba(51, 136, 243, 0.24), transparent 30rem),
linear-gradient(180deg, #06101f 0%, #0a1728 58%, #07111e 100%);
}
.contrast body {
background: #ffffff;
color: #000000;
}
.dark.contrast body {
background: #000000;
color: #ffffff;
}
button,
input,
select,
textarea {
font: inherit;
}
a,
button,
input,
select,
textarea {
touch-action: manipulation;
}
.skip-link {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 1000;
transform: translateY(-150%);
border-radius: 999px;
background: #ffffff;
color: #11519c;
box-shadow: 0 18px 50px rgba(7, 20, 38, 0.2);
font-weight: 800;
padding: 0.75rem 1rem;
transition: transform 160ms ease;
}
.skip-link:focus {
transform: translateY(0);
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(17, 81, 156, 0.45);
}
input:focus-visible {
border-radius: 1rem;
}
.media-visual {
position: relative;
overflow: hidden;
}
.media-visual::after {
content: '';
position: absolute;
inset: 16% 12%;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
transform: rotate(-10deg);
}
.no-images .media-visual {
display: none;
}
.safe-area {
width: min(1180px, calc(100vw - 32px));
margin-inline: auto;
}
.scrollbar-soft::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-soft::-webkit-scrollbar-thumb {
background: rgba(17, 81, 156, 0.35);
border-radius: 999px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

71
apps/web/src/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export type ContentType = 'news' | 'article' | 'video' | 'audio' | 'graphic' | 'event';
export type MediaKind = 'image' | 'video' | 'audio' | 'document' | 'other';
export type ContentStatus = 'Черновик' | 'На модерации' | 'На проверке' | 'Опубликовано' | 'Архив';
export type Visibility = 'Публично' | 'После входа' | 'По роли';
export type RoleCode = 'администратор' | 'редактор' | 'менеджер' | 'пользователь';
export type ContentItem = {
id: string;
title: string;
lead: string;
body: string;
type: ContentType;
category: string;
tags: string[];
author: string;
publishedAt: string;
duration?: string;
visibility: Visibility;
status: ContentStatus;
views: number;
imageTone: string;
mediaUrl?: string;
mediaKind?: MediaKind;
mimeType?: string;
fileName?: string;
fileSize?: number;
};
export type Speaker = {
id: string;
name: string;
role: string;
topics: string[];
materials: number;
subscribers: number;
};
export type UserProfile = {
id: string;
name: string;
login: string;
roles: RoleCode[];
subscriptions: string[];
};
export type NotificationItem = {
id: string;
title: string;
description: string;
read: boolean;
createdAt: string;
};
export type CommentItem = {
id: string;
author: string;
text: string;
createdAt: string;
};
export type AuditItem = {
id: string;
actor: string;
action: string;
target: string;
createdAt: string;
};

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
university: {
50: '#eef7ff',
100: '#d9edff',
200: '#bce0ff',
300: '#8ecaff',
400: '#59a9ff',
500: '#3388f3',
600: '#1d6fd8',
700: '#1558b0',
800: '#11519c',
900: '#123f73'
},
ink: '#071426'
},
fontFamily: {
sans: [
'Golos Text',
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'sans-serif'
]
},
boxShadow: {
soft: '0 24px 80px rgba(7, 20, 38, 0.12)',
card: '0 14px 40px rgba(17, 81, 156, 0.12)'
}
}
},
plugins: []
};

21
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

15
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_GATEWAY_URL ?? 'http://localhost:3000',
changeOrigin: true
}
}
}
});

View File

@@ -0,0 +1,218 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
CREATE TYPE content_type AS ENUM ('news', 'article', 'video', 'audio', 'graphic', 'event_announcement');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE content_status AS ENUM ('draft', 'moderation', 'review', 'published', 'archived');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE content_visibility AS ENUM ('public', 'authenticated', 'role_restricted');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE subscription_type AS ENUM ('category', 'tag', 'speaker');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TYPE comment_status AS ENUM ('visible', 'moderation', 'hidden');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (code IN ('administrator', 'editor', 'manager', 'user'))
);
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
email TEXT UNIQUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS speakers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
display_name TEXT NOT NULL,
role_description TEXT,
biography TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS content_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
lead TEXT,
body TEXT,
content_type content_type NOT NULL,
status content_status NOT NULL DEFAULT 'draft',
visibility content_visibility NOT NULL DEFAULT 'public',
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
author_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
author_label TEXT,
speaker_id UUID REFERENCES speakers(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS content_tags (
content_id UUID NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (content_id, tag_id)
);
CREATE TABLE IF NOT EXISTS media_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_id UUID REFERENCES content_items(id) ON DELETE CASCADE,
uploaded_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
storage_key TEXT NOT NULL UNIQUE,
public_url TEXT,
checksum TEXT,
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,
subscription_type subscription_type NOT NULL,
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
speaker_id UUID REFERENCES speakers(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(subscription_type = 'category' AND category_id IS NOT NULL AND tag_id IS NULL AND speaker_id IS NULL) OR
(subscription_type = 'tag' AND tag_id IS NOT NULL AND category_id IS NULL AND speaker_id IS NULL) OR
(subscription_type = 'speaker' AND speaker_id IS NOT NULL AND category_id IS NULL AND tag_id IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_unique_category
ON subscriptions(user_id, category_id)
WHERE subscription_type = 'category';
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_unique_tag
ON subscriptions(user_id, tag_id)
WHERE subscription_type = 'tag';
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_unique_speaker
ON subscriptions(user_id, speaker_id)
WHERE subscription_type = 'speaker';
CREATE TABLE IF NOT EXISTS comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_id UUID NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
status comment_status NOT NULL DEFAULT 'visible',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
content_id UUID REFERENCES content_items(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS action_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
request_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS content_views (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_id UUID NOT NULL REFERENCES content_items(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
anonymous_key TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS users_login_idx ON users(login);
CREATE INDEX IF NOT EXISTS content_items_type_status_idx ON content_items(content_type, status);
CREATE INDEX IF NOT EXISTS content_items_category_idx ON content_items(category_id);
CREATE INDEX IF NOT EXISTS content_items_published_at_idx ON content_items(published_at DESC);
CREATE INDEX IF NOT EXISTS content_items_search_idx ON content_items USING GIN (to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(lead, '') || ' ' || coalesce(body, '')));
CREATE INDEX IF NOT EXISTS comments_content_id_idx ON comments(content_id);
CREATE INDEX IF NOT EXISTS notifications_user_read_idx ON notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS action_logs_actor_created_idx ON action_logs(actor_user_id, created_at DESC);
INSERT INTO roles (code, name, description) VALUES
('administrator', 'Администратор', 'Расширенное управление системой, пользователями, ролями и настройками'),
('editor', 'Редактор', 'Создание и редактирование материалов, участие в модерации'),
('manager', 'Менеджер', 'Публикация, управление контентом и подписками'),
('user', 'Пользователь', 'Просмотр материалов, комментарии и подписки')
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description;

167
docker-compose.yml Normal file
View File

@@ -0,0 +1,167 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-fable}
POSTGRES_USER: ${POSTGRES_USER:-fable}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fable_dev_password}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/migrations:/docker-entrypoint-initdb.d:ro
gateway:
build:
context: .
dockerfile: apps/gateway/Dockerfile
environment:
GATEWAY_PORT: 3000
CORS_ORIGIN: "*"
AUTH_SERVICE_URL: http://auth-service:8080
USER_SERVICE_URL: http://user-service:8080
CONTENT_SERVICE_URL: http://content-service:8080
TAXONOMY_SERVICE_URL: http://taxonomy-service:8080
SPEAKER_SERVICE_URL: http://speaker-service:8080
SUBSCRIPTION_SERVICE_URL: http://subscription-service:8080
NOTIFICATION_SERVICE_URL: http://notification-service:8080
COMMENT_SERVICE_URL: http://comment-service:8080
SEARCH_SERVICE_URL: http://search-service:8080
ANALYTICS_SERVICE_URL: http://analytics-service:8080
AUDIT_SERVICE_URL: http://audit-service:8080
MEDIA_SERVICE_URL: http://media-service:8080
ports:
- "3000:3000"
depends_on:
- auth-service
- user-service
- content-service
- taxonomy-service
- speaker-service
- subscription-service
- notification-service
- comment-service
- search-service
- analytics-service
- audit-service
- media-service
web:
build:
context: .
dockerfile: apps/web/Dockerfile
ports:
- "5173:80"
depends_on:
- gateway
auth-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: auth
environment:
PORT: 8080
user-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: user
environment:
PORT: 8080
content-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: content
environment:
PORT: 8080
taxonomy-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: taxonomy
environment:
PORT: 8080
speaker-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: speaker
environment:
PORT: 8080
subscription-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: subscription
environment:
PORT: 8080
notification-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: notification
environment:
PORT: 8080
comment-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: comment
environment:
PORT: 8080
search-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: search
environment:
PORT: 8080
analytics-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: analytics
environment:
PORT: 8080
audit-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: audit
environment:
PORT: 8080
media-service:
build:
context: .
dockerfile: infra/docker/go-service.Dockerfile
args:
SERVICE: media
environment:
PORT: 8080
volumes:
postgres_data:

View File

@@ -0,0 +1,16 @@
FROM golang:1.26-alpine AS build
ARG SERVICE
WORKDIR /src
COPY services/go.mod ./
COPY services ./
RUN go build -o /out/service ./cmd/${SERVICE}
FROM alpine:3.22
RUN adduser -D app
USER app
WORKDIR /app
COPY --from=build /out/service /app/service
EXPOSE 8080
CMD ["/app/service"]

2782
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "fable-media-platform",
"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:backend": "bash scripts/dev-backend.sh",
"dev:gateway": "npm --workspace @fable/gateway run dev",
"check:gateway": "npm --workspace @fable/gateway run check",
"test:go": "cd services && go test ./...",
"check": "npm run build:web && npm run check:gateway && npm run test:go"
}
}

333
plan.md Normal file
View File

@@ -0,0 +1,333 @@
# План задач для Fable
Источник требований: `ТЗ_единый.md`.
Визуальный референс: `https://donstu.ru/`.
Назначение системы по ТЗ: единая централизованная цифровая платформа университета для хранения, систематизации, управления и распространения медиаконтента.
Важно: не добавлять реальные данные, людей, подразделения, интеграции, юридические сведения, брендовые материалы или функциональность, которых нет в ТЗ или в явно подтвержденных требованиях. Если нужен контент для макетов, использовать явно помеченные демо-заглушки.
## 0. Ограничения и продуктовые правила
- [ ] Строить платформу управления медиаконтентом университета, а не общий сайт университета.
- [ ] Реализовать два контура: публичный доступ и персонифицированный доступ после аутентификации.
- [ ] Использовать русский язык интерфейса.
- [ ] Учитывать группы пользователей из ТЗ: студенты, преподаватели, сотрудники редакций, приглашенные спикеры, администраторы.
- [ ] Использовать начальную ролевую модель из ТЗ: администратор, редактор, менеджер, пользователь.
- [ ] Не копировать логотипы, фотографии, тексты и брендовые материалы `donstu.ru`, если они не предоставлены отдельно.
- [ ] Использовать `donstu.ru` только как референс по структуре, визуальному языку и UX-паттернам.
- [ ] Все спорные пункты выносить в список вопросов, а не додумывать самостоятельно.
## 1. Дизайн и UI-система
- [ ] Сделать адаптивную UI-систему в официальном университетском стиле по мотивам `donstu.ru`.
- [ ] Использовать визуальные признаки референса: крупная типографика, официальный синий акцент, светлые секции, контрастные кнопки, карточная сетка, большие отступы.
- [ ] Использовать синий акцент, близкий к `#11519c`, как у `donstu.ru`, если заказчик не предоставит другую палитру.
- [ ] Использовать современный гротеск в духе Golos, как на референсе, либо доступный аналог.
- [ ] Реализовать переключатель светлой и темной темы.
- [ ] Реализовать панель доступности: размер текста, цветовая схема, включение/отключение изображений.
- [ ] Реализовать переиспользуемые компоненты: шапка, полноэкранное меню, поиск, вкладки, карточки, кнопки, формы, фильтры, таблицы, модальные окна, предпросмотр медиа.
- [ ] Проверить все ключевые экраны на desktop и mobile.
- [ ] Не делать визуальный клон `donstu.ru`; задача — использовать референс как направление.
## 2. Публичный контур
- [ ] Создать публичную главную страницу платформы.
- [ ] Добавить верхнюю шапку с меню, поиском, переключателем темы, кнопкой доступности и входом в личный кабинет.
- [ ] Добавить полноэкранное меню по примеру `donstu.ru`.
- [ ] В меню использовать только разделы, относящиеся к платформе: новости, медиа, мероприятия, спикеры, категории, вход.
- [ ] Добавить hero-блок для избранного публичного контента.
- [ ] Добавить популярные поисковые запросы или быстрые фильтры, если для них есть данные; иначе показать демо-заглушки.
- [ ] Добавить публичный блок новостей и статей с вкладками категорий.
- [ ] Добавить публичный блок медиаматериалов: аудио, видео, текстовые и графические материалы.
- [ ] Добавить публичный блок мероприятий, так как просмотр мероприятий указан в ТЗ.
- [ ] Добавить публичный блок спикеров, так как поиск спикеров указан в ТЗ.
- [ ] Добавить футер с навигацией по платформе и сервисными ссылками в пределах подтвержденного объема.
## 3. Каталоги, поиск и просмотр материалов
- [ ] Реализовать глобальный полнотекстовый поиск по всем типам контента.
- [ ] Добавить фильтрацию по категориям, авторам, тегам, типам контента и дате.
- [ ] Добавить сортировку результатов.
- [ ] Добавить страницу результатов поиска.
- [ ] Добавить состояния загрузки, ошибки и пустой выдачи.
- [ ] Создать публичный каталог публикаций.
- [ ] Создать публичный каталог медиаматериалов.
- [ ] Создать публичный каталог мероприятий.
- [ ] Создать публичный каталог спикеров.
- [ ] Создать страницу детального просмотра публикации или медиаматериала.
- [ ] Создать страницу детального просмотра спикера.
- [ ] Уточнить, является ли мероприятие отдельной сущностью или типом публикации/медиаматериала, потому что в ТЗ мероприятия есть в функциях, но отсутствуют в таблице информационных объектов.
## 4. Аутентификация и доступ
- [ ] Реализовать регистрацию пользователя.
- [ ] Реализовать вход по логину и паролю.
- [ ] Реализовать token-based authentication.
- [ ] Реализовать выход из системы.
- [ ] Реализовать смену пароля.
- [ ] Хранить пароли только в защищенном хэшированном виде.
- [ ] Связывать действия пользователя с его учетной записью.
- [ ] Защитить маршруты персонифицированного контура.
- [ ] Добавить обработку истекшей или некорректной сессии.
## 5. Профиль пользователя
- [ ] Создать страницу профиля пользователя.
- [ ] Реализовать редактирование профиля.
- [ ] Показывать роль и уровень доступа пользователя.
- [ ] Показывать подписки пользователя.
- [ ] Показывать уведомления пользователя.
- [ ] Показывать действия пользователя там, где это разрешено ролью.
## 6. Управление контентом
- [ ] Реализовать создание публикаций.
- [ ] Реализовать редактирование публикаций.
- [ ] Реализовать удаление публикаций с учетом прав доступа.
- [ ] Реализовать публикацию и архивирование материалов.
- [ ] Реализовать загрузку медиаматериалов: аудио, видео, текстовые и графические файлы.
- [ ] Реализовать назначение категорий.
- [ ] Реализовать назначение тегов.
- [ ] Реализовать метаданные автора.
- [ ] Реализовать управление видимостью материала с учетом роли.
- [ ] Реализовать жизненный цикл контента: создание, модерация, проверка, публикация, архивирование.
- [ ] Показывать статус модерации материала.
- [ ] Реализовать сохранение черновика.
## 7. Подписки и уведомления
- [ ] Реализовать подписку на категории.
- [ ] Реализовать подписку на спикеров.
- [ ] Реализовать подписку на темы или теги, если они утверждены как направления/темы.
- [ ] Реализовать подписку на мероприятия, если мероприятие утверждено как отдельная сущность.
- [ ] Создать персональную ленту по подпискам.
- [ ] Создавать уведомления о новом контенте по подпискам.
- [ ] Реализовать центр уведомлений.
- [ ] Реализовать статусы уведомлений: прочитано и не прочитано.
## 8. Комментарии и взаимодействие
- [ ] Реализовать комментарии для авторизованных пользователей.
- [ ] Реализовать создание комментария.
- [ ] Реализовать редактирование и удаление комментария с учетом прав доступа.
- [ ] Добавить модерацию комментариев, если это требуется ролевой моделью.
- [ ] Показывать комментарии на странице детального просмотра материала.
- [ ] Добавить состояния загрузки и пустого списка комментариев.
## 9. Административная панель
- [ ] Создать административный dashboard.
- [ ] Реализовать управление пользователями.
- [ ] Реализовать управление ролями.
- [ ] Реализовать управление правами доступа.
- [ ] Реализовать очередь модерации контента.
- [ ] Реализовать управление медиатекой.
- [ ] Реализовать управление категориями.
- [ ] Реализовать управление тегами.
- [ ] Реализовать управление спикерами.
- [ ] Реализовать управление уведомлениями.
- [ ] Реализовать просмотр журнала действий.
- [ ] Реализовать аналитический dashboard.
## 10. Аналитика
- [ ] Считать просмотры публикаций.
- [ ] Считать активность пользователей.
- [ ] Считать количество подписчиков.
- [ ] Показывать популярность публикаций.
- [ ] Показывать показатели эффективности контента, если формула показателей согласована.
- [ ] Формировать аналитические отчеты или экран отчетов.
- [ ] Ограничить доступ к аналитике ролями.
## 11. Модель данных
- [ ] Спроектировать сущность `User`.
- [ ] Спроектировать сущность `Role`.
- [ ] Спроектировать сущность `Permission`.
- [ ] Спроектировать связь `UserRole`.
- [ ] Спроектировать сущность `Speaker`.
- [ ] Спроектировать сущность `MediaMaterial`.
- [ ] Спроектировать сущность `Category`.
- [ ] Спроектировать сущность `Tag`.
- [ ] Спроектировать сущность `Subscription`.
- [ ] Спроектировать связь подписки на спикера.
- [ ] Спроектировать связь подписки на категорию.
- [ ] Спроектировать сущность `Comment`.
- [ ] Спроектировать сущность `Notification`.
- [ ] Спроектировать сущность `ActionLog`.
- [ ] Уточнить и при необходимости спроектировать сущность `Event`.
## 12. Backend-архитектура
- [ ] Реализовать backend business logic на Go.
- [ ] Разделить backend на Go-микросервисы.
- [ ] Использовать Elysia.js как API gateway/router layer.
- [ ] Запускать gateway на выбранном JS runtime: Bun, Node.js или Deno.
- [ ] Для Elysia.js считать Bun предпочтительным вариантом, если совместимость Node.js/Deno не подтверждена отдельно.
- [ ] Пропускать все frontend API-запросы через Elysia gateway.
- [ ] Держать Go-сервисы внутренними, за gateway.
- [ ] Использовать PostgreSQL как основную реляционную БД.
- [ ] Использовать CDN или media storage для загруженных медиафайлов.
- [ ] Обеспечить единые auth, RBAC, request context, logging и error handling через gateway и сервисы.
## 13. Go-микросервисы
- [ ] Auth service: регистрация, вход, токены, смена пароля.
- [ ] User service: профили, пользователи, роли, права доступа.
- [ ] Content service: публикации, медиаматериалы, черновики, публикация, архивирование.
- [ ] Taxonomy service: категории и теги.
- [ ] Speaker service: профили спикеров и подписки на спикеров.
- [ ] Subscription service: подписки на категории, темы/теги и спикеров.
- [ ] Notification service: создание уведомлений, статусы прочитано/не прочитано.
- [ ] Comment service: комментарии авторизованных пользователей и hooks для модерации.
- [ ] Search service: полнотекстовый поиск, фильтры, сортировка.
- [ ] Analytics service: просмотры, активность, подписчики, отчеты.
- [ ] Audit service: журнал действий пользователей и администраторов.
- [ ] Media service: валидация загрузок, метаданные файлов, ссылки на CDN/media storage.
- [ ] Event service добавлять только после подтверждения отдельной сущности мероприятий.
## 14. Elysia Gateway
- [ ] Создать маршруты `/api/auth/*` к Auth service.
- [ ] Создать маршруты `/api/users/*` к User service.
- [ ] Создать маршруты `/api/content/*` к Content service.
- [ ] Создать маршруты `/api/media/*` к Media service.
- [ ] Создать маршруты `/api/categories/*` и `/api/tags/*` к Taxonomy service.
- [ ] Создать маршруты `/api/speakers/*` к Speaker service.
- [ ] Создать маршруты `/api/subscriptions/*` к Subscription service.
- [ ] Создать маршруты `/api/notifications/*` к Notification service.
- [ ] Создать маршруты `/api/comments/*` к Comment service.
- [ ] Создать маршруты `/api/search/*` к Search service.
- [ ] Создать маршруты `/api/analytics/*` к Analytics service.
- [ ] Создать маршруты `/api/admin/*` для admin-only операций.
- [ ] Валидировать входящие запросы на gateway.
- [ ] Передавать authenticated user context во внутренние Go-сервисы.
- [ ] Централизовать CORS на gateway.
- [ ] Централизовать rate limiting на gateway.
- [ ] Централизовать auth middleware на gateway.
- [ ] Централизовать формат ошибок на gateway.
- [ ] Добавить health checks для gateway и каждого Go-сервиса.
## 15. Взаимодействие микросервисов
- [ ] Начать с HTTP/JSON между Elysia gateway и Go-сервисами, если не появится подтвержденная причина использовать другой транспорт.
- [ ] Описать OpenAPI-контракты для каждого сервиса.
- [ ] Сделать внутренние URL сервисов настраиваемыми через environment variables.
- [ ] Использовать единый формат error response.
- [ ] Добавить request ID для трассировки через gateway и сервисы.
- [ ] Добавить structured logs.
- [ ] Добавить базовые метрики состояния сервисов.
## 16. API и хранение данных
- [ ] Создать миграции PostgreSQL.
- [ ] Создать API для аутентификации.
- [ ] Создать API для пользователей, ролей и прав.
- [ ] Создать API для публикаций и медиаматериалов.
- [ ] Создать API для категорий и тегов.
- [ ] Создать API для спикеров.
- [ ] Создать API для подписок.
- [ ] Создать API для комментариев.
- [ ] Создать API для уведомлений.
- [ ] Создать API для поиска.
- [ ] Создать API для аналитики.
- [ ] Создать API для журнала действий.
- [ ] Создать abstraction layer для CDN/media storage.
## 17. Безопасность
- [ ] Требовать аутентификацию для функций персонифицированного контура.
- [ ] Реализовать role-based access control.
- [ ] Разграничить доступ к данным по ролям.
- [ ] Проверять права доступа на каждом защищенном API endpoint.
- [ ] Защитить персональные данные согласно требованиям законодательства РФ, указанным в ТЗ.
- [ ] Валидировать загружаемые файлы.
- [ ] Ограничить типы и размеры загружаемых файлов.
- [ ] Журналировать действия пользователей и администраторов.
- [ ] Не раскрывать внутренние URL Go-сервисов наружу.
## 18. Frontend
- [ ] Использовать React и TailwindCSS, как указано в ТЗ.
- [ ] Построить публичный контур.
- [ ] Построить персонифицированный контур.
- [ ] Построить административную панель.
- [ ] Подключить frontend к Elysia gateway.
- [ ] Реализовать protected routes.
- [ ] Реализовать role-based UI states.
- [ ] Реализовать адаптивность для мобильных устройств.
- [ ] Реализовать кроссбраузерную совместимость.
## 19. Тестирование
- [ ] Добавить unit tests.
- [ ] Добавить integration tests.
- [ ] Добавить system tests.
- [ ] Добавить security tests.
- [ ] Добавить load tests.
- [ ] Протестировать публичный контур без авторизации.
- [ ] Протестировать персонифицированный контур с авторизацией.
- [ ] Протестировать ролевые ограничения.
- [ ] Протестировать загрузку медиафайлов.
- [ ] Протестировать поиск, фильтры и сортировку.
- [ ] Протестировать уведомления по подпискам.
- [ ] Протестировать административные сценарии.
- [ ] Протестировать responsive layout.
- [ ] Протестировать доступность базовых UI controls.
## 20. Развертывание и инфраструктура
- [ ] Подготовить Docker setup.
- [ ] Подготовить окружение для Elysia gateway.
- [ ] Подготовить окружение для Go-сервисов.
- [ ] Подготовить PostgreSQL.
- [ ] Подготовить CDN/media storage.
- [ ] Настроить резервное копирование.
- [ ] Настроить восстановление из резервной копии.
- [ ] Подготовить тестовое развертывание.
- [ ] Подготовить checklist промышленного ввода.
- [ ] Добавить health checks.
- [ ] Добавить базовый мониторинг и логи.
## 21. Миграция и подготовка данных
- [ ] Проанализировать существующие каналы хранения и распространения: мессенджеры, облака, архивы, сайты, соцсети.
- [ ] Привести данные по спикерам, публикациям, медиа и пользователям к структурированному виду.
- [ ] Удалить дубликаты.
- [ ] Нормализовать форматы.
- [ ] Проверить метаданные.
- [ ] Категоризировать материалы.
- [ ] Подготовить план миграции данных.
- [ ] Назначить ответственных со стороны заказчика.
## 22. Документация
- [ ] Подготовить руководство пользователя.
- [ ] Подготовить руководство администратора.
- [ ] Подготовить программу и методику испытаний.
- [ ] Подготовить эксплуатационную документацию по согласованию с заказчиком.
- [ ] Подготовить приемочный checklist.
- [ ] Подготовить материалы для обучения пользователей и администраторов.
## 23. Приемка
- [ ] Провести промежуточные демонстрации модулей.
- [ ] Провести тестовое развертывание.
- [ ] Провести опытную эксплуатацию.
- [ ] Собрать замечания.
- [ ] Устранить замечания.
- [ ] Подготовить финальную передачу после тестирования.
- [ ] Подготовить акт сдачи-приемки.
## 24. Вопросы, которые нужно подтвердить
- [ ] Финальное название платформы.
- [ ] Логотип и разрешенные брендовые материалы.
- [ ] Источник реальных данных для миграции.
- [ ] Точная матрица ролей и прав доступа.
- [ ] Являются ли мероприятия отдельной сущностью или частью контента.
- [ ] Точный runtime для Elysia gateway: Bun, Node.js или Deno.
- [ ] Допустимо ли использовать дизайн только как вдохновение или требуется максимально близкая визуальная стилизация без копирования защищенных материалов.
- [ ] Формулы показателей эффективности контента для аналитики.

65
scripts/dev-backend.sh Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="$ROOT_DIR/.tmp/backend-logs"
mkdir -p "$LOG_DIR"
pids=()
cleanup() {
for pid in "${pids[@]:-}"; do
if kill -0 "$pid" >/dev/null 2>&1; then
kill "$pid" >/dev/null 2>&1 || true
fi
done
}
trap cleanup EXIT INT TERM
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 &
pids+=("$!")
}
start_service auth 8081
start_service user 8082
start_service content 8083
start_service taxonomy 8084
start_service speaker 8085
start_service subscription 8086
start_service notification 8087
start_service comment 8088
start_service search 8089
start_service analytics 8090
start_service audit 8091
start_service media 8092
echo "Building Node gateway"
npm --workspace @fable/gateway run build
echo "Starting Node gateway on :3000"
AUTH_SERVICE_URL=http://127.0.0.1:8081 \
USER_SERVICE_URL=http://127.0.0.1:8082 \
CONTENT_SERVICE_URL=http://127.0.0.1:8083 \
TAXONOMY_SERVICE_URL=http://127.0.0.1:8084 \
SPEAKER_SERVICE_URL=http://127.0.0.1:8085 \
SUBSCRIPTION_SERVICE_URL=http://127.0.0.1:8086 \
NOTIFICATION_SERVICE_URL=http://127.0.0.1:8087 \
COMMENT_SERVICE_URL=http://127.0.0.1:8088 \
SEARCH_SERVICE_URL=http://127.0.0.1:8089 \
ANALYTICS_SERVICE_URL=http://127.0.0.1:8090 \
AUDIT_SERVICE_URL=http://127.0.0.1:8091 \
MEDIA_SERVICE_URL=http://127.0.0.1:8092 \
GATEWAY_PORT=3000 \
node "$ROOT_DIR/apps/gateway/dist/index.js" &
pids+=("$!")
echo "Backend is running. Logs: $LOG_DIR"
echo "Gateway health: http://localhost:3000/health"
echo "Press Ctrl+C to stop."
wait

35
scripts/dev.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pids=()
cleanup() {
for pid in "${pids[@]:-}"; do
if kill -0 "$pid" >/dev/null 2>&1; then
kill -TERM "-$pid" >/dev/null 2>&1 || kill "$pid" >/dev/null 2>&1 || true
fi
done
wait >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
start_group() {
local name="$1"
shift
echo "Starting $name"
setsid "$@" &
pids+=("$!")
}
cd "$ROOT_DIR"
start_group "backend" npm run dev:backend
start_group "web" npm run dev:web
echo "Dev environment is running."
echo "Frontend: http://localhost:5173"
echo "Gateway: http://localhost:3000"
echo "Press Ctrl+C to stop everything."
wait -n "${pids[@]}"

View File

@@ -0,0 +1,17 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Analytics Service",
Domain: "analytics",
DefaultPort: "8090",
Capabilities: []string{
"views",
"user-activity",
"subscribers",
"reports",
},
})
}

View File

@@ -0,0 +1,15 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Audit Service",
Domain: "audit",
DefaultPort: "8091",
Capabilities: []string{
"action-log",
"admin-log",
},
})
}

17
services/cmd/auth/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Auth Service",
Domain: "auth",
DefaultPort: "8081",
Capabilities: []string{
"registration",
"login",
"token-authentication",
"password-change",
},
})
}

View File

@@ -0,0 +1,15 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Comment Service",
Domain: "comment",
DefaultPort: "8088",
Capabilities: []string{
"comments",
"comment-moderation-hooks",
},
})
}

View File

@@ -0,0 +1,17 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Content Service",
Domain: "content",
DefaultPort: "8083",
Capabilities: []string{
"publications",
"drafts",
"moderation",
"publication-lifecycle",
},
})
}

View File

@@ -0,0 +1,16 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Media Service",
Domain: "media",
DefaultPort: "8092",
Capabilities: []string{
"upload-validation",
"file-metadata",
"cdn-links",
},
})
}

View File

@@ -0,0 +1,16 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Notification Service",
Domain: "notification",
DefaultPort: "8087",
Capabilities: []string{
"subscription-notifications",
"read-status",
"notification-center",
},
})
}

View File

@@ -0,0 +1,16 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Search Service",
Domain: "search",
DefaultPort: "8089",
Capabilities: []string{
"full-text-search",
"filters",
"sorting",
},
})
}

View File

@@ -0,0 +1,15 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Speaker Service",
Domain: "speaker",
DefaultPort: "8085",
Capabilities: []string{
"speaker-profiles",
"speaker-subscriptions",
},
})
}

View File

@@ -0,0 +1,17 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Subscription Service",
Domain: "subscription",
DefaultPort: "8086",
Capabilities: []string{
"category-subscriptions",
"tag-subscriptions",
"speaker-subscriptions",
"personal-feed",
},
})
}

View File

@@ -0,0 +1,15 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable Taxonomy Service",
Domain: "taxonomy",
DefaultPort: "8084",
Capabilities: []string{
"categories",
"tags",
},
})
}

17
services/cmd/user/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import "fable/services/internal/service"
func main() {
service.MustRun(service.Config{
Name: "Fable User Service",
Domain: "user",
DefaultPort: "8082",
Capabilities: []string{
"profiles",
"users",
"roles",
"permissions",
},
})
}

3
services/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module fable/services
go 1.26

View File

@@ -0,0 +1,702 @@
package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type tokenPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
}
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func routeAPI(w http.ResponseWriter, r *http.Request, cfg Config) bool {
path := strings.TrimPrefix(r.URL.Path, "/api")
if path == "" {
path = "/"
}
switch cfg.Domain {
case "auth":
return handleAuth(w, r, path)
case "user":
return handleUser(w, r, path)
case "content":
return handleContent(w, r, path)
case "taxonomy":
return handleTaxonomy(w, r, path)
case "speaker":
return handleSpeaker(w, r, path)
case "subscription":
return handleSubscription(w, r, path)
case "notification":
return handleNotification(w, r, path)
case "comment":
return handleComment(w, r, path)
case "search":
return handleSearch(w, r, path)
case "analytics":
return handleAnalytics(w, r, path)
case "audit":
return handleAudit(w, r, path)
case "media":
return handleMedia(w, r, path)
default:
return false
}
}
func handleAuth(w http.ResponseWriter, r *http.Request, path string) bool {
switch {
case r.Method == http.MethodPost && path == "/auth/login":
var payload struct {
Login string `json:"login"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.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")
}
writeJSON(w, http.StatusOK, map[string]any{"token": makeToken(user), "user": user})
return true
case r.Method == http.MethodPost && path == "/auth/register":
var payload struct {
Login string `json:"login"`
Password string `json:"password"`
Name string `json:"name"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Login) == "" || len(payload.Password) < 8 {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите логин и пароль не короче 8 символов")
return true
}
name := strings.TrimSpace(payload.Name)
if name == "" {
name = "Демо-пользователь"
}
user := backendStore.addUser(payload.Login, name)
writeJSON(w, http.StatusCreated, map[string]any{"token": makeToken(user), "user": user})
return true
case r.Method == http.MethodGet && path == "/auth/me":
user, ok := requireAuth(w, r)
if !ok {
return true
}
writeJSON(w, http.StatusOK, map[string]any{"user": user})
return true
case r.Method == http.MethodPost && path == "/auth/logout":
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 {
return true
}
var payload struct {
NextPassword string `json:"nextPassword"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if len(payload.NextPassword) < 8 {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Новый пароль должен быть не короче 8 символов")
return true
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return true
default:
return false
}
}
func handleUser(w http.ResponseWriter, r *http.Request, path string) bool {
switch {
case r.Method == http.MethodGet && (path == "/users" || path == "/admin/users"):
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]UserProfile(nil), backendStore.Users...)
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodGet && (path == "/roles" || path == "/admin/roles"):
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": []map[string]any{
{"code": RoleAdministrator, "permissions": []string{"*"}},
{"code": RoleEditor, "permissions": []string{"content:create", "content:update", "comments:moderate"}},
{"code": RoleManager, "permissions": []string{"content:create", "content:publish", "subscriptions:manage"}},
{"code": RoleUser, "permissions": []string{"content:read", "comments:create", "subscriptions:create"}},
}})
return true
default:
return false
}
}
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()
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)
}
}
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)
if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
}
writeJSON(w, http.StatusOK, map[string]any{"item": item})
return true
case r.Method == http.MethodPost && path == "/content":
user, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager)
if !ok {
return true
}
var payload ContentItem
if !decodeJSON(w, r, &payload) {
return true
}
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,
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
return true
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/content/"):
if _, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager); !ok {
return true
}
var payload ContentItem
if !decodeJSON(w, r, &payload) {
return true
}
item, ok := backendStore.patchContent(strings.TrimPrefix(path, "/content/"), payload)
if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
}
writeJSON(w, http.StatusOK, map[string]any{"item": item})
return true
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/content/"):
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
if !backendStore.deleteContent(strings.TrimPrefix(path, "/content/")) {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Материал не найден")
return true
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return true
default:
return false
}
}
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...)})
return true
case "/tags":
writeJSON(w, http.StatusOK, map[string]any{"items": append([]string(nil), backendStore.Tags...)})
return true
default:
return false
}
}
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()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
return false
}
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)
}
}
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/"))
if !ok {
writeAPIError(w, http.StatusNotFound, "NOT_FOUND", "Файл не найден")
return true
}
w.Header().Set("Content-Type", firstNonEmpty(file.MimeType, "application/octet-stream"))
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", file.Name))
http.ServeContent(w, r, file.Name, time.Now(), bytes.NewReader(file.Data))
return true
case r.Method == http.MethodPost && path == "/media":
user, ok := requireAnyRole(w, r, RoleAdministrator, RoleEditor, RoleManager)
if !ok {
return true
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if err := r.ParseMultipartForm(64 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "INVALID_MULTIPART", "Не удалось прочитать multipart-запрос")
return true
}
uploaded, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Прикрепите файл")
return true
}
defer uploaded.Close()
data, err := io.ReadAll(uploaded)
if err != nil || len(data) == 0 {
writeAPIError(w, http.StatusBadRequest, "INVALID_FILE", "Файл пустой или поврежден")
return true
}
mimeType := header.Header.Get("Content-Type")
if mimeType == "" || mimeType == "application/octet-stream" {
sample := data
if len(sample) > 512 {
sample = sample[:512]
}
mimeType = http.DetectContentType(sample)
}
mediaKind := inferMediaKind(mimeType, header.Filename)
contentType := ContentType(strings.TrimSpace(r.FormValue("type")))
if contentType == "" {
contentType = defaultContentTypeForMediaKind(mediaKind)
}
if !isValidContentType(contentType) {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите корректный тип материала")
return true
}
stored := backendStore.addFile(StoredFile{
ID: fmt.Sprintf("demo-file-%d", time.Now().UnixNano()),
Name: firstNonEmpty(header.Filename, "uploaded-file"),
MimeType: mimeType,
Size: int64(len(data)),
Data: data,
})
category := strings.TrimSpace(r.FormValue("category"))
if category == "" {
category = defaultCategoryForMediaKind(mediaKind)
}
item := ContentItem{
ID: fmt.Sprintf("demo-media-%d", time.Now().UnixNano()),
Title: firstNonEmpty(r.FormValue("title"), stored.Name),
Lead: firstNonEmpty(r.FormValue("lead"), "Демонстрационный медиаматериал с загруженным файлом."),
Body: firstNonEmpty(r.FormValue("body"), "Файл загружен в in-memory demo-хранилище и доступен только до перезапуска сервиса."),
Type: contentType,
Category: category,
Tags: parseTags(r.FormValue("tags")),
Author: user.Name,
PublishedAt: time.Now().Format("2006-01-02"),
Visibility: VisibilityAuthenticated,
Status: ContentStatusDraft,
Views: 0,
ImageTone: "from-university-800 via-slate-700 to-sky-300",
MediaURL: "/api/media/files/" + stored.ID,
MediaKind: mediaKind,
MimeType: stored.MimeType,
FileName: stored.Name,
FileSize: stored.Size,
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addContent(item)})
return true
}
return false
}
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"))})
return true
}
return false
}
func handleSubscription(w http.ResponseWriter, r *http.Request, path string) bool {
if path != "/subscriptions" {
return false
}
user, ok := requireAuth(w, r)
if !ok {
return true
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"items": user.Subscriptions})
return true
case http.MethodPost:
var payload struct {
Target string `json:"target"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Target) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Укажите объект подписки")
return true
}
writeJSON(w, http.StatusOK, map[string]any{"items": backendStore.upsertSubscription(user.ID, payload.Target)})
return true
default:
return false
}
}
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()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/notifications/") && strings.HasSuffix(path, "/read"):
if _, ok := requireAuth(w, r); !ok {
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
}
}
backendStore.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"item": updated})
return true
default:
return false
}
}
func handleComment(w http.ResponseWriter, r *http.Request, path string) bool {
if !strings.HasPrefix(path, "/comments/") {
return false
}
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)
}
}
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
case http.MethodPost:
user, ok := requireAuth(w, r)
if !ok {
return true
}
var payload struct {
Text string `json:"text"`
}
if !decodeJSON(w, r, &payload) {
return true
}
if strings.TrimSpace(payload.Text) == "" {
writeAPIError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Комментарий не может быть пустым")
return true
}
writeJSON(w, http.StatusCreated, map[string]any{"item": backendStore.addComment(contentID, user, strings.TrimSpace(payload.Text))})
return true
default:
return false
}
}
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 {
return true
}
backendStore.mu.RLock()
defer backendStore.mu.RUnlock()
totalViews := 0
moderationQueue := 0
for _, item := range backendStore.Content {
totalViews += item.Views
if item.Status != ContentStatusPublished {
moderationQueue++
}
}
subscribers := 0
for _, speaker := range backendStore.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}})
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})
return true
}
func handleAudit(w http.ResponseWriter, r *http.Request, path string) bool {
if r.Method == http.MethodGet && (path == "/admin/audit" || path == "/audit") {
if _, ok := requireRole(w, r, RoleAdministrator); !ok {
return true
}
backendStore.mu.RLock()
items := append([]AuditItem(nil), backendStore.Audit...)
backendStore.mu.RUnlock()
writeJSON(w, http.StatusOK, map[string]any{"items": items})
return true
}
return false
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeAPIError(w, http.StatusBadRequest, "INVALID_JSON", "Некорректный JSON-запрос")
return false
}
return true
}
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
payload := apiError{}
payload.Error.Code = code
payload.Error.Message = message
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 userFromRequest(r *http.Request) (UserProfile, bool) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
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)
if err != nil {
return UserProfile{}, false
}
var payload tokenPayload
if err := json.Unmarshal(bytes, &payload); 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
}
}
return user, true
}
func requireAuth(w http.ResponseWriter, r *http.Request) (UserProfile, bool) {
user, ok := userFromRequest(r)
if !ok {
writeAPIError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Требуется аутентификация")
return UserProfile{}, false
}
return user, true
}
func requireRole(w http.ResponseWriter, r *http.Request, role RoleCode) (UserProfile, bool) {
return requireAnyRole(w, r, role)
}
func requireAnyRole(w http.ResponseWriter, r *http.Request, roles ...RoleCode) (UserProfile, bool) {
user, ok := requireAuth(w, r)
if !ok {
return UserProfile{}, false
}
for _, actual := range user.Roles {
for _, expected := range roles {
if actual == expected {
return user, true
}
}
}
writeAPIError(w, http.StatusForbidden, "FORBIDDEN", "Недостаточно прав доступа")
return UserProfile{}, false
}
func firstNonEmpty(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func inferMediaKind(mimeType, fileName string) string {
lowerName := strings.ToLower(fileName)
switch {
case strings.HasPrefix(mimeType, "image/"):
return "image"
case strings.HasPrefix(mimeType, "video/"):
return "video"
case strings.HasPrefix(mimeType, "audio/"):
return "audio"
case mimeType == "application/pdf" || strings.HasSuffix(lowerName, ".pdf") || strings.HasSuffix(lowerName, ".doc") || strings.HasSuffix(lowerName, ".docx") || strings.HasSuffix(lowerName, ".ppt") || strings.HasSuffix(lowerName, ".pptx") || strings.HasSuffix(lowerName, ".xls") || strings.HasSuffix(lowerName, ".xlsx") || strings.HasSuffix(lowerName, ".txt") || strings.HasSuffix(lowerName, ".rtf"):
return "document"
default:
return "other"
}
}
func defaultContentTypeForMediaKind(kind string) ContentType {
switch kind {
case "video":
return ContentTypeVideo
case "audio":
return ContentTypeAudio
case "image":
return ContentTypeGraphic
default:
return ContentTypeArticle
}
}
func defaultCategoryForMediaKind(kind string) string {
switch kind {
case "video":
return "Видео"
case "audio":
return "Аудио"
case "image":
return "Графика"
default:
return "Статьи"
}
}
func isValidContentType(contentType ContentType) bool {
switch contentType {
case ContentTypeNews, ContentTypeArticle, ContentTypeVideo, ContentTypeAudio, ContentTypeGraphic, ContentTypeEvent:
return true
default:
return false
}
}
func parseTags(value string) []string {
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == '\n' })
tags := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
tag := strings.TrimSpace(part)
if tag == "" || seen[tag] {
continue
}
seen[tag] = true
tags = append(tags, tag)
}
if len(tags) == 0 {
return []string{"демо", "файл"}
}
return tags
}

View File

@@ -0,0 +1,135 @@
package service
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
)
// Config describes one internal Go service behind the Elysia gateway.
type Config struct {
Name string `json:"name"`
Domain string `json:"domain"`
DefaultPort string `json:"defaultPort"`
Capabilities []string `json:"capabilities"`
}
type response struct {
Status string `json:"status"`
Service string `json:"service"`
Domain string `json:"domain"`
Capabilities []string `json:"capabilities,omitempty"`
Time time.Time `json:"time"`
}
// EnvPort reads PORT with a safe fallback for local service runs.
func EnvPort(defaultPort string) string {
if port := strings.TrimSpace(os.Getenv("PORT")); port != "" {
return port
}
if strings.TrimSpace(defaultPort) != "" {
return defaultPort
}
return "8080"
}
// MustRun starts a service and terminates the process if startup fails.
func MustRun(cfg Config) {
if err := Run(cfg); err != nil {
log.Fatalf("%s stopped: %v", cfg.Name, err)
}
}
// Run starts an HTTP server for an internal service.
func Run(cfg Config) error {
port := EnvPort(cfg.DefaultPort)
server := &http.Server{
Addr: ":" + port,
Handler: requestIDMiddleware(loggingMiddleware(NewHandler(cfg))),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("%s listening on :%s", cfg.Name, port)
return server.ListenAndServe()
}
// NewHandler returns the standard internal service HTTP API.
func NewHandler(cfg Config) http.Handler {
if cfg.Name == "" {
cfg.Name = "Fable Service"
}
if cfg.Domain == "" {
cfg.Domain = "unknown"
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{
Status: "ok",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
Time: time.Now().UTC(),
})
})
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response{
Status: "ready",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
Time: time.Now().UTC(),
})
})
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, cfg)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if routeAPI(w, r, cfg) {
return
}
if r.URL.Path != "/" {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "route not found"})
return
}
writeJSON(w, http.StatusOK, response{
Status: "ok",
Service: cfg.Name,
Domain: cfg.Domain,
Capabilities: cfg.Capabilities,
Time: time.Now().UTC(),
})
})
return mux
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("write response: %v", err)
}
}
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
if requestID == "" {
requestID = time.Now().UTC().Format("20060102150405.000000000")
}
w.Header().Set("X-Request-Id", requestID)
next.ServeHTTP(w, r)
})
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(started))
})
}

View File

@@ -0,0 +1,28 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthEndpoint(t *testing.T) {
handler := NewHandler(Config{Name: "Test Service", Domain: "test", Capabilities: []string{"health"}})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var body response
if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Status != "ok" || body.Domain != "test" {
t.Fatalf("unexpected body: %+v", body)
}
}

View File

@@ -0,0 +1,229 @@
package service
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
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
}
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
}

View File

@@ -0,0 +1,110 @@
package service
type ContentType string
const (
ContentTypeNews ContentType = "news"
ContentTypeArticle ContentType = "article"
ContentTypeVideo ContentType = "video"
ContentTypeAudio ContentType = "audio"
ContentTypeGraphic ContentType = "graphic"
ContentTypeEvent ContentType = "event"
)
type ContentStatus string
const (
ContentStatusDraft ContentStatus = "Черновик"
ContentStatusModeration ContentStatus = "На модерации"
ContentStatusReview ContentStatus = "На проверке"
ContentStatusPublished ContentStatus = "Опубликовано"
ContentStatusArchived ContentStatus = "Архив"
)
type Visibility string
const (
VisibilityPublic Visibility = "Публично"
VisibilityAuthenticated Visibility = "После входа"
VisibilityRole Visibility = "По роли"
)
type RoleCode string
const (
RoleAdministrator RoleCode = "администратор"
RoleEditor RoleCode = "редактор"
RoleManager RoleCode = "менеджер"
RoleUser RoleCode = "пользователь"
)
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"`
}
type StoredFile struct {
ID string
Name string
MimeType string
Size int64
Data []byte
}
type Speaker struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Topics []string `json:"topics"`
Materials int `json:"materials"`
Subscribers int `json:"subscribers"`
}
type UserProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Roles []RoleCode `json:"roles"`
Subscriptions []string `json:"subscriptions"`
}
type NotificationItem struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Read bool `json:"read"`
CreatedAt string `json:"createdAt"`
}
type CommentItem struct {
ID string `json:"id"`
ContentID string `json:"contentId"`
Author string `json:"author"`
Text string `json:"text"`
CreatedAt string `json:"createdAt"`
}
type AuditItem struct {
ID string `json:"id"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
CreatedAt string `json:"createdAt"`
}

464
ТЗ_единый.md Normal file
View File

@@ -0,0 +1,464 @@
# ТЗ к практике
> **Информация:** Техническое задание на разработку информационной системы управления медиаконтентом университета.
## Содержание
1. [Определения и сокращения](#определения-и-сокращения)
2. [Общие сведения](#общие-сведения)
3. [Назначение и цели системы](#назначение-и-цели-системы)
4. [Характеристика объекта автоматизации](#характеристика-объекта-автоматизации)
5. [Требования к системе](#требования-к-системе)
6. [Функции системы](#функции-системы)
7. [Требования к видам обеспечения](#требования-к-видам-обеспечения)
8. [Технологический стек](#технологический-стек)
9. [Состав и содержание работ](#состав-и-содержание-работ)
10. [Порядок контроля и приемки](#порядок-контроля-и-приемки)
11. [Требования к документированию](#требования-к-документированию)
12. [Источники разработки](#источники-разработки)
---
## Определения и сокращения
| Обозначение | Расшифровка |
|-------------|-------------|
| ИС | информационная система |
| АС | автоматизированная система |
| БД | база данных |
| СУБД | система управления базами данных |
| Пользователь | лицо, имеющее доступ к функциональности системы |
| Администратор | пользователь с расширенными правами управления системой |
| Контент | информационные и медиаматериалы, размещаемые в системе |
| Роль | совокупность прав доступа пользователя к функциям системы |
| Медиаконтент | аудио-, видео-, текстовые и графические материалы |
---
## Общие сведения
### Полное наименование системы и условное обозначение
Полное наименование системы: ________________________________.
Условное обозначение: ________________________________________.
### Номер договора
Настоящее Техническое задание разработано в рамках выполнения работ по договору № ______, заключенному «___» __________ 2026 года.
### Наименование организации-заказчика и организации исполнителя
**Заказчик:** ________________________
- Место нахождения:
- Телефон:
- Банковские реквизиты:
**Исполнители:** студенты _________________
- Место нахождения:
- Телефон:
- Банковские реквизиты:
### Перечень документов, на основании которых создаётся система
Система создаётся на основании договора № _____, от «____» ________ 2026 года.
### Плановые сроки начала и окончания работы по созданию системы
| Начало | Окончание |
|--------|-----------|
| 4 июня 2026 | 7 июля 2026 |
Работы выполняются поэтапно: анализ требований → проектирование → разработка → тестирование → внедрение → документация.
### Порядок оформления и предъявления заказчику результатов
- Результаты оформляются комплектами проектной, эксплуатационной и пользовательской документации + функционирующий продукт.
- Передача — поэтапно по календарному плану.
- По завершении этапа — отчётные материалы.
- Финальная передача — после тестирования и устранения замечаний, оформляется актом сдачи-приёмки.
### Нормативно-технические документы
- **ГОСТ 34.6022020** — ТЗ на создание АС
- **ГОСТ 34.20189** — Виды и комплектность документов АС
- **ГОСТ 2.6012019** — Эксплуатационные документы
- **ГОСТ 19.20178** — ТЗ. Требования к содержанию и оформлению
- Методические материалы по проектированию ИС и веб-приложений
---
## Назначение и цели системы
### Назначение
Разрабатываемая информационная система предназначена для создания **единой централизованной цифровой платформы университета** для хранения, систематизации, управления и распространения медиаконтента.
**Ключевые задачи:**
- Замена разрозненных каналов (мессенджеры, файловые хранилища, email)
- Единое информационное пространство для студентов, преподавателей, сотрудников и спикеров
- Быстрый поиск и доступ к материалам, публикациям, новостям, мероприятиям
- Подписка на направления, темы, мероприятия, спикеров
- Уведомления, персонализация, индивидуальная лента
- Для администраторов: управление пользователями, ролями, правами, модерация, аналитика
### Цели создания
1. Повышение эффективности управления медиаконтентом
2. Сокращение использования сторонних мессенджеров
3. Повышение скорости доступа к актуальным материалам
4. Улучшение взаимодействия участников образовательного процесса
5. Обеспечение масштабирования и информационной безопасности
6. Внедрение ролевой модели доступа
7. Автоматизация административных процессов
8. Создание основы для цифровой экосистемы университета
---
## Характеристика объекта автоматизации
**Объект автоматизации:** процесс информационного взаимодействия, подготовки, публикации и распространения медиаконтента университета.
### Текущая ситуация
- В университете есть свои медиаплощадки: журнал, радио, телевидение
- Создаётся большой объём контента: статьи, интервью, видео, аудио, анонсы
- Распространение — через мессенджеры, email, локальные файловые хранилища
-**Проблемы:** фрагментация данных, сложный поиск, дублирование, нет контроля версий, нет разграничения доступа
### Участники процессов
| Участник | Роль |
|----------|------|
| Студенты | Потребление контента, участие |
| Преподаватели | Публикация, взаимодействие |
| Сотрудники редакций | Создание и публикация контента |
| Приглашённые спикеры | Участие в мероприятиях |
| Администраторы | Управление системой |
### Информационные потоки
- Публикация материалов журнала
- Радио- и телепередачи
- Новости и анонсы мероприятий
- Хранение медиаматериалов
- Поиск и фильтрация
- Управление пользователями и ролями
- Аналитика активности
### Характеристики объекта
- Большой объём мультимедийных данных
- Строгое разграничение прав доступа
- Высокая доступность и отказоустойчивость
- Масштабируемость
- Круглосуточный доступ через веб и мобильные устройства
---
## Требования к системе
### Архитектура: два контура
```mermaid
graph LR
A[Пользователь] --> B[Публичный контур]
B -->|Аутентификация| C[Персонифицированный контур]
```
#### Контур публичного доступа
- Просмотр публичного контента
- Поиск спикеров
- Просмотр мероприятий
- Просмотр новостей и статей
- Регистрация и авторизация
#### Контур персонифицированного доступа
- Управление профилем
- Создание и редактирование контента
- Подписки и уведомления
- Комментарии и взаимодействие
- Административная панель
### Доступ и аутентификация
- Доступ через публичный контур
- Для персонального контура — обязательная аутентификация
- Действия ассоциируются с учётной записью
- Аутентификация по логину/паролю + механизм токенов
### Общие требования
- [x] Круглосуточная работа 24/7
- [x] Многопользовательский режим
- [x] Масштабируемость
- [x] Веб-доступ через браузер
- [x] Возможность мобильного клиента
---
## Функции системы
### 1. Редактирование данных (CRUD)
- Регистрация, редактирование профиля, смена пароля
- Создание/редактирование/удаление публикаций
- Загрузка медиаматериалов
- Управление категориями и тегами
- Публикация/архивация материалов
- Администрирование: управление учётными записями, ролями, модерация
### 2. Поиск и получение информации
- Глобальный полнотекстовый поиск по всем типам контента
- Фильтрация по категориям, авторам, тегам
- Сортировка результатов
- Подробный просмотр выбранных материалов
### 3. Безопасность
- Аутентификация перед доступом к функциям
- Ролевая модель прав доступа
- Разграничение доступа к данным
- Хранение паролей в зашифрованном виде
- Журналирование действий пользователей и администраторов
### 4. Расчётные функции (аналитика)
- Подсчёт просмотров публикаций
- Активность пользователей
- Количество подписчиков
- Формирование отчётов и аналитических материалов
### 5. Технологические функции (жизненный цикл контента)
- Создание → модерация → проверка → публикация → архивирование
- Автоматические уведомления о новом контенте по подпискам
### 6. Аналитические функции
- Отчёты о посещаемости
- Активность пользователей
- Популярность публикаций
- Эффективность контента
---
## Требования к видам обеспечения
### Математическое обеспечение
- Алгоритмы полнотекстового поиска
- Формирование рекомендаций
- Расчёт статистических показателей
- Методы статистической обработки данных
### Информационное обеспечение
- Реляционная СУБД: **PostgreSQL**
- Механизмы целостности: транзакции, ограничения, журналирование
- Разграничение доступа на уровне данных
- Хранение медиафайлов через **CDN**
- Резервное копирование и восстановление
### Лингвистическое обеспечение
- Язык интерфейса: **русский**
- Термины понятны пользователю без спецподготовки
- Возможность локализации на другие языки
### Методическое обеспечение
- Руководство пользователя
- Руководство администратора
### Организационное обеспечение
- Постоянное взаимодействие заказчика и разработчика
- Рабочие группы с необходимыми компетенциями
- Обучение пользователей
### Правовое обеспечение
- Соответствие законодательству РФ
- Защита персональных данных
- Соблюдение нормативных актов по обработке информации
---
## Технологический стек
### Программное обеспечение
| Компонент | Технология |
|-----------|------------|
| Серверная часть | Linux / Windows, **микросервисы** |
| Языки сервера | **Go**, JavaScript (Node.js) |
| База данных | **PostgreSQL** |
| Клиентская часть | **React** + **TailwindCSS** |
| Доставка медиа | **CDN** |
| Развёртывание | Контейнеризация (Docker) |
### Техническое обеспечение
**Сервер:**
- Многоядерный процессор
- RAM ≥ 8 ГБ
- SSD-накопитель
- Стабильное подключение к Интернет
**Клиент:**
- ПК или мобильное устройство
- Веб-браузер
### Эргономическое обеспечение
- Современные требования **UX/UI**
- Удобная навигация
- Адаптивность под различные устройства
- Кроссбраузерная совместимость
- Дизайн-макеты согласовываются с заказчиком
---
## Состав и содержание работ
### Этапы разработки
```mermaid
flowchart LR
A[Предпроектное<br>обследование] --> B[Техническое<br>задание]
B --> C[Эскизное и<br>техпроектирование]
C --> D[Разработка ПО]
D --> E[Тестирование]
E --> F[Внедрение]
```
#### 1. Предпроектное обследование
- Анализ предметной области
- Сбор и формализация требований
- Анализ аналогов
- Целевая аудитория
- Концепция архитектуры
- Технологические решения
#### 2. Разработка ТЗ
- Функциональные и нефункциональные требования
- Состав работ
- Структура данных
- Процессы взаимодействия
#### 3. Эскизное и техническое проектирование
- Архитектура ПО
- Структура БД
- Роли и права доступа
- UX/UI прототипы
- Сценарии использования
#### 4. Разработка ПО
- Серверная часть
- Пользовательский интерфейс
- Административная панель
- Авторизация и аутентификация
- Поиск и подписки
- API + CDN
#### 5. Тестирование
- Модульное, интеграционное, системное
- Нагрузочное тестирование
- Тестирование безопасности
- Устранение ошибок
#### 6. Внедрение
- Развёртывание на сервере
- Настройка окружения
- Миграция данных
- Обучение пользователей и администраторов
### Порядок разработки
- Разработка по календарному плану
- Последовательные этапы, с уточнением требований по согласованию
- После каждого этапа — комплект отчётных материалов заказчику
- Промежуточные проверки, демонстрация модулей
- Сдача-приёмка поэтапно
- Приёмочные испытания по программе и методике
- После испытаний — опытная эксплуатация → акт приёмки
### Информационные объекты системы
| Объект | Ключевой атрибут |
|--------|-----------------|
| Пользователь | ID пользователя |
| Роль | ID роли |
| Права доступа | ID права |
| Пользователь–Роль | ID записи |
| Спикер | ID спикера |
| Медиа-материал | ID материала |
| Категория | ID категории |
| Теги | ID тега |
| Подписка | ID подписки |
| Подписка на спикера | ID записи |
| Подписка на категорию | ID записи |
| Комментарий | ID комментария |
| Уведомление | ID уведомления |
| Логи действий | ID записи |
---
## Порядок контроля и приемки
### Подготовка к вводу системы
- [ ] Приведение данных (спикеры, публикации, медиа, пользователи) к структурированному виду
- [ ] Анализ существующих каналов хранения и распространения (мессенджеры, облака, архивы, сайты, соцсети)
- [ ] Очистка данных: удаление дубликатов, нормализация форматов, проверка метаданных, категоризация
- [ ] Определение ролей и прав доступа (администратор, редактор, менеджер, пользователь)
- [ ] Создание организационных условий: безопасность, разграничение доступа
- [ ] Назначение ответственных со стороны Заказчика
- [ ] Подготовка серверной инфраструктуры: backend, PostgreSQL, CDN, резервное копирование
- [ ] Соответствие рабочих мест минимальным требованиям
- [ ] План миграции данных
- [ ] Обучение пользователей и администраторов
- [ ] Тестовое развёртывание → опытная эксплуатация
- [ ] Устранение замечаний → промышленный ввод
---
## Требования к документированию
Виды, комплектность и обозначение документов — по **ГОСТ 34.201-89**, согласовываются с Заказчиком.
### Перечень документации
| № | Стадия | Документ | Норматив |
|---|--------|----------|----------|
| 1 | ТЗ | Техническое задание на разработку системы | ГОСТ 34.602-2020 |
| 2 | ЭП | Эскизный проект | ГОСТ 34.201-89 |
| 3 | ТП | Технический проект системы | ГОСТ 34.201-89 |
| 4 | РП | Рабочий проект | ГОСТ 34.201-89 |
| 5 | РП | Руководство пользователя | ГОСТ 2.610-2006 |
| 6 | РП | Руководство администратора | ГОСТ 2.610-2006 |
| 7 | РП | Программа и методика испытаний | ГОСТ 19.301-79 |
| 8 | ВВ | Акт ввода системы в эксплуатацию | — |
---
## Источники разработки
### Нормативная база
- **ГОСТ 34** — стандарты на создание автоматизированных систем:
- ГОСТ 34.602-2020 — ТЗ на АС
- ГОСТ 34.201-89 — Виды, комплектность документов АС
- **ЕСПД** — требования к программной документации
- Действующее законодательство РФ в области ИТ и защиты персональных данных
### Прочие источники
- Аналитические материалы по масштабируемым веб-сервисам
- Документация используемых технологий и платформ
- Анализ аналогичных ИС управления медиаконтентом
- Материалы предпроектного исследования