feat(ops-api/audit): annotate audit log IP with region lookup
Add an IPRegionResolver wrapping the ip2region xdb library and attach it to AuditService so each appended event records both the raw IP and a resolved region in a new ops.audit_logs.ip_region column. Loopback and private addresses short-circuit to local labels; missing xdb data or lookup errors degrade silently so auditing keeps working without it. The ops console audit view shows the region beneath the IP. Bundle the v4/v6 xdb data under internal/ops/app/ipregiondata so the resolver works out of the box, with config paths/env overrides for swapping in updated data sets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,7 +64,13 @@
|
||||
<span v-else style="color: #cbd5e1">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'ip'">
|
||||
<span>{{ record.ip || "—" }}</span>
|
||||
<div v-if="record.ip">
|
||||
<span>{{ record.ip }}</span>
|
||||
<div v-if="record.ip_region" class="audit-ip-region">
|
||||
{{ formatIPRegion(record.ip_region) }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else style="color: #94a3b8">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -88,6 +94,7 @@ interface AuditRow {
|
||||
target_id: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
ip_region: string | null;
|
||||
user_agent: string | null;
|
||||
request_id: string | null;
|
||||
created_at: string;
|
||||
@@ -112,7 +119,7 @@ const columns = [
|
||||
{ title: "动作", key: "action", width: 220 },
|
||||
{ title: "对象", key: "target", width: 200 },
|
||||
{ title: "元数据", key: "metadata" },
|
||||
{ title: "IP", key: "ip", width: 140 },
|
||||
{ title: "IP", key: "ip", width: 220 },
|
||||
];
|
||||
|
||||
const rows = ref<AuditRow[]>([]);
|
||||
@@ -183,6 +190,14 @@ function formatMetadata(meta: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatIPRegion(region: string): string {
|
||||
const parts = region
|
||||
.split("|")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part !== "0");
|
||||
return parts.length > 0 ? parts.join(" / ") : region;
|
||||
}
|
||||
|
||||
function resolveActionColor(action: string): string {
|
||||
if (action.endsWith(".failure") || action.includes("disable")) return "red";
|
||||
if (action.endsWith(".success") || action.includes("create")) return "green";
|
||||
@@ -194,3 +209,12 @@ onMounted(() => {
|
||||
void reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-ip-region {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,3 +28,7 @@ default_admin:
|
||||
|
||||
admin_users:
|
||||
default_plan_code: free
|
||||
|
||||
ip_region:
|
||||
v4_xdb_path: /app/data/ip2region_v4.xdb
|
||||
v6_xdb_path: /app/data/ip2region_v6.xdb
|
||||
|
||||
@@ -48,7 +48,13 @@ func main() {
|
||||
adminUsersRepo := repository.NewAdminUserRepository(pool)
|
||||
auditsRepo := repository.NewAuditRepository(pool)
|
||||
|
||||
auditSvc := app.NewAuditService(auditsRepo, logger)
|
||||
ipRegionResolver, err := app.NewIPRegionResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
|
||||
if err != nil {
|
||||
logger.Sugar().Warnf("ops-api ip2region disabled: %v", err)
|
||||
}
|
||||
defer ipRegionResolver.Close()
|
||||
|
||||
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
|
||||
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
|
||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer)
|
||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||
|
||||
@@ -28,3 +28,8 @@ default_admin:
|
||||
|
||||
admin_users:
|
||||
default_plan_code: free
|
||||
|
||||
ip_region:
|
||||
# 可选外部 xdb 路径;文件不存在时自动使用二进制 embed 内置数据。
|
||||
v4_xdb_path: ""
|
||||
v6_xdb_path: ""
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.5.5
|
||||
github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308
|
||||
github.com/minio/minio-go/v7 v7.0.83
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/prometheus/common v0.60.1
|
||||
|
||||
@@ -123,6 +123,8 @@ github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3 h1:DDPccSesc/SJu8Ci
|
||||
github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308 h1:Qbh9Tg+EWuHW2o3Ux0ifWve+IRYWFAzSUt/90I2iRcE=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
|
||||
@@ -10,15 +10,24 @@ import (
|
||||
)
|
||||
|
||||
type AuditService struct {
|
||||
repo *repository.AuditRepository
|
||||
logger *zap.Logger
|
||||
repo *repository.AuditRepository
|
||||
logger *zap.Logger
|
||||
ipRegions *IPRegionResolver
|
||||
}
|
||||
|
||||
func NewAuditService(repo *repository.AuditRepository, logger *zap.Logger) *AuditService {
|
||||
return &AuditService{repo: repo, logger: logger}
|
||||
}
|
||||
|
||||
func (s *AuditService) WithIPRegionResolver(resolver *IPRegionResolver) *AuditService {
|
||||
s.ipRegions = resolver
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AuditService) Append(ctx context.Context, e domain.AuditEvent) error {
|
||||
if e.IPRegion == "" && s.ipRegions != nil {
|
||||
e.IPRegion = s.ipRegions.Lookup(e.IP)
|
||||
}
|
||||
if err := s.repo.Append(ctx, e); err != nil {
|
||||
s.logger.Warn("ops audit append failed",
|
||||
zap.String("action", e.Action),
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||
)
|
||||
|
||||
//go:embed ipregiondata/ip2region_v4.xdb ipregiondata/ip2region_v6.xdb
|
||||
var embeddedIPRegionFS embed.FS
|
||||
|
||||
const (
|
||||
embeddedV4XDBPath = "ipregiondata/ip2region_v4.xdb"
|
||||
embeddedV6XDBPath = "ipregiondata/ip2region_v6.xdb"
|
||||
)
|
||||
|
||||
type IPRegionResolver struct {
|
||||
searcher ipRegionSearcher
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type ipRegionSearcher interface {
|
||||
Search(ip any) (string, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
func NewIPRegionResolver(v4XDBPath, v6XDBPath string, logger *zap.Logger) (*IPRegionResolver, error) {
|
||||
searcher, err := newIPRegionSearcher(v4XDBPath, v6XDBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IPRegionResolver{searcher: searcher, logger: logger}, nil
|
||||
}
|
||||
|
||||
func newIPRegionSearcher(v4XDBPath, v6XDBPath string) (*bufferIPRegionSearcher, error) {
|
||||
v4Content, err := loadIPRegionContent(strings.TrimSpace(v4XDBPath), embeddedV4XDBPath, xdb.IPv4)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ipv4 xdb: %w", err)
|
||||
}
|
||||
v6Content, err := loadIPRegionContent(strings.TrimSpace(v6XDBPath), embeddedV6XDBPath, xdb.IPv6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ipv6 xdb: %w", err)
|
||||
}
|
||||
|
||||
searcher := &bufferIPRegionSearcher{}
|
||||
if len(v4Content) > 0 {
|
||||
searcher.v4, err = xdb.NewWithBuffer(xdb.IPv4, v4Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ipv4 searcher: %w", err)
|
||||
}
|
||||
}
|
||||
if len(v6Content) > 0 {
|
||||
searcher.v6, err = xdb.NewWithBuffer(xdb.IPv6, v6Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ipv6 searcher: %w", err)
|
||||
}
|
||||
}
|
||||
return searcher, nil
|
||||
}
|
||||
|
||||
func loadIPRegionContent(externalPath, embeddedPath string, version *xdb.Version) ([]byte, error) {
|
||||
if externalPath != "" {
|
||||
content, err := loadXDBContentFromFile(externalPath, version)
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
content, err := xdb.LoadContentFromFS(embeddedIPRegionFS, embeddedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyXDBVersion(content, version); err != nil {
|
||||
return nil, fmt.Errorf("verify embedded xdb %s: %w", embeddedPath, err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func loadXDBContentFromFile(path string, version *xdb.Version) ([]byte, error) {
|
||||
content, err := xdb.LoadContentFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyXDBVersion(content, version); err != nil {
|
||||
return nil, fmt.Errorf("verify external xdb %s: %w", path, err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func verifyXDBVersion(content []byte, expected *xdb.Version) error {
|
||||
if len(content) < xdb.HeaderInfoLength {
|
||||
return fmt.Errorf("xdb content too short: got %d bytes", len(content))
|
||||
}
|
||||
header, err := xdb.LoadHeaderFromBuff(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load header: %w", err)
|
||||
}
|
||||
version, err := xdb.VersionFromHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if version.Id != expected.Id {
|
||||
return fmt.Errorf("ip version mismatch: got %s, want %s", version.Name, expected.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *IPRegionResolver) Lookup(rawIP string) string {
|
||||
ipText := strings.TrimSpace(rawIP)
|
||||
if ipText == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(ipText); err == nil {
|
||||
ipText = host
|
||||
}
|
||||
|
||||
ip := net.ParseIP(ipText)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
return "本机"
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return "内网IP"
|
||||
}
|
||||
if r == nil || r.searcher == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
region, err := r.searcher.Search(ip.String())
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("ops audit ip2region lookup failed",
|
||||
zap.String("ip", ip.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(region)
|
||||
}
|
||||
|
||||
type bufferIPRegionSearcher struct {
|
||||
v4 *xdb.Searcher
|
||||
v6 *xdb.Searcher
|
||||
}
|
||||
|
||||
func (s *bufferIPRegionSearcher) Search(ip any) (string, error) {
|
||||
ipBytes, err := parseIPBytes(ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch len(ipBytes) {
|
||||
case xdb.IPv4.Bytes:
|
||||
if s == nil || s.v4 == nil {
|
||||
return "", nil
|
||||
}
|
||||
return s.v4.Search(ipBytes)
|
||||
case xdb.IPv6.Bytes:
|
||||
if s == nil || s.v6 == nil {
|
||||
return "", nil
|
||||
}
|
||||
return s.v6.Search(ipBytes)
|
||||
default:
|
||||
return "", fmt.Errorf("invalid byte ip address with len=%d", len(ipBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *bufferIPRegionSearcher) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.v4 != nil {
|
||||
s.v4.Close()
|
||||
}
|
||||
if s.v6 != nil {
|
||||
s.v6.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPBytes(ip any) ([]byte, error) {
|
||||
switch v := ip.(type) {
|
||||
case string:
|
||||
return xdb.ParseIP(v)
|
||||
case []byte:
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ip value type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *IPRegionResolver) Close() {
|
||||
if r != nil && r.searcher != nil {
|
||||
r.searcher.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIPRegionResolverLookupSpecialAddresses(t *testing.T) {
|
||||
resolver := &IPRegionResolver{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want string
|
||||
}{
|
||||
{name: "ipv4 loopback", ip: "127.0.0.1", want: "本机"},
|
||||
{name: "ipv6 loopback", ip: "::1", want: "本机"},
|
||||
{name: "private network", ip: "192.168.1.10", want: "内网IP"},
|
||||
{name: "host port", ip: "10.0.0.8:443", want: "内网IP"},
|
||||
{name: "invalid", ip: "not-an-ip", want: ""},
|
||||
{name: "public disabled", ip: "8.8.8.8", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := resolver.Lookup(tt.ip); got != tt.want {
|
||||
t.Fatalf("Lookup(%q) = %q, want %q", tt.ip, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPRegionResolverLookupFromXDB(t *testing.T) {
|
||||
resolver, err := NewIPRegionResolver("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPRegionResolver() error = %v", err)
|
||||
}
|
||||
defer resolver.Close()
|
||||
|
||||
if got := resolver.Lookup("8.8.8.8"); got == "" {
|
||||
t.Fatal("Lookup(8.8.8.8) returned empty region")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPRegionResolverExternalPathFallsBackToEmbedWhenMissing(t *testing.T) {
|
||||
resolver, err := NewIPRegionResolver("/path/not/exist/ip2region_v4.xdb", "/path/not/exist/ip2region_v6.xdb", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPRegionResolver() error = %v", err)
|
||||
}
|
||||
defer resolver.Close()
|
||||
|
||||
if got := resolver.Lookup("8.8.8.8"); got == "" {
|
||||
t.Fatal("Lookup(8.8.8.8) returned empty region")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -18,6 +18,7 @@ type Config struct {
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
@@ -36,6 +37,11 @@ type AdminUsersConfig struct {
|
||||
DefaultPlanCode string `mapstructure:"default_plan_code"`
|
||||
}
|
||||
|
||||
type IPRegionConfig struct {
|
||||
V4XDBPath string `mapstructure:"v4_xdb_path"`
|
||||
V6XDBPath string `mapstructure:"v6_xdb_path"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
@@ -52,6 +58,8 @@ func Load(path string) (*Config, error) {
|
||||
v.SetDefault("default_admin.username", "admin")
|
||||
v.SetDefault("default_admin.display_name", "Administrator")
|
||||
v.SetDefault("admin_users.default_plan_code", "free")
|
||||
v.SetDefault("ip_region.v4_xdb_path", "")
|
||||
v.SetDefault("ip_region.v6_xdb_path", "")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
@@ -92,5 +100,11 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" {
|
||||
cfg.AdminUsers.DefaultPlanCode = v
|
||||
}
|
||||
if v := os.Getenv("OPS_IP_REGION_V4_XDB_PATH"); v != "" {
|
||||
cfg.IPRegion.V4XDBPath = v
|
||||
}
|
||||
if v := os.Getenv("OPS_IP_REGION_V6_XDB_PATH"); v != "" {
|
||||
cfg.IPRegion.V6XDBPath = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type AuditEvent struct {
|
||||
TargetID string
|
||||
Metadata map[string]any
|
||||
IP string
|
||||
IPRegion string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
}
|
||||
@@ -48,6 +49,7 @@ type AuditLog struct {
|
||||
TargetID *string
|
||||
Metadata map[string]any
|
||||
IP *string
|
||||
IPRegion *string
|
||||
UserAgent *string
|
||||
RequestID *string
|
||||
CreatedAt time.Time
|
||||
|
||||
@@ -33,15 +33,16 @@ func (r *AuditRepository) Append(ctx context.Context, e domain.AuditEvent) error
|
||||
targetType = nullableString(e.TargetType)
|
||||
targetID = nullableString(e.TargetID)
|
||||
ip = nullableString(e.IP)
|
||||
ipRegion = nullableString(e.IPRegion)
|
||||
ua = nullableString(e.UserAgent)
|
||||
reqID = nullableString(e.RequestID)
|
||||
)
|
||||
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO ops.audit_logs
|
||||
(operator_id, operator_name, action, target_type, target_id, metadata, ip, user_agent, request_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
e.OperatorID, operatorName, e.Action, targetType, targetID, metaBytes, ip, ua, reqID)
|
||||
(operator_id, operator_name, action, target_type, target_id, metadata, ip, ip_region, user_agent, request_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
e.OperatorID, operatorName, e.Action, targetType, targetID, metaBytes, ip, ipRegion, ua, reqID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ func (r *AuditRepository) List(ctx context.Context, f domain.AuditLogFilter) ([]
|
||||
|
||||
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT id, operator_id, operator_name, action, target_type, target_id,
|
||||
metadata, ip, user_agent, request_id, created_at
|
||||
metadata, ip, ip_region, user_agent, request_id, created_at
|
||||
FROM ops.audit_logs
|
||||
WHERE %s
|
||||
ORDER BY created_at DESC, id DESC
|
||||
@@ -104,8 +105,8 @@ func (r *AuditRepository) List(ctx context.Context, f domain.AuditLogFilter) ([]
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&log.ID, &log.OperatorID, &log.OperatorName, &log.Action,
|
||||
&log.TargetType, &log.TargetID, &metaRaw, &log.IP, &log.UserAgent,
|
||||
&log.RequestID, &log.CreatedAt,
|
||||
&log.TargetType, &log.TargetID, &metaRaw, &log.IP, &log.IPRegion,
|
||||
&log.UserAgent, &log.RequestID, &log.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ type auditLogDTO struct {
|
||||
TargetID *string `json:"target_id"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
IP *string `json:"ip"`
|
||||
IPRegion *string `json:"ip_region"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -97,6 +98,7 @@ func toAuditLogDTO(l domain.AuditLog) auditLogDTO {
|
||||
TargetID: l.TargetID,
|
||||
Metadata: l.Metadata,
|
||||
IP: l.IP,
|
||||
IPRegion: l.IPRegion,
|
||||
UserAgent: l.UserAgent,
|
||||
RequestID: l.RequestID,
|
||||
CreatedAt: l.CreatedAt,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE ops.audit_logs
|
||||
DROP COLUMN IF EXISTS ip_region;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE ops.audit_logs
|
||||
ADD COLUMN ip_region VARCHAR(255);
|
||||
Reference in New Issue
Block a user