feat: add Postgres implementations for AuditRepository and ThresholdRepository

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 15:51:13 +01:00
parent 4d33592015
commit e8842e3bdc
2 changed files with 189 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditRecord;
import com.cameleer3.server.core.admin.AuditRepository;
import com.cameleer3.server.core.admin.AuditResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Repository
public class PostgresAuditRepository implements AuditRepository {
private static final Set<String> ALLOWED_SORT_COLUMNS =
Set.of("timestamp", "username", "action", "category");
private static final int MAX_PAGE_SIZE = 100;
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public PostgresAuditRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
@Override
public void insert(AuditRecord record) {
String detailJson = null;
if (record.detail() != null) {
try {
detailJson = objectMapper.writeValueAsString(record.detail());
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize audit detail", e);
}
}
jdbc.update("""
INSERT INTO audit_log (username, action, category, target, detail, result, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?)
""",
record.username(), record.action(),
record.category() != null ? record.category().name() : null,
record.target(), detailJson,
record.result() != null ? record.result().name() : null,
record.ipAddress(), record.userAgent());
}
@Override
public AuditPage find(AuditQuery query) {
int pageSize = Math.min(query.size() > 0 ? query.size() : 20, MAX_PAGE_SIZE);
int offset = query.page() * pageSize;
StringBuilder where = new StringBuilder("WHERE timestamp >= ? AND timestamp <= ?");
List<Object> params = new ArrayList<>();
params.add(Timestamp.from(query.from()));
params.add(Timestamp.from(query.to()));
if (query.username() != null && !query.username().isBlank()) {
where.append(" AND username = ?");
params.add(query.username());
}
if (query.category() != null) {
where.append(" AND category = ?");
params.add(query.category().name());
}
if (query.search() != null && !query.search().isBlank()) {
where.append(" AND (action ILIKE ? OR target ILIKE ?)");
String like = "%" + query.search() + "%";
params.add(like);
params.add(like);
}
// Count query
String countSql = "SELECT COUNT(*) FROM audit_log " + where;
Long totalCount = jdbc.queryForObject(countSql, Long.class, params.toArray());
// Sort column validation
String sortCol = ALLOWED_SORT_COLUMNS.contains(query.sort()) ? query.sort() : "timestamp";
String order = "asc".equalsIgnoreCase(query.order()) ? "ASC" : "DESC";
String dataSql = "SELECT * FROM audit_log " + where
+ " ORDER BY " + sortCol + " " + order
+ " LIMIT ? OFFSET ?";
List<Object> dataParams = new ArrayList<>(params);
dataParams.add(pageSize);
dataParams.add(offset);
List<AuditRecord> items = jdbc.query(dataSql, (rs, rowNum) -> mapRecord(rs), dataParams.toArray());
return new AuditPage(items, totalCount != null ? totalCount : 0);
}
@SuppressWarnings("unchecked")
private AuditRecord mapRecord(ResultSet rs) throws SQLException {
Map<String, Object> detail = null;
String detailStr = rs.getString("detail");
if (detailStr != null) {
try {
detail = objectMapper.readValue(detailStr, Map.class);
} catch (JsonProcessingException e) {
// leave detail as null if unparseable
}
}
Timestamp ts = rs.getTimestamp("timestamp");
String categoryStr = rs.getString("category");
String resultStr = rs.getString("result");
return new AuditRecord(
rs.getLong("id"),
ts != null ? ts.toInstant() : null,
rs.getString("username"),
rs.getString("action"),
categoryStr != null ? AuditCategory.valueOf(categoryStr) : null,
rs.getString("target"),
detail,
resultStr != null ? AuditResult.valueOf(resultStr) : null,
rs.getString("ip_address"),
rs.getString("user_agent")
);
}
}

View File

@@ -0,0 +1,58 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.admin.ThresholdConfig;
import com.cameleer3.server.core.admin.ThresholdRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class PostgresThresholdRepository implements ThresholdRepository {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public PostgresThresholdRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
@Override
public Optional<ThresholdConfig> find() {
List<ThresholdConfig> results = jdbc.query(
"SELECT config FROM admin_thresholds WHERE id = 1",
(rs, rowNum) -> {
String json = rs.getString("config");
try {
return objectMapper.readValue(json, ThresholdConfig.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize threshold config", e);
}
});
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public void save(ThresholdConfig config, String updatedBy) {
String json;
try {
json = objectMapper.writeValueAsString(config);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize threshold config", e);
}
jdbc.update("""
INSERT INTO admin_thresholds (id, config, updated_by, updated_at)
VALUES (1, ?::jsonb, ?, now())
ON CONFLICT (id) DO UPDATE SET
config = EXCLUDED.config,
updated_by = EXCLUDED.updated_by,
updated_at = now()
""",
json, updatedBy);
}
}