add missed files
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
target/
|
||||
.git/
|
||||
.idea/
|
||||
*.iml
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.log
|
||||
.env
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Email settings
|
||||
EMAIL_FROM=m.ryabyh@gmail.com
|
||||
EMAIL_TOKEN=your_app_password_here
|
||||
EMAIL_TO=m.ryabyh@gmail.com,AAAtapina@sberbank.ru,Oreshkina.V.V@sberbank.ru,MEvRyabykh@sberbank.ru
|
||||
EMAIL_DAYS=1
|
||||
SPECIAL_EMAIL=m.ryabyh+torgi@gmail.com
|
||||
ENABLE_EMAIL_SCAN=false
|
||||
|
||||
# Database settings
|
||||
DB_URL=jdbc:postgresql://localhost:5432/torgi
|
||||
DB_USERNAME=torgi
|
||||
DB_PASSWORD=your_db_password_here
|
||||
44
.gitea/workflows/cd.yaml
Normal file
44
.gitea/workflows/cd.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.rmevs.vip
|
||||
BACKEND_IMAGE: gitea.rmevs.vip/rm/torgi2-backend
|
||||
FRONTEND_IMAGE: gitea.rmevs.vip/rm/torgi2-frontend
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea registry
|
||||
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
run: docker build -t "$BACKEND_IMAGE:latest" -f Dockerfile . && docker push "$BACKEND_IMAGE:latest"
|
||||
|
||||
- name: Build and push frontend
|
||||
run: docker build -t "$FRONTEND_IMAGE:latest" ./frontend && docker push "$FRONTEND_IMAGE:latest"
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to prod over SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
DEPLOY_DIR: ${{ secrets.SSH_DEPLOY_DIR }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o BatchMode=yes "$SSH_USER@$SSH_HOST" \
|
||||
"cd $DEPLOY_DIR && git pull --ff-only && docker compose pull && docker compose up -d && docker image prune -f"
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Этап 1: сборка React-приложения.
|
||||
# VITE_API_URL не задаем: фронт ходит на относительный /api,
|
||||
# который этот же nginx проксирует в бэкенд (см. nginx.conf).
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
# Этап 2: раздача статики + прокси API.
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
28
frontend/nginx.conf
Normal file
28
frontend/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# API бэкенда (Spring Boot в compose-сервисе client-backend)
|
||||
location /api/ {
|
||||
proxy_pass http://client-backend:8181;
|
||||
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;
|
||||
}
|
||||
|
||||
# Swagger UI бэкенда (удобно на первое время, потом можно закрыть)
|
||||
location /swagger-ui/ {
|
||||
proxy_pass http://client-backend:8181;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location /v3/api-docs {
|
||||
proxy_pass http://client-backend:8181;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Статика React SPA
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
1792
frontend/package-lock.json
generated
Normal file
1792
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
src/main/java/com/pledge/torgi/config/WebConfig.java
Normal file
18
src/main/java/com/pledge/torgi/config/WebConfig.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.pledge.torgi.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:5173", "http://localhost:3000")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowCredentials(false)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.pledge.torgi.controller.api;
|
||||
|
||||
import com.pledge.torgi.model.TorgiEvent;
|
||||
import com.pledge.torgi.repositories.TorgiEventRepository;
|
||||
import org.springframework.data.domain.Example;
|
||||
import org.springframework.data.domain.ExampleMatcher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/events")
|
||||
public class EventController {
|
||||
|
||||
private final TorgiEventRepository repository;
|
||||
|
||||
public EventController(TorgiEventRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Page<TorgiEvent> list(@RequestParam(required = false) String cadastral,
|
||||
@RequestParam(required = false) String status,
|
||||
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
|
||||
TorgiEvent probe = new TorgiEvent();
|
||||
probe.setCadastral(cadastral);
|
||||
probe.setStatus(status);
|
||||
ExampleMatcher matcher = ExampleMatcher.matchingAll()
|
||||
.withIgnoreNullValues()
|
||||
.withIgnoreCase()
|
||||
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING);
|
||||
return repository.findAll(Example.of(probe, matcher), pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public TorgiEvent get(@PathVariable Long id) {
|
||||
return repository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Event not found: " + id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.pledge.torgi.controller.api;
|
||||
|
||||
import com.pledge.torgi.model.MissedData;
|
||||
import com.pledge.torgi.repositories.MissedDataRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/missed")
|
||||
public class MissedDataController {
|
||||
|
||||
private final MissedDataRepository repository;
|
||||
|
||||
public MissedDataController(MissedDataRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Page<MissedData> list(@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) {
|
||||
return repository.findAll(pageable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.pledge.torgi.controller.api;
|
||||
|
||||
import com.pledge.torgi.model.TorgiEvent;
|
||||
import com.pledge.torgi.repositories.TorgiEventRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.Example;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EventControllerTest {
|
||||
|
||||
@Mock
|
||||
private TorgiEventRepository repository;
|
||||
|
||||
@InjectMocks
|
||||
private EventController controller;
|
||||
|
||||
@Test
|
||||
void list_shouldPassFiltersAndPageableToRepository() {
|
||||
// given
|
||||
TorgiEvent event = new TorgiEvent();
|
||||
event.setKey("key-1");
|
||||
Page<TorgiEvent> page = new PageImpl<>(List.of(event));
|
||||
when(repository.findAll(any(Example.class), any(Pageable.class))).thenReturn(page);
|
||||
Pageable pageable = PageRequest.of(0, 20);
|
||||
|
||||
// when
|
||||
Page<TorgiEvent> result = controller.list("77:01", "PUBLISHED", pageable);
|
||||
|
||||
// then
|
||||
assertThat(result.getContent()).containsExactly(event);
|
||||
ArgumentCaptor<Example<TorgiEvent>> exampleCaptor = ArgumentCaptor.forClass(Example.class);
|
||||
verify(repository).findAll(exampleCaptor.capture(), eq(pageable));
|
||||
assertThat(exampleCaptor.getValue().getProbe().getCadastral()).isEqualTo("77:01");
|
||||
assertThat(exampleCaptor.getValue().getProbe().getStatus()).isEqualTo("PUBLISHED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_shouldReturnEvent() {
|
||||
// given
|
||||
TorgiEvent event = new TorgiEvent();
|
||||
event.setKey("key-1");
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(event));
|
||||
|
||||
// when
|
||||
TorgiEvent result = controller.get(1L);
|
||||
|
||||
// then
|
||||
assertThat(result).isSameAs(event);
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_shouldThrow404WhenMissing() {
|
||||
// given
|
||||
when(repository.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
// when / then
|
||||
assertThatThrownBy(() -> controller.get(42L))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("404");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.pledge.torgi.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.retry.annotation.Backoff;
|
||||
import org.springframework.retry.annotation.EnableRetry;
|
||||
import org.springframework.retry.annotation.Retryable;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = DownloadHeadlessRetryTest.RetryConfig.class)
|
||||
class DownloadHeadlessRetryTest {
|
||||
|
||||
@Configuration
|
||||
@EnableRetry
|
||||
static class RetryConfig {
|
||||
@Bean
|
||||
DownloadHeadlessService downloadHeadlessService() {
|
||||
return new DownloadHeadlessService();
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private DownloadHeadlessService service;
|
||||
|
||||
@Test
|
||||
void downloadExcel_shouldBeRetryable() throws Exception {
|
||||
Method method = DownloadHeadlessService.class.getMethod("downloadExcel", String.class);
|
||||
Retryable retryable = method.getAnnotation(Retryable.class);
|
||||
assertThat(retryable).isNotNull();
|
||||
assertThat(retryable.maxAttempts()).isEqualTo(11);
|
||||
assertThat(Arrays.asList(retryable.retryFor())).contains(UncheckedIOException.class);
|
||||
Backoff backoff = retryable.backoff();
|
||||
assertThat(backoff.delay()).isEqualTo(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadExcelFallback_shouldReturnEmptyMap() {
|
||||
Map<String, Lot> result = new DownloadHeadlessService()
|
||||
.downloadExcelFallback(new RuntimeException("boom"), "http://example.com/file.xlsx");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadExcel_shouldRetryAndRecoverToEmptyMap() {
|
||||
// connection refused -> UncheckedIOException on every attempt -> @Recover fallback
|
||||
Map<String, Lot> result = service.downloadExcel("http://127.0.0.1:1/missing.xlsx");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user