feat(license): PostgresLicenseRepository + LicenseRecord

JdbcTemplate-backed repo; upsert is ON CONFLICT (tenant_id), touch
updates only last_validated_at, delete is provided for future
operator-clear flow (not exposed as REST in v1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 11:05:35 +02:00
parent 20aefd5bf6
commit 2e51deb511
5 changed files with 150 additions and 0 deletions

View File

@@ -203,4 +203,12 @@ public class StorageBeanConfig {
ClickHouseUsageTracker usageTracker) {
return new com.cameleer.server.app.analytics.UsageFlushScheduler(usageTracker);
}
// ── License Repository ───────────────────────────────────────────
@Bean
public com.cameleer.server.app.license.LicenseRepository licenseRepository(
JdbcTemplate jdbcTemplate) {
return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate);
}
}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.license;
import java.time.Instant;
import java.util.UUID;
public record LicenseRecord(
String tenantId,
String token,
UUID licenseId,
Instant installedAt,
String installedBy,
Instant expiresAt,
Instant lastValidatedAt
) {}

View File

@@ -0,0 +1,17 @@
package com.cameleer.server.app.license;
import java.time.Instant;
import java.util.Optional;
public interface LicenseRepository {
Optional<LicenseRecord> findByTenantId(String tenantId);
/** Insert or replace the row for tenantId. */
void upsert(LicenseRecord record);
/** Update last_validated_at to `now` and return rows affected (0 = no row). */
int touchValidated(String tenantId, Instant now);
/** Delete the row (used when the operator clears a license; not a public API in v1). */
int delete(String tenantId);
}

View File

@@ -0,0 +1,66 @@
package com.cameleer.server.app.license;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
public class PostgresLicenseRepository implements LicenseRepository {
private final JdbcTemplate jdbc;
public PostgresLicenseRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
private static final RowMapper<LicenseRecord> MAPPER = (rs, n) -> new LicenseRecord(
rs.getString("tenant_id"),
rs.getString("token"),
(UUID) rs.getObject("license_id"),
rs.getTimestamp("installed_at").toInstant(),
rs.getString("installed_by"),
rs.getTimestamp("expires_at").toInstant(),
rs.getTimestamp("last_validated_at").toInstant()
);
@Override
public Optional<LicenseRecord> findByTenantId(String tenantId) {
return jdbc.query(
"SELECT tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at " +
"FROM license WHERE tenant_id = ?",
MAPPER, tenantId).stream().findFirst();
}
@Override
public void upsert(LicenseRecord r) {
jdbc.update(
"INSERT INTO license (tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (tenant_id) DO UPDATE SET " +
" token = EXCLUDED.token, " +
" license_id = EXCLUDED.license_id, " +
" installed_at = EXCLUDED.installed_at, " +
" installed_by = EXCLUDED.installed_by, " +
" expires_at = EXCLUDED.expires_at, " +
" last_validated_at = EXCLUDED.last_validated_at",
r.tenantId(), r.token(), r.licenseId(),
Timestamp.from(r.installedAt()), r.installedBy(),
Timestamp.from(r.expiresAt()), Timestamp.from(r.lastValidatedAt())
);
}
@Override
public int touchValidated(String tenantId, Instant now) {
return jdbc.update(
"UPDATE license SET last_validated_at = ? WHERE tenant_id = ?",
Timestamp.from(now), tenantId);
}
@Override
public int delete(String tenantId) {
return jdbc.update("DELETE FROM license WHERE tenant_id = ?", tenantId);
}
}

View File

@@ -0,0 +1,45 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PostgresLicenseRepositoryIT extends AbstractPostgresIT {
@Autowired LicenseRepository repo;
@Test
void roundTrip() {
UUID id = UUID.randomUUID();
Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);
LicenseRecord rec = new LicenseRecord(
"default", "tok.sig", id, now, "system",
now.plus(365, ChronoUnit.DAYS), now);
repo.upsert(rec);
var loaded = repo.findByTenantId("default").orElseThrow();
assertThat(loaded.licenseId()).isEqualTo(id);
assertThat(loaded.installedBy()).isEqualTo("system");
}
@Test
void touchValidated_updatesTimestamp() throws Exception {
UUID id = UUID.randomUUID();
Instant t0 = Instant.now().truncatedTo(ChronoUnit.MILLIS);
repo.upsert(new LicenseRecord("default", "tok.sig", id, t0, "system",
t0.plus(7, ChronoUnit.DAYS), t0));
Thread.sleep(10);
Instant t1 = Instant.now().truncatedTo(ChronoUnit.MILLIS);
int affected = repo.touchValidated("default", t1);
assertThat(affected).isEqualTo(1);
assertThat(repo.findByTenantId("default").orElseThrow().lastValidatedAt())
.isAfterOrEqualTo(t1);
}
}