Solana-first регистрация: lazy-import пользователя при входе, AddUser отключен, UI ожидание 15с

This commit is contained in:
AidarKC 2026-05-27 18:38:45 +04:00
parent 6f0bb01b61
commit 101fd2eaa4
8 changed files with 408 additions and 225 deletions

View File

@ -1,14 +1,14 @@
# API для разработчиков: Регистрация пользователя # API для разработчиков: Регистрация пользователя
Этот файл описывает временный раздел API, связанный с заведением пользователя на сервере и проверкой, существует ли пользователь. Этот файл описывает раздел API, связанный с проверкой наличия пользователя на сервере и dev/test операциями.
Сейчас здесь три метода: Сейчас здесь три метода:
- `AddUser`временная серверная регистрация пользователя; - `AddUser`операция отключена (регистрация только через Solana);
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных; - `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
- `SearchUsers` — dev/test поиск логинов по префиксу. - `SearchUsers` — dev/test поиск логинов по префиксу.
Их логика пока вспомогательная и dev-oriented: сервер сам хранит эти данные локально и сам отвечает на existence-check. В будущем оба сценария должны быть заменены на нормальную работу напрямую через Solana, но пока этот контракт нужен клиентам для разработки и интеграции. Регистрация выполняется через Solana (`shine_users`). Сервер при входе может лениво импортировать пользователя из Solana PDA в локальную БД, если записи ещё нет.
## Статус документа ## Статус документа
@ -22,12 +22,7 @@
### Назначение ### Назначение
Временная регистрация локального пользователя на сервере. Операция отключена. Используется только как явный ответ клиентам старых версий.
Сервер:
- создаёт запись в `solana_users`;
- создаёт стартовое состояние в `blockchain_state`.
### Запрос ### Запрос
@ -46,29 +41,16 @@
} }
``` ```
### Успешный ответ ### Пример ответа
```json ```json
{ {
"op": "AddUser", "op": "AddUser",
"requestId": "reg-001", "requestId": "reg-001",
"status": 200, "status": 410,
"ok": true,
"payload": {
}
}
```
### Пример ошибки
```json
{
"op": "AddUser",
"requestId": "reg-001",
"status": 409,
"ok": false, "ok": false,
"error": "USER_ALREADY_EXISTS", "error": "ADD_USER_DISABLED",
"message": "Пользователь с таким login уже существует", "message": "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana.",
"payload": { "payload": {
} }
} }
@ -76,14 +58,7 @@
### Специфические коды ошибок `AddUser` ### Специфические коды ошибок `AddUser`
- `400 / BAD_FIELDS` — не переданы обязательные поля регистрации. - `410 / ADD_USER_DISABLED` — серверная регистрация отключена, используйте Solana-first flow.
- `400 / BAD_BLOCKCHAIN_NAME``blockchainName` не соответствует формату `<login>-NNN`.
- `400 / BAD_KEY_FORMAT` — один из ключей не является корректным `Base64(32 bytes)`.
- `409 / USER_ALREADY_EXISTS` — пользователь с таким `login` уже есть.
- `409 / BLOCKCHAIN_ALREADY_EXISTS` — такой `blockchainName` уже занят.
- `409 / BLOCKCHAIN_STATE_ALREADY_EXISTS` — стартовое состояние blockchain уже существует.
- `501 / DB_ERROR` — ошибка БД при создании пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
--- ---
@ -95,9 +70,8 @@
Важно: Важно:
- это временное решение; - это server-side existence-check;
- позже клиент должен проверять existence/identity напрямую через Solana; - если пользователя нет в локальной БД, он может быть импортирован при авторизации из Solana PDA.
- на финальный production flow не стоит жёстко завязывать архитектуру клиента на `GetUser`.
### Запрос ### Запрос
@ -209,7 +183,7 @@
## 4. Короткое резюме ## 4. Короткое резюме
- `AddUser`временная регистрация пользователя на сервере. - `AddUser`отключен (`410 / ADD_USER_DISABLED`).
- `GetUser`временная проверка существования пользователя на сервере. - `GetUser` — проверка существования пользователя на сервере.
- `SearchUsers` — временный поиск пользователей по префиксу. - `SearchUsers` — временный поиск пользователей по префиксу.
- И регистрация, и existence-check позже должны быть переведены на Solana. - Регистрация выполняется только через Solana.

View File

@ -73,6 +73,7 @@ ed25519/BASE64_PUBLIC_KEY
- `400 / EMPTY_LOGIN` — пустой `login`. - `400 / EMPTY_LOGIN` — пустой `login`.
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация. - `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден. - `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
- `501 / SOLANA_IMPORT_FAILED` — сервер не смог проверить/импортировать пользователя из Solana при lazy-import.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
--- ---

View File

@ -12,7 +12,7 @@
| Операция | Раздел документации | Кратко | | Операция | Раздел документации | Кратко |
| --- | --- | --- | | --- | --- | --- |
| `AddUser` | `01_User_Registration_API.md` | временная регистрация пользователя | | `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |

View File

@ -1,2 +1,2 @@
client.version=1.2.93 client.version=1.2.94
server.version=1.2.87 server.version=1.2.88

View File

@ -15,6 +15,7 @@ import { registerUserOnSolana } from '../services/solana-register-service.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01; const MIN_REQUIRED_SOL = 0.01;
const SOLANA_SYNC_WAIT_SEC = 15;
function parseBalanceSol(value) { function parseBalanceSol(value) {
const parsed = Number.parseFloat(String(value || '').replace(',', '.')); const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
@ -198,18 +199,7 @@ export function render({ navigate }) {
} }
} }
// Регистрация на сервере SHiNE renderSolanaDoneStage({ navigate, status, keyBundle });
submitButton.textContent = 'Регистрация на сервере...';
await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.registerUserWithKeyBundle(state.registrationDraft.login, keyBundle);
state.registrationDraft.flowType = 'registration';
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = result.keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
navigate('registration-keys-view');
} catch (error) { } catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message); setAuthError(message);
@ -254,3 +244,94 @@ export function render({ navigate }) {
return screen; return screen;
} }
function renderSolanaDoneStage({ navigate, status, keyBundle }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
const card = screen.querySelector('.card.stack');
if (!card) return;
let remainingSec = SOLANA_SYNC_WAIT_SEC;
let canTryLogin = false;
let timerId = null;
const info = document.createElement('p');
info.className = 'auth-copy';
info.textContent = 'Регистрация в Solana прошла успешно.';
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent = 'Подождите 1015 секунд, пока запись обновится в блокчейне. После этого можно входить на сервер.';
const timer = document.createElement('p');
timer.className = 'meta-muted';
timer.textContent = `До попытки входа: ${remainingSec} сек`;
const tryLoginBtn = document.createElement('button');
tryLoginBtn.className = 'primary-btn';
tryLoginBtn.type = 'button';
tryLoginBtn.textContent = `Попробовать войти (${remainingSec})`;
tryLoginBtn.disabled = true;
const backBtn = document.createElement('button');
backBtn.className = 'ghost-btn';
backBtn.type = 'button';
backBtn.textContent = 'Назад';
backBtn.addEventListener('click', () => navigate('register-view'));
const stopTimer = () => {
if (timerId) {
window.clearInterval(timerId);
timerId = null;
}
};
const updateTimerUi = () => {
if (remainingSec > 0) {
timer.textContent = `До попытки входа: ${remainingSec} сек`;
tryLoginBtn.textContent = `Попробовать войти (${remainingSec})`;
tryLoginBtn.disabled = true;
} else {
canTryLogin = true;
timer.textContent = 'Можно входить на сервер.';
tryLoginBtn.textContent = 'Попробовать войти на сервер';
tryLoginBtn.disabled = false;
stopTimer();
}
};
tryLoginBtn.addEventListener('click', async () => {
if (!canTryLogin) return;
status.style.display = 'none';
tryLoginBtn.disabled = true;
tryLoginBtn.textContent = 'Вход...';
try {
await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser(
state.registrationDraft.login,
state.registrationDraft.password,
);
state.registrationDraft.flowType = 'registration';
state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = keyBundle;
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
navigate('registration-keys-view');
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = toUserMessage(error, 'Пока не удалось войти. Попробуйте ещё раз через несколько секунд.');
status.style.display = '';
tryLoginBtn.disabled = false;
tryLoginBtn.textContent = 'Попробовать войти на сервер';
}
});
card.innerHTML = '';
card.append(info, hint, timer, tryLoginBtn, backBtn, status);
updateTimerUi();
timerId = window.setInterval(() => {
remainingSec -= 1;
updateTimerUi();
}, 1000);
}

View File

@ -11,6 +11,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SolanaUsersDAO; import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SolanaUserEntry; import shine.db.entities.SolanaUserEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.SecureRandom; import java.security.SecureRandom;
@ -30,6 +32,7 @@ import java.security.SecureRandom;
*/ */
public class Net_AuthChallenge_Handler implements JsonMessageHandler { public class Net_AuthChallenge_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_AuthChallenge_Handler.class);
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
@Override @Override
@ -58,6 +61,22 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
} }
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
if (solanaUserEntry == null) {
try {
solanaUserEntry = SolanaUserPdaImportService.findOrImportByLogin(login);
if (solanaUserEntry != null) {
log.info("AuthChallenge: пользователь {} импортирован из Solana PDA", solanaUserEntry.getLogin());
}
} catch (Exception e) {
log.error("AuthChallenge: ошибка lazy-import пользователя {} из Solana", login, e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"SOLANA_IMPORT_FAILED",
"Ошибка проверки пользователя в Solana"
);
}
}
if (solanaUserEntry == null) { if (solanaUserEntry == null) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
@ -84,4 +103,4 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
return resp; return resp;
} }
} }

View File

@ -0,0 +1,267 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.SolanaUsersDAO;
import shine.db.dao.UserCreateDAO;
import shine.db.entities.SolanaUserEntry;
import utils.config.SolanaProgramsConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
* Используется при входе, если в solana_users нет записи по login.
*/
public final class SolanaUserPdaImportService {
private static final Logger log = LoggerFactory.getLogger(SolanaUserPdaImportService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final int USER_PDA_SPACE = 768;
private static final String MAGIC = "SHiNE";
private SolanaUserPdaImportService() {}
public static SolanaUserEntry findOrImportByLogin(String loginRaw) throws Exception {
String login = normalizeLogin(loginRaw);
if (login == null) return null;
SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
SolanaUserEntry existing = usersDao.getByLogin(login);
if (existing != null) return existing;
ParsedSolanaUser parsed = fetchFromSolana(login);
if (parsed == null) return null;
long now = System.currentTimeMillis();
long sizeLimit = parsed.paidLimitBytes > 0 ? parsed.paidLimitBytes : 100_000L;
boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain(
parsed.login,
parsed.blockchainName,
parsed.deviceKeyB64, // в текущей модели solanaKey = deviceKey
parsed.blockchainKeyB64,
parsed.deviceKeyB64,
sizeLimit,
now
);
if (!inserted) {
return usersDao.getByLogin(login);
}
return usersDao.getByLogin(login);
}
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
String lenB58 = toBase58(new byte[]{(byte) login.length()});
String reqJson = """
{
"jsonrpc":"2.0",
"id":1,
"method":"getProgramAccounts",
"params":[
"%s",
{
"encoding":"base64",
"filters":[
{"dataSize":%d},
{"memcmp":{"offset":61,"bytes":"%s"}},
{"memcmp":{"offset":62,"bytes":"%s"}}
]
}
]
}
""".formatted(SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID, USER_PDA_SPACE, lenB58, loginB58);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(SolanaProgramsConfig.SOLANA_RPC_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(reqJson))
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IllegalStateException("Solana RPC HTTP status=" + resp.statusCode());
}
JsonNode root = MAPPER.readTree(resp.body());
if (root.hasNonNull("error")) {
throw new IllegalStateException("Solana RPC error: " + root.get("error").toString());
}
JsonNode result = root.path("result");
if (!result.isArray() || result.isEmpty()) return null;
for (JsonNode item : result) {
JsonNode dataNode = item.path("account").path("data");
if (!dataNode.isArray() || dataNode.size() < 1) continue;
String b64 = dataNode.get(0).asText("");
if (b64.isBlank()) continue;
byte[] raw = Base64.getDecoder().decode(b64);
ParsedSolanaUser parsed = parseUserPda(raw);
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
return parsed;
}
}
return null;
}
private static ParsedSolanaUser parseUserPda(byte[] raw) {
if (raw == null || raw.length < 128) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
int recordLen = u16le(raw, 7);
if (recordLen < 73 || recordLen > raw.length) return null;
int c = 9;
c += 8; // created_at_ms
c += 8; // updated_at_ms
c += 4; // record_number
c += 32; // prev_record_hash
int loginLen = u8(raw, c++);
if (loginLen <= 0 || c + loginLen > recordLen) return null;
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
c += loginLen;
int blocksCount = u8(raw, c++);
String blockchainName = null;
byte[] blockchainKey32 = null;
byte[] deviceKey32 = null;
long paidLimitBytes = 0L;
for (int i = 0; i < blocksCount; i++) {
int blockType = u8(raw, c++);
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 1) {
c += 32;
} else if (blockType == 2) {
deviceKey32 = slice(raw, c, 32);
c += 32;
} else if (blockType == 3) {
int count = u8(raw, c++);
for (int j = 0; j < count; j++) {
c += 1; // blockchain_type
int bchLen = u8(raw, c++);
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
c += bchLen;
blockchainKey32 = slice(raw, c, 32);
c += 32;
paidLimitBytes = u64le(raw, c);
c += 8;
c += 8; // used_bytes
c += 4; // last_block_number
c += 32; // last_block_hash
c += 64; // last_block_signature
int arweavePresent = u8(raw, c++);
if (arweavePresent == 1) {
int arLen = u8(raw, c++);
c += arLen;
} else if (arweavePresent != 0) {
return null;
}
}
} else if (blockType == 4) {
c += 1 + 32;
int addrLen = u8(raw, c++);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (blockType == 40) {
int accessCount = u8(raw, c++);
for (int j = 0; j < accessCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (blockType == 50) {
c += 1;
} else {
return null;
}
if (c > recordLen) return null;
}
if (blockchainName == null || blockchainKey32 == null || deviceKey32 == null) return null;
return new ParsedSolanaUser(
login,
blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(deviceKey32),
paidLimitBytes
);
}
private static String normalizeLogin(String login) {
if (login == null) return null;
String s = login.trim();
if (s.isEmpty()) return null;
return s.toLowerCase();
}
private static int u8(byte[] b, int o) { return b[o] & 0xFF; }
private static int u16le(byte[] b, int o) {
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
}
private static long u64le(byte[] b, int o) {
long out = 0L;
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
return out;
}
private static byte[] slice(byte[] b, int o, int len) {
byte[] out = new byte[len];
System.arraycopy(b, o, out, 0, len);
return out;
}
// Нужен для memcmp.bytes в Solana RPC.
private static String toBase58(byte[] input) {
final char[] alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
int zeros = 0;
while (zeros < input.length && input[zeros] == 0) zeros++;
byte[] tmp = new byte[input.length * 2];
int j = tmp.length;
int startAt = zeros;
while (startAt < input.length) {
int mod = divmod58(input, startAt);
if (input[startAt] == 0) startAt++;
tmp[--j] = (byte) alphabet[mod];
}
while (j < tmp.length && tmp[j] == alphabet[0]) j++;
while (--zeros >= 0) tmp[--j] = (byte) alphabet[0];
return new String(tmp, j, tmp.length - j, StandardCharsets.US_ASCII);
}
private static int divmod58(byte[] number, int startAt) {
int remainder = 0;
for (int i = startAt; i < number.length; i++) {
int digit256 = number[i] & 0xFF;
int temp = remainder * 256 + digit256;
number[i] = (byte) (temp / 58);
remainder = temp % 58;
}
return remainder;
}
private record ParsedSolanaUser(
String login,
String blockchainName,
String blockchainKeyB64,
String deviceKeyB64,
long paidLimitBytes
) {}
}

View File

@ -1,185 +1,26 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest; package server.logic.ws_protocol.JSON.handlers.tempToTest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserEntry;
import utils.blockchain.BlockchainNameUtil;
import java.sql.Connection;
import java.sql.SQLException;
/**
* AddUser отключен: регистрация работает через Solana.
*/
public class Net_AddUser_Handler implements JsonMessageHandler { public class Net_AddUser_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class);
/** TEST ONLY */
private static final int TEST_BCH_LIMIT = 1_000_000;
@Override @Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; Net_AddUser_Request req = (Net_AddUser_Request) baseRequest;
return NetExceptionResponseFactory.error(
if (req.getLogin() == null || req.getLogin().isBlank() req,
|| req.getBlockchainName() == null || req.getBlockchainName().isBlank() 410,
|| req.getSolanaKey() == null || req.getSolanaKey().isBlank() "ADD_USER_DISABLED",
|| req.getBlockchainKey() == null || req.getBlockchainKey().isBlank() "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana."
|| req.getDeviceKey() == null || req.getDeviceKey().isBlank()) { );
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey"
);
}
// blockchainName должен быть вида: <login>-NNN
if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BLOCKCHAIN_NAME",
"blockchainName должен быть вида <login>-NNN (пример: anya-001)"
);
}
int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0)
? TEST_BCH_LIMIT
: req.getBchLimit();
try {
// базовая валидация форматов ключей: Base64(32 bytes)
byte[] solanaKey32;
byte[] blockchainKey32;
byte[] deviceKey32;
try {
solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey");
blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey");
deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey");
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_KEY_FORMAT",
e.getMessage()
);
}
// (переменные не используются дальше, но оставляем для ясности проверки длины)
if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_KEY_FORMAT",
"solanaKey/blockchainKey/deviceKey должны быть Base64(32 bytes)"
);
}
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
SqliteDbController db = SqliteDbController.getInstance();
try (Connection c = db.getConnection()) {
c.setAutoCommit(false);
// 1. Проверяем, что пользователя нет (case-insensitive)
if (usersDAO.getByLogin(c, req.getLogin()) != null) {
return NetExceptionResponseFactory.error(
req,
409,
"USER_ALREADY_EXISTS",
"Пользователь с таким login уже существует"
);
}
// 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД)
if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) {
return NetExceptionResponseFactory.error(
req,
409,
"BLOCKCHAIN_ALREADY_EXISTS",
"Пользователь с таким blockchainName уже существует"
);
}
// 3. На всякий случай оставляем старую проверку blockchain_state,
// потому что эта таблица нужна серверу (состояние цепочки/лимиты).
if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) {
return NetExceptionResponseFactory.error(
req,
409,
"BLOCKCHAIN_STATE_ALREADY_EXISTS",
"blockchain_state уже существует"
);
}
// 4. Создаём пользователя (все поля теперь лежат в solana_users)
SolanaUserEntry user = new SolanaUserEntry();
user.setLogin(req.getLogin());
user.setBlockchainName(req.getBlockchainName());
user.setSolanaKey(req.getSolanaKey());
user.setBlockchainKey(req.getBlockchainKey());
user.setDeviceKey(req.getDeviceKey());
usersDAO.insert(c, user);
// 5. Создаём INITIAL blockchain_state (для работы сервера)
BlockchainStateEntry st = new BlockchainStateEntry();
st.setBlockchainName(req.getBlockchainName());
st.setLogin(req.getLogin());
st.setBlockchainKey(req.getBlockchainKey()); // Base64(32)
st.setLastBlockNumber(-1);
st.setLastBlockHash(new byte[32]);
st.setFileSizeBytes(0);
st.setSizeLimit(limit);
st.setUpdatedAtMs(System.currentTimeMillis());
stateDAO.upsert(c, st);
c.commit();
}
Net_AddUser_Response resp = new Net_AddUser_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}",
req.getLogin(), req.getBlockchainName(), limit);
return resp;
} catch (SQLException e) {
log.error("❌ DB error AddUser", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка БД"
);
} catch (Exception e) {
log.error("❌ Internal error AddUser", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при AddUser", e)
);
}
} }
} }