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:
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user