Public Access
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:
1 parent
3512159b41
commit
504c935b27
15 files changed
+312
-20
No files matched your search
@@ -129,6 +129,7 @@ method calling another service's method that needs more.
|
|||||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
||||||
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
@@ -173,9 +174,9 @@ flowchart LR
|
|||||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||||
Stats -->|"listed_user_game, deleted_user"| Db
|
Stats -->|"listed_user_game, deleted_user, blocked_user"| Db
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Content |
|
| Module | Content |
|
||||||
@@ -183,15 +184,16 @@ flowchart LR
|
|||||||
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
||||||
| `stats` | Public numbers for the landing page |
|
| `stats` | Public numbers for the landing page |
|
||||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||||
| `account` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows |
|
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, Steam ID changes stop the nightly sync |
|
||||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||||
|
|
||||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes
|
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
|
||||||
the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into
|
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||||
its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls.
|
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||||
|
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
||||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
|
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
|
||||||
|
|
||||||
@@ -222,7 +224,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
dates finished games by the unlock of their ending achievement
|
dates finished games by the unlock of their ending achievement
|
||||||
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
|
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
|
||||||
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
|
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
|
||||||
a user in `deleted_user`.
|
a user in `deleted_user` or `blocked_user`.
|
||||||
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
||||||
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
||||||
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
||||||
@@ -310,6 +312,16 @@ flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned
|
|||||||
user ids, which removes the rows of users Auth does not know (refused for more than
|
user ids, which removes the rows of users Auth does not know (refused for more than
|
||||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||||
|
|
||||||
|
### Blocked users and Steam ID changes
|
||||||
|
|
||||||
|
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
||||||
|
took by a verified link, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
|
||||||
|
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until
|
||||||
|
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
|
||||||
|
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
|
||||||
|
more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one
|
||||||
|
nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
|
||||||
|
|
||||||
### Manual games next to Steam games
|
### Manual games next to Steam games
|
||||||
|
|
||||||
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
|
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package core_service.modules.account.api;
|
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.CleanupResultDto;
|
||||||
import core_service.modules.account.api.dto.KnownUsersRequest;
|
import core_service.modules.account.api.dto.KnownUsersRequest;
|
||||||
import core_service.modules.account.api.dto.PurgeRequest;
|
import core_service.modules.account.api.dto.PurgeRequest;
|
||||||
import core_service.modules.account.api.dto.UserIdsRequest;
|
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.domain.CleanupResult;
|
||||||
import core_service.modules.account.service.AccountCleanupService;
|
import core_service.modules.account.service.AccountCleanupService;
|
||||||
|
import core_service.modules.account.service.AccountStateService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/accounts")
|
@RequestMapping("/internal/accounts")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InternalAccountController {
|
public class InternalAccountController {
|
||||||
|
|
||||||
private final AccountCleanupService accountCleanupService;
|
private final AccountCleanupService accountCleanupService;
|
||||||
|
private final AccountStateService accountStateService;
|
||||||
|
|
||||||
@PostMapping("/deleted")
|
@PostMapping("/deleted")
|
||||||
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
|
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())));
|
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) {
|
private static CleanupResultDto toDto(CleanupResult result) {
|
||||||
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
|
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.
|
// 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",
|
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_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.
|
// 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",
|
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;
|
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
|
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
|
||||||
* (row created, or existing row was not RUNNING / RUNNING marker stale).
|
* (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
|
* @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)
|
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
|
||||||
SELECT :userId, :steamId, 'RUNNING', :startedAt
|
SELECT :userId, :steamId, 'RUNNING', :startedAt
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
|
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
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
SET steam_id = EXCLUDED.steam_id,
|
SET steam_id = EXCLUDED.steam_id,
|
||||||
last_sync_status = 'RUNNING',
|
last_sync_status = 'RUNNING',
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import java.util.Map;
|
|||||||
@Repository
|
@Repository
|
||||||
public class StatsRepository {
|
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;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -21,31 +22,31 @@ public class StatsRepository {
|
|||||||
|
|
||||||
public long countDistinctGames() {
|
public long countDistinctGames() {
|
||||||
Long v = jdbc.queryForObject(
|
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;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countLibraryEntries() {
|
public long countLibraryEntries() {
|
||||||
Long v = jdbc.queryForObject(
|
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;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countGamesPlayed() {
|
public long countGamesPlayed() {
|
||||||
Long v = jdbc.queryForObject(
|
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;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long sumPlaytimeMinutes() {
|
public long sumPlaytimeMinutes() {
|
||||||
Long v = jdbc.queryForObject(
|
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;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUsersWithLibrary() {
|
public long countUsersWithLibrary() {
|
||||||
Long v = jdbc.queryForObject(
|
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;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ public class StatsRepository {
|
|||||||
SELECT COUNT(*) AS c FROM listed_user_game
|
SELECT COUNT(*) AS c FROM listed_user_game
|
||||||
WHERE %s GROUP BY user_id
|
WHERE %s GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""".formatted(NOT_DELETED), Long.class);
|
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ public class StatsRepository {
|
|||||||
SELECT COUNT(*) AS c FROM listed_user_game
|
SELECT COUNT(*) AS c FROM listed_user_game
|
||||||
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""".formatted(NOT_DELETED), Long.class);
|
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||||
return v != null ? v : 0L;
|
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
|
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""".formatted(NOT_DELETED), limit);
|
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> topPlayedGames(int 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
|
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT ?
|
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
|
@AfterEach
|
||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
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() {
|
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();
|
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
|
@Test
|
||||||
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
|
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
|
||||||
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
|
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_LeaveEveryNumber();
|
||||||
hiddenGames_LeaveTheTopLists();
|
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",
|
"path": "/internal/accounts/deleted",
|
||||||
"request": {"userIds": []}
|
"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": {
|
"purge": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/internal/accounts/purge",
|
"path": "/internal/accounts/purge",
|
||||||
|
|||||||
Reference in new issue
Block a user