Ревью backend-кода: архитектура и безопасность
Проводит ревью серверного кода: слои и границы модулей, работа с БД и транзакциями, обработка ошибок, инъекции и утечки данных — замечания ранжированы, правки «до/после».
Пример запуска
Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.
Значения переменных
- Язык и фреймворк
- Node.js 20, Express 4, TypeORM 0.3 (PostgreSQL 14)
- Описание сервиса
- Внутренний сервис заказов для админ-панели интернет-магазина «КофеШоп». Используется сотрудниками поддержки и складом. Основные функции: - создание заказа менеджером по телефонному звонку клиента (POST /orders): принимает список товаров с количеством, email клиента, промокод; - применение промокода к уже созданному заказу (POST /orders/:id/discount); - получение заказа по id для оператора поддержки (GET /orders/:id) — должен видеть только заказы, привязанные к его региону/складу; - обновление статуса заказа складом (PATCH /orders/:id/status) — переходы new → paid → shipped → delivered, а также cancelled. При создании заказа сервис должен одновременно: посчитать сумму, списать товар со склада (таблица inventory) и создать запись в orders + order_items. Если списание склада не удалось (товара не хватило), заказ создаваться не должен. Аутентификация — JWT, в токене есть userId и role (support/warehouse/admin). Права по ролям: support может читать заказы и применять промокоды, warehouse — менять статус, admin — всё. База — PostgreSQL, таблицы: orders(id, user_id, region_id, status, total, created_at), order_items(id, order_id, product_id, qty, price), inventory(product_id, region_id, qty), discounts(code, percent, region_id, active). Нагрузка небольшая: ~200 заказов в день, но недавно были жалобы, что один оператор поддержки смог открыть чужой заказ по прямой ссылке, и что при одновременном оформлении двух заказов на последний товар склад ушёл в минус.
- Код
- // routes/orders.js const express = require('express'); const router = express.Router(); const { AppDataSource } = require('../data-source'); const jwt = require('jsonwebtoken'); router.post('/orders', async (req, res) => { try { const { email, items, promoCode } = req.body; const orderRepo = AppDataSource.getRepository('Order'); const itemRepo = AppDataSource.getRepository('OrderItem'); const invRepo = AppDataSource.getRepository('Inventory'); let total = 0; const order = await orderRepo.save({ userId: req.user.userId, regionId: req.user.regionId, status: 'new', total: 0 }); for (const it of items) { const inv = await invRepo.findOneBy({ productId: it.productId, regionId: req.user.regionId }); if (!inv || inv.qty < it.qty) { console.log('not enough stock for', it, 'user email', email); return res.status(400).json({ error: 'no stock' }); } inv.qty -= it.qty; await invRepo.save(inv); await itemRepo.save({ orderId: order.id, productId: it.productId, qty: it.qty, price: it.price }); total += it.price * it.qty; } if (promoCode) { const discount = await AppDataSource.query( `SELECT * FROM discounts WHERE code = '${promoCode}' AND region_id = ${req.user.regionId}` ); if (discount.length && discount[0].active) { total = total * (1 - discount[0].percent / 100); } } order.total = total; await orderRepo.save(order); res.json(order); } catch (e) { console.log('order create failed'); } }); router.get('/orders/:id', async (req, res) => { const orderRepo = AppDataSource.getRepository('Order'); const order = await orderRepo.findOneBy({ id: req.params.id }); const items = []; const itemRepo = AppDataSource.getRepository('OrderItem'); const itemIds = await itemRepo.find({ where: { orderId: order.id } }); for (const it of itemIds) { const product = await AppDataSource.getRepository('Product').findOneBy({ id: it.productId }); items.push({ ...it, productName: product.name }); } res.json({ ...order, items }); }); router.patch('/orders/:id/status', async (req, res) => { const orderRepo = AppDataSource.getRepository('Order'); const order = await orderRepo.findOneBy({ id: req.params.id }); order.status = req.body.status; await orderRepo.save(order); res.json(order); }); module.exports = router;
Ревью routes/orders.js
1. Резюме
Код в текущем виде нельзя выпускать. В нём есть SQL-инъекция, нет проверки ролей и региона, нет транзакции при создании заказа, а цену товара задаёт клиент. Обе жалобы из контекста объясняются этим кодом:
- чужой заказ по ссылке:
GET /orders/:idне фильтрует по региону; - минус на складе: списание сделано как неатомарный read-modify-write без транзакции.
Главный риск — целостность денег и склада. Можно создать заказ с любой ценой, «отрицательным» количеством (это пополняет склад) и оставить в базе полусписанные заказы.
2. Замечания
🔴 Критично
К1. SQL-инъекция в промокоде
Место: POST /orders, запрос к discounts.
Чем грозит: промокод ' OR 1=1 -- вернёт первую попавшуюся активную скидку, в том числе 100% из чужого региона. Через UNION SELECT можно читать любые таблицы, к которым есть доступ у роли приложения.
До:
const discount = await AppDataSource.query(
`SELECT * FROM discounts WHERE code = '${promoCode}' AND region_id = ${req.user.regionId}`
);
if (discount.length && discount[0].active) { ... }
После:
const [discount] = await manager.query(
`SELECT code, percent FROM discounts
WHERE code = $1 AND region_id = $2 AND active = true`,
[promoCode, regionId]
);
Заодно условие active перенесено в SQL, а SELECT * заменён на явный список колонок.
К2. Нет авторизации по ролям и нет региональной изоляции (IDOR)
Место: все три маршрута.
Чем грозит:
- GET: любой аутентифицированный пользователь читает любой заказ, перебирая
id. Это та самая жалоба. - PATCH:
supportможет менять статус, хотя по требованиям не должен; склад одного региона меняет заказы другого. - POST: по требованиям заказы создаёт «менеджер», но какая роль ему соответствует, не определено.
jwtимпортирован, но не используется. Откуда берётсяreq.userи проверяется ли подпись, по коду не видно (см. блок вопросов).
После: middleware плюс фильтр по региону прямо в запросе.
// middleware/auth.js
const requireRole = (...roles) => (req, res, next) =>
roles.includes(req.user?.role) ? next() : res.status(403).json({ error: 'forbidden' });
// scope: admin видит всё, остальные — только свой регион
const regionScope = (user) => (user.role === 'admin' ? {} : { regionId: user.regionId });
router.get('/orders/:id', requireRole('support', 'admin'), asyncHandler(async (req, res) => {
const id = parseId(req.params.id);
const order = await orderRepo.findOneBy({ id, ...regionScope(req.user) });
if (!order) return res.status(404).json({ error: 'order_not_found' }); // 404, а не 403 — не раскрываем существование
...
}));
router.patch('/orders/:id/status', requireRole('warehouse', 'admin'), ...);
К3. Создание заказа без транзакции и гонка на складе
Место: POST /orders, цикл по items.
Чем грозит:
- Гонка. Два запроса одновременно читают
qty = 1, оба проходят проверкуinv.qty < it.qtyи оба сохраняют0. Товар продан дважды. Если количества разные,saveперезаписывает остаток абсолютным значением, и склад уходит в минус или «теряет» списания. - Частичная запись. Заказ вставляется до проверки склада. Если не хватило третьего товара:
- в
ordersостаётся «мусорный» заказ со статусомnewиtotal = 0; - первые два товара уже списаны и не возвращены;
- их
order_itemsзаписаны. Прямо нарушено требование «если списание не удалось, заказ создаваться не должен».
- в
- Падение посередине (обрыв соединения, исключение) даёт то же самое.
После: одна транзакция и пессимистичная блокировка строк склада. Строки блокируются в стабильном порядке, чтобы избежать дедлоков. Полная версия хендлера — в конце ответа, ключевой фрагмент такой:
await AppDataSource.transaction(async (manager) => {
for (const it of sortedItems) {
const inv = await manager.getRepository('Inventory').findOne({
where: { productId: it.productId, regionId },
lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
});
if (!inv || inv.qty < it.qty) throw new OutOfStockError(it.productId);
inv.qty -= it.qty;
await manager.save('Inventory', inv);
}
// ...только после этого вставляем orders и order_items
});
В качестве страховки на уровне БД добавьте ограничение:
ALTER TABLE inventory ADD CONSTRAINT inventory_qty_non_negative CHECK (qty >= 0);
К4. Цена товара берётся из тела запроса
Место: price: it.price, total += it.price * it.qty.
Чем грозит: оператор (или любой, у кого есть токен) создаёт заказ на любую сумму: price: 0.01 или отрицательную цену. Это прямая финансовая дыра.
После: цена читается из БД внутри той же транзакции и фиксируется в order_items.price как снимок на момент заказа.
const product = await manager.getRepository('Product').findOneBy({ id: it.productId });
if (!product) throw new ValidationError(`unknown product ${it.productId}`);
const price = product.price; // см. вопрос о том, где хранится цена
К5. Отрицательное или дробное qty и отсутствие валидации входа
Место: POST /orders, PATCH /orders/:id/status.
Чем грозит:
qty: -100проходит проверкуinv.qty < it.qty(например,5 < -100ложно). Строкаinv.qty -= -100пополняет склад на 100, аtotalстановится отрицательным.itemsне массив или отсутствует —TypeError, запрос виснет (см. К6).- В
PATCHможно записать любую строку вstatus, например"hacked", или сделатьdelivered → new. - Дубли
productIdвitemsобрабатываются как отдельные позиции. После исправления К3 это безопасно, но лучше агрегировать.
После: валидация на входе до бизнес-логики, например через zod.
const CreateOrderSchema = z.object({
email: z.string().email(),
promoCode: z.string().trim().max(64).optional(),
items: z.array(z.object({
productId: z.number().int().positive(),
qty: z.number().int().positive().max(1000),
})).min(1).max(100),
}).strict(); // price больше не принимаем вообще
const parsed = CreateOrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'validation', details: parsed.error.flatten() });
Для статусов нужна явная машина переходов и атомарная смена статуса:
const TRANSITIONS = {
new: ['paid', 'cancelled'],
paid: ['shipped', 'cancelled'],
shipped: ['delivered'],
delivered: [],
cancelled: [],
};
router.patch('/orders/:id/status', requireRole('warehouse', 'admin'), asyncHandler(async (req, res) => {
const id = parseId(req.params.id);
const { status: next } = StatusSchema.parse(req.body); // z.enum([...])
const result = await AppDataSource.transaction(async (manager) => {
const order = await manager.getRepository('Order').findOne({
where: { id, ...regionScope(req.user) },
lock: { mode: 'pessimistic_write' },
});
if (!order) return { code: 404, body: { error: 'order_not_found' } };
if (!TRANSITIONS[order.status].includes(next)) {
return { code: 409, body: { error: 'invalid_transition', from: order.status, to: next } };
}
order.status = next;
await manager.save('Order', order);
// TODO: при cancelled — вернуть товар на склад? (см. вопросы)
return { code: 200, body: order };
});
res.status(result.code).json(result.body);
}));
К6. Проглоченные исключения и зависающие запросы
Место:
catch (e) { console.log('order create failed'); }в POST: ответ не отправляется, клиент ждёт до таймаута, текст ошибки и стек теряются.- GET и PATCH вообще без обработки. Express 4 не ловит rejected promise из async-хендлера, поэтому:
order === null(несуществующий id) даётTypeError: Cannot read properties of null;- возникает
unhandledRejection, и запрос висит; - в Node 20 по умолчанию процесс падает, если нет обработчика
unhandledRejection. То есть запрос по несуществующему id может уронить сервис. Это тривиальный DoS.
- То же происходит, если
productудалён:product.nameнаnull.
После:
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// app.js — единый обработчик
app.use((err, req, res, next) => {
if (err instanceof OutOfStockError) return res.status(409).json({ error: 'out_of_stock', productId: err.productId });
if (err instanceof ValidationError) return res.status(400).json({ error: 'validation', message: err.message });
req.log.error({ err, orderId: req.params?.id }, 'unhandled error'); // стек + контекст, без PII
res.status(500).json({ error: 'internal' });
});
Вместо обёртки можно подключить пакет express-async-errors.
🟠 Важно
В1. PII в логах
Место: console.log('not enough stock for', it, 'user email', email).
Чем грозит: email клиента попадает в лог-агрегатор, где хранится дольше и доступен шире, чем БД. Для диагностики нехватки товара email не нужен.
После:
logger.warn({ productId: it.productId, requested: it.qty, available: inv?.qty, regionId }, 'out of stock');
В2. Email клиента принимается, но нигде не сохраняется
В схеме orders нет колонки для email или клиента, а user_id заполняется req.user.userId, то есть id менеджера, а не клиента. Непонятно, как потом связать заказ с покупателем. Это вопрос к схеме (см. блок вопросов). Если email нужен, его стоит хранить, но не возвращать в ответах шире, чем требуется.
В3. regionId в токене может отсутствовать
По описанию в JWT есть только userId и role, а код использует req.user.regionId. Если его там нет:
- заказы создаются с
region_id = NULL; - в старом SQL получалось
region_id = undefined, то есть синтаксическая ошибка; - региональная изоляция из К2 молча превращается в фильтр
regionId: undefined. TypeORM 0.3 игнорируетundefinedвwhere, и фильтр пропадёт. Это ещё один путь к IDOR.
Правка: явно проверять наличие региона в auth-middleware и отвечать 401/403, если у не-админа его нет:
if (req.user.role !== 'admin' && !Number.isInteger(req.user.regionId)) {
return res.status(403).json({ error: 'no_region' });
}
В4. Деньги в float
Место: total = total * (1 - percent / 100).
Чем грозит: 19.99 * 3 * 0.85 даёт 50.974499999.... Копейки «плывут», и суммы в отчётах не сходятся.
После: считать в целых копейках и округлять один раз, явно. Колонки total и price должны быть numeric(12,2) или integer в копейках, но не real/double (см. вопросы).
const subtotalKop = lines.reduce((s, l) => s + l.priceKop * l.qty, 0);
const totalKop = discount ? Math.round(subtotalKop * (100 - discount.percent) / 100) : subtotalKop;
Добавьте в БД CHECK (percent BETWEEN 0 AND 100) на discounts.
В5. Эндпоинт POST /orders/:id/discount отсутствует
В описании он есть, в коде нет. Когда будете его писать:
- проверка роли
supportилиadminи региона заказа; - запрет повторного применения: колонка
discount_codeвordersи условиеWHERE discount_code IS NULL; - применение только к заказам в статусе
new; - пересчёт от исходной суммы, а не от уже сниженного
total; - транзакция с блокировкой строки заказа.
Иначе промокод можно применить N раз подряд и довести сумму почти до нуля.
В6. Ответ POST возвращает сырую сущность
res.json(order) отдаёт всё, что есть в entity, включая будущие служебные поля. Лучше явный DTO: { id, status, total, items, createdAt }. То же в GET: { ...order } плюс { ...it } отдаёт orderId и все внутренние поля.
🟡 Желательно
Ж1. N+1 в GET /orders/:id
Для заказа из N позиций выполняется 2+N запросов. При 200 заказах в день это не проблема производительности, но и один запрос не сложнее:
const items = await AppDataSource.query(
`SELECT oi.id, oi.product_id AS "productId", oi.qty, oi.price, p.name AS "productName"
FROM order_items oi
LEFT JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = $1`,
[order.id]
);
LEFT JOIN нужен, чтобы удалённый товар не ронял ответ. Альтернатива — relation OrderItem → Product и relations: { items: { product: true } }.
Ж2. Индексы под реальные выборки
Проверьте наличие (схему нужно запросить):
CREATE INDEX IF NOT EXISTS order_items_order_id_idx ON order_items(order_id);
-- inventory: PK или UNIQUE (product_id, region_id) — нужен и для корректности, и для FOR UPDATE по точке
ALTER TABLE inventory ADD PRIMARY KEY (product_id, region_id); -- если ещё нет
CREATE UNIQUE INDEX IF NOT EXISTS discounts_code_region_uq ON discounts(code, region_id);
CREATE INDEX IF NOT EXISTS orders_region_created_idx ON orders(region_id, created_at DESC); -- под списки оператора
Ж3. Бизнес-логика в роутере
Расчёт суммы, списание склада, машина статусов и промокоды лежат в хендлере. Предлагаемая раскладка:
routes/orders.js— только разбор запроса, валидация и маппинг ошибок в HTTP;services/orderService.js—createOrder,changeStatus,applyDiscount(транзакции живут здесь);domain/orderStatus.js—TRANSITIONS.
Это упрощает тесты: гонку склада можно проверить интеграционным тестом на сервисе без HTTP.
Ж4. Мелочи
- Неиспользуемый
require('jsonwebtoken')удалить. - Репозитории по строковому имени (
getRepository('Order')) лучше заменить на импорт entity-класса: так опечатки ловятся сразу, а не в рантайме. req.params.idнужно валидировать как int или uuid. СейчасGET /orders/abcдаёт ошибку PostgreSQLinvalid input syntax, то есть 500 вместо 400/404.- Идемпотентность
POST /orders: менеджер может нажать «Создать» дважды при медленной сети. Если это реальная проблема, стоит добавить заголовокIdempotency-Keyс уникальным индексом.
Итоговый вариант POST /orders (К1, К3, К4, К5, К6, В1, В4)
router.post('/orders', requireRole('support', 'admin' /* уточнить роль менеджера */),
asyncHandler(async (req, res) => {
const { email, items, promoCode } = CreateOrderSchema.parse(req.body);
const regionId = req.user.regionId;
// агрегируем дубли и сортируем — стабильный порядок блокировок, без дедлоков
const merged = [...items.reduce((m, it) => m.set(it.productId, (m.get(it.productId) ?? 0) + it.qty), new Map())]
.map(([productId, qty]) => ({ productId, qty }))
.sort((a, b) => a.productId - b.productId);
const order = await AppDataSource.transaction(async (manager) => {
const lines = [];
for (const it of merged) {
const inv = await manager.getRepository('Inventory').findOne({
where: { productId: it.productId, regionId },
lock: { mode: 'pessimistic_write' },
});
if (!inv || inv.qty < it.qty) throw new OutOfStockError(it.productId);
const product = await manager.getRepository('Product').findOneBy({ id: it.productId });
if (!product) throw new ValidationError(`unknown product ${it.productId}`);
inv.qty -= it.qty;
await manager.save('Inventory', inv);
lines.push({ productId: it.productId, qty: it.qty, priceKop: product.priceKop });
}
const subtotal = lines.reduce((s, l) => s + l.priceKop * l.qty, 0);
let total = subtotal;
if (promoCode) {
const [d] = await manager.query(
`SELECT percent FROM discounts WHERE code = $1 AND region_id = $2 AND active = true`,
[promoCode, regionId]
);
if (!d) throw new ValidationError('invalid_promo'); // явная ошибка лучше молчаливого игнора
total = Math.round(subtotal * (100 - d.percent) / 100);
}
const saved = await manager.save('Order', {
userId: req.user.userId, regionId, status: 'new', total, /* customerEmail: email — см. вопросы */
});
await manager.save('OrderItem', lines.map((l) => ({
orderId: saved.id, productId: l.productId, qty: l.qty, price: l.priceKop,
})));
return { id: saved.id, status: saved.status, total, items: lines };
});
res.status(201).json(order);
}));
Если любой шаг бросает исключение, транзакция откатывается целиком: ни заказа, ни списаний не остаётся.
Как поступать с неверным промокодом — ошибка или тихое игнорирование — продуктовое решение. Сейчас он молча игнорируется, и менеджер не узнаёт, что скидка не применилась.
3. Что сделано хорошо
- Регион берётся из токена, а не из тела запроса. Правильная идея: клиент не может подставить чужой регион. Осталось применять её последовательно (GET, PATCH) и гарантировать наличие
regionIdв токене. - Промокод фильтруется по региону. Бизнес-правило учтено. Нужно лишь параметризовать запрос и перенести
activeв SQL. - Код короткий и линейный. В нём легко увидеть поток данных, поэтому перенос в сервисный слой с транзакцией — механическая работа, а не переписывание.
Что запросить для окончательного вердикта
- Auth-middleware. Где проверяется подпись JWT (алгоритм, секрет,
exp)? Откуда берётсяreq.user.regionId, если в токене по описанию толькоuserIdиrole? - DDL таблиц. Типы
total,price,percent(numeric, integer или float), существующие PK, UNIQUE и индексы (особенноinventory(product_id, region_id)), наличие FKorder_items.order_id → orders.id. - Где хранится цена товара. В схеме
productsне упомянута, хотя код её использует. Есть ли тамprice, и в каких единицах? - Кто такой
orders.user_id: менеджер, создавший заказ, или клиент? Где должен храниться email клиента? - Какая роль создаёт заказы (
support,adminили отдельнаяmanager)? Видит лиadminвсе регионы? - Отмена заказа. Должен ли переход в
cancelledвозвращать товар на склад, и из каких статусов отмена разрешена (например, послеshipped)? - Код
POST /orders/:id/discount. Он заявлен, но не приложен, а риск повторного применения скидки там высокий. - Уровень изоляции транзакций и настройки пула в
data-source.js:extra.max,statement_timeout,idle_in_transaction_session_timeout. Последний важен, чтобы зависшая транзакция не держала блокировки склада.
Похожие промпты
Ревью pull request по diff
Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.
Ревью Python-кода: типизация и идиомы
Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».
Ревью SQL-запросов и миграций
Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».