SHA256
Добавлен SHiNE-promo-solana-devnet в основной репозиторий без вложенного git
This commit is contained in:
+30
@@ -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();
|
||||
}
|
||||
}
|
||||
+136
@@ -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,}", " ");
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+139
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
server.port=8021
|
||||
|
||||
spring.application.name=SHiNE-promo-solana-devnet
|
||||
spring.thymeleaf.cache=false
|
||||
|
||||
solana.rpc.url=https://api.devnet.solana.com
|
||||
solana.sender.keypair-file=./config/devnet-wallet.json
|
||||
|
||||
promo.transfer.amount-sol=0.1
|
||||
promo.codes.file=./data/promo-codes.txt
|
||||
promo.used.file=./data/promo-used.txt
|
||||
promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet
|
||||
|
||||
# Вечный промокод для временной раздачи в devnet.
|
||||
# Если enabled=true, код можно использовать неограниченно (он не "сгорает").
|
||||
promo.eternal-code.enabled=true
|
||||
promo.eternal-code.value=0000
|
||||
@@ -0,0 +1,200 @@
|
||||
:root {
|
||||
--bg-main: #070707;
|
||||
--bg-card: #111217;
|
||||
--bg-card-soft: #171a22;
|
||||
--text-main: #f6f8fb;
|
||||
--text-muted: #aeb4c1;
|
||||
--accent: #51ffd3;
|
||||
--accent-soft: rgba(81, 255, 211, 0.15);
|
||||
--danger: #ff6f6f;
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Noto Sans", system-ui, sans-serif;
|
||||
color: var(--text-main);
|
||||
background:
|
||||
radial-gradient(circle at 20% 5%, rgba(81, 255, 211, 0.08), transparent 34%),
|
||||
radial-gradient(circle at 100% 0%, rgba(86, 135, 255, 0.12), transparent 40%),
|
||||
var(--bg-main);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: calc(100vh - 48px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(720px, 100%);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 12px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.3rem, 3.8vw, 1.9rem);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.warning {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
color: #f0f4ff;
|
||||
}
|
||||
|
||||
.promo-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.promo-form label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.promo-form input {
|
||||
width: 100%;
|
||||
background: var(--bg-card-soft);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.promo-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.promo-form button {
|
||||
margin-top: 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(140deg, #2dd4bf, #4cc9f0);
|
||||
color: #0b1116;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.promo-form button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #ffd6d6;
|
||||
border-color: rgba(255, 111, 111, 0.6);
|
||||
background: rgba(255, 111, 111, 0.12);
|
||||
}
|
||||
|
||||
.success {
|
||||
margin-top: 16px;
|
||||
border: 1px solid rgba(81, 255, 211, 0.35);
|
||||
background: rgba(81, 255, 211, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.success h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.12rem;
|
||||
}
|
||||
|
||||
.success p {
|
||||
margin-top: 8px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.success ul {
|
||||
padding-left: 18px;
|
||||
margin: 10px 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.success a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.faq {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.faq h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.faq details {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.faq summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq p {
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
text-align: center;
|
||||
color: #727a88;
|
||||
font-size: 0.8rem;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
padding: 26px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
(() => {
|
||||
const form = document.getElementById("promoForm");
|
||||
const walletInput = document.getElementById("wallet");
|
||||
const nameInput = document.getElementById("name");
|
||||
const promoCodeInput = document.getElementById("promoCode");
|
||||
const submitButton = document.getElementById("submitButton");
|
||||
|
||||
const loadingState = document.getElementById("loadingState");
|
||||
const errorState = document.getElementById("errorState");
|
||||
const successState = document.getElementById("successState");
|
||||
|
||||
const successAmount = document.getElementById("successAmount");
|
||||
const successWallet = document.getElementById("successWallet");
|
||||
const successName = document.getElementById("successName");
|
||||
const successSignature = document.getElementById("successSignature");
|
||||
const successExplorerUrl = document.getElementById("successExplorerUrl");
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const walletFromQuery = (params.get("wallet") || "").trim();
|
||||
if (walletFromQuery && !walletInput.value.trim()) {
|
||||
walletInput.value = walletFromQuery;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
hideMessages();
|
||||
toggleLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
wallet: walletInput.value.trim(),
|
||||
name: nameInput.value.trim(),
|
||||
promoCode: promoCodeInput.value.trim()
|
||||
};
|
||||
|
||||
const response = await fetch("/api/promo/top-up", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
showError(result.message || "Не удалось выполнить операцию");
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess(result);
|
||||
} catch (error) {
|
||||
showError("Ошибка сети или недоступен backend");
|
||||
} finally {
|
||||
toggleLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleLoading(active) {
|
||||
submitButton.disabled = active;
|
||||
loadingState.classList.toggle("hidden", !active);
|
||||
submitButton.textContent = active ? "Отправляем..." : "Пополнить тестовый счёт";
|
||||
}
|
||||
|
||||
function hideMessages() {
|
||||
errorState.classList.add("hidden");
|
||||
successState.classList.add("hidden");
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorState.textContent = message;
|
||||
errorState.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showSuccess(data) {
|
||||
successAmount.textContent = data.amountSol || "0.1";
|
||||
successWallet.textContent = data.wallet || "—";
|
||||
successName.textContent = data.name || "—";
|
||||
successSignature.textContent = data.signature || "—";
|
||||
successExplorerUrl.href = data.explorerUrl || "#";
|
||||
successState.classList.remove("hidden");
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SHiNE / Сияние — тестовое пополнение</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="card">
|
||||
<header class="hero">
|
||||
<h1>SHiNE / Сияние — тестовое пополнение</h1>
|
||||
<p class="subtitle">
|
||||
Временная devnet-страница для приглашённых тестеров Web3-социальной сети SHiNE.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class="lead">
|
||||
Если вы получили промокод, введите его ниже. Мы отправим на ваш Solana devnet-кошелёк 0.1 SOL для
|
||||
тестирования функций SHiNE.
|
||||
</p>
|
||||
|
||||
<div class="warning">
|
||||
Это тестовая сеть Solana Devnet. Эти SOL не являются настоящими деньгами и используются только для
|
||||
проверки работы приложения.
|
||||
</div>
|
||||
|
||||
<form id="promoForm" class="promo-form" novalidate>
|
||||
<label for="wallet">Кошелёк Solana</label>
|
||||
<input id="wallet" name="wallet" type="text" th:value="${wallet}" placeholder="Введите публичный адрес"
|
||||
required>
|
||||
|
||||
<label for="name">Ваше имя / имя тестера</label>
|
||||
<input id="name" name="name" type="text" maxlength="120" placeholder="Например, Иван Петров" required>
|
||||
|
||||
<label for="promoCode">Промокод</label>
|
||||
<input id="promoCode" name="promoCode" type="text" maxlength="8" placeholder="Например, x7k3m2qn"
|
||||
required>
|
||||
|
||||
<button id="submitButton" type="submit">Пополнить тестовый счёт</button>
|
||||
</form>
|
||||
|
||||
<div id="loadingState" class="status hidden">Отправляем транзакцию...</div>
|
||||
<div id="errorState" class="status status-error hidden"></div>
|
||||
|
||||
<section id="successState" class="success hidden" aria-live="polite">
|
||||
<h2>Готово! Тестовое пополнение выполнено</h2>
|
||||
<p>
|
||||
На указанный devnet-кошелёк отправлено 0.1 SOL. Возвращайтесь в приложение SHiNE / Сияние и
|
||||
продолжайте тестирование.
|
||||
</p>
|
||||
<p>
|
||||
Можете закрыть эту страницу и продолжить регистрацию на сайте.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Сумма:</strong> <span id="successAmount">—</span> SOL</li>
|
||||
<li><strong>Кошелёк:</strong> <span id="successWallet">—</span></li>
|
||||
<li><strong>Имя:</strong> <span id="successName">—</span></li>
|
||||
<li><strong>Подпись транзакции:</strong> <span id="successSignature">—</span></li>
|
||||
</ul>
|
||||
<a id="successExplorerUrl" href="#" target="_blank" rel="noopener noreferrer">
|
||||
Открыть транзакцию в Solana Explorer
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="faq">
|
||||
<h3>FAQ</h3>
|
||||
|
||||
<details>
|
||||
<summary>Что такое SHiNE / Сияние?</summary>
|
||||
<p>
|
||||
SHiNE / «Сияние» — это тестируемая Web3-социальная сеть, где аккаунты, ключи и часть действий
|
||||
связаны с блокчейном Solana. Пользователь управляет своим кошельком, а не просто логином и
|
||||
паролем на обычном сервере.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Зачем нужен тестовый баланс?</summary>
|
||||
<p>
|
||||
В SHiNE регистрация и некоторые действия требуют небольших списаний. Это помогает тестировать
|
||||
реальную экономику приложения: регистрацию, переводы, сообщения, звонки и другие функции. Сейчас
|
||||
всё работает в Solana Devnet, поэтому средства тестовые.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Сколько стоит регистрация?</summary>
|
||||
<p>
|
||||
Текущая тестовая стоимость регистрации в SHiNE — 0.1 SOL. Промо-страница отправляет стартовое
|
||||
тестовое пополнение 0.1 SOL. Условия тестирования могут меняться по мере развития проекта.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Можно ли пригласить друга?</summary>
|
||||
<p>
|
||||
Да. После получения тестового баланса и регистрации в SHiNE вы сможете переводить часть тестовых
|
||||
SOL другим пользователям, например друзьям или родственникам, чтобы вместе проверить сообщения,
|
||||
звонки и взаимодействие внутри социальной сети.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Это настоящие деньги?</summary>
|
||||
<p>
|
||||
Нет. Это Solana Devnet — тестовая сеть. Devnet SOL не имеют реальной рыночной ценности и
|
||||
используются только для разработки и тестирования.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Почему в Web3 действия платные?</summary>
|
||||
<p>
|
||||
В Web3 часть действий связана с транзакциями, хранением данных, подписями и сетевой
|
||||
инфраструктурой. Зато такая модель позволяет строить систему без навязчивой рекламы и с большей
|
||||
прозрачностью: пользователь понимает, за что платит, а ключи остаются у него.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Кто хранит мои ключи?</summary>
|
||||
<p>
|
||||
Эта промо-страница не просит и не хранит приватные ключи пользователя. Для пополнения нужен только
|
||||
публичный адрес кошелька Solana Devnet.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">SHiNE Devnet Promo · temporary testing page</footer>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user