perf: 拆分热力图为独立接口并添加 Redis 缓存

- 新增独立热力图 API 端点 (/api/admin/usage/heatmap, /api/users/me/usage/heatmap)
- 添加 Redis 缓存层 (5分钟 TTL),减少数据库查询
- 用户角色变更时清除热力图缓存
- 前端并行加载统计数据和热力图,添加加载/错误状态显示
- 修复 cache_decorator 缺少 JSON 解析错误处理的问题
- 更新 docker-compose 启动命令提示
This commit is contained in:
fawney19
2026-01-04 22:42:58 +08:00
parent b6bd6357ed
commit a2f33a6c35
13 changed files with 271 additions and 31 deletions

View File

@@ -286,5 +286,14 @@ export const meApi = {
}> {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data
},
/**
* 获取活跃度热力图数据(用户)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
}
}

View File

@@ -198,5 +198,14 @@ export const usageApi = {
const params = ids?.length ? { ids: ids.join(',') } : {}
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
},
/**
* 获取活跃度热力图数据(管理员)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
return response.data
}
}

View File

@@ -18,8 +18,22 @@
<span class="flex-shrink-0"></span>
</div>
</div>
<div
v-if="isLoading"
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
>
<Loader2 class="h-5 w-5 animate-spin mr-2" />
加载中...
</div>
<div
v-else-if="hasError"
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
>
<AlertCircle class="h-4 w-4 mr-1.5" />
加载失败
</div>
<ActivityHeatmap
v-if="hasData"
v-else-if="hasData"
:data="data"
:show-header="false"
/>
@@ -34,6 +48,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Loader2, AlertCircle } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
@@ -41,6 +56,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
const props = defineProps<{
data: ActivityHeatmapData | null
title: string
isLoading?: boolean
hasError?: boolean
}>()
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]

View File

@@ -64,9 +64,6 @@ export function useUsageData(options: UseUsageDataOptions) {
}))
})
// 活跃度热图数据
const activityHeatmapData = computed(() => stats.value.activity_heatmap)
// 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams) {
isLoadingStats.value = true
@@ -93,7 +90,7 @@ export function useUsageData(options: UseUsageDataOptions) {
cache_stats: (statsData as any).cache_stats,
period_start: '',
period_end: '',
activity_heatmap: statsData.activity_heatmap || null
activity_heatmap: null
}
modelStats.value = modelData.map(item => ({
@@ -143,7 +140,7 @@ export function useUsageData(options: UseUsageDataOptions) {
avg_response_time: userData.avg_response_time || 0,
period_start: '',
period_end: '',
activity_heatmap: userData.activity_heatmap || null
activity_heatmap: null
}
modelStats.value = (userData.summary_by_model || []).map((item: any) => ({
@@ -305,7 +302,6 @@ export function useUsageData(options: UseUsageDataOptions) {
// 计算属性
enhancedModelStats,
activityHeatmapData,
// 方法
loadStats,

View File

@@ -1,5 +1,3 @@
import type { ActivityHeatmap } from '@/types/activity'
// 统计数据状态
export interface UsageStatsState {
total_requests: number
@@ -17,7 +15,6 @@ export interface UsageStatsState {
}
period_start: string
period_end: string
activity_heatmap: ActivityHeatmap | null
}
// 模型统计
@@ -115,7 +112,6 @@ export function createDefaultStats(): UsageStatsState {
error_rate: undefined,
cache_stats: undefined,
period_start: '',
period_end: '',
activity_heatmap: null
period_end: ''
}
}

View File

@@ -5,6 +5,8 @@
<ActivityHeatmapCard
:data="activityHeatmapData"
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
:is-loading="isLoadingHeatmap"
:has-error="heatmapError"
/>
<IntervalTimelineCard
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
@@ -112,8 +114,11 @@ import {
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const { warning } = useToast()
const authStore = useAuthStore()
// 判断是否是管理员页面
@@ -144,13 +149,35 @@ const {
currentRecords,
totalRecords,
enhancedModelStats,
activityHeatmapData,
availableModels,
availableProviders,
loadStats,
loadRecords
} = useUsageData({ isAdminPage })
// 热力图状态
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
const isLoadingHeatmap = ref(false)
const heatmapError = ref(false)
// 加载热力图数据
async function loadHeatmapData() {
isLoadingHeatmap.value = true
heatmapError.value = false
try {
if (isAdminPage.value) {
activityHeatmapData.value = await usageApi.getActivityHeatmap()
} else {
activityHeatmapData.value = await meApi.getActivityHeatmap()
}
} catch (error) {
log.error('加载热力图数据失败:', error)
heatmapError.value = true
} finally {
isLoadingHeatmap.value = false
}
}
// 用户页面需要前端筛选
const filteredRecords = computed(() => {
if (!isAdminPage.value) {
@@ -335,7 +362,22 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载
onMounted(async () => {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
const [statsResult, heatmapResult] = await Promise.allSettled([
loadStats(dateRange),
loadHeatmapData()
])
// 检查加载结果并通知用户
if (statsResult.status === 'rejected') {
log.error('加载统计数据失败:', statsResult.reason)
warning('统计数据加载失败,请刷新重试')
}
if (heatmapResult.status === 'rejected') {
log.error('加载热力图数据失败:', heatmapResult.reason)
// 热力图加载失败不提示,因为 UI 已显示占位符
}
// 管理员页面加载用户列表和第一页记录
if (isAdminPage.value) {

View File

@@ -73,6 +73,20 @@ async def get_usage_stats(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/heatmap")
async def get_activity_heatmap(
request: Request,
db: Session = Depends(get_db),
):
"""
Get activity heatmap data for the past 365 days.
This endpoint is cached for 5 minutes to reduce database load.
"""
adapter = AdminActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/records")
async def get_usage_records(
request: Request,
@@ -168,12 +182,6 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
).count()
activity_heatmap = UsageService.get_daily_activity(
db=db,
window_days=365,
include_actual_cost=True,
)
context.add_audit_metadata(
action="usage_stats",
start_date=self.start_date.isoformat() if self.start_date else None,
@@ -204,10 +212,22 @@ class AdminUsageStatsAdapter(AdminApiAdapter):
),
"cache_read_cost": float(cache_stats.cache_read_cost or 0) if cache_stats else 0,
},
"activity_heatmap": activity_heatmap,
}
class AdminActivityHeatmapAdapter(AdminApiAdapter):
"""Activity heatmap adapter with Redis caching."""
async def handle(self, context): # type: ignore[override]
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=None,
include_actual_cost=True,
)
context.add_audit_metadata(action="activity_heatmap")
return result
class AdminUsageByModelAdapter(AdminApiAdapter):
def __init__(self, start_date: Optional[datetime], end_date: Optional[datetime], limit: int):
self.start_date = start_date

View File

@@ -248,6 +248,7 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
raise InvalidRequestException("请求数据验证失败")
update_data = request.model_dump(exclude_unset=True)
old_role = existing_user.role
if "role" in update_data and update_data["role"]:
if hasattr(update_data["role"], "value"):
update_data["role"] = update_data["role"]
@@ -258,6 +259,12 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
if not user:
raise NotFoundException("用户不存在", "user")
# 角色变更时清除热力图缓存(影响 include_actual_cost 权限)
if "role" in update_data and update_data["role"] != old_role:
from src.services.usage.service import UsageService
await UsageService.clear_user_heatmap_cache(self.user_id)
changed_fields = list(update_data.keys())
context.add_audit_metadata(
action="update_user",

View File

@@ -135,6 +135,20 @@ async def get_my_interval_timeline(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/usage/heatmap")
async def get_my_activity_heatmap(
request: Request,
db: Session = Depends(get_db),
):
"""
Get user's activity heatmap data for the past 365 days.
This endpoint is cached for 5 minutes to reduce database load.
"""
adapter = GetMyActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/providers")
async def list_available_providers(request: Request, db: Session = Depends(get_db)):
adapter = ListAvailableProvidersAdapter()
@@ -650,13 +664,6 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
],
}
response_data["activity_heatmap"] = UsageService.get_daily_activity(
db=db,
user_id=user.id,
window_days=365,
include_actual_cost=user.role == "admin",
)
# 管理员可以看到真实成本
if user.role == "admin":
response_data["total_actual_cost"] = total_actual_cost
@@ -723,6 +730,20 @@ class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
return result
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
"""Activity heatmap adapter with Redis caching for user."""
async def handle(self, context): # type: ignore[override]
user = context.user
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=user.id,
include_actual_cost=user.role == "admin",
)
context.add_audit_metadata(action="activity_heatmap")
return result
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import selectinload

View File

@@ -21,6 +21,9 @@ class CacheTTL:
# L1 本地缓存(用于减少 Redis 访问)
L1_LOCAL = 3 # 3秒
# 活跃度热力图缓存 - 历史数据变化不频繁
ACTIVITY_HEATMAP = 300 # 5分钟
# 并发锁 TTL - 防止死锁
CONCURRENCY_LOCK = 600 # 10分钟

View File

@@ -411,7 +411,7 @@ def init_db():
print(" 3. 数据库用户名和密码是否正确", file=sys.stderr)
print("", file=sys.stderr)
print("如果使用 Docker请先运行:", file=sys.stderr)
print(" docker compose up -d postgres redis", file=sys.stderr)
print(" docker compose -f docker-compose.build.yml up -d postgres redis", file=sys.stderr)
print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)
# 使用 os._exit 直接退出,避免 uvicorn 捕获并打印堆栈

View File

@@ -86,6 +86,118 @@ class UsageRecordParams:
class UsageService:
"""用量统计服务"""
# ==================== 缓存键常量 ====================
# 热力图缓存键前缀(依赖 TTL 自动过期,用户角色变更时主动清除)
HEATMAP_CACHE_KEY_PREFIX = "activity_heatmap"
# ==================== 热力图缓存 ====================
@classmethod
def _get_heatmap_cache_key(cls, user_id: Optional[str], include_actual_cost: bool) -> str:
"""生成热力图缓存键"""
cost_suffix = "with_cost" if include_actual_cost else "no_cost"
if user_id:
return f"{cls.HEATMAP_CACHE_KEY_PREFIX}:user:{user_id}:{cost_suffix}"
else:
return f"{cls.HEATMAP_CACHE_KEY_PREFIX}:admin:all:{cost_suffix}"
@classmethod
async def clear_user_heatmap_cache(cls, user_id: str) -> None:
"""
清除用户的热力图缓存(用户角色变更时调用)
Args:
user_id: 用户ID
"""
from src.clients.redis_client import get_redis_client
redis_client = await get_redis_client(require_redis=False)
if not redis_client:
return
# 清除该用户的所有热力图缓存with_cost 和 no_cost
keys_to_delete = [
cls._get_heatmap_cache_key(user_id, include_actual_cost=True),
cls._get_heatmap_cache_key(user_id, include_actual_cost=False),
]
for key in keys_to_delete:
try:
await redis_client.delete(key)
logger.debug(f"已清除热力图缓存: {key}")
except Exception as e:
logger.warning(f"清除热力图缓存失败: {key}, error={e}")
@classmethod
async def get_cached_heatmap(
cls,
db: Session,
user_id: Optional[str] = None,
include_actual_cost: bool = False,
) -> Dict[str, Any]:
"""
获取带缓存的热力图数据
缓存策略:
- TTL: 5分钟CacheTTL.ACTIVITY_HEATMAP
- 仅依赖 TTL 自动过期,新使用记录最多延迟 5 分钟出现
- 用户角色变更时通过 clear_user_heatmap_cache() 主动清除
Args:
db: 数据库会话
user_id: 用户IDNone 表示获取全局热力图(管理员)
include_actual_cost: 是否包含实际成本
Returns:
热力图数据字典
"""
from src.clients.redis_client import get_redis_client
from src.config.constants import CacheTTL
import json
cache_key = cls._get_heatmap_cache_key(user_id, include_actual_cost)
cache_ttl = CacheTTL.ACTIVITY_HEATMAP
redis_client = await get_redis_client(require_redis=False)
# 尝试从缓存获取
if redis_client:
try:
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached) # type: ignore[no-any-return]
except json.JSONDecodeError as e:
logger.warning(f"热力图缓存解析失败,删除损坏缓存: {cache_key}, error={e}")
try:
await redis_client.delete(cache_key)
except Exception:
pass
except Exception as e:
logger.error(f"读取热力图缓存出错: {cache_key}, error={e}")
# 从数据库查询
result = cls.get_daily_activity(
db=db,
user_id=user_id,
window_days=365,
include_actual_cost=include_actual_cost,
)
# 保存到缓存(失败不影响返回结果)
if redis_client:
try:
await redis_client.setex(
cache_key,
cache_ttl,
json.dumps(result, ensure_ascii=False, default=str),
)
except Exception as e:
logger.warning(f"保存热力图缓存失败: {cache_key}, error={e}")
return result
# ==================== 内部数据类 ====================
@staticmethod

View File

@@ -49,8 +49,16 @@ def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) ->
# 尝试从缓存获取
cached = await redis_client.get(cache_key)
if cached:
logger.debug(f"缓存命中: {cache_key}")
return json.loads(cached)
try:
result = json.loads(cached)
logger.debug(f"缓存命中: {cache_key}")
return result
except json.JSONDecodeError as e:
logger.warning(f"缓存解析失败,删除损坏缓存: {cache_key}, 错误: {e}")
try:
await redis_client.delete(cache_key)
except Exception:
pass
# 执行原函数
result = await func(*args, **kwargs)