Добавлен SHiNE-promo-solana-devnet в основной репозиторий без вложенного git

This commit is contained in:
AidarKC
2026-05-01 13:58:59 +03:00
parent 9b03273055
commit 3061bf3d1e
25 changed files with 1812 additions and 0 deletions
@@ -0,0 +1,30 @@
package ru.shine.promo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import ru.shine.promo.config.AppProperties;
@SpringBootApplication
public class ShinePromoSolanaDevnetApplication {
private static final Logger log = LoggerFactory.getLogger(ShinePromoSolanaDevnetApplication.class);
public static void main(String[] args) {
SpringApplication.run(ShinePromoSolanaDevnetApplication.class, args);
}
@Bean
CommandLineRunner startupLogger(AppProperties appProperties) {
return args -> log.info(
"SHiNE promo app started. RPC: {}, promoCodesFile: {}, usedFile: {}, transferAmountSol: {}",
appProperties.getSolanaRpcUrl(),
appProperties.getPromoCodesFile(),
appProperties.getPromoUsedFile(),
appProperties.getPromoTransferAmountSol()
);
}
}
@@ -0,0 +1,77 @@
package ru.shine.promo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
public class AppProperties {
private static final long LAMPORTS_PER_SOL = 1_000_000_000L;
@Value("${solana.rpc.url}")
private String solanaRpcUrl;
@Value("${solana.sender.keypair-file}")
private String solanaSenderKeypairFile;
@Value("${promo.transfer.amount-sol}")
private BigDecimal promoTransferAmountSol;
@Value("${promo.codes.file}")
private String promoCodesFile;
@Value("${promo.used.file}")
private String promoUsedFile;
@Value("${promo.explorer.tx-url-template}")
private String promoExplorerTxUrlTemplate;
@Value("${promo.eternal-code.enabled:false}")
private boolean promoEternalCodeEnabled;
@Value("${promo.eternal-code.value:0000}")
private String promoEternalCodeValue;
public String getSolanaRpcUrl() {
return solanaRpcUrl;
}
public String getSolanaSenderKeypairFile() {
return solanaSenderKeypairFile;
}
public BigDecimal getPromoTransferAmountSol() {
return promoTransferAmountSol;
}
public String getPromoCodesFile() {
return promoCodesFile;
}
public String getPromoUsedFile() {
return promoUsedFile;
}
public String getPromoExplorerTxUrlTemplate() {
return promoExplorerTxUrlTemplate;
}
public boolean isPromoEternalCodeEnabled() {
return promoEternalCodeEnabled;
}
public String getPromoEternalCodeValue() {
if (promoEternalCodeValue == null) {
return "";
}
return promoEternalCodeValue.trim().toLowerCase();
}
public long getPromoTransferAmountLamports() {
BigDecimal lamports = promoTransferAmountSol.multiply(BigDecimal.valueOf(LAMPORTS_PER_SOL));
return lamports.setScale(0, RoundingMode.UNNECESSARY).longValueExact();
}
}
@@ -0,0 +1,136 @@
package ru.shine.promo.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import ru.shine.promo.config.AppProperties;
import ru.shine.promo.dto.PromoRequest;
import ru.shine.promo.dto.PromoResponse;
import ru.shine.promo.service.PromoCodeService;
import ru.shine.promo.service.PromoException;
import ru.shine.promo.service.PromoTransferService;
import ru.shine.promo.service.UsedPromoStorageService;
import java.util.Map;
@RestController
public class PromoApiController {
private static final Logger log = LoggerFactory.getLogger(PromoApiController.class);
private final AppProperties appProperties;
private final PromoCodeService promoCodeService;
private final PromoTransferService promoTransferService;
private final UsedPromoStorageService usedPromoStorageService;
public PromoApiController(
AppProperties appProperties,
PromoCodeService promoCodeService,
PromoTransferService promoTransferService,
UsedPromoStorageService usedPromoStorageService
) {
this.appProperties = appProperties;
this.promoCodeService = promoCodeService;
this.promoTransferService = promoTransferService;
this.usedPromoStorageService = usedPromoStorageService;
}
@PostMapping("/api/promo/top-up")
public ResponseEntity<PromoResponse> topUp(@RequestBody PromoRequest request) {
String wallet = trimToEmpty(request == null ? null : request.getWallet());
String name = trimToEmpty(request == null ? null : request.getName());
String promoCode = promoCodeService.normalizePromoCode(request == null ? null : request.getPromoCode());
if (wallet.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой wallet");
}
if (!promoTransferService.isSolanaWalletValid(wallet)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
if (name.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустое имя");
}
if (name.length() < 2 || name.length() > 120) {
return error(HttpStatus.BAD_REQUEST, "Имя должно быть длиной от 2 до 120 символов");
}
if (promoCode.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой промокод");
}
boolean eternalPromo = promoCodeService.isEternalPromoCode(promoCode);
if (!eternalPromo && !promoCodeService.isPromoCodeFormatValid(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат промокода");
}
try {
if (!eternalPromo && !promoCodeService.promoCodeExists(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Промокод не найден");
}
String signature = usedPromoStorageService.executeLocked(() -> {
if (!eternalPromo && usedPromoStorageService.isPromoUsed(promoCode)) {
throw new PromoException(HttpStatus.CONFLICT, "Промокод уже использован");
}
String txSignature = promoTransferService.sendPromoTransfer(wallet);
// Для вечного промокода ведём только лог использования, но не блокируем повторное применение.
usedPromoStorageService.appendUsedPromo(promoCode, wallet, name, txSignature);
return txSignature;
});
String explorerUrl = promoTransferService.buildExplorerUrl(signature);
String amount = appProperties.getPromoTransferAmountSol().stripTrailingZeros().toPlainString();
log.info(
"Promo top-up success: wallet={}, name={}, promoCode={}, signature={}",
wallet,
name,
promoCode,
signature
);
PromoResponse response = PromoResponse.success(
"Тестовое пополнение выполнено",
wallet,
name,
amount,
signature,
explorerUrl
);
return ResponseEntity.ok(response);
} catch (PromoException e) {
if (e.getStatus().is5xxServerError()) {
log.error("Top-up failed: {}", e.getMessage(), e);
} else {
log.warn("Top-up rejected: {}", e.getMessage());
}
return error(e.getStatus(), e.getMessage());
} catch (Exception e) {
log.error("Unexpected top-up error", e);
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера");
}
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of(
"status", "ok",
"app", "SHiNE-promo-solana-devnet"
);
}
private ResponseEntity<PromoResponse> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(PromoResponse.error(message));
}
private String trimToEmpty(String value) {
if (value == null) {
return "";
}
return value.trim().replaceAll("\\s{2,}", " ");
}
}
@@ -0,0 +1,16 @@
package ru.shine.promo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PromoPageController {
@GetMapping("/")
public String index(@RequestParam(name = "wallet", required = false) String wallet, Model model) {
model.addAttribute("wallet", wallet == null ? "" : wallet.trim());
return "index";
}
}
@@ -0,0 +1,32 @@
package ru.shine.promo.dto;
public class PromoRequest {
private String wallet;
private String name;
private String promoCode;
public String getWallet() {
return wallet;
}
public void setWallet(String wallet) {
this.wallet = wallet;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPromoCode() {
return promoCode;
}
public void setPromoCode(String promoCode) {
this.promoCode = promoCode;
}
}
@@ -0,0 +1,69 @@
package ru.shine.promo.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PromoResponse {
private boolean success;
private String message;
private String wallet;
private String name;
private String amountSol;
private String signature;
private String explorerUrl;
public static PromoResponse success(
String message,
String wallet,
String name,
String amountSol,
String signature,
String explorerUrl
) {
PromoResponse response = new PromoResponse();
response.success = true;
response.message = message;
response.wallet = wallet;
response.name = name;
response.amountSol = amountSol;
response.signature = signature;
response.explorerUrl = explorerUrl;
return response;
}
public static PromoResponse error(String message) {
PromoResponse response = new PromoResponse();
response.success = false;
response.message = message;
return response;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
public String getWallet() {
return wallet;
}
public String getName() {
return name;
}
public String getAmountSol() {
return amountSol;
}
public String getSignature() {
return signature;
}
public String getExplorerUrl() {
return explorerUrl;
}
}
@@ -0,0 +1,77 @@
package ru.shine.promo.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Pattern;
@Service
public class PromoCodeService {
private static final Logger log = LoggerFactory.getLogger(PromoCodeService.class);
private static final Pattern PROMO_CODE_PATTERN = Pattern.compile("^[a-z0-9]{8}$");
private final AppProperties appProperties;
public PromoCodeService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String normalizePromoCode(String rawPromoCode) {
if (rawPromoCode == null) {
return "";
}
return rawPromoCode.trim().toLowerCase();
}
public boolean isPromoCodeFormatValid(String promoCode) {
return PROMO_CODE_PATTERN.matcher(promoCode).matches();
}
public boolean isEternalPromoCode(String promoCode) {
if (!appProperties.isPromoEternalCodeEnabled()) {
return false;
}
String configured = appProperties.getPromoEternalCodeValue();
return !configured.isEmpty() && configured.equals(promoCode);
}
public boolean promoCodeExists(String promoCode) {
Set<String> codes = readPromoCodesFromFile();
return codes.contains(promoCode);
}
private Set<String> readPromoCodesFromFile() {
Path file = Path.of(appProperties.getPromoCodesFile());
if (!Files.exists(file)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов");
}
try {
Set<String> result = new LinkedHashSet<>();
for (String row : Files.readAllLines(file, StandardCharsets.UTF_8)) {
String line = row.trim().toLowerCase();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
if (!isPromoCodeFormatValid(line)) {
log.warn("Skipped invalid promo code row in {}: {}", file, line);
continue;
}
result.add(line);
}
return result;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов", e);
}
}
}
@@ -0,0 +1,22 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
public class PromoException extends RuntimeException {
private final HttpStatus status;
public PromoException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public PromoException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}
@@ -0,0 +1,139 @@
package ru.shine.promo.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.p2p.solanaj.core.Account;
import org.p2p.solanaj.core.PublicKey;
import org.p2p.solanaj.core.Transaction;
import org.p2p.solanaj.programs.SystemProgram;
import org.p2p.solanaj.rpc.RpcClient;
import org.p2p.solanaj.rpc.RpcException;
import org.p2p.solanaj.rpc.types.config.Commitment;
import org.p2p.solanaj.rpc.types.config.RpcSendTransactionConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Locale;
@Service
public class PromoTransferService {
private static final Logger log = LoggerFactory.getLogger(PromoTransferService.class);
private final AppProperties appProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
public PromoTransferService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public boolean isSolanaWalletValid(String wallet) {
if (wallet == null || wallet.isBlank()) {
return false;
}
try {
PublicKey publicKey = new PublicKey(wallet.trim());
return publicKey.toByteArray().length == PublicKey.PUBLIC_KEY_LENGTH;
} catch (Exception e) {
return false;
}
}
public String sendPromoTransfer(String recipientWallet) {
PublicKey recipient = parseWalletOrThrow(recipientWallet);
Account sender = readSenderAccount();
long lamports = appProperties.getPromoTransferAmountLamports();
RpcClient rpcClient = new RpcClient(appProperties.getSolanaRpcUrl());
try {
long senderBalance = rpcClient.getApi().getBalance(sender.getPublicKey(), Commitment.CONFIRMED);
if (senderBalance < lamports) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя");
}
Transaction transaction = new Transaction()
.addInstruction(SystemProgram.transfer(sender.getPublicKey(), recipient, lamports));
RpcSendTransactionConfig config = RpcSendTransactionConfig.builder()
.encoding(RpcSendTransactionConfig.Encoding.base64)
.skipPreFlight(false)
.maxRetries(3)
.build();
return rpcClient.getApi().sendTransaction(
transaction,
Collections.singletonList(sender),
null,
config
);
} catch (PromoException e) {
throw e;
} catch (RpcException e) {
String message = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT);
if (message.contains("insufficient")) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя", e);
}
if (message.contains("rpc")) {
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка RPC Solana", e);
}
log.error("Solana transfer failed: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
} catch (Exception e) {
log.error("Unexpected Solana transfer error: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
}
}
public String buildExplorerUrl(String signature) {
return String.format(appProperties.getPromoExplorerTxUrlTemplate(), signature);
}
private PublicKey parseWalletOrThrow(String wallet) {
if (!isSolanaWalletValid(wallet)) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
return new PublicKey(wallet.trim());
}
private Account readSenderAccount() {
Path keypairFile = Path.of(appProperties.getSolanaSenderKeypairFile());
if (!Files.exists(keypairFile)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Файл keypair отправителя не найден");
}
try {
int[] keyArray = objectMapper.readValue(Files.readString(keypairFile), int[].class);
if (keyArray.length != 64) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: ожидается массив из 64 чисел"
);
}
byte[] secret = new byte[64];
for (int i = 0; i < keyArray.length; i++) {
int value = keyArray[i];
if (value < 0 || value > 255) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: числа должны быть в диапазоне 0..255"
);
}
secret[i] = (byte) value;
}
return new Account(secret);
} catch (PromoException e) {
throw e;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения keypair отправителя", e);
}
}
}
@@ -0,0 +1,101 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.ReentrantLock;
@Service
public class UsedPromoStorageService {
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ROOT);
private final AppProperties appProperties;
private final ReentrantLock promoLock = new ReentrantLock(true);
public UsedPromoStorageService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public <T> T executeLocked(LockedOperation<T> operation) {
promoLock.lock();
try {
return operation.run();
} finally {
promoLock.unlock();
}
}
public boolean isPromoUsed(String promoCode) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
if (!Files.exists(usedFile)) {
return false;
}
try {
List<String> rows = Files.readAllLines(usedFile, StandardCharsets.UTF_8);
for (String row : rows) {
String line = row.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\\|");
if (parts.length == 0) {
continue;
}
String usedCode = parts[0].trim().toLowerCase(Locale.ROOT);
if (usedCode.equals(promoCode)) {
return true;
}
}
return false;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла использованных промокодов", e);
}
}
public void appendUsedPromo(String promoCode, String wallet, String name, String signature) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
try {
Path parent = usedFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
if (!Files.exists(usedFile)) {
Files.createFile(usedFile);
}
String timestamp = LocalDateTime.now().format(DATE_TIME_FORMAT);
String line = String.format(
Locale.ROOT,
"%s | %s | %s | %s | %s%n",
promoCode,
wallet,
name,
timestamp,
signature
);
Files.writeString(usedFile, line, StandardCharsets.UTF_8, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка записи файла использованных промокодов", e);
}
}
@FunctionalInterface
public interface LockedOperation<T> {
T run();
}
}