feat(account): Steam ID and blocked state from Auth (V25)

A blocked user gets no sync and leaves the landing stats. A new or removed Steam ID stops the nightly sync of the old one. No token check in Core: the token lives until it expires.
This commit is contained in:
lobo committed 2026-09-30 15:00:47 +02:00
1 parent 3512159b41
commit 504c935b27
15 files changed
+312 -20

No files matched your search

@@ -1,11 +1,14 @@
package core_service.modules.account.api;
import core_service.modules.account.api.dto.AccountStatesRequest;
import core_service.modules.account.api.dto.CleanupResultDto;
import core_service.modules.account.api.dto.KnownUsersRequest;
import core_service.modules.account.api.dto.PurgeRequest;
import core_service.modules.account.api.dto.UserIdsRequest;
import core_service.modules.account.domain.AccountState;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.service.AccountCleanupService;
import core_service.modules.account.service.AccountStateService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -15,12 +18,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/internal/accounts")
@RequiredArgsConstructor
public class InternalAccountController {
private final AccountCleanupService accountCleanupService;
private final AccountStateService accountStateService;
@PostMapping("/deleted")
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
@@ -44,6 +50,24 @@ public class InternalAccountController {
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
}
@PostMapping("/states")
public ResponseEntity<Void> applyStates(@Valid @RequestBody AccountStatesRequest request) {
accountStateService.apply(toStates(request));
return ResponseEntity.noContent().build();
}
@PutMapping("/states")
public ResponseEntity<Void> replaceStates(@Valid @RequestBody AccountStatesRequest request) {
accountStateService.replaceAll(toStates(request));
return ResponseEntity.noContent().build();
}
private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.blocked()))
.toList();
}
private static CleanupResultDto toDto(CleanupResult result) {
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
}
@@ -0,0 +1,12 @@
package core_service.modules.account.api.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) {
}
}
@@ -0,0 +1,9 @@
package core_service.modules.account.domain;
/**
* What Core copies from Auth for one user.
*
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
*/
public record AccountState(long userId, Long steamId, boolean blocked) {
}
@@ -21,11 +21,11 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "user_game", "game_import", "deleted_user");
"user_steam_sync", "user_game", "game_import", "blocked_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
"game_import", "deleted_user");
"game_import", "blocked_user", "deleted_user");
private final NamedParameterJdbcTemplate jdbc;
@@ -0,0 +1,54 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/**
* Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library
* module owns the entities.
*/
@Repository
@RequiredArgsConstructor
public class AccountStateRepository {
private final NamedParameterJdbcTemplate jdbc;
public void apply(AccountState state) {
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else {
jdbc.update("delete from blocked_user where user_id = :userId", params);
}
if (state.blocked() || state.steamId() == null) {
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
params);
} else {
// A new ID: no nightly sync with the old one, and the next visit syncs at once. steam_id stays, so that
// sync sees the change and drops the old account's games.
jdbc.update("""
update user_steam_sync set user_seen_at = null, last_synced_at = null
where user_id = :userId and steam_id is distinct from :steamId""", params);
}
}
/**
* After this, exactly the blocked users of the list are blocked.
*/
public void replaceAll(Collection<AccountState> states) {
states.forEach(this::apply);
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
if (blocked.isEmpty()) {
jdbc.update("delete from blocked_user", new MapSqlParameterSource());
} else {
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
}
}
}
@@ -0,0 +1,39 @@
package core_service.modules.account.service;
import core_service.modules.account.domain.AccountState;
import core_service.modules.account.repo.AccountStateRepository;
import core_service.shared.security.Internal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Steam ID and block of each user, driven by Auth. Auth sends a change at once and the whole set nightly, so a
* missed call heals by the next night.
*/
@Service
@Internal
@RequiredArgsConstructor
@Slf4j
public class AccountStateService {
private final AccountStateRepository repository;
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void apply(List<AccountState> states) {
states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size());
}
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public void replaceAll(List<AccountState> states) {
repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size());
}
}
@@ -26,7 +26,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
/**
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
* (row created, or existing row was not RUNNING / RUNNING marker stale).
* Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip.
* Returns 0 if another sync is already in flight, or the user is deleted or blocked (deleted_user,
* blocked_user) — caller must skip.
*
* @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it
*/
@@ -35,6 +36,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
SELECT :userId, :steamId, 'RUNNING', :startedAt
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING',
@@ -10,7 +10,8 @@ import java.util.Map;
@Repository
public class StatsRepository {
private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)";
private static final String NOT_DELETED_OR_BLOCKED =
"user_id NOT IN (SELECT user_id FROM deleted_user UNION SELECT user_id FROM blocked_user)";
private final JdbcTemplate jdbc;
@@ -21,31 +22,31 @@ public class StatsRepository {
public long countDistinctGames() {
Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L;
}
public long countLibraryEntries() {
Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L;
}
public long countGamesPlayed() {
Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " AND playtime_minutes > 0", Long.class);
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
return v != null ? v : 0L;
}
public long sumPlaytimeMinutes() {
Long v = jdbc.queryForObject(
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L;
}
public long countUsersWithLibrary() {
Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
"SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L;
}
@@ -55,7 +56,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id
) t
""".formatted(NOT_DELETED), Long.class);
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L;
}
@@ -65,7 +66,7 @@ public class StatsRepository {
SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t
""".formatted(NOT_DELETED), Long.class);
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
return v != null ? v : 0L;
}
@@ -78,7 +79,7 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC
LIMIT ?
""".formatted(NOT_DELETED), limit);
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
}
public List<Map<String, Object>> topPlayedGames(int limit) {
@@ -90,6 +91,6 @@ public class StatsRepository {
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC
LIMIT ?
""".formatted(NOT_DELETED), limit);
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
}
}
@@ -0,0 +1,6 @@
-- Users Auth has blocked (item 28 X4). Like deleted_user, but a block can be lifted: no nightly sync, not in the
-- stats, no new sync run. The rows stay; unblock deletes the marker.
CREATE TABLE blocked_user (
user_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -45,6 +45,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
}
private static String serviceToken() {
@@ -0,0 +1,103 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountStateRepository.class)
class AccountStateRepositoryTest extends AbstractPersistenceTest {
private static final long OLD_STEAM_ID = 76561198000000001L;
private static final long NEW_STEAM_ID = 76561198000000002L;
@Autowired
private AccountStateRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from blocked_user");
for (long userId : List.of(1L, 2L)) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
}
}
private Map<String, Object> sync(long userId) {
return jdbc.queryForMap("select steam_id, last_synced_at, user_seen_at from user_steam_sync where user_id = ?",
userId);
}
private List<Long> blocked() {
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
}
@Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull();
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
assertThat(blocked()).isEmpty();
}
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
repository.apply(new AccountState(1L, NEW_STEAM_ID, false));
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull();
assertThat(sync(1L).get("user_seen_at")).isNull();
}
@Test
void sameSteamId_KeepsTheLastSync() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
assertThat(sync(1L).get("last_synced_at")).isNotNull();
}
@Test
void noSteamId_StopsTheNightlySync() {
repository.apply(new AccountState(1L, null, false));
assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
}
@Test
void userWithoutASyncRow_GetsOnlyTheBlock() {
repository.apply(new AccountState(3L, NEW_STEAM_ID, true));
assertThat(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
}
@Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
repository.apply(new AccountState(2L, OLD_STEAM_ID, true));
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true)));
assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of());
assertThat(blocked()).isEmpty();
}
}
@@ -45,6 +45,14 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(claim(3L)).isZero();
}
@Test
void tryClaimRun_RefusesABlockedUser() {
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
jdbc.update("insert into blocked_user (user_id) values (3)");
assertThat(claim(3L)).isZero();
}
@Test
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
@@ -76,4 +76,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists();
}
@Test
void blockedUsers_LeaveEveryNumberAndTheTopLists() {
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
hiddenGames_LeaveEveryNumber();
hiddenGames_LeaveTheTopLists();
}
}
@@ -55,6 +55,16 @@
"path": "/internal/accounts/deleted",
"request": {"userIds": []}
},
"applyStates": {
"method": "POST",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
},
"replaceStates": {
"method": "PUT",
"path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
},
"purge": {
"method": "POST",
"path": "/internal/accounts/purge",