SMPT, SMS, Rejestracja + GUS
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
||||
import pl.polskalokalnie.send.EmailSender;
|
||||
import pl.polskalokalnie.send.SendResult;
|
||||
|
||||
/**
|
||||
* Wysylka transakcyjnych wiadomosci e-mail (aktywacja konta, reset hasla) w firmowym szablonie HTML
|
||||
* (logo + nazwa u gory, tresc, przycisk CTA). Korzysta z konfiguracji SMTP z panelu admina.
|
||||
*
|
||||
* Gdy SMTP nie jest skonfigurowany/wlaczony - link jest logowany (tryb deweloperski), aby proces
|
||||
* dalo sie przetestowac bez realnej skrzynki. Po skonfigurowaniu SMTP wiadomosci ida realnie.
|
||||
*/
|
||||
@Service
|
||||
public class AccountEmailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountEmailService.class);
|
||||
|
||||
private final EmailSender emailSender;
|
||||
private final MailConfigService mailConfigService;
|
||||
private final String baseUrl;
|
||||
|
||||
public AccountEmailService(EmailSender emailSender,
|
||||
MailConfigService mailConfigService,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
this.emailSender = emailSender;
|
||||
this.mailConfigService = mailConfigService;
|
||||
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
public void sendActivation(String email, String name, String token) {
|
||||
String link = baseUrl + "/aktywacja/" + token;
|
||||
String html = template(
|
||||
"Aktywuj swoje konto",
|
||||
greeting(name),
|
||||
"<p>Dziękujemy za rejestrację w serwisie Polska Lokalnie. Aby dokończyć zakładanie konta i móc"
|
||||
+ " korzystać ze wszystkich funkcji, potwierdź swój adres e-mail klikając poniższy przycisk.</p>"
|
||||
+ "<p style=\"color:#6b7f96;font-size:13px\">Link jest ważny przez 48 godzin. Jeśli to nie Ty"
|
||||
+ " zakładałeś konto, zignoruj tę wiadomość.</p>",
|
||||
"Aktywuj konto",
|
||||
link);
|
||||
send(email, "Aktywuj konto w serwisie Polska Lokalnie", html, "AKTYWACJA", link);
|
||||
}
|
||||
|
||||
public void sendPasswordReset(String email, String name, String token) {
|
||||
String link = baseUrl + "/reset-hasla/" + token;
|
||||
String html = template(
|
||||
"Ustaw nowe hasło",
|
||||
greeting(name),
|
||||
"<p>Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta. Kliknij poniższy przycisk, aby ustawić"
|
||||
+ " nowe hasło.</p>"
|
||||
+ "<p style=\"color:#6b7f96;font-size:13px\">Link jest ważny przez 1 godzinę. Dotychczasowe hasło"
|
||||
+ " pozostaje aktywne do momentu ustawienia nowego. Jeśli nie prosiłeś o zmianę hasła, zignoruj tę wiadomość.</p>",
|
||||
"Ustaw nowe hasło",
|
||||
link);
|
||||
send(email, "Reset hasła w serwisie Polska Lokalnie", html, "RESET HASŁA", link);
|
||||
}
|
||||
|
||||
private void send(String email, String subject, String html, String label, String link) {
|
||||
SmtpConfig smtp = mailConfigService.getSmtp();
|
||||
boolean configured = smtp.isEnabled()
|
||||
&& smtp.getHost() != null && !smtp.getHost().isBlank();
|
||||
if (!configured) {
|
||||
// Tryb deweloperski - brak skonfigurowanej skrzynki. Logujemy link, by dalo sie przetestowac.
|
||||
log.info("[E-MAIL:{}] SMTP nieskonfigurowany. Link dla {}: {}", label, email, link);
|
||||
return;
|
||||
}
|
||||
SendResult result = emailSender.send(smtp, email, subject, html);
|
||||
if (!result.ok()) {
|
||||
log.warn("[E-MAIL:{}] Nie udalo sie wyslac do {} ({}). Link: {}", label, email, result.error(), link);
|
||||
}
|
||||
}
|
||||
|
||||
private static String greeting(String name) {
|
||||
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
|
||||
return "<p>Cześć" + who + ",</p>";
|
||||
}
|
||||
|
||||
/** Firmowy szablon HTML: naglowek z logo i nazwa, tresc, przycisk CTA, stopka. */
|
||||
private String template(String heading, String greetingHtml, String bodyHtml, String ctaLabel, String ctaUrl) {
|
||||
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa;\">"
|
||||
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">"
|
||||
// Naglowek z logo + nazwa
|
||||
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
|
||||
+ "<img src=\"" + baseUrl + "/favicon.png\" width=\"34\" height=\"34\" alt=\"Polska Lokalnie\""
|
||||
+ " style=\"vertical-align:middle;border-radius:8px;\">"
|
||||
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
|
||||
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
|
||||
+ "</div>"
|
||||
// Tresc
|
||||
+ "<div style=\"background:#ffffff;padding:28px 24px;\">"
|
||||
+ "<h1 style=\"margin:0 0 16px;font-size:22px;color:#13243d;\">" + heading + "</h1>"
|
||||
+ "<div style=\"font-size:15px;line-height:1.6;\">" + greetingHtml + bodyHtml + "</div>"
|
||||
+ "<div style=\"text-align:center;margin:26px 0 8px;\">"
|
||||
+ "<a href=\"" + ctaUrl + "\" style=\"display:inline-block;background:#12a764;color:#ffffff;"
|
||||
+ "text-decoration:none;font-weight:bold;font-size:15px;padding:13px 26px;border-radius:10px;\">"
|
||||
+ ctaLabel + "</a></div>"
|
||||
+ "<p style=\"font-size:12px;color:#8a99ad;word-break:break-all;\">Jeśli przycisk nie działa, skopiuj"
|
||||
+ " link do przeglądarki:<br><a href=\"" + ctaUrl + "\" style=\"color:#12a764;\">" + ctaUrl + "</a></p>"
|
||||
+ "</div>"
|
||||
// Stopka
|
||||
+ "<div style=\"background:#eef2f6;border-radius:0 0 14px 14px;padding:16px 24px;font-size:12px;color:#6b7f96;text-align:center;\">"
|
||||
+ "<p style=\"margin:0 0 4px;\"><strong>Polska Lokalnie | SoftSPM</strong></p>"
|
||||
+ "<p style=\"margin:0;\">Ogłoszenia nieruchomości bez prowizji. Ta wiadomość została wysłana automatycznie - prosimy na nią nie odpowiadać.</p>"
|
||||
+ "</div>"
|
||||
+ "</div></body></html>";
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,14 @@ import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
@@ -28,7 +33,7 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
public ResponseEntity<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
|
||||
}
|
||||
|
||||
@@ -42,6 +47,48 @@ public class AuthController {
|
||||
return authService.socialLogin(request);
|
||||
}
|
||||
|
||||
// --- Aktywacja konta ---
|
||||
|
||||
@PostMapping("/activate")
|
||||
public ResponseEntity<Void> activate(@Valid @RequestBody TokenRequest request) {
|
||||
authService.activate(request.token());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/resend-activation")
|
||||
public ResponseEntity<Void> resendActivation(@Valid @RequestBody EmailRequest request) {
|
||||
authService.resendActivation(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Reset hasla ---
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public ResponseEntity<Void> forgotPassword(@Valid @RequestBody EmailRequest request) {
|
||||
authService.forgotPassword(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
|
||||
authService.resetPassword(request.token(), request.password());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Weryfikacja telefonu (OTP SMS) ---
|
||||
|
||||
@PostMapping("/verify-phone")
|
||||
public ResponseEntity<Void> verifyPhone(@Valid @RequestBody VerifyPhoneRequest request) {
|
||||
authService.verifyPhone(request.email(), request.code());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/resend-phone-otp")
|
||||
public ResponseEntity<Void> resendPhoneOtp(@Valid @RequestBody EmailRequest request) {
|
||||
authService.resendPhoneOtp(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public UserResponse me(Authentication authentication) {
|
||||
return authService.currentUser(authentication.getName());
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.lead.LeadSyncService;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
@@ -27,12 +33,19 @@ import pl.polskalokalnie.user.UserRepository;
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
|
||||
private static final int ACTIVATION_TTL_MINUTES = 48 * 60;
|
||||
private static final int RESET_TTL_MINUTES = 60;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BlockedEmailRepository blockedEmailRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final TextModerationService textModerationService;
|
||||
private final LeadSyncService leadSyncService;
|
||||
private final AuthTokenRepository authTokenRepository;
|
||||
private final AccountEmailService accountEmailService;
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
|
||||
public AuthService(
|
||||
UserRepository userRepository,
|
||||
@@ -40,7 +53,10 @@ public class AuthService {
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
TextModerationService textModerationService,
|
||||
LeadSyncService leadSyncService
|
||||
LeadSyncService leadSyncService,
|
||||
AuthTokenRepository authTokenRepository,
|
||||
AccountEmailService accountEmailService,
|
||||
PhoneVerificationService phoneVerificationService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
@@ -48,9 +64,13 @@ public class AuthService {
|
||||
this.jwtService = jwtService;
|
||||
this.textModerationService = textModerationService;
|
||||
this.leadSyncService = leadSyncService;
|
||||
this.authTokenRepository = authTokenRepository;
|
||||
this.accountEmailService = accountEmailService;
|
||||
this.phoneVerificationService = phoneVerificationService;
|
||||
}
|
||||
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
@Transactional
|
||||
public RegisterResponse register(RegisterRequest request) {
|
||||
String email = normalizeEmail(request.email());
|
||||
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
|
||||
@@ -59,7 +79,7 @@ public class AuthService {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Konto z tym adresem e-mail już istnieje");
|
||||
}
|
||||
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip());
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip(), request.address());
|
||||
|
||||
AppUser user = new AppUser();
|
||||
user.setEmail(email);
|
||||
@@ -68,14 +88,28 @@ public class AuthService {
|
||||
user.setRole(Role.USER);
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL);
|
||||
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
|
||||
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
|
||||
user.setPhone(phone);
|
||||
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
|
||||
// Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora.
|
||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
|
||||
user.setVerified(false);
|
||||
user.setPhoneVerified(false);
|
||||
|
||||
AppUser saved = userRepository.save(user);
|
||||
leadSyncService.syncUser(saved);
|
||||
return buildAuthResponse(saved);
|
||||
|
||||
// Wysylka linku aktywacyjnego na e-mail.
|
||||
String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
|
||||
accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token);
|
||||
|
||||
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru.
|
||||
boolean phoneVerificationRequired = phone != null;
|
||||
if (phoneVerificationRequired) {
|
||||
phoneVerificationService.sendOtp(saved.getEmail(), phone);
|
||||
}
|
||||
|
||||
return new RegisterResponse(saved.getEmail(), phoneVerificationRequired);
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
@@ -89,10 +123,112 @@ public class AuthService {
|
||||
if (user.isBlocked()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
|
||||
}
|
||||
// Logowanie mozliwe dopiero po aktywacji konta linkiem z e-maila.
|
||||
if (!user.isVerified()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
||||
}
|
||||
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
// --- Aktywacja konta ---
|
||||
|
||||
@Transactional
|
||||
public void activate(String token) {
|
||||
AuthToken authToken = requireToken(token, TokenPurpose.ACTIVATION);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setVerified(true);
|
||||
userRepository.save(user);
|
||||
authToken.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(authToken);
|
||||
}
|
||||
|
||||
// Ponowne wyslanie linku aktywacyjnego. Nie ujawniamy, czy konto istnieje.
|
||||
@Transactional
|
||||
public void resendActivation(String email) {
|
||||
userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> {
|
||||
if (!user.isVerified()) {
|
||||
String token = issueToken(user.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
|
||||
accountEmailService.sendActivation(user.getEmail(), user.getFullName(), token);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Reset hasla ---
|
||||
|
||||
// Wyslanie linku do resetu hasla. Zawsze zwraca sukces (nie ujawniamy istnienia konta).
|
||||
@Transactional
|
||||
public void forgotPassword(String email) {
|
||||
userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> {
|
||||
String token = issueToken(user.getEmail(), TokenPurpose.PASSWORD_RESET, RESET_TTL_MINUTES);
|
||||
accountEmailService.sendPasswordReset(user.getEmail(), user.getFullName(), token);
|
||||
});
|
||||
}
|
||||
|
||||
// Ustawienie nowego hasla. Dotychczasowe haslo dziala az do tego momentu.
|
||||
@Transactional
|
||||
public void resetPassword(String token, String newPassword) {
|
||||
AuthToken authToken = requireToken(token, TokenPurpose.PASSWORD_RESET);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
|
||||
user.setVerified(true);
|
||||
userRepository.save(user);
|
||||
authToken.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(authToken);
|
||||
}
|
||||
|
||||
// --- Weryfikacja telefonu (OTP) ---
|
||||
|
||||
public void verifyPhone(String email, String code) {
|
||||
phoneVerificationService.verifyOtp(normalizeEmail(email), code);
|
||||
}
|
||||
|
||||
public void resendPhoneOtp(String email) {
|
||||
String normalized = normalizeEmail(email);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||
}
|
||||
phoneVerificationService.sendOtp(normalized, user.getPhone());
|
||||
}
|
||||
|
||||
// --- Pomocnicze tokeny ---
|
||||
|
||||
private String issueToken(String email, TokenPurpose purpose, int ttlMinutes) {
|
||||
// Uniewazniamy poprzednie, nieuzyte tokeny tego samego przeznaczenia.
|
||||
authTokenRepository.findByUserEmailAndPurpose(email, purpose).forEach(old -> {
|
||||
if (old.getUsedAt() == null) {
|
||||
old.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(old);
|
||||
}
|
||||
});
|
||||
byte[] bytes = new byte[32];
|
||||
TOKEN_RANDOM.nextBytes(bytes);
|
||||
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
|
||||
AuthToken authToken = new AuthToken();
|
||||
authToken.setToken(token);
|
||||
authToken.setUserEmail(email);
|
||||
authToken.setPurpose(purpose);
|
||||
authToken.setExpiresAt(Instant.now().plus(ttlMinutes, ChronoUnit.MINUTES));
|
||||
authTokenRepository.save(authToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
private AuthToken requireToken(String token, TokenPurpose purpose) {
|
||||
AuthToken authToken = authTokenRepository.findByToken(token == null ? "" : token.trim())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link"));
|
||||
if (authToken.getPurpose() != purpose || !authToken.isUsable()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link wygasł lub został już wykorzystany");
|
||||
}
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca.
|
||||
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Jednorazowy token (aktywacja konta / reset hasla) wysylany w linku mailowym. */
|
||||
@Entity
|
||||
@Table(name = "auth_tokens")
|
||||
public class AuthToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String token;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private TokenPurpose purpose;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public boolean isUsable() {
|
||||
return usedAt == null && expiresAt != null && expiresAt.isAfter(Instant.now());
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public TokenPurpose getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(TokenPurpose purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public Instant getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public void setUsedAt(Instant usedAt) {
|
||||
this.usedAt = usedAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||
Optional<AuthToken> findByToken(String token);
|
||||
List<AuthToken> findByUserEmailAndPurpose(String userEmail, TokenPurpose purpose);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Jednorazowy kod SMS (OTP) do potwierdzenia numeru telefonu. Jeden aktywny kod na uzytkownika. */
|
||||
@Entity
|
||||
@Table(name = "phone_otps")
|
||||
public class PhoneOtp {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String phone;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int attempts = 0;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PhoneOtpRepository extends JpaRepository<PhoneOtp, Long> {
|
||||
Optional<PhoneOtp> findByUserEmail(String userEmail);
|
||||
void deleteByUserEmail(String userEmail);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||
import pl.polskalokalnie.send.SmsSender;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
/**
|
||||
* Potwierdzanie numeru telefonu kodem SMS (OTP). Kod jednorazowy, wazny 10 minut, maks. 5 prob.
|
||||
* Gdy bramka SMS nie jest skonfigurowana - kod jest logowany (tryb deweloperski).
|
||||
*/
|
||||
@Service
|
||||
public class PhoneVerificationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PhoneVerificationService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final int TTL_MINUTES = 10;
|
||||
|
||||
private final PhoneOtpRepository otpRepository;
|
||||
private final SmsSender smsSender;
|
||||
private final MailConfigService mailConfigService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public PhoneVerificationService(PhoneOtpRepository otpRepository,
|
||||
SmsSender smsSender,
|
||||
MailConfigService mailConfigService,
|
||||
UserRepository userRepository) {
|
||||
this.otpRepository = otpRepository;
|
||||
this.smsSender = smsSender;
|
||||
this.mailConfigService = mailConfigService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void sendOtp(String email, String phone) {
|
||||
if (phone == null || phone.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu");
|
||||
}
|
||||
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||
|
||||
PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new);
|
||||
otp.setUserEmail(email);
|
||||
otp.setPhone(phone.trim());
|
||||
otp.setCode(code);
|
||||
otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES));
|
||||
otp.setAttempts(0);
|
||||
otpRepository.save(otp);
|
||||
|
||||
SmsGatewayConfig sms = mailConfigService.getSms();
|
||||
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank();
|
||||
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
|
||||
if (!configured) {
|
||||
log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code);
|
||||
return;
|
||||
}
|
||||
var result = smsSender.send(sms, phone.trim(), message);
|
||||
if (!result.ok()) {
|
||||
log.warn("[SMS-OTP] Nie udalo sie wyslac kodu do {} ({}). Kod: {}", email, phone, code);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void verifyOtp(String email, String code) {
|
||||
PhoneOtp otp = otpRepository.findByUserEmail(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Brak kodu do weryfikacji - wyślij kod ponownie"));
|
||||
if (otp.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ResponseStatusException(HttpStatus.GONE, "Kod wygasł - wyślij nowy kod");
|
||||
}
|
||||
if (otp.getAttempts() >= MAX_ATTEMPTS) {
|
||||
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Przekroczono liczbę prób - wyślij nowy kod");
|
||||
}
|
||||
if (code == null || !code.trim().equals(otp.getCode())) {
|
||||
otp.setAttempts(otp.getAttempts() + 1);
|
||||
otpRepository.save(otp);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy kod");
|
||||
}
|
||||
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setPhoneVerified(true);
|
||||
userRepository.save(user);
|
||||
otpRepository.delete(otp);
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,19 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/error").permitAll()
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll()
|
||||
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
|
||||
"/api/auth/forgot-password", "/api/auth/reset-password",
|
||||
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
||||
.requestMatchers("/api/i18n/translate").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||
.requestMatchers("/api/auth/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/banners/*/click").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/payments/autopay/itn").permitAll()
|
||||
.requestMatchers("/api/promotion/**").authenticated()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/listings").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
/** Przeznaczenie jednorazowego tokenu wysylanego mailem. */
|
||||
public enum TokenPurpose {
|
||||
ACTIVATION,
|
||||
PASSWORD_RESET
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** Zbior prostych rekordow zadan dla operacji na koncie (aktywacja, reset hasla, OTP). */
|
||||
public final class AccountRequests {
|
||||
|
||||
private AccountRequests() {
|
||||
}
|
||||
|
||||
public record TokenRequest(@NotBlank String token) {
|
||||
}
|
||||
|
||||
public record EmailRequest(@NotBlank @Email String email) {
|
||||
}
|
||||
|
||||
public record ResetPasswordRequest(@NotBlank String token, @NotBlank @Size(min = 8, max = 100) String password) {
|
||||
}
|
||||
|
||||
public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) {
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public record RegisterRequest(
|
||||
@NotBlank String fullName,
|
||||
AccountType accountType,
|
||||
String phone,
|
||||
String nip
|
||||
String nip,
|
||||
String address
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
/**
|
||||
* Odpowiedz po rejestracji. Konto wymaga aktywacji linkiem e-mail (brak od razu tokenu logowania).
|
||||
* phoneVerificationRequired = true, gdy podano numer telefonu i wyslano kod SMS do potwierdzenia.
|
||||
*/
|
||||
public record RegisterResponse(
|
||||
String email,
|
||||
boolean phoneVerificationRequired
|
||||
) {
|
||||
}
|
||||
@@ -23,7 +23,9 @@ public record UserResponse(
|
||||
String nip,
|
||||
LocalDate birthDate,
|
||||
boolean verified,
|
||||
boolean phoneVerified,
|
||||
boolean blocked,
|
||||
int promotionCredits,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static UserResponse from(AppUser user) {
|
||||
@@ -41,7 +43,9 @@ public record UserResponse(
|
||||
user.getNip(),
|
||||
user.getBirthDate(),
|
||||
user.isVerified(),
|
||||
user.isPhoneVerified(),
|
||||
user.isBlocked(),
|
||||
user.getPromotionCredits(),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Zarzadzanie banerami/promocjami w panelu admina (/admin/promocje). Wymaga roli ADMIN. */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/banners")
|
||||
public class AdminBannerController {
|
||||
|
||||
private final BannerService bannerService;
|
||||
|
||||
public AdminBannerController(BannerService bannerService) {
|
||||
this.bannerService = bannerService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<BannerResponse> list() {
|
||||
return bannerService.listAll();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public BannerResponse create(@Valid @RequestBody BannerRequest request) {
|
||||
return bannerService.create(request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public BannerResponse update(@PathVariable Long id, @Valid @RequestBody BannerRequest request) {
|
||||
return bannerService.update(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/toggle")
|
||||
public BannerResponse toggle(@PathVariable Long id) {
|
||||
return bannerService.toggle(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
bannerService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Baner reklamowy/promocyjny przypisany do slotu (miejsca) w serwisie.
|
||||
* Grafika przechowywana jako data URL (base64) w kolumnie TEXT - jak zdjecia ogloszen.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "banners")
|
||||
public class Banner {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 30)
|
||||
private BannerSlot slot;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String title;
|
||||
|
||||
@Column(length = 400)
|
||||
private String subtitle;
|
||||
|
||||
// Grafika jako data URL (base64). TEXT, bo przekracza limity VARCHAR.
|
||||
@Column(columnDefinition = "text")
|
||||
private String imageUrl;
|
||||
|
||||
@Column(length = 500)
|
||||
private String linkUrl;
|
||||
|
||||
@Column(length = 60)
|
||||
private String ctaLabel;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
// Kolejnosc w obrebie slotu (mniejsza = wyzej/pierwsza).
|
||||
@Column(nullable = false)
|
||||
private int position = 0;
|
||||
|
||||
// Opcjonalne okno emisji. null = bez ograniczenia.
|
||||
private Instant startsAt;
|
||||
private Instant endsAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private long impressions = 0;
|
||||
|
||||
@Column(nullable = false)
|
||||
private long clicks = 0;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public BannerSlot getSlot() {
|
||||
return slot;
|
||||
}
|
||||
|
||||
public void setSlot(BannerSlot slot) {
|
||||
this.slot = slot;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getSubtitle() {
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
public void setSubtitle(String subtitle) {
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public String getLinkUrl() {
|
||||
return linkUrl;
|
||||
}
|
||||
|
||||
public void setLinkUrl(String linkUrl) {
|
||||
this.linkUrl = linkUrl;
|
||||
}
|
||||
|
||||
public String getCtaLabel() {
|
||||
return ctaLabel;
|
||||
}
|
||||
|
||||
public void setCtaLabel(String ctaLabel) {
|
||||
this.ctaLabel = ctaLabel;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Instant getStartsAt() {
|
||||
return startsAt;
|
||||
}
|
||||
|
||||
public void setStartsAt(Instant startsAt) {
|
||||
this.startsAt = startsAt;
|
||||
}
|
||||
|
||||
public Instant getEndsAt() {
|
||||
return endsAt;
|
||||
}
|
||||
|
||||
public void setEndsAt(Instant endsAt) {
|
||||
this.endsAt = endsAt;
|
||||
}
|
||||
|
||||
public long getImpressions() {
|
||||
return impressions;
|
||||
}
|
||||
|
||||
public void setImpressions(long impressions) {
|
||||
this.impressions = impressions;
|
||||
}
|
||||
|
||||
public long getClicks() {
|
||||
return clicks;
|
||||
}
|
||||
|
||||
public void setClicks(long clicks) {
|
||||
this.clicks = clicks;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Publiczny odczyt aktywnych banerow dla slotu oraz zliczanie klikniec. */
|
||||
@RestController
|
||||
@RequestMapping("/api/banners")
|
||||
public class BannerController {
|
||||
|
||||
private final BannerService bannerService;
|
||||
|
||||
public BannerController(BannerService bannerService) {
|
||||
this.bannerService = bannerService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PublicBannerResponse> bySlot(@RequestParam BannerSlot slot) {
|
||||
return bannerService.publicBySlot(slot);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/click")
|
||||
public ResponseEntity<Void> registerClick(@PathVariable Long id) {
|
||||
bannerService.registerClick(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface BannerRepository extends JpaRepository<Banner, Long> {
|
||||
|
||||
List<Banner> findByOrderBySlotAscPositionAscIdAsc();
|
||||
|
||||
List<Banner> findBySlotAndActiveTrueOrderByPositionAscIdAsc(BannerSlot slot);
|
||||
|
||||
@Modifying
|
||||
@Query("update Banner b set b.impressions = b.impressions + 1 where b.id in :ids")
|
||||
void incrementImpressions(@Param("ids") List<Long> ids);
|
||||
|
||||
@Modifying
|
||||
@Query("update Banner b set b.clicks = b.clicks + 1 where b.id = :id")
|
||||
void incrementClicks(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Dane wejsciowe tworzenia/edycji banera (panel admina). */
|
||||
public record BannerRequest(
|
||||
@NotNull BannerSlot slot,
|
||||
@NotBlank String title,
|
||||
String subtitle,
|
||||
String imageUrl,
|
||||
String linkUrl,
|
||||
String ctaLabel,
|
||||
Boolean active,
|
||||
Integer position,
|
||||
Instant startsAt,
|
||||
Instant endsAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Pelna reprezentacja banera dla panelu admina (ze statystykami). */
|
||||
public record BannerResponse(
|
||||
Long id,
|
||||
BannerSlot slot,
|
||||
String title,
|
||||
String subtitle,
|
||||
String imageUrl,
|
||||
String linkUrl,
|
||||
String ctaLabel,
|
||||
boolean active,
|
||||
int position,
|
||||
Instant startsAt,
|
||||
Instant endsAt,
|
||||
long impressions,
|
||||
long clicks,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static BannerResponse from(Banner b) {
|
||||
return new BannerResponse(
|
||||
b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(), b.getImageUrl(),
|
||||
b.getLinkUrl(), b.getCtaLabel(), b.isActive(), b.getPosition(),
|
||||
b.getStartsAt(), b.getEndsAt(), b.getImpressions(), b.getClicks(), b.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class BannerService {
|
||||
|
||||
private final BannerRepository bannerRepository;
|
||||
|
||||
public BannerService(BannerRepository bannerRepository) {
|
||||
this.bannerRepository = bannerRepository;
|
||||
}
|
||||
|
||||
// --- Panel admina ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<BannerResponse> listAll() {
|
||||
return bannerRepository.findByOrderBySlotAscPositionAscIdAsc().stream()
|
||||
.map(BannerResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerResponse create(BannerRequest request) {
|
||||
Banner banner = new Banner();
|
||||
apply(banner, request);
|
||||
return BannerResponse.from(bannerRepository.save(banner));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerResponse update(Long id, BannerRequest request) {
|
||||
Banner banner = find(id);
|
||||
apply(banner, request);
|
||||
return BannerResponse.from(bannerRepository.save(banner));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerResponse toggle(Long id) {
|
||||
Banner banner = find(id);
|
||||
banner.setActive(!banner.isActive());
|
||||
return BannerResponse.from(bannerRepository.save(banner));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
bannerRepository.delete(find(id));
|
||||
}
|
||||
|
||||
// --- Publiczne ---
|
||||
|
||||
// Aktywne banery danego slotu, w oknie emisji; przy okazji zliczamy wyswietlenia.
|
||||
@Transactional
|
||||
public List<PublicBannerResponse> publicBySlot(BannerSlot slot) {
|
||||
Instant now = Instant.now();
|
||||
List<Banner> visible = bannerRepository.findBySlotAndActiveTrueOrderByPositionAscIdAsc(slot).stream()
|
||||
.filter(b -> b.getStartsAt() == null || !b.getStartsAt().isAfter(now))
|
||||
.filter(b -> b.getEndsAt() == null || !b.getEndsAt().isBefore(now))
|
||||
.toList();
|
||||
if (!visible.isEmpty()) {
|
||||
bannerRepository.incrementImpressions(visible.stream().map(Banner::getId).toList());
|
||||
}
|
||||
return visible.stream().map(PublicBannerResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void registerClick(Long id) {
|
||||
// Cichy no-op dla nieistniejacego id - klikniecia nie moga wywrocic UX.
|
||||
if (bannerRepository.existsById(id)) {
|
||||
bannerRepository.incrementClicks(id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wspolne ---
|
||||
|
||||
private Banner find(Long id) {
|
||||
return bannerRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Baner nie istnieje"));
|
||||
}
|
||||
|
||||
private void apply(Banner banner, BannerRequest request) {
|
||||
banner.setSlot(request.slot());
|
||||
banner.setTitle(request.title().trim());
|
||||
banner.setSubtitle(trimToNull(request.subtitle()));
|
||||
banner.setImageUrl(trimToNull(request.imageUrl()));
|
||||
banner.setLinkUrl(trimToNull(request.linkUrl()));
|
||||
banner.setCtaLabel(trimToNull(request.ctaLabel()));
|
||||
banner.setActive(request.active() == null || request.active());
|
||||
banner.setPosition(request.position() == null ? 0 : request.position());
|
||||
banner.setStartsAt(request.startsAt());
|
||||
banner.setEndsAt(request.endsAt());
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
/**
|
||||
* Miejsca (sloty) reklamowe w serwisie. Front renderuje baner przypisany do danego slotu.
|
||||
* HERO - pasek/karta w sekcji hero na stronie glownej.
|
||||
* HOME_TOP - szeroki baner pod wyszukiwarka na stronie glownej.
|
||||
* HOME_MIDDLE - baner miedzy sekcjami ofert (polecane / ostatnio ogladane).
|
||||
* SIDEBAR - baner w bocznej kolumnie (np. karta oferty).
|
||||
*/
|
||||
public enum BannerSlot {
|
||||
HERO,
|
||||
HOME_TOP,
|
||||
HOME_MIDDLE,
|
||||
SIDEBAR
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
/** Publiczna, minimalna reprezentacja banera (bez statystyk i harmonogramu). */
|
||||
public record PublicBannerResponse(
|
||||
Long id,
|
||||
BannerSlot slot,
|
||||
String title,
|
||||
String subtitle,
|
||||
String imageUrl,
|
||||
String linkUrl,
|
||||
String ctaLabel
|
||||
) {
|
||||
public static PublicBannerResponse from(Banner b) {
|
||||
return new PublicBannerResponse(
|
||||
b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(),
|
||||
b.getImageUrl(), b.getLinkUrl(), b.getCtaLabel());
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,18 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import pl.polskalokalnie.banner.Banner;
|
||||
import pl.polskalokalnie.banner.BannerRepository;
|
||||
import pl.polskalokalnie.banner.BannerSlot;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.OfferType;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
import pl.polskalokalnie.promotion.PromotionPackage;
|
||||
import pl.polskalokalnie.promotion.PromotionPackageRepository;
|
||||
import pl.polskalokalnie.promotion.PromotionPlan;
|
||||
import pl.polskalokalnie.promotion.PromotionPlanRepository;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
@@ -20,6 +27,9 @@ public class DataSeeder implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final BannerRepository bannerRepository;
|
||||
private final PromotionPlanRepository promotionPlanRepository;
|
||||
private final PromotionPackageRepository promotionPackageRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final String adminEmail;
|
||||
private final String adminPassword;
|
||||
@@ -28,6 +38,9 @@ public class DataSeeder implements CommandLineRunner {
|
||||
public DataSeeder(
|
||||
UserRepository userRepository,
|
||||
ListingRepository listingRepository,
|
||||
BannerRepository bannerRepository,
|
||||
PromotionPlanRepository promotionPlanRepository,
|
||||
PromotionPackageRepository promotionPackageRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||
@@ -35,6 +48,9 @@ public class DataSeeder implements CommandLineRunner {
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.bannerRepository = bannerRepository;
|
||||
this.promotionPlanRepository = promotionPlanRepository;
|
||||
this.promotionPackageRepository = promotionPackageRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.adminEmail = adminEmail;
|
||||
this.adminPassword = adminPassword;
|
||||
@@ -45,6 +61,74 @@ public class DataSeeder implements CommandLineRunner {
|
||||
public void run(String... args) {
|
||||
seedAdmin();
|
||||
seedDemoListings();
|
||||
seedBanners();
|
||||
seedPromotion();
|
||||
}
|
||||
|
||||
private void seedPromotion() {
|
||||
if (promotionPlanRepository.count() == 0) {
|
||||
promotionPlanRepository.save(promotionPlan("Wyróżnienie 4 dni", 4, "19.99", 0));
|
||||
promotionPlanRepository.save(promotionPlan("Wyróżnienie 14 dni", 14, "49.99", 1));
|
||||
promotionPlanRepository.save(promotionPlan("Wyróżnienie 30 dni", 30, "89.99", 2));
|
||||
}
|
||||
if (promotionPackageRepository.count() == 0) {
|
||||
promotionPackageRepository.save(promotionPackage("Pakiet 5 promowań", 5, "199.99", 0));
|
||||
promotionPackageRepository.save(promotionPackage("Pakiet 10 promowań", 10, "349.99", 1));
|
||||
}
|
||||
}
|
||||
|
||||
private PromotionPlan promotionPlan(String name, int days, String price, int position) {
|
||||
PromotionPlan plan = new PromotionPlan();
|
||||
plan.setName(name);
|
||||
plan.setDurationDays(days);
|
||||
plan.setPrice(new BigDecimal(price));
|
||||
plan.setActive(true);
|
||||
plan.setPosition(position);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private PromotionPackage promotionPackage(String name, int quantity, String price, int position) {
|
||||
PromotionPackage pack = new PromotionPackage();
|
||||
pack.setName(name);
|
||||
pack.setQuantity(quantity);
|
||||
pack.setPrice(new BigDecimal(price));
|
||||
pack.setActive(true);
|
||||
pack.setPosition(position);
|
||||
return pack;
|
||||
}
|
||||
|
||||
private void seedBanners() {
|
||||
if (bannerRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
bannerRepository.save(banner(BannerSlot.HERO,
|
||||
"Wyróżnij swoje ogłoszenie",
|
||||
"Dotrzyj do tysięcy kupujących i najemców - bez prowizji.",
|
||||
"Dodaj ogłoszenie", "/dodaj-ogloszenie"));
|
||||
bannerRepository.save(banner(BannerSlot.HOME_TOP,
|
||||
"Sprawdź wartość swojej nieruchomości",
|
||||
"Poznaj realistyczną cenę mieszkania w kilka minut.",
|
||||
"Wyceń mieszkanie", "/wycena"));
|
||||
bannerRepository.save(banner(BannerSlot.HOME_MIDDLE,
|
||||
"Planujesz kredyt hipoteczny?",
|
||||
"Policz zdolność kredytową i sprawdź, na co Cię stać.",
|
||||
"Sprawdź zdolność", "/kalkulator-zdolnosci"));
|
||||
bannerRepository.save(banner(BannerSlot.SIDEBAR,
|
||||
"Zareklamuj się w Polska Lokalnie",
|
||||
"Zarezerwuj miejsce reklamowe dla swojej firmy.",
|
||||
"Napisz do nas", "/konto/pomoc"));
|
||||
}
|
||||
|
||||
private Banner banner(BannerSlot slot, String title, String subtitle, String ctaLabel, String linkUrl) {
|
||||
Banner banner = new Banner();
|
||||
banner.setSlot(slot);
|
||||
banner.setTitle(title);
|
||||
banner.setSubtitle(subtitle);
|
||||
banner.setCtaLabel(ctaLabel);
|
||||
banner.setLinkUrl(linkUrl);
|
||||
banner.setActive(true);
|
||||
banner.setPosition(0);
|
||||
return banner;
|
||||
}
|
||||
|
||||
private void seedAdmin() {
|
||||
@@ -93,6 +177,8 @@ public class DataSeeder implements CommandLineRunner {
|
||||
listing.setPrice(price);
|
||||
listing.setArea(area);
|
||||
listing.setRooms(rooms);
|
||||
listing.setContactName("Biuro Polska Lokalnie");
|
||||
listing.setContactPhone("+48 600 100 200");
|
||||
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
|
||||
listing.setStatus(status);
|
||||
return listing;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Panel admina: konfiguracja integracji z GUS (BIR1.1). Wymaga roli ADMIN (/api/admin/**). */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/gus-config")
|
||||
public class AdminGusConfigController {
|
||||
|
||||
private final GusService gusService;
|
||||
|
||||
public AdminGusConfigController(GusService gusService) {
|
||||
this.gusService = gusService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public GusConfigResponse get() {
|
||||
return GusConfigResponse.from(gusService.getConfig());
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public GusConfigResponse update(@RequestBody GusConfigRequest request) {
|
||||
return GusConfigResponse.from(gusService.updateConfig(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
/** Dane firmy pobrane z rejestru REGON - tyle, ile uzupelnia formularz rejestracji. */
|
||||
public record GusCompanyResponse(
|
||||
String name,
|
||||
String nip,
|
||||
String regon,
|
||||
String address
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* Konfiguracja integracji z wyszukiwarka REGON (GUS BIR1.1). Pojedynczy rekord (id=1).
|
||||
* Gdy sandbox=true uzywamy srodowiska testowego i wbudowanego klucza testowego GUS.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "gus_config")
|
||||
public class GusConfig {
|
||||
|
||||
@Id
|
||||
private Long id = 1L;
|
||||
|
||||
// Klucz uzytkownika nadany przez GUS - nigdy nie zwracany w odpowiedzi API.
|
||||
@Column(length = 100)
|
||||
private String userKey;
|
||||
|
||||
// true = srodowisko testowe BIR1.1, false = produkcyjne.
|
||||
@Column(nullable = false)
|
||||
private boolean sandbox = true;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUserKey() {
|
||||
return userKey;
|
||||
}
|
||||
|
||||
public void setUserKey(String userKey) {
|
||||
this.userKey = userKey;
|
||||
}
|
||||
|
||||
public boolean isSandbox() {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
public void setSandbox(boolean sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface GusConfigRepository extends JpaRepository<GusConfig, Long> {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
/** Zapis konfiguracji GUS. Puste userKey = zachowaj dotychczasowy klucz. */
|
||||
public record GusConfigRequest(
|
||||
String userKey,
|
||||
Boolean sandbox
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
/** Odpowiedz konfiguracji - nigdy nie zwraca klucza, tylko flage czy jest ustawiony. */
|
||||
public record GusConfigResponse(
|
||||
boolean userKeySet,
|
||||
boolean sandbox
|
||||
) {
|
||||
public static GusConfigResponse from(GusConfig config) {
|
||||
boolean keySet = config.getUserKey() != null && !config.getUserKey().isBlank();
|
||||
return new GusConfigResponse(keySet, config.isSandbox());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Pobranie danych firmy z rejestru REGON - dostepne publicznie (formularz rejestracji konta firmowego). */
|
||||
@RestController
|
||||
@RequestMapping("/api/gus")
|
||||
public class GusController {
|
||||
|
||||
private final GusService gusService;
|
||||
|
||||
public GusController(GusService gusService) {
|
||||
this.gusService = gusService;
|
||||
}
|
||||
|
||||
@GetMapping("/company")
|
||||
public GusCompanyResponse company(@RequestParam("nip") String nip) {
|
||||
return gusService.lookupByNip(nip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
/**
|
||||
* Integracja z wyszukiwarka REGON (GUS, usluga BIR1.1). Loguje sie kluczem uzytkownika i wyszukuje
|
||||
* podmiot po numerze NIP. Sesja (sid) jest wazna okolo godziny - trzymamy ja w pamieci i odnawiamy,
|
||||
* gdy GUS odpowie pustym wynikiem (tak sygnalizuje nieaktualna sesje).
|
||||
*/
|
||||
@Service
|
||||
public class GusService {
|
||||
|
||||
private static final Long SINGLETON_ID = 1L;
|
||||
private static final String PROD_URL = "https://wyszukiwarkaregon.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc";
|
||||
private static final String TEST_URL = "https://wyszukiwarkaregontest.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc";
|
||||
// Klucz testowy udostepniony publicznie przez GUS dla srodowiska testowego.
|
||||
private static final String TEST_KEY = "abcde12345abcde12345";
|
||||
private static final String ACTION_PREFIX = "http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl/";
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(30);
|
||||
// Usluga odpowiada wiadomoscia MTOM (multipart) - koperte SOAP trzeba wyluskac z opakowania MIME.
|
||||
private static final Pattern ENVELOPE_START = Pattern.compile("<[A-Za-z0-9]*:?Envelope[\\s>]");
|
||||
private static final int[] NIP_WEIGHTS = {6, 5, 7, 2, 3, 4, 5, 6, 7};
|
||||
|
||||
private final GusConfigRepository repository;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private String sessionId;
|
||||
private String sessionEndpoint;
|
||||
private Instant sessionCreatedAt;
|
||||
|
||||
public GusService(GusConfigRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GusConfig getConfig() {
|
||||
return repository.findById(SINGLETON_ID).orElseGet(() -> {
|
||||
GusConfig config = new GusConfig();
|
||||
config.setId(SINGLETON_ID);
|
||||
return repository.save(config);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GusConfig updateConfig(GusConfigRequest request) {
|
||||
GusConfig config = getConfig();
|
||||
if (request.sandbox() != null) {
|
||||
config.setSandbox(request.sandbox());
|
||||
}
|
||||
// Klucz nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni).
|
||||
if (request.userKey() != null && !request.userKey().isBlank()) {
|
||||
config.setUserKey(request.userKey().trim());
|
||||
}
|
||||
resetSession();
|
||||
return repository.save(config);
|
||||
}
|
||||
|
||||
/** Pobiera dane firmy z rejestru REGON po numerze NIP. */
|
||||
public GusCompanyResponse lookupByNip(String rawNip) {
|
||||
String nip = normalizeNip(rawNip);
|
||||
GusConfig config = getConfig();
|
||||
String endpoint = config.isSandbox() ? TEST_URL : PROD_URL;
|
||||
String key = config.isSandbox() ? TEST_KEY : config.getUserKey();
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Pobieranie danych z GUS nie jest skonfigurowane. Skontaktuj się z administratorem serwisu.");
|
||||
}
|
||||
|
||||
Map<String, String> dane = search(endpoint, key, nip, true);
|
||||
if (dane.containsKey("ErrorCode")) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON.");
|
||||
}
|
||||
return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane));
|
||||
}
|
||||
|
||||
// Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze.
|
||||
private Map<String, String> search(String endpoint, String key, String nip, boolean canRetry) {
|
||||
String body = """
|
||||
<ns:DaneSzukajPodmioty>
|
||||
<ns:pParametryWyszukiwania>
|
||||
<dat:Nip>%s</dat:Nip>
|
||||
</ns:pParametryWyszukiwania>
|
||||
</ns:DaneSzukajPodmioty>""".formatted(nip);
|
||||
String response = call(endpoint, "DaneSzukajPodmioty", body, session(endpoint, key));
|
||||
Map<String, String> dane = firstDane(resultOf(response, "DaneSzukajPodmiotyResult"));
|
||||
if (dane == null) {
|
||||
if (canRetry) {
|
||||
resetSession();
|
||||
return search(endpoint, key, nip, false);
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||
"Rejestr REGON nie zwrócił danych. Spróbuj ponownie za chwilę.");
|
||||
}
|
||||
return dane;
|
||||
}
|
||||
|
||||
private synchronized String session(String endpoint, String key) {
|
||||
boolean expired = sessionId == null
|
||||
|| !endpoint.equals(sessionEndpoint)
|
||||
|| sessionCreatedAt == null
|
||||
|| sessionCreatedAt.plus(SESSION_TTL).isBefore(Instant.now());
|
||||
if (expired) {
|
||||
String body = "<ns:Zaloguj><ns:pKluczUzytkownika>%s</ns:pKluczUzytkownika></ns:Zaloguj>".formatted(key);
|
||||
String sid = resultOf(call(endpoint, "Zaloguj", body, null), "ZalogujResult");
|
||||
if (sid == null || sid.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||
"Nie udało się połączyć z rejestrem REGON. Sprawdź klucz dostępowy GUS.");
|
||||
}
|
||||
sessionId = sid.trim();
|
||||
sessionEndpoint = endpoint;
|
||||
sessionCreatedAt = Instant.now();
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private synchronized void resetSession() {
|
||||
sessionId = null;
|
||||
sessionEndpoint = null;
|
||||
sessionCreatedAt = null;
|
||||
}
|
||||
|
||||
private String call(String endpoint, String action, String body, String sid) {
|
||||
String envelope = """
|
||||
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://www.w3.org/2005/08/addressing" xmlns:ns="http://CIS/BIR/PUBL/2014/07" xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract">
|
||||
<soap:Header>
|
||||
<wsa:Action>%s%s</wsa:Action>
|
||||
<wsa:To>%s</wsa:To>
|
||||
</soap:Header>
|
||||
<soap:Body>%s</soap:Body>
|
||||
</soap:Envelope>""".formatted(ACTION_PREFIX, action, endpoint, body);
|
||||
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(endpoint))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(envelope, StandardCharsets.UTF_8));
|
||||
if (sid != null) {
|
||||
request.header("sid", sid);
|
||||
}
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() != 200) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił błąd (HTTP " + response.statusCode() + ").");
|
||||
}
|
||||
return unwrapSoap(response.body());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Połączenie z rejestrem REGON zostało przerwane.");
|
||||
} catch (java.io.IOException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się połączyć z rejestrem REGON. Spróbuj ponownie za chwilę.");
|
||||
}
|
||||
}
|
||||
|
||||
// Odcina naglowki i stopke MIME (MTOM), zostawiajac sama koperte SOAP.
|
||||
private static String unwrapSoap(String body) {
|
||||
Matcher matcher = ENVELOPE_START.matcher(body);
|
||||
if (!matcher.find()) {
|
||||
return body;
|
||||
}
|
||||
int end = body.lastIndexOf("Envelope>");
|
||||
return end < 0 ? body.substring(matcher.start()) : body.substring(matcher.start(), end + "Envelope>".length());
|
||||
}
|
||||
|
||||
// Wynik operacji BIR to XML zagniezdzony jako tekst w kopercie SOAP.
|
||||
private static String resultOf(String soap, String localName) {
|
||||
NodeList nodes = parse(soap).getElementsByTagNameNS("*", localName);
|
||||
return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null;
|
||||
}
|
||||
|
||||
// Zwraca pola pierwszego rekordu <dane> albo null, gdy odpowiedz jest pusta (wygasla sesja).
|
||||
private static Map<String, String> firstDane(String xml) {
|
||||
if (xml == null || xml.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
NodeList danes = parse(xml).getElementsByTagName("dane");
|
||||
if (danes.getLength() == 0) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> values = new HashMap<>();
|
||||
NodeList children = danes.item(0).getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node child = children.item(i);
|
||||
if (child instanceof Element element) {
|
||||
String value = element.getTextContent().trim();
|
||||
if (!value.isEmpty()) {
|
||||
values.put(element.getTagName(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Document parse(String xml) {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setNamespaceAware(true);
|
||||
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił nieczytelną odpowiedź.");
|
||||
}
|
||||
}
|
||||
|
||||
// Adres w jednej linii: "ul. Krucza 208/3, 00-925 Warszawa".
|
||||
private static String buildAddress(Map<String, String> dane) {
|
||||
StringBuilder line = new StringBuilder();
|
||||
String street = dane.get("Ulica");
|
||||
if (street != null) {
|
||||
line.append(street);
|
||||
}
|
||||
String building = dane.get("NrNieruchomosci");
|
||||
if (building != null) {
|
||||
if (line.length() > 0) {
|
||||
line.append(' ');
|
||||
}
|
||||
line.append(building);
|
||||
String flat = dane.get("NrLokalu");
|
||||
if (flat != null) {
|
||||
line.append('/').append(flat);
|
||||
}
|
||||
}
|
||||
String postalCode = formatPostalCode(dane.get("KodPocztowy"));
|
||||
String city = dane.get("Miejscowosc");
|
||||
String cityLine = postalCode != null && city != null ? postalCode + " " + city : (postalCode != null ? postalCode : city);
|
||||
if (cityLine != null) {
|
||||
if (line.length() > 0) {
|
||||
line.append(", ");
|
||||
}
|
||||
line.append(cityLine);
|
||||
}
|
||||
return line.length() > 0 ? line.toString() : null;
|
||||
}
|
||||
|
||||
// Czesc odpowiedzi GUS zawiera kod pocztowy bez myslnika.
|
||||
private static String formatPostalCode(String code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
String digits = code.replaceAll("\\D", "");
|
||||
return digits.length() == 5 ? digits.substring(0, 2) + "-" + digits.substring(2) : code;
|
||||
}
|
||||
|
||||
/** NIP bez separatorow, z kontrola sumy - bledny numer odrzucamy bez odpytywania GUS. */
|
||||
private static String normalizeNip(String rawNip) {
|
||||
String nip = rawNip == null ? "" : rawNip.replaceAll("\\D", "");
|
||||
boolean valid = nip.length() == 10;
|
||||
if (valid) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < NIP_WEIGHTS.length; i++) {
|
||||
sum += NIP_WEIGHTS[i] * (nip.charAt(i) - '0');
|
||||
}
|
||||
valid = sum % 11 == (nip.charAt(9) - '0');
|
||||
}
|
||||
if (!valid) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny numer NIP (10 cyfr).");
|
||||
}
|
||||
return nip;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@ public record ListingDetailResponse(
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Instant createdAt,
|
||||
Long viewsCount
|
||||
Long viewsCount,
|
||||
Instant promotedUntil
|
||||
) {
|
||||
public static ListingDetailResponse from(PropertyListing listing) {
|
||||
return new ListingDetailResponse(
|
||||
@@ -91,7 +92,8 @@ public record ListingDetailResponse(
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt(),
|
||||
listing.getViewsCount()
|
||||
listing.getViewsCount(),
|
||||
listing.getPromotedUntil()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public record ListingResponse(
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Long viewsCount,
|
||||
Instant promotedUntil,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static ListingResponse from(PropertyListing listing) {
|
||||
@@ -50,6 +51,7 @@ public record ListingResponse(
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getViewsCount(),
|
||||
listing.getPromotedUntil(),
|
||||
listing.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,19 @@ public class ListingService {
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
|
||||
private static Comparator<PropertyListing> promotedFirst() {
|
||||
return Comparator.comparing(PropertyListing::isPromoted).reversed()
|
||||
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
|
||||
}
|
||||
|
||||
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
||||
return listingRepository.findAll().stream()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim()))
|
||||
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.sorted(promotedFirst())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
@@ -55,7 +61,7 @@ public class ListingService {
|
||||
public List<ListingResponse> findMine(String ownerEmail) {
|
||||
return listingRepository.findAll().stream()
|
||||
.filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail()))
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.sorted(promotedFirst())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
@@ -173,7 +179,7 @@ public class ListingService {
|
||||
|
||||
public List<ListingResponse> findAllForModeration() {
|
||||
return listingRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.sorted(promotedFirst())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -155,6 +155,9 @@ public class PropertyListing {
|
||||
@Column(nullable = false)
|
||||
private Long viewsCount = 0L;
|
||||
|
||||
// Data wygasniecia promowania (wyroznienia). null = nie promowane.
|
||||
private Instant promotedUntil;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -481,6 +484,18 @@ public class PropertyListing {
|
||||
this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount);
|
||||
}
|
||||
|
||||
public Instant getPromotedUntil() {
|
||||
return promotedUntil;
|
||||
}
|
||||
|
||||
public void setPromotedUntil(Instant promotedUntil) {
|
||||
this.promotedUntil = promotedUntil;
|
||||
}
|
||||
|
||||
public boolean isPromoted() {
|
||||
return promotedUntil != null && promotedUntil.isAfter(Instant.now());
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Panel admina: konfiguracja bramki AutoPay. Wymaga roli ADMIN (/api/admin/**). */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/payment-config")
|
||||
public class AdminPaymentConfigController {
|
||||
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
|
||||
public AdminPaymentConfigController(PaymentConfigService paymentConfigService) {
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PaymentConfigResponse get() {
|
||||
return PaymentConfigResponse.from(paymentConfigService.get());
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public PaymentConfigResponse update(@RequestBody PaymentConfigRequest request) {
|
||||
return PaymentConfigResponse.from(paymentConfigService.update(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media).
|
||||
*
|
||||
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych
|
||||
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
|
||||
*
|
||||
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
|
||||
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
|
||||
*/
|
||||
@Service
|
||||
public class AutoPayService {
|
||||
|
||||
private static final String PROD_URL = "https://pay.autopay.eu/payment";
|
||||
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
|
||||
|
||||
/** Adres paywalla z podpisanymi parametrami transakcji. */
|
||||
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||
String description, String customerEmail, String returnUrl) {
|
||||
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
|
||||
// Kolejnosc parametrow ma znaczenie dla podpisu.
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("ServiceID", cfg.getServiceId());
|
||||
params.put("OrderID", orderId);
|
||||
params.put("Amount", amountStr);
|
||||
params.put("Currency", "PLN");
|
||||
params.put("Description", description == null ? "" : description);
|
||||
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
|
||||
if (returnUrl != null && !returnUrl.isBlank()) {
|
||||
params.put("ReturnURL", returnUrl);
|
||||
}
|
||||
|
||||
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
params.put("Hash", hash);
|
||||
|
||||
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||
StringBuilder query = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (query.length() > 0) {
|
||||
query.append('&');
|
||||
}
|
||||
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
|
||||
}
|
||||
return base + "?" + query;
|
||||
}
|
||||
|
||||
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
|
||||
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
|
||||
if (params == null || cfg == null || cfg.getSecretKey() == null) {
|
||||
return false;
|
||||
}
|
||||
String provided = params.get("Hash");
|
||||
if (provided == null || provided.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (!"Hash".equalsIgnoreCase(e.getKey())) {
|
||||
values.add(e.getValue());
|
||||
}
|
||||
}
|
||||
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
return MessageDigest.isEqual(
|
||||
expected.getBytes(StandardCharsets.UTF_8),
|
||||
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
|
||||
public String computeHash(List<String> values, String separator, String key) {
|
||||
List<String> parts = new ArrayList<>(values);
|
||||
parts.add(key);
|
||||
String data = String.join(separator, parts);
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
hex.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Nie udalo sie policzyc podpisu platnosci", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* Konfiguracja bramki szybkich platnosci AutoPay (Autopay/Blue Media). Pojedynczy rekord (id=1).
|
||||
* Gdy wylaczona lub bez danych dostepowych - dziala tryb sandbox (platnosc auto-potwierdzana).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payment_config")
|
||||
public class PaymentConfig {
|
||||
|
||||
@Id
|
||||
private Long id = 1L;
|
||||
|
||||
// ServiceID nadawany przez AutoPay.
|
||||
@Column(length = 40)
|
||||
private String serviceId;
|
||||
|
||||
// Klucz wspolpracy (sekret) do liczenia hasha - nigdy nie zwracany w odpowiedzi API.
|
||||
@Column(length = 200)
|
||||
private String secretKey;
|
||||
|
||||
// Separator wartosci przy liczeniu hasha (AutoPay domyslnie "|").
|
||||
@Column(length = 5)
|
||||
private String hashSeparator = "|";
|
||||
|
||||
// true = srodowisko testowe (testpay.autopay.eu), false = produkcja (pay.autopay.eu).
|
||||
@Column(nullable = false)
|
||||
private boolean sandbox = true;
|
||||
|
||||
// Gdy false lub brak serviceId/secretKey - platnosci sa symulowane (auto-potwierdzenie).
|
||||
@Column(nullable = false)
|
||||
private boolean enabled = false;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(String serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public String getHashSeparator() {
|
||||
return hashSeparator == null || hashSeparator.isEmpty() ? "|" : hashSeparator;
|
||||
}
|
||||
|
||||
public void setHashSeparator(String hashSeparator) {
|
||||
this.hashSeparator = hashSeparator;
|
||||
}
|
||||
|
||||
public boolean isSandbox() {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
public void setSandbox(boolean sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/** Czy skonfigurowana na tyle, by wywolac realna bramke. W innym wypadku - tryb sandbox. */
|
||||
public boolean isLive() {
|
||||
return enabled
|
||||
&& serviceId != null && !serviceId.isBlank()
|
||||
&& secretKey != null && !secretKey.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PaymentConfigRepository extends JpaRepository<PaymentConfig, Long> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
public record PaymentConfigRequest(
|
||||
String serviceId,
|
||||
String secretKey,
|
||||
String hashSeparator,
|
||||
Boolean sandbox,
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
/** Odpowiedz konfiguracji - nigdy nie zwraca sekretu, tylko flage czy jest ustawiony. */
|
||||
public record PaymentConfigResponse(
|
||||
String serviceId,
|
||||
boolean secretKeySet,
|
||||
String hashSeparator,
|
||||
boolean sandbox,
|
||||
boolean enabled
|
||||
) {
|
||||
public static PaymentConfigResponse from(PaymentConfig c) {
|
||||
boolean secretSet = c.getSecretKey() != null && !c.getSecretKey().isBlank();
|
||||
return new PaymentConfigResponse(c.getServiceId(), secretSet, c.getHashSeparator(), c.isSandbox(), c.isEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class PaymentConfigService {
|
||||
|
||||
private static final Long SINGLETON_ID = 1L;
|
||||
|
||||
private final PaymentConfigRepository repository;
|
||||
|
||||
public PaymentConfigService(PaymentConfigRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PaymentConfig get() {
|
||||
return repository.findById(SINGLETON_ID).orElseGet(() -> {
|
||||
PaymentConfig config = new PaymentConfig();
|
||||
config.setId(SINGLETON_ID);
|
||||
return repository.save(config);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PaymentConfig update(PaymentConfigRequest request) {
|
||||
PaymentConfig config = get();
|
||||
config.setServiceId(trimToNull(request.serviceId()));
|
||||
if (request.hashSeparator() != null && !request.hashSeparator().isBlank()) {
|
||||
config.setHashSeparator(request.hashSeparator().trim());
|
||||
}
|
||||
if (request.sandbox() != null) {
|
||||
config.setSandbox(request.sandbox());
|
||||
}
|
||||
if (request.enabled() != null) {
|
||||
config.setEnabled(request.enabled());
|
||||
}
|
||||
// Sekret nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni).
|
||||
if (request.secretKey() != null && !request.secretKey().isBlank()) {
|
||||
config.setSecretKey(request.secretKey().trim());
|
||||
}
|
||||
return repository.save(config);
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Panel admina: definiowanie planow i pakietow promowania + historia platnosci. Wymaga roli ADMIN. */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/promotion")
|
||||
public class AdminPromotionController {
|
||||
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public AdminPromotionController(PromotionService promotionService) {
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
@GetMapping("/plans")
|
||||
public List<PlanResponse> plans() {
|
||||
return promotionService.allPlans();
|
||||
}
|
||||
|
||||
@PostMapping("/plans")
|
||||
public PlanResponse createPlan(@Valid @RequestBody PlanRequest request) {
|
||||
return promotionService.createPlan(request);
|
||||
}
|
||||
|
||||
@PutMapping("/plans/{id}")
|
||||
public PlanResponse updatePlan(@PathVariable Long id, @Valid @RequestBody PlanRequest request) {
|
||||
return promotionService.updatePlan(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/plans/{id}/toggle")
|
||||
public PlanResponse togglePlan(@PathVariable Long id) {
|
||||
return promotionService.togglePlan(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/plans/{id}")
|
||||
public ResponseEntity<Void> deletePlan(@PathVariable Long id) {
|
||||
promotionService.deletePlan(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/packages")
|
||||
public List<PackageResponse> packages() {
|
||||
return promotionService.allPackages();
|
||||
}
|
||||
|
||||
@PostMapping("/packages")
|
||||
public PackageResponse createPackage(@Valid @RequestBody PackageRequest request) {
|
||||
return promotionService.createPackage(request);
|
||||
}
|
||||
|
||||
@PutMapping("/packages/{id}")
|
||||
public PackageResponse updatePackage(@PathVariable Long id, @Valid @RequestBody PackageRequest request) {
|
||||
return promotionService.updatePackage(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/packages/{id}/toggle")
|
||||
public PackageResponse togglePackage(@PathVariable Long id) {
|
||||
return promotionService.togglePackage(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/packages/{id}")
|
||||
public ResponseEntity<Void> deletePackage(@PathVariable Long id) {
|
||||
promotionService.deletePackage(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/orders")
|
||||
public List<OrderResponse> orders() {
|
||||
return promotionService.allOrders();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
|
||||
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
|
||||
*
|
||||
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
|
||||
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
|
||||
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/payments/autopay")
|
||||
public class AutoPayItnController {
|
||||
|
||||
private final AutoPayService autoPayService;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public AutoPayItnController(AutoPayService autoPayService,
|
||||
PaymentConfigService paymentConfigService,
|
||||
PromotionService promotionService) {
|
||||
this.autoPayService = autoPayService;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
@PostMapping("/itn")
|
||||
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!autoPayService.verifyItn(params, cfg)) {
|
||||
return ResponseEntity.badRequest().body("ERROR: invalid hash");
|
||||
}
|
||||
String orderId = params.get("OrderID");
|
||||
if (orderId != null && isSuccess(params)) {
|
||||
promotionService.markPaidByExternalId(orderId);
|
||||
}
|
||||
return ResponseEntity.ok("OK");
|
||||
}
|
||||
|
||||
private static boolean isSuccess(Map<String, String> params) {
|
||||
String status = params.getOrDefault("PaymentStatus",
|
||||
params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS")));
|
||||
String s = status == null ? "" : status.trim().toUpperCase();
|
||||
return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** Zamowienie promowania: plan (z listingId) albo pakiet. */
|
||||
public record CheckoutRequest(
|
||||
@NotNull PromotionKind kind,
|
||||
Long planId,
|
||||
Long packageId,
|
||||
Long listingId,
|
||||
String method
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
/**
|
||||
* Wynik rozpoczecia platnosci.
|
||||
* status = "PAID" (tryb sandbox - od razu oplacone) lub "REDIRECT" (przekierowanie na paywall AutoPay).
|
||||
*/
|
||||
public record CheckoutResponse(
|
||||
String status,
|
||||
Long orderId,
|
||||
String redirectUrl
|
||||
) {
|
||||
public static CheckoutResponse paid(Long orderId) {
|
||||
return new CheckoutResponse("PAID", orderId, null);
|
||||
}
|
||||
|
||||
public static CheckoutResponse redirect(Long orderId, String url) {
|
||||
return new CheckoutResponse("REDIRECT", orderId, url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** Promowanie oferty z wykorzystaniem kredytu (bez platnosci). */
|
||||
public record CreditPromoteRequest(
|
||||
@NotNull Long listingId,
|
||||
@NotNull Long planId
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
public record OrderResponse(
|
||||
Long id,
|
||||
String userEmail,
|
||||
PromotionKind kind,
|
||||
String itemName,
|
||||
BigDecimal amount,
|
||||
String method,
|
||||
Long listingId,
|
||||
OrderStatus status,
|
||||
Instant createdAt,
|
||||
Instant paidAt
|
||||
) {
|
||||
public static OrderResponse from(PromotionOrder o) {
|
||||
return new OrderResponse(o.getId(), o.getUserEmail(), o.getKind(), o.getItemName(), o.getAmount(),
|
||||
o.getMethod(), o.getListingId(), o.getStatus(), o.getCreatedAt(), o.getPaidAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
/** Status platnosci za promowanie. */
|
||||
public enum OrderStatus {
|
||||
PENDING,
|
||||
PAID,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PackageRequest(
|
||||
@NotBlank String name,
|
||||
@Min(1) int quantity,
|
||||
@NotNull @PositiveOrZero BigDecimal price,
|
||||
Boolean active,
|
||||
Integer position
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PackageResponse(
|
||||
Long id,
|
||||
String name,
|
||||
int quantity,
|
||||
BigDecimal price,
|
||||
boolean active,
|
||||
int position
|
||||
) {
|
||||
public static PackageResponse from(PromotionPackage p) {
|
||||
return new PackageResponse(p.getId(), p.getName(), p.getQuantity(), p.getPrice(), p.isActive(), p.getPosition());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PlanRequest(
|
||||
@NotBlank String name,
|
||||
@Min(1) int durationDays,
|
||||
@NotNull @PositiveOrZero BigDecimal price,
|
||||
Boolean active,
|
||||
Integer position
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PlanResponse(
|
||||
Long id,
|
||||
String name,
|
||||
int durationDays,
|
||||
BigDecimal price,
|
||||
boolean active,
|
||||
int position
|
||||
) {
|
||||
public static PlanResponse from(PromotionPlan p) {
|
||||
return new PlanResponse(p.getId(), p.getName(), p.getDurationDays(), p.getPrice(), p.isActive(), p.getPosition());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
||||
@RestController
|
||||
@RequestMapping("/api/promotion")
|
||||
public class PromotionController {
|
||||
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public PromotionController(PromotionService promotionService) {
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
@GetMapping("/plans")
|
||||
public List<PlanResponse> plans() {
|
||||
return promotionService.activePlans();
|
||||
}
|
||||
|
||||
@GetMapping("/packages")
|
||||
public List<PackageResponse> packages() {
|
||||
return promotionService.activePackages();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public CreditsResponse me(Authentication authentication) {
|
||||
return new CreditsResponse(promotionService.credits(authentication.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/checkout")
|
||||
public CheckoutResponse checkout(@Valid @RequestBody CheckoutRequest request, Authentication authentication) {
|
||||
return promotionService.checkout(authentication.getName(), request);
|
||||
}
|
||||
|
||||
@PostMapping("/promote-with-credit")
|
||||
public CreditsResponse promoteWithCredit(@Valid @RequestBody CreditPromoteRequest request, Authentication authentication) {
|
||||
return new CreditsResponse(promotionService.promoteWithCredit(authentication.getName(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/order/{id}")
|
||||
public OrderStatusResponse orderStatus(@PathVariable Long id, Authentication authentication) {
|
||||
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
|
||||
}
|
||||
|
||||
public record CreditsResponse(int credits) {
|
||||
}
|
||||
|
||||
public record OrderStatusResponse(String status) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
/** Rodzaj zamowienia promowania: pojedynczy plan (wyroznienie oferty) albo pakiet kredytow. */
|
||||
public enum PromotionKind {
|
||||
PLAN,
|
||||
PACKAGE
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Zamowienie/platnosc za promowanie (plan lub pakiet). Sluzy tez jako historia platnosci. */
|
||||
@Entity
|
||||
@Table(name = "promotion_orders")
|
||||
public class PromotionOrder {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
// Unikalny identyfikator przekazywany do bramki (OrderID w AutoPay).
|
||||
@Column(nullable = false, unique = true, length = 40)
|
||||
private String externalId;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private PromotionKind kind;
|
||||
|
||||
// Id planu lub pakietu.
|
||||
private Long refId;
|
||||
|
||||
@Column(length = 160)
|
||||
private String itemName;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(length = 60)
|
||||
private String method;
|
||||
|
||||
// Dla planu: promowana oferta.
|
||||
private Long listingId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private OrderStatus status = OrderStatus.PENDING;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
private Instant paidAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getExternalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
public void setExternalId(String externalId) {
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public PromotionKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(PromotionKind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public Long getRefId() {
|
||||
return refId;
|
||||
}
|
||||
|
||||
public void setRefId(Long refId) {
|
||||
this.refId = refId;
|
||||
}
|
||||
|
||||
public String getItemName() {
|
||||
return itemName;
|
||||
}
|
||||
|
||||
public void setItemName(String itemName) {
|
||||
this.itemName = itemName;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public OrderStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(OrderStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getPaidAt() {
|
||||
return paidAt;
|
||||
}
|
||||
|
||||
public void setPaidAt(Instant paidAt) {
|
||||
this.paidAt = paidAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
||||
Optional<PromotionOrder> findByExternalId(String externalId);
|
||||
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Pakiet promowan definiowany przez admina: liczba kredytow (sztuk) w cenie promocyjnej. */
|
||||
@Entity
|
||||
@Table(name = "promotion_packages")
|
||||
public class PromotionPackage {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int quantity;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal price;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int position = 0;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(int quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PromotionPackageRepository extends JpaRepository<PromotionPackage, Long> {
|
||||
List<PromotionPackage> findByOrderByPositionAscIdAsc();
|
||||
List<PromotionPackage> findByActiveTrueOrderByPositionAscIdAsc();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Plan promowania definiowany przez admina: czas trwania (dni) i cena. */
|
||||
@Entity
|
||||
@Table(name = "promotion_plans")
|
||||
public class PromotionPlan {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int durationDays;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal price;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int position = 0;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getDurationDays() {
|
||||
return durationDays;
|
||||
}
|
||||
|
||||
public void setDurationDays(int durationDays) {
|
||||
this.durationDays = durationDays;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PromotionPlanRepository extends JpaRepository<PromotionPlan, Long> {
|
||||
List<PromotionPlan> findByOrderByPositionAscIdAsc();
|
||||
List<PromotionPlan> findByActiveTrueOrderByPositionAscIdAsc();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Service
|
||||
public class PromotionService {
|
||||
|
||||
private final PromotionPlanRepository planRepository;
|
||||
private final PromotionPackageRepository packageRepository;
|
||||
private final PromotionOrderRepository orderRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final AutoPayService autoPayService;
|
||||
private final String baseUrl;
|
||||
|
||||
public PromotionService(PromotionPlanRepository planRepository,
|
||||
PromotionPackageRepository packageRepository,
|
||||
PromotionOrderRepository orderRepository,
|
||||
UserRepository userRepository,
|
||||
ListingRepository listingRepository,
|
||||
PaymentConfigService paymentConfigService,
|
||||
AutoPayService autoPayService,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
this.planRepository = planRepository;
|
||||
this.packageRepository = packageRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.autoPayService = autoPayService;
|
||||
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
// --- Publiczne (uzytkownik) ---
|
||||
|
||||
public List<PlanResponse> activePlans() {
|
||||
return planRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
|
||||
}
|
||||
|
||||
public List<PackageResponse> activePackages() {
|
||||
return packageRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
|
||||
}
|
||||
|
||||
public int credits(String email) {
|
||||
return userRepository.findByEmailIgnoreCase(email).map(AppUser::getPromotionCredits).orElse(0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CheckoutResponse checkout(String email, CheckoutRequest request) {
|
||||
PromotionOrder order = new PromotionOrder();
|
||||
order.setUserEmail(email);
|
||||
order.setKind(request.kind());
|
||||
order.setExternalId(generateExternalId());
|
||||
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
||||
|
||||
if (request.kind() == PromotionKind.PLAN) {
|
||||
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||
order.setRefId(plan.getId());
|
||||
order.setItemName("Promowanie: " + plan.getName());
|
||||
order.setAmount(plan.getPrice());
|
||||
order.setListingId(listing.getId());
|
||||
} else {
|
||||
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
order.setRefId(pack.getId());
|
||||
order.setItemName("Pakiet promowan: " + pack.getName());
|
||||
order.setAmount(pack.getPrice());
|
||||
}
|
||||
order = orderRepository.save(order);
|
||||
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!cfg.isLive()) {
|
||||
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay).
|
||||
applyPaid(order);
|
||||
return CheckoutResponse.paid(order.getId());
|
||||
}
|
||||
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
|
||||
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
|
||||
order.getItemName(), email, returnUrl);
|
||||
return CheckoutResponse.redirect(order.getId(), url);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void applyPaid(PromotionOrder order) {
|
||||
if (order.getStatus() == OrderStatus.PAID) {
|
||||
return;
|
||||
}
|
||||
if (order.getKind() == PromotionKind.PLAN) {
|
||||
PromotionPlan plan = planRepository.findById(order.getRefId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PropertyListing listing = listingRepository.findById(order.getListingId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje"));
|
||||
extendPromotion(listing, plan.getDurationDays());
|
||||
listingRepository.save(listing);
|
||||
} else {
|
||||
PromotionPackage pack = packageRepository.findById(order.getRefId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
|
||||
userRepository.save(user);
|
||||
}
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(Instant.now());
|
||||
orderRepository.save(order);
|
||||
}
|
||||
|
||||
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
|
||||
@Transactional
|
||||
public void markPaidByExternalId(String externalId) {
|
||||
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||
if (user.getPromotionCredits() <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
||||
}
|
||||
PromotionPlan plan = planRepository.findById(request.planId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||
|
||||
user.setPromotionCredits(user.getPromotionCredits() - 1);
|
||||
userRepository.save(user);
|
||||
extendPromotion(listing, plan.getDurationDays());
|
||||
listingRepository.save(listing);
|
||||
|
||||
PromotionOrder order = new PromotionOrder();
|
||||
order.setUserEmail(email);
|
||||
order.setKind(PromotionKind.PLAN);
|
||||
order.setExternalId(generateExternalId());
|
||||
order.setRefId(plan.getId());
|
||||
order.setItemName("Promowanie (kredyt): " + plan.getName());
|
||||
order.setAmount(BigDecimal.ZERO);
|
||||
order.setMethod("Kredyt promocji");
|
||||
order.setListingId(listing.getId());
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(Instant.now());
|
||||
orderRepository.save(order);
|
||||
|
||||
return user.getPromotionCredits();
|
||||
}
|
||||
|
||||
public String orderStatus(String email, Long id) {
|
||||
PromotionOrder order = orderRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
|
||||
if (!order.getUserEmail().equalsIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
|
||||
}
|
||||
return order.getStatus().name();
|
||||
}
|
||||
|
||||
// --- Admin ---
|
||||
|
||||
public List<PlanResponse> allPlans() {
|
||||
return planRepository.findByOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
|
||||
}
|
||||
|
||||
public List<PackageResponse> allPackages() {
|
||||
return packageRepository.findByOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
|
||||
}
|
||||
|
||||
public List<OrderResponse> allOrders() {
|
||||
return orderRepository.findByOrderByCreatedAtDesc().stream().map(OrderResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlanResponse createPlan(PlanRequest request) {
|
||||
PromotionPlan plan = new PromotionPlan();
|
||||
applyPlan(plan, request);
|
||||
return PlanResponse.from(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlanResponse updatePlan(Long id, PlanRequest request) {
|
||||
PromotionPlan plan = planRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
applyPlan(plan, request);
|
||||
return PlanResponse.from(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlanResponse togglePlan(Long id) {
|
||||
PromotionPlan plan = planRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
plan.setActive(!plan.isActive());
|
||||
return PlanResponse.from(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deletePlan(Long id) {
|
||||
planRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PackageResponse createPackage(PackageRequest request) {
|
||||
PromotionPackage pack = new PromotionPackage();
|
||||
applyPackage(pack, request);
|
||||
return PackageResponse.from(packageRepository.save(pack));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PackageResponse updatePackage(Long id, PackageRequest request) {
|
||||
PromotionPackage pack = packageRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
applyPackage(pack, request);
|
||||
return PackageResponse.from(packageRepository.save(pack));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PackageResponse togglePackage(Long id) {
|
||||
PromotionPackage pack = packageRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
pack.setActive(!pack.isActive());
|
||||
return PackageResponse.from(packageRepository.save(pack));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deletePackage(Long id) {
|
||||
packageRepository.deleteById(id);
|
||||
}
|
||||
|
||||
// --- Pomocnicze ---
|
||||
|
||||
private void applyPlan(PromotionPlan plan, PlanRequest request) {
|
||||
plan.setName(request.name().trim());
|
||||
plan.setDurationDays(request.durationDays());
|
||||
plan.setPrice(request.price());
|
||||
plan.setActive(request.active() == null || request.active());
|
||||
plan.setPosition(request.position() == null ? 0 : request.position());
|
||||
}
|
||||
|
||||
private void applyPackage(PromotionPackage pack, PackageRequest request) {
|
||||
pack.setName(request.name().trim());
|
||||
pack.setQuantity(request.quantity());
|
||||
pack.setPrice(request.price());
|
||||
pack.setActive(request.active() == null || request.active());
|
||||
pack.setPosition(request.position() == null ? 0 : request.position());
|
||||
}
|
||||
|
||||
private void extendPromotion(PropertyListing listing, int days) {
|
||||
Instant now = Instant.now();
|
||||
Instant base = listing.getPromotedUntil() != null && listing.getPromotedUntil().isAfter(now)
|
||||
? listing.getPromotedUntil() : now;
|
||||
listing.setPromotedUntil(base.plus(days, ChronoUnit.DAYS));
|
||||
}
|
||||
|
||||
private PropertyListing requireOwnedListing(Long listingId, String email) {
|
||||
if (listingId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak listingId");
|
||||
}
|
||||
PropertyListing listing = listingRepository.findById(listingId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje"));
|
||||
if (listing.getOwnerEmail() == null || !listing.getOwnerEmail().equalsIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "To nie jest Twoja oferta");
|
||||
}
|
||||
return listing;
|
||||
}
|
||||
|
||||
private static Long requireId(Long id, String field) {
|
||||
if (id == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak " + field);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String generateExternalId() {
|
||||
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package pl.polskalokalnie.send;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
@@ -11,19 +13,21 @@ import org.springframework.stereotype.Component;
|
||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||
|
||||
/**
|
||||
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST
|
||||
* (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji.
|
||||
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}).
|
||||
*
|
||||
* UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z
|
||||
* dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac.
|
||||
* Zgodnie z manualem bramki: POST {@code application/json} z polami {@code api_key}, {@code to},
|
||||
* {@code message} oraz opcjonalnym {@code creator}. Odpowiedz to JSON
|
||||
* {@code {"status":"success|error","message":"..."}}. Sukcesem jest HTTP 2xx i {@code status=success};
|
||||
* przy bledzie zwracamy komunikat z pola {@code message} bramki.
|
||||
*/
|
||||
@Component
|
||||
public class SmsSender {
|
||||
|
||||
private static final String PARAM_API_KEY = "apiKey";
|
||||
private static final String PARAM_CREATOR = "creator";
|
||||
private static final String PARAM_TO = "to";
|
||||
private static final String PARAM_MESSAGE = "message";
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SmsSender(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public SendResult send(SmsGatewayConfig config, String phone, String message) {
|
||||
if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank()
|
||||
@@ -33,10 +37,14 @@ public class SmsSender {
|
||||
try {
|
||||
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
||||
? config.getTimeoutSeconds() : 10;
|
||||
String form = PARAM_API_KEY + "=" + enc(config.getApiKey())
|
||||
+ "&" + PARAM_CREATOR + "=" + enc(config.getCreator())
|
||||
+ "&" + PARAM_TO + "=" + enc(phone)
|
||||
+ "&" + PARAM_MESSAGE + "=" + enc(message);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("api_key", config.getApiKey());
|
||||
payload.put("to", phone == null ? "" : phone.trim());
|
||||
payload.put("message", message == null ? "" : message);
|
||||
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
|
||||
payload.put("creator", creator);
|
||||
String jsonBody = objectMapper.writeValueAsString(payload);
|
||||
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(timeout))
|
||||
@@ -44,24 +52,38 @@ public class SmsSender {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.getEndpointUrl()))
|
||||
.timeout(Duration.ofSeconds(timeout))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
String body = response.body() == null ? "" : response.body().trim();
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||
return SendResult.ok(shorten(body));
|
||||
|
||||
JsonNode json = tryParse(body);
|
||||
String gatewayStatus = json != null && json.hasNonNull("status") ? json.get("status").asText() : null;
|
||||
String gatewayMessage = json != null && json.hasNonNull("message") ? json.get("message").asText() : null;
|
||||
boolean http2xx = response.statusCode() >= 200 && response.statusCode() < 300;
|
||||
|
||||
if (http2xx && (gatewayStatus == null || gatewayStatus.equalsIgnoreCase("success"))) {
|
||||
return SendResult.ok(gatewayMessage != null ? gatewayMessage : "SMS dodany do kolejki.");
|
||||
}
|
||||
return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode()
|
||||
+ (body.isEmpty() ? "" : ": " + shorten(body)));
|
||||
String detail = gatewayMessage != null && !gatewayMessage.isBlank() ? gatewayMessage : shorten(body);
|
||||
return SendResult.failure("Bramka SMS: " + (detail.isBlank() ? "status " + response.statusCode() : detail));
|
||||
} catch (Exception ex) {
|
||||
return SendResult.failure(shorten(ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
private JsonNode tryParse(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String shorten(String value) {
|
||||
|
||||
@@ -65,6 +65,11 @@ public class AppUser {
|
||||
|
||||
private LocalDate birthDate;
|
||||
|
||||
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("0")
|
||||
private int promotionCredits = 0;
|
||||
|
||||
// Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora.
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
@@ -73,6 +78,11 @@ public class AppUser {
|
||||
@Column(nullable = false)
|
||||
private boolean blocked = false;
|
||||
|
||||
// Czy numer telefonu zostal potwierdzony kodem SMS (OTP).
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean phoneVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -183,6 +193,14 @@ public class AppUser {
|
||||
this.birthDate = birthDate;
|
||||
}
|
||||
|
||||
public int getPromotionCredits() {
|
||||
return promotionCredits;
|
||||
}
|
||||
|
||||
public void setPromotionCredits(int promotionCredits) {
|
||||
this.promotionCredits = promotionCredits;
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
return verified;
|
||||
}
|
||||
@@ -191,6 +209,14 @@ public class AppUser {
|
||||
this.verified = verified;
|
||||
}
|
||||
|
||||
public boolean isPhoneVerified() {
|
||||
return phoneVerified;
|
||||
}
|
||||
|
||||
public void setPhoneVerified(boolean phoneVerified) {
|
||||
this.phoneVerified = phoneVerified;
|
||||
}
|
||||
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user