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:
@@ -203,4 +203,12 @@ public class StorageBeanConfig {
|
|||||||
ClickHouseUsageTracker usageTracker) {
|
ClickHouseUsageTracker usageTracker) {
|
||||||
return new com.cameleer.server.app.analytics.UsageFlushScheduler(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user