diff --git a/SHiNE-promo-solana-devnet/.gitignore b/SHiNE-promo-solana-devnet/.gitignore new file mode 100644 index 0000000..a420490 --- /dev/null +++ b/SHiNE-promo-solana-devnet/.gitignore @@ -0,0 +1,9 @@ +.gradle +/build +.idea +out +*.log + +config/devnet-wallet.json + +!gradle/wrapper/gradle-wrapper.jar diff --git a/SHiNE-promo-solana-devnet/README.md b/SHiNE-promo-solana-devnet/README.md new file mode 100644 index 0000000..9c907ba --- /dev/null +++ b/SHiNE-promo-solana-devnet/README.md @@ -0,0 +1,198 @@ +# SHiNE-promo-solana-devnet + +Временное промо-приложение для тестеров Web3-социальной сети SHiNE / «Сияние» в Solana Devnet. + +Основной сценарий: +- приложение SHiNE открывает страницу вида `/?wallet=SOLANA_PUBLIC_KEY`; +- пользователь вводит имя и промокод; +- backend проверяет промокод и отправляет **реальную** devnet-транзакцию на `0.1 SOL`; +- использованный промокод фиксируется в файле и больше не может быть применён. + +## Стек + +- Java 21 +- Spring Boot +- Gradle +- Thymeleaf + HTML/CSS/JS (server-side UI) + +## Локальный запуск + +1. Скопируйте пример настроек: + +```bash +cp config/application.example.properties src/main/resources/application.properties +``` + +2. Положите реальный keypair-файл Solana CLI формата в: + +```text +config/devnet-wallet.json +``` + +3. Запустите: + +```bash +./gradlew bootRun +``` + +Приложение будет доступно на `http://localhost:8021`. + +## Как указать порт + +По умолчанию используется: + +```properties +server.port=8021 +``` + +Изменить можно: +- в `src/main/resources/application.properties`; +- или через параметр запуска: + +```bash +./gradlew bootRun --args='--server.port=8090' +``` + +## Настройка Solana RPC Devnet + +Параметр: + +```properties +solana.rpc.url=https://api.devnet.solana.com +``` + +Для другого RPC достаточно заменить URL в properties. + +## Настройка devnet keypair + +- Файл должен быть в формате Solana keypair JSON: массив из 64 чисел (`0..255`). +- Пример лежит в `config/devnet-wallet.example.json`. +- Рабочий файл: `config/devnet-wallet.json`. + +Важно: настоящий keypair **нельзя коммитить в GitHub**. +Он добавлен в `.gitignore`. + +## Где лежат промокоды + +Файл: + +```text +data/promo-codes.txt +``` + +## Где лежит файл использованных промокодов + +Файл: + +```text +data/promo-used.txt +``` + +## Формат `promo-codes.txt` + +- одна строка = один промокод; +- пустые строки игнорируются; +- строки с `#` игнорируются как комментарии; +- промокод должен соответствовать regex: `[a-z0-9]{8}`. + +## Формат `promo-used.txt` + +Каждая запись в формате: + +```text +promoCode | wallet | name | yyyy.MM.dd HH:mm | signature +``` + +Пример: + +```text +aidar2km | 8xF...abc | Иван Петров | 2026.04.27 18:45 | 5xTxSignature... +``` + +## Пример URL + +```text +http://localhost:8021/?wallet=8zYQ...DevnetAddress +``` + +## Пример API-запроса + +```bash +curl -X POST http://localhost:8021/api/promo/top-up \ + -H "Content-Type: application/json" \ + -d '{ + "wallet":"SOLANA_PUBLIC_KEY", + "name":"Иван Петров", + "promoCode":"aidar2km" + }' +``` + +## Проверка транзакции в Solana Explorer Devnet + +Explorer URL формируется по шаблону: + +```text +https://explorer.solana.com/tx/{signature}?cluster=devnet +``` + +## Сборка jar через Gradle + +```bash +./gradlew clean build +``` + +JAR: + +```text +build/libs/SHiNE-promo-solana-devnet.jar +``` + +## Запуск jar + +```bash +java -jar build/libs/SHiNE-promo-solana-devnet.jar +``` + +## Health endpoint + +```text +GET /health +``` + +Ответ: + +```json +{ + "status": "ok", + "app": "SHiNE-promo-solana-devnet" +} +``` + +## Логи + +- логируется старт приложения; +- логируются успешные пополнения; +- логируются ошибки транзакций; +- приватный ключ не логируется. + +## Gradle-задачи для серверного деплоя + +В `build.gradle` добавлены задачи: + +- `buildServerBundle` — готовит bundle в `build/server-bundle`; +- `deployToServer` — копирует JAR и `application.properties` на сервер и перезапускает systemd-сервис. + +Запуск: + +```bash +./gradlew deployToServer +``` + +Переопределение хоста/пути: + +```bash +./gradlew deployToServer \ + -PdeployHost=user@10.147.20.7 \ + -PdeployPath=/home/user/docker/SHiNE-promo-solana-devnet \ + -PdeployService=SHiNE-promo-solana-devnet +``` diff --git a/SHiNE-promo-solana-devnet/build.gradle b/SHiNE-promo-solana-devnet/build.gradle new file mode 100644 index 0000000..eb35d19 --- /dev/null +++ b/SHiNE-promo-solana-devnet/build.gradle @@ -0,0 +1,103 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'ru.shine' +version = '0.1.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.mmorrell:solanaj:1.20.4' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.named('bootJar') { + archiveFileName = 'SHiNE-promo-solana-devnet.jar' +} + +// ------------------------------------------------------------ +// ДЕПЛОЙ ВРЕМЕННОГО СЕРВИСА "SHiNE-promo-solana-devnet" +// +// Назначение сервиса: +// - это отдельный продукт для тестовой раздачи SOL в devnet; +// - нужен для упрощённого онбординга тестовых пользователей; +// - работает как самостоятельное Spring Boot приложение. +// +// Почему отдельный деплой: +// - сервис изолирован от основного SHiNE-сервера; +// - его можно обновлять/перезапускать независимо; +// - жизненный цикл временного сервиса не должен ломать основной прод. +// +// ВАЖНО: +// - деплой по умолчанию выполняется ЧЕРЕЗ ДОМЕН (shineup.me), а не по IP; +// - целевая папка на сервере: /home/player/SHiNE/SHiNE-promo-solana-devnet; +// - целевой systemd-сервис: SHiNE-promo-solana-devnet. +// ------------------------------------------------------------ +def deployHost = project.findProperty('deployHost') ?: 'root@shineup.me' +def deployPath = project.findProperty('deployPath') ?: '/home/player/SHiNE/SHiNE-promo-solana-devnet' +def remoteServiceName = project.findProperty('deployService') ?: 'SHiNE-promo-solana-devnet' + +tasks.register('buildServerBundle', Copy) { + // Сборка минимального серверного бандла: + // 1) fat-jar приложения + // 2) шаблон application.properties (чтобы был эталон конфигурации) + dependsOn tasks.named('bootJar') + from(tasks.named('bootJar').flatMap { it.archiveFile }) { + rename { 'SHiNE-promo-solana-devnet.jar' } + } + from('config/application.example.properties') { + rename { 'application.properties' } + } + into(layout.buildDirectory.dir('server-bundle')) +} + +tasks.register('deployToServerMkdir', Exec) { + // Шаг 1: гарантируем существование целевой директории на удалённом сервере. + dependsOn tasks.named('buildServerBundle') + commandLine 'bash', '-lc', "ssh ${deployHost} 'mkdir -p ${deployPath}'" +} + +tasks.register('deployToServerJar', Exec) { + // Шаг 2: загружаем исполняемый jar. + dependsOn tasks.named('deployToServerMkdir') + commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/SHiNE-promo-solana-devnet.jar').get().asFile.absolutePath} ${deployHost}:${deployPath}/" +} + +tasks.register('deployToServerConfig', Exec) { + // Шаг 3: загружаем конфиг-шаблон. + // На проде при необходимости его можно заменить на рабочий конфиг с секретами. + dependsOn tasks.named('deployToServerJar') + commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/application.properties').get().asFile.absolutePath} ${deployHost}:${deployPath}/" +} + +tasks.register('deployToServerRestart', Exec) { + // Шаг 4: перезапускаем systemd-сервис и показываем статус. + // Если сервис не поднялся, ошибка будет видна сразу в этом шаге. + dependsOn tasks.named('deployToServerConfig') + commandLine 'bash', '-lc', "ssh ${deployHost} 'sudo systemctl restart ${remoteServiceName} && sudo systemctl --no-pager status ${remoteServiceName}'" +} + +tasks.register('deployToServer') { + // Единая точка входа для деплоя временного сервиса. + // Запуск: ./gradlew deployToServer + dependsOn tasks.named('deployToServerRestart') +} diff --git a/SHiNE-promo-solana-devnet/config/application.example.properties b/SHiNE-promo-solana-devnet/config/application.example.properties new file mode 100644 index 0000000..944690a --- /dev/null +++ b/SHiNE-promo-solana-devnet/config/application.example.properties @@ -0,0 +1,15 @@ +server.port=8021 + +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 diff --git a/SHiNE-promo-solana-devnet/config/devnet-wallet.example.json b/SHiNE-promo-solana-devnet/config/devnet-wallet.example.json new file mode 100644 index 0000000..61463f7 --- /dev/null +++ b/SHiNE-promo-solana-devnet/config/devnet-wallet.example.json @@ -0,0 +1,6 @@ +[ + 151, 22, 193, 47, 88, 234, 15, 201, 42, 19, 180, 76, 211, 5, 164, 91, + 207, 135, 44, 173, 61, 242, 14, 99, 158, 208, 39, 117, 10, 226, 95, 132, + 56, 72, 164, 209, 41, 189, 76, 239, 121, 18, 66, 213, 30, 145, 201, 9, + 177, 53, 120, 87, 200, 65, 33, 251, 102, 74, 186, 44, 160, 7, 92, 137 +] diff --git a/SHiNE-promo-solana-devnet/deploy/SHiNE-promo-solana-devnet.service b/SHiNE-promo-solana-devnet/deploy/SHiNE-promo-solana-devnet.service new file mode 100644 index 0000000..c75f976 --- /dev/null +++ b/SHiNE-promo-solana-devnet/deploy/SHiNE-promo-solana-devnet.service @@ -0,0 +1,16 @@ +[Unit] +Description=SHiNE Promo Solana Devnet +After=network.target + +[Service] +Type=simple +User=player +Group=player +WorkingDirectory=/home/player/SHiNE/SHiNE-promo-solana-devnet +ExecStart=/usr/bin/java -jar /home/player/SHiNE/SHiNE-promo-solana-devnet/SHiNE-promo-solana-devnet.jar --spring.config.location=/home/player/SHiNE/SHiNE-promo-solana-devnet/application.properties +Restart=always +RestartSec=5 +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.jar b/SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e06f1ed Binary files /dev/null and b/SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.jar differ diff --git a/SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.properties b/SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5b07891 --- /dev/null +++ b/SHiNE-promo-solana-devnet/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Apr 26 23:49:28 MSK 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/SHiNE-promo-solana-devnet/gradlew b/SHiNE-promo-solana-devnet/gradlew new file mode 100755 index 0000000..2721119 --- /dev/null +++ b/SHiNE-promo-solana-devnet/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/SHiNE-promo-solana-devnet/gradlew.bat b/SHiNE-promo-solana-devnet/gradlew.bat new file mode 100644 index 0000000..9a51268 --- /dev/null +++ b/SHiNE-promo-solana-devnet/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/SHiNE-promo-solana-devnet/settings.gradle b/SHiNE-promo-solana-devnet/settings.gradle new file mode 100644 index 0000000..5c50eed --- /dev/null +++ b/SHiNE-promo-solana-devnet/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'SHiNE-promo-solana-devnet' \ No newline at end of file diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/ShinePromoSolanaDevnetApplication.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/ShinePromoSolanaDevnetApplication.java new file mode 100644 index 0000000..989d6dd --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/ShinePromoSolanaDevnetApplication.java @@ -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() + ); + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/config/AppProperties.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/config/AppProperties.java new file mode 100644 index 0000000..a7b3781 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/config/AppProperties.java @@ -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(); + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoApiController.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoApiController.java new file mode 100644 index 0000000..18d6ee5 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoApiController.java @@ -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 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 health() { + return Map.of( + "status", "ok", + "app", "SHiNE-promo-solana-devnet" + ); + } + + private ResponseEntity 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,}", " "); + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoPageController.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoPageController.java new file mode 100644 index 0000000..f9e38d1 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoPageController.java @@ -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"; + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoRequest.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoRequest.java new file mode 100644 index 0000000..c0ef86a --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoRequest.java @@ -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; + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoResponse.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoResponse.java new file mode 100644 index 0000000..ebd7835 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoResponse.java @@ -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; + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoCodeService.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoCodeService.java new file mode 100644 index 0000000..f14b4f8 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoCodeService.java @@ -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 codes = readPromoCodesFromFile(); + return codes.contains(promoCode); + } + + private Set readPromoCodesFromFile() { + Path file = Path.of(appProperties.getPromoCodesFile()); + if (!Files.exists(file)) { + throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов"); + } + + try { + Set 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); + } + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoException.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoException.java new file mode 100644 index 0000000..a28db39 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoException.java @@ -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; + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoTransferService.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoTransferService.java new file mode 100644 index 0000000..dda24a2 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoTransferService.java @@ -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); + } + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/UsedPromoStorageService.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/UsedPromoStorageService.java new file mode 100644 index 0000000..f27acaf --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/UsedPromoStorageService.java @@ -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 executeLocked(LockedOperation 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 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 run(); + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/resources/application.properties b/SHiNE-promo-solana-devnet/src/main/resources/application.properties new file mode 100644 index 0000000..ef6e660 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/resources/application.properties @@ -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 diff --git a/SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css b/SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css new file mode 100644 index 0000000..1846927 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css @@ -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; + } +} diff --git a/SHiNE-promo-solana-devnet/src/main/resources/static/js/app.js b/SHiNE-promo-solana-devnet/src/main/resources/static/js/app.js new file mode 100644 index 0000000..df32781 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/resources/static/js/app.js @@ -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"); + } +})(); diff --git a/SHiNE-promo-solana-devnet/src/main/resources/templates/index.html b/SHiNE-promo-solana-devnet/src/main/resources/templates/index.html new file mode 100644 index 0000000..fb0c853 --- /dev/null +++ b/SHiNE-promo-solana-devnet/src/main/resources/templates/index.html @@ -0,0 +1,137 @@ + + + + + + SHiNE / Сияние — тестовое пополнение + + + +
+
+
+

SHiNE / Сияние — тестовое пополнение

+

+ Временная devnet-страница для приглашённых тестеров Web3-социальной сети SHiNE. +

+
+ +

+ Если вы получили промокод, введите его ниже. Мы отправим на ваш Solana devnet-кошелёк 0.1 SOL для + тестирования функций SHiNE. +

+ +
+ Это тестовая сеть Solana Devnet. Эти SOL не являются настоящими деньгами и используются только для + проверки работы приложения. +
+ +
+ + + + + + + + + + +
+ + + + + + +
+

FAQ

+ +
+ Что такое SHiNE / Сияние? +

+ SHiNE / «Сияние» — это тестируемая Web3-социальная сеть, где аккаунты, ключи и часть действий + связаны с блокчейном Solana. Пользователь управляет своим кошельком, а не просто логином и + паролем на обычном сервере. +

+
+ +
+ Зачем нужен тестовый баланс? +

+ В SHiNE регистрация и некоторые действия требуют небольших списаний. Это помогает тестировать + реальную экономику приложения: регистрацию, переводы, сообщения, звонки и другие функции. Сейчас + всё работает в Solana Devnet, поэтому средства тестовые. +

+
+ +
+ Сколько стоит регистрация? +

+ Текущая тестовая стоимость регистрации в SHiNE — 0.1 SOL. Промо-страница отправляет стартовое + тестовое пополнение 0.1 SOL. Условия тестирования могут меняться по мере развития проекта. +

+
+ +
+ Можно ли пригласить друга? +

+ Да. После получения тестового баланса и регистрации в SHiNE вы сможете переводить часть тестовых + SOL другим пользователям, например друзьям или родственникам, чтобы вместе проверить сообщения, + звонки и взаимодействие внутри социальной сети. +

+
+ +
+ Это настоящие деньги? +

+ Нет. Это Solana Devnet — тестовая сеть. Devnet SOL не имеют реальной рыночной ценности и + используются только для разработки и тестирования. +

+
+ +
+ Почему в Web3 действия платные? +

+ В Web3 часть действий связана с транзакциями, хранением данных, подписями и сетевой + инфраструктурой. Зато такая модель позволяет строить систему без навязчивой рекламы и с большей + прозрачностью: пользователь понимает, за что платит, а ключи остаются у него. +

+
+ +
+ Кто хранит мои ключи? +

+ Эта промо-страница не просит и не хранит приватные ключи пользователя. Для пополнения нужен только + публичный адрес кошелька Solana Devnet. +

+
+
+
+
+ +
SHiNE Devnet Promo · temporary testing page
+ + + +