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