Лимитированные наборы
Ограниченное число генераций в этом месяце
Премиум-сборки
Комплекты с максимальной номинальной ценностью
Фарм · базовые
Низкая стоимость активации, быстрая проверка механики
Пятьдесят на пятьдесят
Равное математическое распределение между двумя наградами
Панель администратора
Конструктор наградных боксов и журнал верификации списания баллов
Создание нового набора
Заполните параметры коробки и распределение шансов выпадения карточек
Содержимое набора
Сумма всех вероятностей должна равняться 100%
| Карточка | Уровень | Номинал, ПГ | Шанс, % |
|---|---|---|---|
| Бронзовый пак | Базовый | 480 | |
| Бонус Плюс | Стандарт | 1 200 | |
| ПиАр Дроп | Эпик | 3 250 | |
| Крипто-Бокс | Эпик | 4 800 | |
| Мажор Грэм | Легенда | 9 500 |
Журнал верификации списаний
Внутренние запросы сотрудников на обмен ПиАр Грэм на корпоративный мерч
| ID | Сотрудник | Сумма (ПГ) | Адрес назначения | Дата | Статус | Действия |
|---|---|---|---|---|---|---|
| #10842 | А. Соколов | 12 500 | Деп. Маркетинга, каб. 412 | 04.12.2024 | В очереди | |
| #10841 | Е. Никитина | 4 800 | HR-департамент, ресепшн | 04.12.2024 | В очереди | |
| #10840 | М. Дегтярёв | 1 200 | Отдел разработки, 7 этаж | 03.12.2024 | Подтверждено | — |
| #10839 | О. Зайцева | 18 700 | Финансовый деп., каб. 205 | 03.12.2024 | В очереди | |
| #10838 | Д. Гаврилов | 3 250 | Отдел продаж, ресепшн | 02.12.2024 | Отклонено | — |
| #10837 | Т. Беляева | 480 | Деп. дизайна, 5 этаж | 02.12.2024 | Подтверждено | — |
Техническое задание
Схема БД на Prisma ORM и REST API эндпоинты
Prisma Schema · PostgreSQL
Полная схема реляционной базы данных корпоративной системы мотивации.
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum Role { EMPLOYEE ADMIN } enum BoxCategory { LIMITED BUNDLE FARM FIFTY_FIFTY } enum ItemTier { BASIC STANDARD EPIC LEGEND } enum AccountActionStatus { QUEUED APPROVED REJECTED } enum InventoryStatus { IN_INVENTORY RETURNED } model User { id Int @id @default(autoincrement()) name String email String @unique balance Int @default(0) // баланс в ПиАр Грэм role Role @default(EMPLOYEE) createdAt DateTime @default(now()) inventory EmployeeInventory[] accountActions AccountAction[] } model Box { id Int @id @default(autoincrement()) name String slug String @unique price Int // стоимость активации, ПГ oldPrice Int? // базовая цена для отображения скидки cycleLimit Int? // null = без лимита cyclesDone Int @default(0) category BoxCategory imageUrl String? isArchived Boolean @default(false) createdAt DateTime @default(now()) items BoxItem[] } model Item { id Int @id @default(autoincrement()) name String value Int // номинал в ПГ tier ItemTier imageUrl String? boxes BoxItem[] inventory EmployeeInventory[] } model BoxItem { id Int @id @default(autoincrement()) boxId Int itemId Int dropChance Float // % вероятности 0..100 box Box @relation(fields: [boxId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id]) @@unique([boxId, itemId]) } model AccountAction { id Int @id @default(autoincrement()) userId Int amount Int // сумма ПГ к списанию address String // текстовый адрес/реквизиты status AccountActionStatus @default(QUEUED) rejectReason String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) @@index([status]) @@index([userId]) } model EmployeeInventory { id Int @id @default(autoincrement()) userId Int itemId Int status InventoryStatus @default(IN_INVENTORY) obtainedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) item Item @relation(fields: [itemId], references: [id]) @@index([userId, status]) }
REST API · Эндпоинты
Express + Prisma · Реализация
// src/server.js import express from 'express'; import { PrismaClient } from '@prisma/client'; import { z } from 'zod'; const prisma = new PrismaClient(); const app = express(); app.use(express.json()); // === Валидация входных данных === const CreateBoxSchema = z.object({ name: z.string().min(2).max(80), slug: z.string().regex(/^[a-z0-9-]+$/), price: z.number().int().positive(), oldPrice: z.number().int().positive().optional(), cycleLimit: z.number().int().positive().nullable().optional(), category: z.enum(['LIMITED','BUNDLE','FARM','FIFTY_FIFTY']), imageUrl: z.string().url().optional(), items: z.array(z.object({ itemId: z.number().int().positive(), dropChance: z.number().min(0).max(100) })).min(1) }); // === POST /api/admin/boxes — создание набора === app.post('/api/admin/boxes', async (req, res) => { try { const data = CreateBoxSchema.parse(req.body); // Сумма шансов должна строго равняться 100% const sum = data.items.reduce((s, it) => s + it.dropChance, 0); if (Math.abs(sum - 100) > 0.001) { return res.status(400).json({ error: 'INVALID_CHANCES', message: `Сумма вероятностей = ${sum}%, должна быть 100%` }); } const box = await prisma.box.create({ data: { name: data.name, slug: data.slug, price: data.price, oldPrice: data.oldPrice, cycleLimit: data.cycleLimit, category: data.category, imageUrl: data.imageUrl, items: { create: data.items.map(it => ({ itemId: it.itemId, dropChance: it.dropChance })) } }, include: { items: { include: { item: true } } } }); res.status(201).json(box); } catch (e) { if (e.code === 'P2002') return res.status(409).json({ error: 'SLUG_TAKEN' }); res.status(400).json({ error: 'BAD_REQUEST', details: e.message }); } }); // === PATCH /api/admin/account-actions/:id/status === // Подтверждение или отклонение запроса на списание ПГ const UpdateStatusSchema = z.object({ status: z.enum(['APPROVED', 'REJECTED']), rejectReason: z.string().max(500).optional() }); app.patch('/api/admin/account-actions/:id/status', async (req, res) => { try { const id = Number(req.params.id); const { status, rejectReason } = UpdateStatusSchema.parse(req.body); const action = await prisma.accountAction.findUnique({ where: { id }, include: { user: true } }); if (!action) return res.status(404).json({ error: 'NOT_FOUND' }); if (action.status !== 'QUEUED') { return res.status(409).json({ error: 'ALREADY_PROCESSED' }); } // Атомарная транзакция: меняем статус + двигаем баланс const result = await prisma.$transaction(async (tx) => { if (status === 'APPROVED') { // Подтверждение — окончательное списание баллов if (action.user.balance < action.amount) { throw new Error('INSUFFICIENT_BALANCE'); } await tx.user.update({ where: { id: action.userId }, data: { balance: { decrement: action.amount } } }); } else { // Отклонение — возврат суммы на баланс сотрудника await tx.user.update({ where: { id: action.userId }, data: { balance: { increment: action.amount } } }); // TODO: запушить системное уведомление с rejectReason } return tx.accountAction.update({ where: { id }, data: { status, rejectReason: status === 'REJECTED' ? rejectReason : null } }); }); res.json(result); } catch (e) { if (e.message === 'INSUFFICIENT_BALANCE') return res.status(409).json({ error: 'INSUFFICIENT_BALANCE' }); res.status(400).json({ error: 'BAD_REQUEST', details: e.message }); } }); app.listen(3000, () => console.log('API on :3000'));