diff --git a/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java b/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java index 5465d91..87c6689 100644 --- a/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java +++ b/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java @@ -14,9 +14,14 @@ import java.util.List; * - методы с Connection НЕ закрывают соединение * - методы без Connection сами открывают и закрывают соединение * - * ВАЖНО по логике времени: - * - сам DAO делает "технический upsert" - * - правила "не принимать более старый time_ms" должны проверяться в handler-е, в транзакции. + * ЛОГИКА time_ms: + * - БД принимает запись только если она "новее" (time_ms строго больше текущего). + * - Реализовано атомарно одним SQL: UPSERT + WHERE users_params.time_ms < excluded.time_ms + * + * Возврат результата: + * - upsertIfNewer(...) возвращает количество изменённых строк: + * 1 = вставили/обновили + * 0 = проигнорировали (запись уже новее или равная) */ public final class UserParamsDAO { @@ -34,10 +39,14 @@ public final class UserParamsDAO { return instance; } - // -------------------- UPSERT -------------------- + // -------------------- UPSERT (IF NEWER) -------------------- - /** UPSERT с внешним соединением. Соединение НЕ закрывает. */ - public void upsert(Connection c, UserParamEntry e) throws SQLException { + /** + * Атомарный UPSERT "только если новее". + * + * @return 1 если вставили/обновили; 0 если запись не тронули (existing.time_ms >= incoming.time_ms). + */ + public int upsertIfNewer(Connection c, UserParamEntry e) throws SQLException { String sql = """ INSERT INTO users_params ( login, @@ -53,6 +62,7 @@ public final class UserParamsDAO { value = excluded.value, device_key = excluded.device_key, signature = excluded.signature + WHERE users_params.time_ms < excluded.time_ms """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -67,14 +77,14 @@ public final class UserParamsDAO { if (e.getSignature() != null) ps.setString(6, e.getSignature()); else ps.setNull(6, Types.VARCHAR); - ps.executeUpdate(); + return ps.executeUpdate(); // 1 или 0 } } - /** UPSERT без внешнего соединения. Сам открывает/закрывает. */ - public void upsert(UserParamEntry e) throws SQLException { + /** То же самое, но сам открывает/закрывает соединение. */ + public int upsertIfNewer(UserParamEntry e) throws SQLException { try (Connection c = db.getConnection()) { - upsert(c, e); + return upsertIfNewer(c, e); } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java index 1bc618c..2522311 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java @@ -21,32 +21,22 @@ import utils.crypto.Ed25519Util; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.SQLException; -import java.sql.Statement; import java.util.Base64; /** * Net_UpsertUserParam_Handler * - * Делает: - * 1) Проверяет, что пользователь существует и что device_key действительно его. - * 2) Проверяет, что нет "более нового" значения этого param (time_ms монотонно растёт). - * 3) Проверяет подпись Ed25519 по device_key. - * 4) Пишет в БД только если time_ms строго больше текущего сохранённого. + * Делает (MVP, без "сессий"): + * 1) Проверка входных полей. + * 2) Проверка подписи Ed25519 по device_key. + * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. + * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). * - * БОЛЬШОЙ КОММЕНТ ПРО АВТОРИЗАЦИЮ НА БУДУЩЕЕ: - * --------------------------------------------------------------------------------- - * Сейчас (MVP) этот эндпоинт намеренно не делает полноценную "сессию/авторизацию", - * потому что целостность обеспечивается криптографией: сервер проверяет подпись - * и то, что device_key принадлежит login. - * - * В будущем, если понадобится "ограничить кто может писать параметры", можно добавить: - * - проверку активной сессии (active_sessions) и соответствие login в сессии; - * - rate-limit на пользователя; - * - отдельные права на запись конкретных param. - * - * Но возможно это вообще не потребуется, если модель безопасности строится - * строго на подписи и владении device_key (как сейчас). - * --------------------------------------------------------------------------------- + * ВАЖНО: + * - НИКАКИХ ручных транзакций / BEGIN здесь нет. + * - autoCommit=true, каждый statement завершённый сам по себе. + * - Гонки не страшны: если за время проверок кто-то записал более новый time_ms, + * наш финальный UPSERT просто вернёт 0 обновлённых строк. */ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { @@ -79,6 +69,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { final String signatureB64 = req.getSignature().trim(); try { + // ---------------- Base64 decode ---------------- byte[] pubKey32; byte[] sig64; try { @@ -110,6 +101,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { ); } + // ---------------- Signature verify ---------------- String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX + login + param @@ -128,108 +120,68 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { ); } + // ---------------- DB checks + upsert ---------------- SqliteDbController db = SqliteDbController.getInstance(); SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); try (Connection c = db.getConnection()) { - boolean oldAuto = c.getAutoCommit(); - c.setAutoCommit(false); - - try (Statement st = c.createStatement()) { - st.execute("BEGIN IMMEDIATE"); - } - - try { - SolanaUserEntry user = usersDAO.getByLogin(c, login); - if (user == null) { - c.rollback(); - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { - c.rollback(); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" - ); - } - - if (!userDeviceKey.trim().equals(deviceKeyB64)) { - c.rollback(); - return NetExceptionResponseFactory.error( - req, - 403, - "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" - ); - } - - UserParamEntry existing = paramsDAO.getByLoginAndParam(c, login, param); - - // если есть более новое — запрет - if (existing != null && existing.getTimeMs() > timeMs) { - c.rollback(); - return NetExceptionResponseFactory.error( - req, - 409, - "PARAM_NEWER_EXISTS", - "Уже есть более новое значение этого параметра (time_ms больше)" - ); - } - - // если time_ms равен — ничего не делаем (твой кейс) - if (existing != null && existing.getTimeMs() == timeMs) { - c.commit(); - c.setAutoCommit(oldAuto); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("ℹ️ UpsertUserParam noop (same time_ms): login={}, param={}, time_ms={}", - login, param, timeMs); - return resp; - } - - // иначе existing==null или existingTime < timeMs -> пишем - UserParamEntry e = new UserParamEntry( - login, - param, - timeMs, - value, - deviceKeyB64, - signatureB64 + // 1) user exists + SolanaUserEntry user = usersDAO.getByLogin(c, login); + if (user == null) { + return NetExceptionResponseFactory.error( + req, + 404, + "USER_NOT_FOUND", + "Пользователь не найден" ); - - paramsDAO.upsert(c, e); - - c.commit(); - c.setAutoCommit(oldAuto); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("✅ UpsertUserParam ok: login={}, param={}, time_ms={}", login, param, timeMs); - return resp; - - } catch (SQLException ex) { - c.rollback(); - throw ex; - } finally { - c.setAutoCommit(oldAuto); } + + // 2) device key must match the user's stored deviceKey + String userDeviceKey = user.getDeviceKey(); + if (userDeviceKey == null || userDeviceKey.isBlank()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "USER_DEVICE_KEY_EMPTY", + "У пользователя не задан deviceKey в БД" + ); + } + + if (!userDeviceKey.trim().equals(deviceKeyB64)) { + return NetExceptionResponseFactory.error( + req, + 403, + "DEVICE_KEY_MISMATCH", + "device_key не соответствует пользователю" + ); + } + + // 3) atomic upsert-if-newer + UserParamEntry e = new UserParamEntry( + login, + param, + timeMs, + value, + deviceKeyB64, + signatureB64 + ); + + int changed = paramsDAO.upsertIfNewer(c, e); + + Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + if (changed == 1) { + log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs); + } else { + // 0 строк — значит в БД уже есть time_ms >= incoming + log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs); + } + + return resp; } } catch (SQLException e) {