feat(admin): 用户排行榜每日快照——手动刷新当天 + 凌晨自动定稿前一天

- 新增 POST /api/admin/users/leaderboard/refresh-today:同步强制重建当天快照
  并返回合并区间;前端 useRefreshTodayLeaderboard + AdminUserLeaderboardPage
  「刷新当天」按钮
- Gateway 调度循环在每日 ADMIN_LEADERBOARD_FINALIZE_HOUR(默认凌晨1点)对前一天
  强制重算一次,避免临近午夜落库的 runs/tool-calls 漏统计;判定逻辑下沉为纯函数
  deerflow.persistence.admin_stats.finalize.should_finalize_previous_day
- 新增 scripts/sql/column_additions_mysql.sql:既有 MySQL 库手动补列的幂等脚本
  (Alembic 自动迁移已禁用,create_all 不改既有表)
- CLAUDE.md 补充排行榜快照、Schema 迁移约定,并补齐技能/知识库选模型的文档

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-10 11:24:21 +08:00
1 parent 96e0d695c0
commit d61aa853f1
8 files changed
+356 -3

No files matched your search

+26
View File
@@ -247,6 +247,32 @@ export async function getUserLeaderboard(
);
}
/**
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
* real-time records are included, then return the leaderboard for `range`.
*/
export async function refreshTodayLeaderboard(
range: {
since: string;
until: string;
days?: number;
},
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard/refresh-today?${params}`, {
method: "POST",
}),
"Failed to refresh today's leaderboard",
);
}
export async function getUserAnalytics(
userId: string,
range: { since: string; until: string; days?: number },
+31
View File
@@ -9,6 +9,7 @@ import {
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
refreshTodayLeaderboard,
listUserQa,
listUserThreadMessages,
listUserThreads,
@@ -41,6 +42,36 @@ export function useUserLeaderboard(
});
}
/**
* Force-rebuild today's snapshot, then prime + invalidate the matching
* leaderboard query so the page shows the freshly recomputed numbers.
*/
export function useRefreshTodayLeaderboard() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
range,
limit = 20,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
}) => refreshTodayLeaderboard(range, limit),
onSuccess: (data, variables) => {
qc.setQueryData(
[
"admin-activity",
"leaderboard",
variables.range.since,
variables.range.until,
variables.limit ?? 20,
],
data,
);
qc.invalidateQueries({ queryKey: ["admin-activity", "leaderboard"] });
},
});
}
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
@@ -18,6 +18,7 @@ import {
Medal,
MessageSquareText,
MousePointerClick,
RefreshCw,
Search,
Settings2,
Sparkles,
@@ -55,7 +56,7 @@ import type {
AdminLeaderboardUser,
} from "@/core/admin/api";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useRefreshTodayLeaderboard, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useModels } from "@/core/models/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
@@ -1052,6 +1053,7 @@ export default function AdminUserLeaderboardPage() {
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const refreshToday = useRefreshTodayLeaderboard();
const [reportOpen, setReportOpen] = useState(false);
const { models: reportModels, isLoading: reportModelsLoading } = useModels({ enabled: reportOpen });
const [reportModelName, setReportModelName] = useState(DEFAULT_REPORT_MODEL);
@@ -1269,6 +1271,30 @@ export default function AdminUserLeaderboardPage() {
>
<Settings2 className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={refreshToday.isPending}
onClick={() =>
refreshToday.mutate(
{ range: apiRange, limit: 100 },
{
onSuccess: () => toast.success("已刷新当天最新统计数据"),
onError: (err) =>
toast.error(err instanceof Error ? err.message : "刷新当天数据失败"),
},
)
}
className="h-9"
title="强制重算今天的统计,把最新的实时记录纳入榜单"
>
{refreshToday.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<RefreshCw className="mr-1 size-4" />
)}
{refreshToday.isPending ? "刷新中" : "刷新当天"}
</Button>
<Button
variant="outline"
size="sm"