fix: optimize summary count query and refine row keys for external api logs

This commit is contained in:
SmartUp Developer
2026-07-02 15:07:32 +08:00
parent e2129d3796
commit ea3fd64686
4 changed files with 195 additions and 9 deletions
+40 -2
View File
@@ -85,13 +85,15 @@ def get_external_api_log_summary(
slow_ms: Optional[int] = Query(None),
hours: int = Query(24, ge=1, le=720),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
"""Return aggregated summary grouped by direction + target + method + path.
- hours: time window (default 24, max 720). None means no time filter.
- hours: time window (default 24, max 720).
- limit: max rows returned (default 50, max 200).
- offset: pagination offset (default 0).
"""
settings = get_settings()
slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms
@@ -107,7 +109,7 @@ def get_external_api_log_summary(
ExternalApiLog.method,
ExternalApiLog.path,
).order_by(func.sum(ExternalApiLog.duration_ms).desc())
q = q.limit(limit)
q = q.offset(offset).limit(limit)
rows = q.all()
return [
@@ -129,6 +131,42 @@ def get_external_api_log_summary(
]
@router.get("/summary/count")
def count_external_api_log_summary(
slow_ms: Optional[int] = Query(None),
hours: int = Query(24, ge=1, le=720),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
"""Return the number of distinct aggregated summary groups.
Uses the same grouping as /summary, returns the count of groups
(not the count of raw log rows).
"""
settings = get_settings()
slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms
q = db.query(
ExternalApiLog.direction,
ExternalApiLog.target_type,
ExternalApiLog.target_id,
ExternalApiLog.target_name,
ExternalApiLog.method,
ExternalApiLog.path,
)
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
q = q.filter(ExternalApiLog.created_at >= cutoff)
q = q.group_by(
ExternalApiLog.direction,
ExternalApiLog.target_type,
ExternalApiLog.target_id,
ExternalApiLog.target_name,
ExternalApiLog.method,
ExternalApiLog.path,
)
return {"total": q.count()}
def _summary_cols(slow_threshold: int) -> list:
return [
ExternalApiLog.direction,
+90 -4
View File
@@ -625,13 +625,13 @@ def test_route_summary_hours_window(session):
# hours=1 → only recent
result = get_external_api_log_summary(
slow_ms=None, hours=1, limit=50, db=session, _=None,
slow_ms=None, hours=1, limit=50, offset=0, db=session, _=None,
)
assert len(result) == 1 and result[0].path == "/recent"
# hours=72 → both
result = get_external_api_log_summary(
slow_ms=None, hours=72, limit=50, db=session, _=None,
slow_ms=None, hours=72, limit=50, offset=0, db=session, _=None,
)
assert len(result) == 2
@@ -648,7 +648,7 @@ def test_route_summary_limit(session):
session.commit()
result = get_external_api_log_summary(
slow_ms=None, hours=24, limit=3, db=session, _=None,
slow_ms=None, hours=24, limit=3, offset=0, db=session, _=None,
)
assert len(result) == 3
@@ -670,8 +670,94 @@ def test_route_summary_slow_threshold(session):
session.commit()
result = get_external_api_log_summary(
slow_ms=1000, hours=24, limit=50, db=session, _=None,
slow_ms=1000, hours=24, limit=50, offset=0, db=session, _=None,
)
slow_paths = [r for r in result if r.slow_count > 0]
assert len(slow_paths) == 1
assert slow_paths[0].path == "/slow"
def test_route_summary_offset_pagination(session):
"""get_external_api_log_summary respects offset parameter."""
from app.routers.external_api_logs import get_external_api_log_summary
now = datetime.now(timezone.utc)
for i in range(10):
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path=f"/path-{i}", url_host="h",
status_code=200, success=True, duration_ms=50 + i, created_at=now))
session.commit()
# Page 1: first 4
page1 = get_external_api_log_summary(
slow_ms=None, hours=24, limit=4, offset=0, db=session, _=None,
)
assert len(page1) == 4
assert page1[0].path == "/path-9" # sorted by sum(duration_ms) desc
# Page 2: next 4
page2 = get_external_api_log_summary(
slow_ms=None, hours=24, limit=4, offset=4, db=session, _=None,
)
assert len(page2) == 4
assert page2[0].path == "/path-5"
# Page 3: last 2
page3 = get_external_api_log_summary(
slow_ms=None, hours=24, limit=4, offset=8, db=session, _=None,
)
assert len(page3) == 2
# Beyond end
page4 = get_external_api_log_summary(
slow_ms=None, hours=24, limit=4, offset=20, db=session, _=None,
)
assert len(page4) == 0
def test_route_summary_count(session):
"""count_external_api_log_summary returns group count, not raw row count."""
from app.routers.external_api_logs import count_external_api_log_summary
now = datetime.now(timezone.utc)
# 3 calls to same path (should be 1 group)
for i in range(3):
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/grouped", url_host="h",
status_code=200, success=True, duration_ms=100, created_at=now))
# 2 calls to different path (another group)
for i in range(2):
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="POST", path="/other", url_host="h",
status_code=201, success=True, duration_ms=200, created_at=now))
session.commit()
# Should be 2 groups, not 5 raw rows
result = count_external_api_log_summary(
slow_ms=None, hours=24, db=session, _=None,
)
assert result == {"total": 2}
def test_route_summary_count_respects_hours(session):
"""count_external_api_log_summary respects hours filter."""
from app.routers.external_api_logs import count_external_api_log_summary
now = datetime.now(timezone.utc)
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/recent", url_host="h",
status_code=200, success=True, duration_ms=50,
created_at=now - timedelta(minutes=30)))
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/old", url_host="h",
status_code=200, success=True, duration_ms=50,
created_at=now - timedelta(hours=48)))
session.commit()
# hours=1 → 1 group
r1 = count_external_api_log_summary(slow_ms=None, hours=1, db=session, _=None)
assert r1 == {"total": 1}
# hours=72 → 2 groups
r2 = count_external_api_log_summary(slow_ms=None, hours=72, db=session, _=None)
assert r2 == {"total": 2}
+3 -1
View File
@@ -547,8 +547,10 @@ export const externalApiLogsApi = {
status_code?: number
path?: string
}) => api.get<{ total: number }>('/api/external-api-logs/count', { params }),
summary: (params?: { slow_ms?: number }) =>
summary: (params?: { slow_ms?: number; hours?: number; limit?: number; offset?: number }) =>
api.get<ExternalApiLogSummaryItem[]>('/api/external-api-logs/summary', { params }),
summaryCount: (params?: { slow_ms?: number; hours?: number }) =>
api.get<{ total: number }>('/api/external-api-logs/summary/count', { params }),
}
// ——— Auth Capture ———
+62 -2
View File
@@ -57,7 +57,7 @@
</tr>
</thead>
<tbody class="code-font">
<tr v-for="row in summary" :key="row.path + row.method + row.direction" class="hover-row">
<tr v-for="row in summary" :key="`${row.path}_${row.method}_${row.direction}_${row.target_type}_${row.target_id ?? row.target_name ?? ''}`" class="hover-row">
<td>
<span class="direction-tag" :class="row.direction">
<svg v-if="row.direction === 'upstream'" class="direction-icon" viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px; display: inline-block; vertical-align: middle;"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
@@ -90,6 +90,35 @@
</tbody>
</table>
</div>
<!-- 慢接口汇总分页器 -->
<div class="pagination-container" v-if="summaryTotalRecords > 0">
<div class="pagination-left">
<span> <span class="highlight-text">{{ summaryTotalRecords }}</span> 个分组</span>
<div class="page-size-selector">
<span>每页</span>
<select v-model="summaryPageSize" @change="handleSummaryPageSizeChange" class="page-size-select">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<span></span>
</div>
</div>
<div class="pagination-right">
<button :disabled="summaryOffset === 0 || summaryLoading" @click="summaryPrevPage" class="page-btn">
<el-icon><ArrowLeft /></el-icon>
<span>上一页</span>
</button>
<span class="page-indicator"> {{ summaryCurrentPage }} </span>
<button :disabled="!summaryHasNextPage || summaryLoading" @click="summaryNextPage" class="page-btn">
<span>下一页</span>
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
</div>
<!-- 接口调用记录 -->
@@ -347,6 +376,14 @@ const pageSize = ref(10)
const hasNextPage = ref(false)
const totalRecords = ref(0)
const currentPage = computed(() => Math.floor(offset.value / pageSize.value) + 1)
// Slow summary pagination state (independent from list pagination)
const summaryOffset = ref(0)
const summaryPageSize = ref(10)
const summaryTotalRecords = ref(0)
const summaryHasNextPage = computed(() => summaryOffset.value + summaryPageSize.value < summaryTotalRecords.value)
const summaryCurrentPage = computed(() => Math.floor(summaryOffset.value / summaryPageSize.value) + 1)
const slowThreshold = 3000
// In-app Notification System
@@ -489,13 +526,36 @@ async function loadList(silent = false) {
async function loadSummary(silent = false) {
if (!silent) summaryLoading.value = true
try {
const res = await externalApiLogsApi.summary({ slow_ms: slowThreshold })
const [res, countRes] = await Promise.all([
externalApiLogsApi.summary({
slow_ms: slowThreshold,
limit: summaryPageSize.value,
offset: summaryOffset.value,
}),
externalApiLogsApi.summaryCount({ slow_ms: slowThreshold }),
])
summary.value = res.data
summaryTotalRecords.value = countRes.data.total
} finally {
if (!silent) summaryLoading.value = false
}
}
function summaryPrevPage() {
summaryOffset.value = Math.max(0, summaryOffset.value - summaryPageSize.value)
loadSummary()
}
function summaryNextPage() {
summaryOffset.value += summaryPageSize.value
loadSummary()
}
function handleSummaryPageSizeChange() {
summaryOffset.value = 0
loadSummary()
}
function handleFilterChange() {
offset.value = 0
loadList()