first commit 2
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
services/bin
|
||||||
6
.env.example
Normal file
6
.env.example
Normal 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
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
tmp/
|
||||||
|
*.tmp
|
||||||
|
services/bin/
|
||||||
|
.tmp/
|
||||||
14
.omx/state/notify-fallback-authority-owner.json
Normal file
14
.omx/state/notify-fallback-authority-owner.json
Normal 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"
|
||||||
|
}
|
||||||
14
.omx/state/notify-fallback-authority-state.json
Normal file
14
.omx/state/notify-fallback-authority-state.json
Normal 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"
|
||||||
|
}
|
||||||
4
.omx/state/update-check.json
Normal file
4
.omx/state/update-check.json
Normal 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
31
apps/gateway/Dockerfile
Normal 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
19
apps/gateway/package.json
Normal 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
841
apps/gateway/src/index.ts
Normal 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}`);
|
||||||
|
});
|
||||||
14
apps/gateway/tsconfig.json
Normal file
14
apps/gateway/tsconfig.json
Normal 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
17
apps/web/Dockerfile
Normal 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
16
apps/web/index.html
Normal 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
29
apps/web/nginx.conf
Normal 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
26
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.cjs
Normal file
6
apps/web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
1891
apps/web/src/App.tsx
Normal file
1891
apps/web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
145
apps/web/src/components/AppMenu.tsx
Normal file
145
apps/web/src/components/AppMenu.tsx
Normal 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
216
apps/web/src/demo-data.ts
Normal 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
140
apps/web/src/index.css
Normal 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
10
apps/web/src/main.tsx
Normal 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
71
apps/web/src/types.ts
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
40
apps/web/tailwind.config.cjs
Normal file
40
apps/web/tailwind.config.cjs
Normal 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
21
apps/web/tsconfig.json
Normal 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
15
apps/web/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
218
database/migrations/001_init.sql
Normal file
218
database/migrations/001_init.sql
Normal 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
167
docker-compose.yml
Normal 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:
|
||||||
16
infra/docker/go-service.Dockerfile
Normal file
16
infra/docker/go-service.Dockerfile
Normal 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
2782
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
333
plan.md
Normal 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
65
scripts/dev-backend.sh
Normal 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
35
scripts/dev.sh
Normal 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[@]}"
|
||||||
17
services/cmd/analytics/main.go
Normal file
17
services/cmd/analytics/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
15
services/cmd/audit/main.go
Normal file
15
services/cmd/audit/main.go
Normal 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
17
services/cmd/auth/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
15
services/cmd/comment/main.go
Normal file
15
services/cmd/comment/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
17
services/cmd/content/main.go
Normal file
17
services/cmd/content/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
16
services/cmd/media/main.go
Normal file
16
services/cmd/media/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
16
services/cmd/notification/main.go
Normal file
16
services/cmd/notification/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
16
services/cmd/search/main.go
Normal file
16
services/cmd/search/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
15
services/cmd/speaker/main.go
Normal file
15
services/cmd/speaker/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
17
services/cmd/subscription/main.go
Normal file
17
services/cmd/subscription/main.go
Normal 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
15
services/cmd/taxonomy/main.go
Normal file
15
services/cmd/taxonomy/main.go
Normal 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
17
services/cmd/user/main.go
Normal 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
3
services/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module fable/services
|
||||||
|
|
||||||
|
go 1.26
|
||||||
702
services/internal/service/api.go
Normal file
702
services/internal/service/api.go
Normal 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
|
||||||
|
}
|
||||||
135
services/internal/service/service.go
Normal file
135
services/internal/service/service.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
28
services/internal/service/service_test.go
Normal file
28
services/internal/service/service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
229
services/internal/service/store.go
Normal file
229
services/internal/service/store.go
Normal 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
|
||||||
|
}
|
||||||
110
services/internal/service/types.go
Normal file
110
services/internal/service/types.go
Normal 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
464
ТЗ_единый.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# ТЗ к практике
|
||||||
|
|
||||||
|
> **Информация:** Техническое задание на разработку информационной системы управления медиаконтентом университета.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
1. [Определения и сокращения](#определения-и-сокращения)
|
||||||
|
2. [Общие сведения](#общие-сведения)
|
||||||
|
3. [Назначение и цели системы](#назначение-и-цели-системы)
|
||||||
|
4. [Характеристика объекта автоматизации](#характеристика-объекта-автоматизации)
|
||||||
|
5. [Требования к системе](#требования-к-системе)
|
||||||
|
6. [Функции системы](#функции-системы)
|
||||||
|
7. [Требования к видам обеспечения](#требования-к-видам-обеспечения)
|
||||||
|
8. [Технологический стек](#технологический-стек)
|
||||||
|
9. [Состав и содержание работ](#состав-и-содержание-работ)
|
||||||
|
10. [Порядок контроля и приемки](#порядок-контроля-и-приемки)
|
||||||
|
11. [Требования к документированию](#требования-к-документированию)
|
||||||
|
12. [Источники разработки](#источники-разработки)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Определения и сокращения
|
||||||
|
|
||||||
|
| Обозначение | Расшифровка |
|
||||||
|
|-------------|-------------|
|
||||||
|
| ИС | информационная система |
|
||||||
|
| АС | автоматизированная система |
|
||||||
|
| БД | база данных |
|
||||||
|
| СУБД | система управления базами данных |
|
||||||
|
| Пользователь | лицо, имеющее доступ к функциональности системы |
|
||||||
|
| Администратор | пользователь с расширенными правами управления системой |
|
||||||
|
| Контент | информационные и медиаматериалы, размещаемые в системе |
|
||||||
|
| Роль | совокупность прав доступа пользователя к функциям системы |
|
||||||
|
| Медиаконтент | аудио-, видео-, текстовые и графические материалы |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общие сведения
|
||||||
|
|
||||||
|
### Полное наименование системы и условное обозначение
|
||||||
|
|
||||||
|
Полное наименование системы: ________________________________.
|
||||||
|
|
||||||
|
Условное обозначение: ________________________________________.
|
||||||
|
|
||||||
|
### Номер договора
|
||||||
|
|
||||||
|
Настоящее Техническое задание разработано в рамках выполнения работ по договору № ______, заключенному «___» __________ 2026 года.
|
||||||
|
|
||||||
|
### Наименование организации-заказчика и организации исполнителя
|
||||||
|
|
||||||
|
**Заказчик:** ________________________
|
||||||
|
- Место нахождения:
|
||||||
|
- Телефон:
|
||||||
|
- Банковские реквизиты:
|
||||||
|
|
||||||
|
**Исполнители:** студенты _________________
|
||||||
|
- Место нахождения:
|
||||||
|
- Телефон:
|
||||||
|
- Банковские реквизиты:
|
||||||
|
|
||||||
|
### Перечень документов, на основании которых создаётся система
|
||||||
|
|
||||||
|
Система создаётся на основании договора № _____, от «____» ________ 2026 года.
|
||||||
|
|
||||||
|
### Плановые сроки начала и окончания работы по созданию системы
|
||||||
|
|
||||||
|
| Начало | Окончание |
|
||||||
|
|--------|-----------|
|
||||||
|
| 4 июня 2026 | 7 июля 2026 |
|
||||||
|
|
||||||
|
Работы выполняются поэтапно: анализ требований → проектирование → разработка → тестирование → внедрение → документация.
|
||||||
|
|
||||||
|
### Порядок оформления и предъявления заказчику результатов
|
||||||
|
|
||||||
|
- Результаты оформляются комплектами проектной, эксплуатационной и пользовательской документации + функционирующий продукт.
|
||||||
|
- Передача — поэтапно по календарному плану.
|
||||||
|
- По завершении этапа — отчётные материалы.
|
||||||
|
- Финальная передача — после тестирования и устранения замечаний, оформляется актом сдачи-приёмки.
|
||||||
|
|
||||||
|
### Нормативно-технические документы
|
||||||
|
|
||||||
|
- **ГОСТ 34.602–2020** — ТЗ на создание АС
|
||||||
|
- **ГОСТ 34.201–89** — Виды и комплектность документов АС
|
||||||
|
- **ГОСТ 2.601–2019** — Эксплуатационные документы
|
||||||
|
- **ГОСТ 19.201–78** — ТЗ. Требования к содержанию и оформлению
|
||||||
|
- Методические материалы по проектированию ИС и веб-приложений
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Назначение и цели системы
|
||||||
|
|
||||||
|
### Назначение
|
||||||
|
|
||||||
|
Разрабатываемая информационная система предназначена для создания **единой централизованной цифровой платформы университета** для хранения, систематизации, управления и распространения медиаконтента.
|
||||||
|
|
||||||
|
**Ключевые задачи:**
|
||||||
|
- Замена разрозненных каналов (мессенджеры, файловые хранилища, 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 — Виды, комплектность документов АС
|
||||||
|
- **ЕСПД** — требования к программной документации
|
||||||
|
- Действующее законодательство РФ в области ИТ и защиты персональных данных
|
||||||
|
|
||||||
|
### Прочие источники
|
||||||
|
|
||||||
|
- Аналитические материалы по масштабируемым веб-сервисам
|
||||||
|
- Документация используемых технологий и платформ
|
||||||
|
- Анализ аналогичных ИС управления медиаконтентом
|
||||||
|
- Материалы предпроектного исследования
|
||||||
Reference in New Issue
Block a user