From 75d1fcf3452a904db37403660d1d31daa310cdc4 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 13 Aug 2026 09:59:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(access-logs):=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E6=94=AF=E6=8C=81=E6=8C=89=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=A0=81=E7=AD=9B=E9=80=89=E5=B9=B6=E6=8A=98=E5=8F=A0=E6=AC=A1?= =?UTF-8?q?=E8=A6=81=E6=90=9C=E7=B4=A2=E9=A1=B9=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=9D=A5=E6=BA=90=E5=88=86=E5=B8=83=E6=97=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 PostgreSQL/SQLite 日志库下首页「来源分布」卡片无数据:RegionCounts 对空 节点 ID 误拼 node_id = '' 恒空条件,改为空节点 ID 表示全节点聚合(对齐 CH 语义), 并过滤空白归属地 - /access-logs?tab=list 新增状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 选项, 校验 100-599,非法值返回 400;ClickHouse 与 PostgreSQL/SQLite 日志库均支持 - 搜索框折叠:默认仅显示来源 IP 与状态码,节点 ID/访问域名/请求路径折叠进 「更多筛选」 --- docs/changelog/index.md | 4 + docs/docs.go | 8 +- docs/swagger.json | 8 +- docs/swagger.yaml | 6 +- .../components/access-log-filters.tsx | 155 ++++++++++++------ .../components/access-log-utils.ts | 18 ++ frontend/app/(main)/access-logs/page.tsx | 5 + frontend/lib/services/openflare/types.ts | 1 + .../observability/access_log_logics.go | 3 + internal/apps/openflare/observability/errs.go | 9 + .../observability/query_params_test.go | 46 ++++++ .../apps/openflare/observability/routers.go | 32 +++- internal/model/analytics/filter.go | 18 +- internal/model/openflare_observability.go | 18 +- .../repository/analytics/access_log_test.go | 10 ++ .../analytics/node_access_log_filter.go | 6 +- .../repository/logstore/postgres_store.go | 12 +- .../logstore/postgres_store_test.go | 38 +++++ 18 files changed, 322 insertions(+), 75 deletions(-) create mode 100644 internal/apps/openflare/observability/errs.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 7c1c1c8a..b0081784 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -18,7 +18,11 @@ sidebar: false ## [Unreleased] +### 新增 +- 访问日志「日志明细」支持按 HTTP 状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 快捷选项,筛选条件贯穿 ClickHouse 与 PostgreSQL/SQLite 日志库。 + ### 🛠 修复 +- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据:聚合查询对空节点 ID 误拼 `node_id = ''` 恒空条件,现改为空节点 ID 表示全节点聚合(与 ClickHouse 语义一致),并同步过滤空白归属地。 - 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应:`proxy_intercept_errors` 会在 Lua 判断前丢弃源站错误响应体,POST/PUT 等请求收到 503 时被 OpenResty 自带错误页覆盖原始报错数据;现改为在代理层用 Lua 过滤器(`header_filter`/`body_filter`)仅对 GET 请求替换错误页,非 GET 请求完整透传源站原始状态码与响应体;非仅 GET 模式继续使用命名 location(`@__openflare_origin_error`)承载错误页,保留原始请求方法与错误状态码。 - 修复 PostgreSQL 作为日志库时节点访问日志/可观测指标/用户访问日志批量写入失败:GORM 对零值 `uint64` 主键会省略 `id` 列,而 PG 日志表 `id` 无默认值,导致持续报「null value in column id violates not-null constraint」;现于落库前为零 ID 行生成雪花 ID(与 ClickHouse 写入路径一致),并新增回归测试覆盖六张日志表。 diff --git a/docs/docs.go b/docs/docs.go index c32de5a9..a765b1b1 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4809,7 +4809,7 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限", + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限", "produces": [ "application/json" ], @@ -4842,6 +4842,12 @@ const docTemplate = `{ "name": "path", "in": "query" }, + { + "type": "integer", + "description": "HTTP 状态码(100-599)", + "name": "status_code", + "in": "query" + }, { "type": "integer", "description": "页码", diff --git a/docs/swagger.json b/docs/swagger.json index a6370e1d..8c4a062d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4802,7 +4802,7 @@ "SessionCookie": [] } ], - "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限", + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限", "produces": [ "application/json" ], @@ -4835,6 +4835,12 @@ "name": "path", "in": "query" }, + { + "type": "integer", + "description": "HTTP 状态码(100-599)", + "name": "status_code", + "in": "query" + }, { "type": "integer", "description": "页码", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f025db8a..6385ee1d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -7194,7 +7194,7 @@ paths: - config /api/v1/d/access-logs: get: - description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限 + description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限 parameters: - description: 节点 ID in: query @@ -7212,6 +7212,10 @@ paths: in: query name: path type: string + - description: HTTP 状态码(100-599) + in: query + name: status_code + type: integer - description: 页码 in: query name: p diff --git a/frontend/app/(main)/access-logs/components/access-log-filters.tsx b/frontend/app/(main)/access-logs/components/access-log-filters.tsx index c06fd6d9..0d4b7a05 100644 --- a/frontend/app/(main)/access-logs/components/access-log-filters.tsx +++ b/frontend/app/(main)/access-logs/components/access-log-filters.tsx @@ -1,8 +1,14 @@ 'use client'; -import { Search } from 'lucide-react'; +import { useState } from 'react'; +import { ChevronDown, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Select, @@ -12,7 +18,7 @@ import { SelectValue, } from '@/components/ui/select'; import type { SearchDraft } from './access-log-utils'; -import { PAGE_SIZE_OPTIONS } from './access-log-utils'; +import { PAGE_SIZE_OPTIONS, STATUS_CODE_OPTIONS } from './access-log-utils'; interface AccessLogFiltersProps { draft: SearchDraft; @@ -23,6 +29,21 @@ interface AccessLogFiltersProps { onReset: () => void; } +function FilterField({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

{label}

+ {children} +
+ ); +} + export function AccessLogFilters({ draft, pageSize, @@ -31,28 +52,12 @@ export function AccessLogFilters({ onSearch, onReset, }: AccessLogFiltersProps) { + const [moreOpen, setMoreOpen] = useState(false); + return (
-
-

节点 ID

-
- - - onDraftChange({ ...draft, nodeId: e.target.value }) - } - onKeyDown={(e) => { - if (e.key === 'Enter') onSearch(); - }} - placeholder='按 node_id 搜索' - className='pl-8 h-9 text-xs' - /> -
-
-
-

来源 IP

+ @@ -64,33 +69,91 @@ export function AccessLogFilters({ placeholder='按 IP 搜索' className='h-9 text-xs' /> -
-
-

访问域名

- onDraftChange({ ...draft, host: e.target.value })} - onKeyDown={(e) => { - if (e.key === 'Enter') onSearch(); - }} - placeholder='按域名搜索' - className='h-9 text-xs' - /> -
-
-

请求路径

- onDraftChange({ ...draft, path: e.target.value })} - onKeyDown={(e) => { - if (e.key === 'Enter') onSearch(); - }} - placeholder='按路径搜索' - className='h-9 text-xs' - /> -
+ + + +
+ + + + + +
+ +
+ + + onDraftChange({ ...draft, nodeId: e.target.value }) + } + onKeyDown={(e) => { + if (e.key === 'Enter') onSearch(); + }} + placeholder='按 node_id 搜索' + className='pl-8 h-9 text-xs' + /> +
+
+ + + onDraftChange({ ...draft, host: e.target.value }) + } + onKeyDown={(e) => { + if (e.key === 'Enter') onSearch(); + }} + placeholder='按域名搜索' + className='h-9 text-xs' + /> + + + + onDraftChange({ ...draft, path: e.target.value }) + } + onKeyDown={(e) => { + if (e.key === 'Enter') onSearch(); + }} + placeholder='按路径搜索' + className='h-9 text-xs' + /> + +
+
+
+

每页条数

diff --git a/frontend/app/(main)/access-logs/components/access-log-utils.ts b/frontend/app/(main)/access-logs/components/access-log-utils.ts index 9d3b4e5a..f01fd797 100644 --- a/frontend/app/(main)/access-logs/components/access-log-utils.ts +++ b/frontend/app/(main)/access-logs/components/access-log-utils.ts @@ -5,6 +5,7 @@ export type SearchDraft = { remoteAddr: string; host: string; path: string; + statusCode: string; }; export type OverviewRangeHours = 24 | 168 | 360 | 720; @@ -14,6 +15,23 @@ export type RateLimitRangeHours = 24 | 72; export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]; +/** 状态码快捷筛选项(空值表示全部,由 Select placeholder 兜底)。 */ +export const STATUS_CODE_OPTIONS: { value: string; label: string }[] = [ + { value: '200', label: '200 OK' }, + { value: '301', label: '301 永久重定向' }, + { value: '302', label: '302 临时重定向' }, + { value: '304', label: '304 未修改' }, + { value: '400', label: '400 请求错误' }, + { value: '401', label: '401 未授权' }, + { value: '403', label: '403 禁止访问' }, + { value: '404', label: '404 未找到' }, + { value: '429', label: '429 请求过多' }, + { value: '500', label: '500 服务器错误' }, + { value: '502', label: '502 网关错误' }, + { value: '503', label: '503 服务不可用' }, + { value: '504', label: '504 网关超时' }, +]; + export const OVERVIEW_RANGE_OPTIONS: { value: OverviewRangeHours; label: string; diff --git a/frontend/app/(main)/access-logs/page.tsx b/frontend/app/(main)/access-logs/page.tsx index bac541a7..bf158196 100644 --- a/frontend/app/(main)/access-logs/page.tsx +++ b/frontend/app/(main)/access-logs/page.tsx @@ -33,6 +33,7 @@ const emptyDraft: SearchDraft = { remoteAddr: '', host: '', path: '', + statusCode: '', }; function resolveTab(value: string | null): AccessLogTab { @@ -111,6 +112,9 @@ function AccessLogsPageContent() { remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, path: filters.path || undefined, + status_code: filters.statusCode + ? Number.parseInt(filters.statusCode, 10) + : undefined, p: page, page_size: pageSize, sort_by: detailSortState.sortBy, @@ -161,6 +165,7 @@ function AccessLogsPageContent() { remoteAddr: draft.remoteAddr.trim(), host: draft.host.trim(), path: draft.path.trim(), + statusCode: draft.statusCode.trim(), }); setPage(0); }, [draft]); diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 6eadbae5..118e1c7d 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -630,6 +630,7 @@ export interface AccessLogFilters { remote_addr?: string; host?: string; path?: string; + status_code?: number; p?: number; page_size?: number; sort_by?: string; diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go index 4f47fb77..ef6b1add 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/internal/apps/openflare/observability/access_log_logics.go @@ -39,6 +39,7 @@ type AccessLogQuery struct { RemoteAddr string `json:"remote_addr"` Host string `json:"host"` Path string `json:"path"` + StatusCode int `json:"status_code"` Page int `json:"page"` PageSize int `json:"page_size"` SortBy string `json:"sort_by"` @@ -887,6 +888,7 @@ func buildModelAccessLogQuery(input AccessLogQuery) model.OpenFlareAccessLogQuer RemoteAddr: strings.TrimSpace(input.RemoteAddr), Host: strings.TrimSpace(input.Host), Path: strings.TrimSpace(input.Path), + StatusCode: input.StatusCode, Since: defaultAccessLogSince(), Page: input.Page, PageSize: input.PageSize, @@ -932,6 +934,7 @@ func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery { RemoteAddr: strings.TrimSpace(input.RemoteAddr), Host: strings.TrimSpace(input.Host), Path: strings.TrimSpace(input.Path), + StatusCode: input.StatusCode, Page: normalizeAccessLogPage(input.Page), PageSize: normalizeAccessLogPageSize(input.PageSize), SortBy: normalizeAccessLogSortBy(input.SortBy), diff --git a/internal/apps/openflare/observability/errs.go b/internal/apps/openflare/observability/errs.go new file mode 100644 index 00000000..5213d1a5 --- /dev/null +++ b/internal/apps/openflare/observability/errs.go @@ -0,0 +1,9 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package observability defines shared error messages for observability operations. +package observability + +const ( + errInvalidStatusCode = "status_code 必须为 100-599 之间的整数" +) diff --git a/internal/apps/openflare/observability/query_params_test.go b/internal/apps/openflare/observability/query_params_test.go index ffe91c33..6a16fbe6 100644 --- a/internal/apps/openflare/observability/query_params_test.go +++ b/internal/apps/openflare/observability/query_params_test.go @@ -55,3 +55,49 @@ func TestReadQueryStringArrayAcceptsHostsBracketForm(t *testing.T) { }) } } + +func TestReadAccessLogQueryIncludesStatusCode(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, err := http.NewRequest( + http.MethodGet, + "/?node_id=n1&remote_addr=1.2.3.4&host=a.example&path=/api&status_code=404&p=2&page_size=50", + nil, + ) + require.NoError(t, err) + c.Request = req + + got, err := readAccessLogQuery(c) + require.NoError(t, err) + require.Equal(t, "n1", got.NodeID) + require.Equal(t, "1.2.3.4", got.RemoteAddr) + require.Equal(t, "a.example", got.Host) + require.Equal(t, "/api", got.Path) + require.Equal(t, 404, got.StatusCode) + require.Equal(t, 2, got.Page) + require.Equal(t, 50, got.PageSize) +} + +func TestReadAccessLogQueryRejectsInvalidStatusCode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, raw := range []string{"abc", "99", "600", "-1"} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, err := http.NewRequest(http.MethodGet, "/?status_code="+raw, nil) + require.NoError(t, err) + c.Request = req + + _, err = readAccessLogQuery(c) + require.Error(t, err, "status_code=%s should be rejected", raw) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + c.Request = req + got, err := readAccessLogQuery(c) + require.NoError(t, err) + require.Equal(t, 0, got.StatusCode) +} diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index e6e0fe7b..6467b950 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -4,12 +4,12 @@ package observability import ( - "net/http" - "strconv" - + "errors" "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" + "net/http" + "strconv" ) // GetAccessLogOverviewHandler 获取访问日志概览。 @@ -45,7 +45,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) { // GetAccessLogsHandler 分页列出访问日志。 // @Summary 列出访问日志 -// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限 +// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限 // @Tags openflare-observability // @Produce json // @Security SessionCookie @@ -53,6 +53,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) { // @Param remote_addr query string false "客户端 IP" // @Param host query string false "请求 Host" // @Param path query string false "请求路径" +// @Param status_code query int false "HTTP 状态码(100-599)" // @Param p query int false "页码" // @Param page_size query int false "每页条数" // @Param sort_by query string false "排序字段" @@ -64,7 +65,11 @@ func GetAccessLogOverviewHandler(c *gin.Context) { // @Failure 500 {object} response.Any "内部错误" // @Router /api/v1/d/access-logs [get] func GetAccessLogsHandler(c *gin.Context) { - logs, err := ListAccessLogs(c.Request.Context(), readAccessLogQuery(c)) + query, err := readAccessLogQuery(c) + if apiutil.AbortBadRequestOnError(c, err) { + return + } + logs, err := ListAccessLogs(c.Request.Context(), query) if apiutil.AbortBadRequestOnError(c, err) { return } @@ -93,7 +98,10 @@ func GetAccessLogsHandler(c *gin.Context) { // @Failure 500 {object} response.Any "内部错误" // @Router /api/v1/d/access-logs/folds [get] func GetFoldedAccessLogsHandler(c *gin.Context) { - query := readAccessLogQuery(c) + query, err := readAccessLogQuery(c) + if apiutil.AbortBadRequestOnError(c, err) { + return + } query.FoldMinutes = readQueryInt(c, "fold_minutes") logs, err := ListFoldedAccessLogs(c.Request.Context(), query) if apiutil.AbortBadRequestOnError(c, err) { @@ -270,8 +278,8 @@ func CleanupAccessLogsHandler(c *gin.Context) { c.JSON(http.StatusOK, response.OK(result)) } -func readAccessLogQuery(c *gin.Context) AccessLogQuery { - return AccessLogQuery{ +func readAccessLogQuery(c *gin.Context) (AccessLogQuery, error) { + query := AccessLogQuery{ NodeID: c.Query("node_id"), RemoteAddr: c.Query("remote_addr"), Host: c.Query("host"), @@ -281,6 +289,14 @@ func readAccessLogQuery(c *gin.Context) AccessLogQuery { SortBy: c.Query("sort_by"), SortOrder: c.Query("sort_order"), } + if raw := c.Query("status_code"); raw != "" { + code, err := strconv.Atoi(raw) + if err != nil || code < 100 || code > 599 { + return AccessLogQuery{}, errors.New(errInvalidStatusCode) + } + query.StatusCode = code + } + return query, nil } func readQueryInt(c *gin.Context, key string) int { diff --git a/internal/model/analytics/filter.go b/internal/model/analytics/filter.go index 0ca4c5d0..f0a4c886 100644 --- a/internal/model/analytics/filter.go +++ b/internal/model/analytics/filter.go @@ -25,14 +25,16 @@ type NodeAccessLogFilter struct { RemoteAddr string Host string // Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. - Hosts []string - Path string - Since time.Time - Until time.Time - Page int - PageSize int - SortBy string - SortOrder string + Hosts []string + Path string + // StatusCode filters by exact HTTP status code when > 0. + StatusCode int + Since time.Time + Until time.Time + Page int + PageSize int + SortBy string + SortOrder string } // NodeObservabilityFilter scopes ClickHouse node observability queries. diff --git a/internal/model/openflare_observability.go b/internal/model/openflare_observability.go index 3c446497..82050813 100644 --- a/internal/model/openflare_observability.go +++ b/internal/model/openflare_observability.go @@ -161,14 +161,16 @@ type OpenFlareAccessLogQuery struct { RemoteAddr string Host string // Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. - Hosts []string - Path string - Since time.Time - Until time.Time - Page int - PageSize int - SortBy string - SortOrder string + Hosts []string + Path string + // StatusCode filters by exact HTTP status code when > 0. + StatusCode int + Since time.Time + Until time.Time + Page int + PageSize int + SortBy string + SortOrder string } // OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub). diff --git a/internal/repository/analytics/access_log_test.go b/internal/repository/analytics/access_log_test.go index 754a40ef..2da7c929 100644 --- a/internal/repository/analytics/access_log_test.go +++ b/internal/repository/analytics/access_log_test.go @@ -45,6 +45,16 @@ func TestBuildUserAccessLogFilterClause_EmptyUserIDs(t *testing.T) { assert.False(t, ok) } +func TestBuildNodeAccessLogFilterClause_StatusCode(t *testing.T) { + clause, args := buildNodeAccessLogFilterClause(NodeAccessLogFilter{StatusCode: 404}) + assert.Equal(t, "status_code = ?", clause) + assert.Equal(t, []any{404}, args) + + clause, args = buildNodeAccessLogFilterClause(NodeAccessLogFilter{}) + assert.Equal(t, "1", clause) + assert.Nil(t, args) +} + func TestCountAccessLogs_EmptyUserIDs(t *testing.T) { count, err := CountAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}}) require.NoError(t, err) diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go index 9866b89c..90ad5a85 100644 --- a/internal/repository/analytics/node_access_log_filter.go +++ b/internal/repository/analytics/node_access_log_filter.go @@ -11,7 +11,7 @@ import ( ) const ( - nodeAccessLogFilterClauseCapacity = 6 + nodeAccessLogFilterClauseCapacity = 7 nodeAccessLogSortDesc = "DESC" nodeAccessLogSortAsc = "ASC" @@ -55,6 +55,10 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) parts = append(parts, "path LIKE ?") args = append(args, trimmed+"%") } + if filter.StatusCode > 0 { + parts = append(parts, "status_code = ?") + args = append(args, filter.StatusCode) + } if !filter.Since.IsZero() { parts = append(parts, "logged_at >= ?") args = append(args, filter.Since.UTC()) diff --git a/internal/repository/logstore/postgres_store.go b/internal/repository/logstore/postgres_store.go index 3c69ab54..94c4d8ae 100644 --- a/internal/repository/logstore/postgres_store.go +++ b/internal/repository/logstore/postgres_store.go @@ -282,7 +282,12 @@ func (s *gormLogStore) RegionCounts(ctx context.Context, nodeID string, since ti var rows []row q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). Select("region, COUNT(*) AS count"). - Where("node_id = ? AND region <> '' AND logged_at >= ?", nodeID, since) + Where("trim(region) <> '' AND logged_at >= ?", since) + // 空 nodeID 表示全节点聚合(对齐 CH 语义),仅非空时追加 node_id 过滤, + // 避免 `node_id = ''` 恒空导致首页来源分布无数据。 + if nodeID = strings.TrimSpace(nodeID); nodeID != "" { + q = q.Where("node_id = ?", nodeID) + } if err := q.Group("region").Order("count DESC").Limit(limitOr(limit, defaultTopN)).Scan(&rows).Error; err != nil { return nil, err } @@ -788,6 +793,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N Host: query.Host, Hosts: query.Hosts, Path: query.Path, + StatusCode: query.StatusCode, Since: query.Since, Until: query.Until, Page: query.Page, @@ -823,6 +829,10 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string parts = append(parts, "path LIKE ?") args = append(args, path+"%") } + if f.StatusCode > 0 { + parts = append(parts, "status_code = ?") + args = append(args, f.StatusCode) + } if !f.Since.IsZero() { parts = append(parts, "logged_at >= ?") args = append(args, f.Since) diff --git a/internal/repository/logstore/postgres_store_test.go b/internal/repository/logstore/postgres_store_test.go index 86e7aeb9..a1150f59 100644 --- a/internal/repository/logstore/postgres_store_test.go +++ b/internal/repository/logstore/postgres_store_test.go @@ -231,6 +231,44 @@ func TestGormNodeAggregatesExcludeEmptyNodeID(t *testing.T) { } } +// TestGormRegionCountsEmptyNodeIDAggregatesAll 回归测试:首页「来源分布」以空 node_id +// 表示全节点聚合,RegionCounts 不得拼出 `node_id = ”` 恒空条件(对齐 CH 语义)。 +func TestGormRegionCountsEmptyNodeIDAggregatesAll(t *testing.T) { + ResetForTest() + SetConfigReader(func(_ context.Context, _ string) (string, error) { return "", nil }) + s := newTestGormStore(t) + ctx := context.Background() + now := time.Now() + rows := []analyticsmodel.NodeAccessLog{ + {ID: 1, NodeID: "n1", LoggedAt: now, RemoteAddr: "1.1.1.1", Region: "CN"}, + {ID: 2, NodeID: "n2", LoggedAt: now, RemoteAddr: "2.2.2.2", Region: "CN"}, + {ID: 3, NodeID: "n3", LoggedAt: now, RemoteAddr: "3.3.3.3", Region: "US"}, + {ID: 4, NodeID: "n4", LoggedAt: now, RemoteAddr: "4.4.4.4", Region: " "}, + } + if err := s.BatchInsertNodeAccessLogs(ctx, rows); err != nil { + t.Fatalf("insert: %v", err) + } + + all, err := s.RegionCounts(ctx, "", now.Add(-time.Hour), 0) + if err != nil { + t.Fatalf("region counts (all nodes): %v", err) + } + if len(all) != 2 { + t.Fatalf("all-nodes region counts want 2 regions (empty region excluded), got %+v", all) + } + if all[0].Region != "CN" || all[0].Count != 2 || all[1].Region != "US" || all[1].Count != 1 { + t.Fatalf("all-nodes region counts got %+v, want CN=2 US=1", all) + } + + cnOnly, err := s.RegionCounts(ctx, "n1", now.Add(-time.Hour), 0) + if err != nil { + t.Fatalf("region counts (node): %v", err) + } + if len(cnOnly) != 1 || cnOnly[0].Region != "CN" || cnOnly[0].Count != 1 { + t.Fatalf("node-scoped region counts got %+v, want CN=1", cnOnly) + } +} + // testGormStoreSeq 保证每个测试获得独立的共享内存库(cache=shared 下同名 DSN 会复用同一库, // 导致跨测试 id 冲突)。 var testGormStoreSeq int64