правки от claude

This commit is contained in:
Kayashov.SM
2026-08-17 01:43:52 +04:00
parent 5c0262fc13
commit d5571cf032
25 changed files with 533 additions and 149 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Deploy
# Ручной запуск из вкладки Actions в Gitea — как "Run pipeline" в GitLab
# с выбором, что именно катить: back / front / всё сразу.
on:
workflow_dispatch:
inputs:
target:
description: "Что деплоим"
type: choice
default: all
options:
- back
- front
- all
jobs:
build-backend:
if: ${{ inputs.target == 'back' || inputs.target == 'all' }}
runs-on: self-hosted
container:
image: docker:24-cli
outputs:
tag: ${{ steps.vars.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: vars
run: echo "tag=${GITEA_SHA}" >> "$GITEA_OUTPUT"
- name: Docker login
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USER }}" --password-stdin
- name: Build backend image
run: docker build -f deployment/charts/back/Dockerfile --target architector -t kayashov/architector:${{ steps.vars.outputs.tag }} .
- name: Push backend image
run: docker push kayashov/architector:${{ steps.vars.outputs.tag }}
deploy-backend:
if: ${{ inputs.target == 'back' || inputs.target == 'all' }}
needs: build-backend
runs-on: self-hosted
container:
image: alpine/helm:3.14.0
steps:
- uses: actions/checkout@v4
- name: Configure kubectl access
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config
- name: Helm upgrade backend
run: |
helm upgrade -i architector ./deployment/charts/back \
-f ./deployment/values.yaml \
--set global.version=${{ needs.build-backend.outputs.tag }} \
--namespace media
build-front:
if: ${{ inputs.target == 'front' || inputs.target == 'all' }}
runs-on: self-hosted
container:
image: node:20-bookworm
steps:
- uses: actions/checkout@v4
- name: npm ci && build
working-directory: front
run: |
npm ci
npm run build
# /front-build смонтирован в раннер как тот же hostPath, что
# front-под берёт под nginx (см. deployment/ci/runner-deployment.yaml).
# Новый билд подхватится сразу, без рестарта пода.
- name: Publish static files
run: |
rm -rf /front-build/*
cp -r front/build/* /front-build/
@@ -27,4 +27,6 @@ spec:
- name: DB_NAME
value: {{ .Values.global.datasource.login }}
- name: DB_PASSWORD
value: {{ .Values.global.datasource.pass }}
value: {{ .Values.global.datasource.pass }}
- name: CORS_ALLOWED_ORIGINS
value: {{ .Values.global.back.corsAllowedOrigins | default "*" | quote }}
+89
View File
@@ -0,0 +1,89 @@
# CI/CD: пуш → ручной деплой одной кнопкой
Схема: Gitea Actions + раннер (образ `gitea/runner` — старый `gitea/act_runner`
уже deprecated) внутри k3s. Пайплайн запускается вручную из вкладки **Actions**
репозитория (кнопка "Run workflow"), с выбором `target`: `back` / `front` /
`all` — аналог кнопки "Run pipeline" с переменной в GitLab.
Это первая рабочая версия. Она не протестирована на реальном кластере (я не
имею доступа к твоему серверу) — почти наверняка на первом запуске придётся
что-то поправить по логам, это нормально.
## Что нужно сделать один раз руками
### 1. Включить Actions в Gitea
Site Administration → Configuration → убедиться, что Actions включены
(`[actions] ENABLED = true` в app.ini, если ещё не включено — потребуется
перезапуск Gitea). Затем в самом репозитории: Settings → Actions → включить.
### 2. Развернуть раннера в кластере
```bash
# впиши токен регистрации (Site Administration → Actions → Runners →
# Create new Runner, или в настройках репозитория/организации)
echo -n "<токен>" | base64
# вставь результат вместо PLACEHOLDER_BASE64_TOKEN в runner-deployment.yaml,
# поправь GITEA_INSTANCE_URL на свой адрес
kubectl apply -f deployment/ci/namespace.yaml
kubectl apply -f deployment/ci/runner-rbac.yaml
kubectl apply -f deployment/ci/runner-deployment.yaml
```
Раннер должен появиться в Gitea (Site Administration → Actions → Runners) со
статусом Idle. Если нет — смотри `kubectl logs -n ci deploy/act-runner` (там же
будет видно, если, например, неверный `GITEA_INSTANCE_URL` или токен).
### 3. Собрать kubeconfig для пайплайна
После применения `runner-rbac.yaml` у нас есть токен ServiceAccount
`architector-ci` (лежит в namespace `ci`, но права у него через ClusterRole —
на весь кластер, не только на `media`, чтобы этим же раннером и токеном можно
было деплоить будущие проекты в других namespace'ах). Собираем из него
kubeconfig (с любой машины, у которой уже есть доступ к кластеру через свой
kubectl):
```bash
TOKEN=$(kubectl get secret architector-ci-token -n ci -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl get secret architector-ci-token -n ci -o jsonpath='{.data.ca\.crt}')
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
cat > architector-ci-kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
- name: k3s
cluster:
server: ${SERVER}
certificate-authority-data: ${CA}
contexts:
- name: architector-ci
context:
cluster: k3s
namespace: media
user: architector-ci
current-context: architector-ci
users:
- name: architector-ci
user:
token: ${TOKEN}
EOF
base64 -w0 architector-ci-kubeconfig.yaml # это значение пойдёт в секрет KUBE_CONFIG
```
### 4. Секреты пайплайна в Gitea
Repository → Settings → Actions → Secrets, добавить:
- `DOCKERHUB_USER`, `DOCKERHUB_TOKEN` — логин и access token Docker Hub
(Docker Hub → Account Settings → Security → New Access Token, не пароль).
- `KUBE_CONFIG` — вывод команды `base64 -w0` из шага 3.
### 5. Проверка
Repository → Actions → Deploy → Run workflow → выбрать `target` → Run.
Смотреть логи джобов там же.
## На будущее
Ты упоминал переезд фронта на PVC вместо hostPath — когда дойдём до этого,
`build-front` job поменяется на полноценную сборку Docker-образа (нужен будет
свой `deployment/charts/front/Dockerfile`) плюс `helm upgrade` для front-чарта,
аналогично backend. hostPath-подход в `runner-deployment.yaml` тогда тоже
уйдёт — обсудим отдельно, как ты и предлагал.
+6
View File
@@ -0,0 +1,6 @@
# Отдельный namespace для CI-инфраструктуры (раннер, его ServiceAccount и т.д.),
# чтобы не мешать в одну кучу с namespace приложений (media и будущими).
apiVersion: v1
kind: Namespace
metadata:
name: ci
+94
View File
@@ -0,0 +1,94 @@
# Раннер Gitea Actions внутри k3s.
#
# ВАЖНО (правки после первого неудачного запуска):
# - образ gitea/act_runner устарел, проект переехал в gitea/runner —
# используем его.
# - вместо связки "act_runner + отдельный docker:dind сайдкар с ручным
# command/wait-loop" (там был баг: путь /opt/act/run.sh в текущих сборках
# уже не существует) берём вариант "-dind" — это один образ, где раннер и
# docker-демон запущены вместе через s6 и сами разбираются с порядком
# старта. Меньше кастомной логики — меньше способов сломать.
#
# Добавлено относительно оригинала:
# - hostPath-том на тот же путь, что смонтирован в front-чарте
# (global.front.buildPath, сейчас /md0/services/architector/build) —
# чтобы шаг "собрать и выложить фронт" мог прямо записать build/ на диск ноды.
# ВАЖНО: это работает надёжно только если k3s однонодовый (раннер и
# front-под физически на одной машине). Если нод несколько — нужно
# закрепить оба пода на одной ноде через nodeSelector/affinity.
#
# Перед применением:
# 1. Впиши токен регистрации раннера в runner-secret.token (base64) —
# токен берётся в Gitea: Site Administration → Actions → Runners →
# Create new Runner (или в настройках конкретного репозитория/организации).
# 2. Проверь GITEA_INSTANCE_URL — адрес твоего Gitea, доступный изнутри кластера.
# 3. kubectl apply -f namespace.yaml -f runner-rbac.yaml -f runner-deployment.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: act-runner-vol
namespace: ci
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: Secret
metadata:
name: runner-secret
namespace: ci
type: Opaque
data:
# base64 от токена регистрации, например: echo -n "<токен>" | base64
token: VGM5ZzROeXpoNVM1TmxIWlVrMHlZZTdRVXh3Y1NuMzN5WG1va1ZFYQ==
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: act-runner
namespace: ci
labels:
app: act-runner
spec:
replicas: 1
selector:
matchLabels:
app: act-runner
template:
metadata:
labels:
app: act-runner
spec:
restartPolicy: Always
volumes:
- name: runner-data
persistentVolumeClaim:
claimName: act-runner-vol
- name: front-build
hostPath:
path: /md0/services/architector/build
type: DirectoryOrCreate
containers:
- name: runner
# -dind = раннер + docker-демон в одном образе (управляются s6).
# Можно закрепить конкретную версию вместо latest, если хочется
# стабильности между пересозданиями пода, например latest-dind -> 0.6-dind.
image: gitea/runner:latest-dind
securityContext:
privileged: true # нужно докер-демону внутри контейнера
env:
- name: GITEA_INSTANCE_URL
value: http://gitea.git.svc.cluster.local:3000 # поправь под свой адрес Gitea
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
name: runner-secret
key: token
volumeMounts:
- name: runner-data
mountPath: /data
- name: front-build
mountPath: /front-build
+50
View File
@@ -0,0 +1,50 @@
# Права, которыми будет пользоваться CI-пайплайн для деплоя через helm/kubectl.
# Сделано ClusterRole + ClusterRoleBinding (не ограничено одним namespace),
# т.к. раннер планируется переиспользовать под будущие проекты в других
# namespace'ах. Это по-прежнему НЕ cluster-admin — только конкретные
# ресурсы (Deployment/Service/ConfigMap/Secret/Pod), нужные helm upgrade.
#
# Применить один раз: kubectl apply -f namespace.yaml -f runner-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: architector-ci
namespace: ci
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: architector-ci-deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["services", "configmaps", "secrets", "pods", "namespaces"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: architector-ci-deployer-binding
subjects:
- kind: ServiceAccount
name: architector-ci
namespace: ci
roleRef:
kind: ClusterRole
name: architector-ci-deployer
apiGroup: rbac.authorization.k8s.io
---
# Начиная с Kubernetes 1.24 токены для ServiceAccount не создаются
# автоматически — этот Secret явно просит Kubernetes выпустить
# долгоживущий токен для architector-ci. Именно из него мы соберём
# kubeconfig для пайплайна (см. deployment/ci/README.md).
apiVersion: v1
kind: Secret
metadata:
name: architector-ci-token
namespace: ci
annotations:
kubernetes.io/service-account.name: architector-ci
type: kubernetes.io/service-account-token
-13
View File
@@ -3,7 +3,6 @@ import {BrowserRouter as Router, Route, Routes} from 'react-router-dom';
import YearList from './components/YearList';
import ChapterList from './components/ChapterList';
import OrderItemList from './components/orderItems/OrderItemList'; // новый компонент
import OrderBuilder from './components/OrderBuilder';
import StorePage from "./components/store/StorePage";
import OrderList from "./components/OrderList";
import Navigation from "./components/Navigation";
@@ -11,9 +10,6 @@ import Navigation from "./components/Navigation";
function App() {
const [selectedYear, setSelectedYear] = useState(null);
const [selectedChapter, setSelectedChapter] = useState(null);
const [selectedStore, setSelectedStore] = useState(null);
console.log(selectedYear);
return (
<Router>
@@ -59,15 +55,6 @@ function App() {
<StorePage/>
}
/>
<Route
path="/order-builder/:storeId"
element={
<OrderBuilder
storeId={selectedStore?.id}
chapterId={selectedChapter?.id}
/>
}
/>
</Routes>
</main>
</div>
+2 -2
View File
@@ -13,7 +13,7 @@ const ChapterList = ({year, onSelectChapter}) => {
let currentYearId;
if (!year) {
if(!paramYearId) {
if (paramYearId) {
currentYearId = paramYearId;
} else {
navigate('/', {replace: true});
@@ -29,7 +29,7 @@ const ChapterList = ({year, onSelectChapter}) => {
setLoading(true);
try {
const response = await chapterService.getByYear(currentYearId);
setChapters(response.data._embedded?.chapters || []);
setChapters(response.data || []);
} catch (error) {
console.error('Ошибка загрузки глав:', error);
} finally {
-63
View File
@@ -1,63 +0,0 @@
import React, {useEffect, useState} from 'react';
import { useOrders } from '../hooks/useOrders';
const OrderBuilder = ({ storeId, chapterId }) => {
const { activeOrder, loading, loadActiveOrder, addItemToOrder } = useOrders();
const [newItem, setNewItem] = useState({
name: '',
count: 1,
unitPrice: 0,
chapter: { href: `http://localhost:8080/api/chapters/${chapterId}` }
});
useEffect(() => {
loadActiveOrder(storeId);
}, [storeId]);
const handleAddItem = () => {
addItemToOrder(newItem);
setNewItem({ ...newItem, name: '', count: 1, unitPrice: 0 });
};
if (loading) return <div>Загрузка...</div>;
return (
<div>
<h3>Сборка заказа для магазина {storeId}</h3>
{activeOrder && (
<div>
<h4>Активный заказ #{activeOrder.id}</h4>
<ul>
{activeOrder.items.map(item => (
<li key={item.id}>
{item.name} - {item.count} шт. × {item.unitPrice} руб.
</li>
))}
</ul>
</div>
)}
<div>
<input
placeholder="Название товара"
value={newItem.name}
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
/>
<input
type="number"
placeholder="Количество"
value={newItem.count}
onChange={(e) => setNewItem({ ...newItem, count: parseInt(e.target.value) })}
/>
<input
type="number"
placeholder="Цена за единицу"
value={newItem.unitPrice}
onChange={(e) => setNewItem({ ...newItem, unitPrice: parseFloat(e.target.value) })}
/>
<button onClick={handleAddItem}>Добавить в заказ</button>
</div>
</div>
);
};
export default OrderBuilder;
+1 -1
View File
@@ -12,7 +12,7 @@ const YearList = ({ onSelectYear }) => {
setLoading(true);
try {
const response = await yearService.getAll();
setYears(response.data._embedded?.years || []);
setYears(response.data || []);
} catch (error) {
console.error('Ошибка загрузки лет:', error);
} finally {
@@ -3,8 +3,6 @@ import {useNavigate, useParams} from 'react-router-dom';
import {itemsService, orderItemService, storeService, targetService, unitService,} from '../../services/api';
import OrderItemsTable from './OrderItemsTable';
import SummarySection from './SummarySection';
import {c} from "react/compiler-runtime";
import {logDOM} from "@testing-library/dom";
const newItem = (currentChapterId) => {
return {
@@ -80,15 +78,14 @@ const OrderItemList = ({chapter}) => {
// Расчёт итоговых значений
const totalSum = orderItems.reduce((sum, item) => sum + (item.item?.unitPrice * item.count), 0);
const paidSum = orderItems
.filter(item => item.paid)
.reduce((sum, item) => sum + (item.item?.unitPrice * item.count), 0);
const paidSum = orderItems.reduce((sum, item) => sum + (item.paid || 0), 0);
const remainingSum = totalSum - paidSum;
// Обработчики событий
const startEditing = (item) => setEditingItem({...item});
const handleSave = async (item, isNew = false) => {
console.log(item)
try {
let savedItem;
if (!validate(item)) {
@@ -149,7 +146,7 @@ const OrderItemList = ({chapter}) => {
const toggleActive = async (item) => {
try {
const response = await orderItemService.toggle(item);
const response = await orderItemService.toggle(item.id);
loadData();
} catch (error) {
console.error('Ошибка обновления статуса оплаты:', error);
-39
View File
@@ -1,39 +0,0 @@
import { useState, useEffect } from 'react';
import {orderService, orderItemService, API_BASE} from '../services/api';
export const useOrders = () => {
const [activeOrder, setActiveOrder] = useState(null);
const [loading, setLoading] = useState(false);
const loadActiveOrder = async (storeId) => {
setLoading(true);
try {
const response = await orderService.getActiveByStore(storeId);
setActiveOrder(response.data._embedded?.orders?.[0] || null);
} catch (error) {
console.error('Error loading active order:', error);
} finally {
setLoading(false);
}
};
const addItemToOrder = async (itemData) => {
if (!activeOrder) return;
const item = {
...itemData,
orders: {
href: `${API_BASE}/orders/${activeOrder.id}`
}
};
try {
await orderItemService.add(item);
loadActiveOrder(activeOrder.store.id);
} catch (error) {
console.error('Error adding item to order:', error);
}
};
return { activeOrder, loading, loadActiveOrder, addItemToOrder };
};
+7 -6
View File
@@ -1,17 +1,19 @@
import axios from 'axios';
// export const API_BASE = 'http://localhost:8080';
export const API_BASE = 'https://architector.kayashov.keenetic.pro/api';
// Локально по умолчанию бэкенд на localhost:8080.
// Для прод-сборки задать REACT_APP_API_BASE в .env.production (например,
// https://architector.kayashov.keenetic.pro/api).
export const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:8080';
// API для Years
export const yearService = {
getAll: () => axios.get(`${API_BASE}/years`),
getAll: () => axios.get(`${API_BASE}/year`),
create: (year) => axios.post(`${API_BASE}/years`, year),
};
// API для Chapters
export const chapterService = {
getByYear: (yearId) => axios.get(`${API_BASE}/chapters/search/findByYearId?yearId=${yearId}`),
getByYear: (yearId) => axios.get(`${API_BASE}/chapter/${yearId}`),
create: (chapter) => axios.post(`${API_BASE}/chapters`, chapter),
};
@@ -30,7 +32,6 @@ export const orderItemService = {
update: (item) => axios.put(`${API_BASE}/orderItem`, item),
delete: (id) => axios.delete(`${API_BASE}/orderItems/${id}`),
toggle: (id) => axios.patch(`${API_BASE}/orderItem/${id}`),
getByStore: (storeId) => axios.get(`/api/stores/${storeId}/orders`),
// другие методы...
};
@@ -39,7 +40,7 @@ export const unitService = {
}
export const targetService = {
getAll: () => axios.get(`${API_BASE}/targets`),
getAll: () => axios.get(`${API_BASE}/targets?size=500`),
}
export const itemsService = {
+18
View File
@@ -0,0 +1,18 @@
export const OrderItemDTO = {
id: null, // ID записи OrderItem
count: 1,
paidAmount: 0,
chapter: { id: null }, // Ссылка на главу
item: {
id: null, // ID товара (Items)
name: '',
unitPrice: 0,
link: '',
unit: {
id: null,
name: ''
}
},
target: { id: null, name: '' },
store: { id: null, name: '' }
};
@@ -1,21 +1,46 @@
package ru.kayashov.architector.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
/**
* Разрешённые origin настраиваются через свойство app.cors.allowed-origins
* (или переменную окружения CORS_ALLOWED_ORIGINS).
* По умолчанию (локальная разработка) — "*", т.е. разрешено всё, как и раньше.
* В проде задаётся конкретный домен фронта, например:
* CORS_ALLOWED_ORIGINS=https://architector.kayashov.keenetic.pro
* (несколько значений можно перечислить через запятую)
*/
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins:*}")
private String allowedOrigins;
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// config.setAllowCredentials(true);
config.addAllowedOrigin("*");
String origins = allowedOrigins == null ? "*" : allowedOrigins.trim();
if (origins.isEmpty() || origins.equals("*")) {
// Локальный дев-режим: разрешаем всё, credentials не нужны
config.addAllowedOriginPattern("*");
} else {
Arrays.stream(origins.split(","))
.map(String::trim)
.filter(o -> !o.isEmpty())
.forEach(config::addAllowedOrigin);
config.setAllowCredentials(true);
}
config.addAllowedHeader("*");
config.addAllowedMethod("*");
@@ -0,0 +1,37 @@
package ru.kayashov.architector.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.kayashov.architector.model.Chapter;
import ru.kayashov.architector.model.Orders;
import ru.kayashov.architector.repository.ChapterRepository;
import java.util.List;
@RestController
@RequestMapping("chapter")
@RequiredArgsConstructor
public class ChapterController {
private final ChapterRepository chapterRepository;
// readOnly = true запрещает Hibernate автоматически сбрасывать (flush)
// изменённые в памяти сущности в БД — GET не должен иметь побочных эффектов
@Transactional(readOnly = true)
@GetMapping("/{yearId}")
public List<Chapter> getByYear(@PathVariable Long yearId) {
List<Chapter> chapters = chapterRepository.findByYearId(yearId);
for (Chapter chapter : chapters) {
Float sum = (float) chapter.getItems().stream()
.filter(oi -> oi.getOrders().getStatus() == Orders.OrderStatus.COMPLETED)
.mapToDouble(oi -> oi.getCount() * oi.getItem().getUnitPrice())
.sum();
chapter.setCost(sum);
}
return chapters;
}
}
@@ -0,0 +1,51 @@
package ru.kayashov.architector.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
/**
* Единая обработка ошибок для REST-контроллеров.
* Без этого orElseThrow()/IllegalStateException долетали до клиента как голый 500.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Object> handleNotFound(NoSuchElementException ex) {
return build(HttpStatus.NOT_FOUND, "Запрашиваемый объект не найден");
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<Object> handleConflict(IllegalStateException ex) {
return build(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleBadRequest(IllegalArgumentException ex) {
return build(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleGeneric(Exception ex) {
log.error("Необработанная ошибка", ex);
return build(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера");
}
private ResponseEntity<Object> build(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("status", status.value());
body.put("error", status.getReasonPhrase());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}
@@ -1,5 +1,6 @@
package ru.kayashov.architector.controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -19,6 +20,7 @@ public class OrderController {
this.ordersRepository = ordersRepository;
}
@Transactional
@PatchMapping("order")
public Orders updateOrder(@RequestParam String status, @RequestParam Long id) {
Orders order = ordersRepository.findById(id).orElseThrow();
@@ -35,22 +37,28 @@ public class OrderController {
return ordersRepository.save(order);
}
@Transactional
@PutMapping("order")
public Orders pay(@RequestParam Float count, @RequestParam Long id) {
Orders order = ordersRepository.findById(id).orElseThrow();
Float sum = order.getItems().stream().map(o -> o.getCount() * o.getItem().getUnitPrice()).reduce(0F, Float::sum);
if(count < sum) {
order.setPaid(count);
// count — это вносимая сумма, её нужно прибавлять к уже оплаченному,
// а не перезаписывать order.paid целиком
float alreadyPaid = order.getPaid() != null ? order.getPaid() : 0F;
float newPaid = alreadyPaid + count;
order.setUpdatedAt(LocalDateTime.now());
if (newPaid < sum) {
order.setPaid(newPaid);
order.setStatus(OrderStatus.PREPAYED);
order.setUpdatedAt(LocalDateTime.now());
return ordersRepository.save(order);
} else {
// не даём paid уйти выше полной стоимости заказа
order.setPaid(sum);
order.setStatus(OrderStatus.COMPLETED);
}
order.setPaid(sum);
order.setUpdatedAt(LocalDateTime.now());
order.setStatus(OrderStatus.COMPLETED);
return ordersRepository.save(order);
}
}
@@ -2,6 +2,7 @@ package ru.kayashov.architector.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -33,6 +34,7 @@ import java.time.LocalDateTime;
@Slf4j
@RestController
@RequiredArgsConstructor
@Transactional
public class OrderItemController {
private final UnitsRepository unitsRepository;
@@ -82,7 +84,7 @@ public class OrderItemController {
OrderItemResponse itemResponse = update(dto, chapter);
total += item.getItem().getUnitPrice() * item.getCount();
total += itemResponse.getItem().getUnitPrice() * itemResponse.getCount();
totalYear += total;
chapter.setCost(total);
@@ -127,7 +129,7 @@ public class OrderItemController {
Units units = unitsRepository.findByNameIgnoreCase(dto.getItem().getUnit().getName())
.orElseGet(() -> unitsRepository.save(new Units(dto.getItem().getUnit().getName())));
Items items = itemsRepository.findByNameIgnoreCaseAndStoreIdAndUnitId(dto.getItem().getName(), store.getId(), units.getId())
Items items = itemsRepository.findByNameIgnoreCaseAndStoreIdAndUnitIdAndUnitPrice(dto.getItem().getName(), store.getId(), units.getId(), dto.getItem().getUnitPrice())
.orElseGet(() -> itemsRepository.save(Items.builder()
.name(dto.getItem().getName())
.link(dto.getItem().getLink())
@@ -0,0 +1,35 @@
package ru.kayashov.architector.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.kayashov.architector.model.Chapter;
import ru.kayashov.architector.model.Year;
import ru.kayashov.architector.repository.YearRepository;
import java.util.List;
@RestController
@RequestMapping("/year")
@RequiredArgsConstructor
public class YearController {
private final YearRepository yearRepository;
// readOnly = true запрещает Hibernate автоматически сбрасывать (flush)
// изменённые в памяти сущности в БД — GET не должен иметь побочных эффектов
@Transactional(readOnly = true)
@GetMapping
public List<Year> getAll() {
List<Year> years = yearRepository.findAll();
for (Year year : years) {
float totalCost = (float) year.getChapters().stream()
.mapToDouble(Chapter::getCost)
.sum();
year.setTotalCost(totalCost);
}
return years;
}
}
@@ -9,5 +9,5 @@ import java.util.Optional;
@RepositoryRestResource(excerptProjection = ItemsProjection.class)
public interface ItemsRepository extends JpaRepository<Items, Long> {
Optional<Items> findByNameIgnoreCaseAndStoreIdAndUnitId(String name, Long storeId, Integer unitId);
Optional<Items> findByNameIgnoreCaseAndStoreIdAndUnitIdAndUnitPrice(String name, Long storeId, Integer unitId, float price);
}
@@ -7,6 +7,6 @@ import ru.kayashov.architector.model.Units;
import java.util.Optional;
@RepositoryRestResource
public interface UnitsRepository extends JpaRepository<Units, Long> {
public interface UnitsRepository extends JpaRepository<Units, Integer> {
Optional<Units> findByNameIgnoreCase(String name);
}
@@ -6,6 +6,5 @@ import ru.kayashov.architector.model.Year;
@RepositoryRestResource
public interface YearRepository extends JpaRepository<Year, Long> {
}
@@ -1,13 +1,13 @@
package ru.kayashov.architector.repository.projection;
import org.springframework.data.rest.core.config.Projection;
import ru.kayashov.architector.model.OrderItem;
import ru.kayashov.architector.model.Orders;
import ru.kayashov.architector.model.Store;
import java.time.LocalDateTime;
import java.util.List;
@Projection(name = "orderProjection", types = OrderItem.class)
@Projection(name = "orderProjection", types = Orders.class)
public interface OrderProjection {
Long getId();
+5 -1
View File
@@ -5,4 +5,8 @@ spring.datasource.username=${DB_NAME:architector}
spring.datasource.password=${DB_PASS:kayash73}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.generate-ddl=true
#spring.jpa.show-sql=true
#spring.jpa.show-sql=true
# "*" для локальной разработки. В проде переопределить через переменную окружения:
# CORS_ALLOWED_ORIGINS=https://architector.kayashov.keenetic.pro
app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}