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