feat: show formal odds provider blockers on public pages
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m49s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 5m8s

This commit is contained in:
OG T
2026-06-19 01:24:30 +08:00
parent 41e2ee48d2
commit c5638d2087
2 changed files with 283 additions and 18 deletions

View File

@@ -2,8 +2,40 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
type MarketStatus = { market_type: string; label?: string; match_count?: number; bookmaker_count?: number; odds_rows?: number; passes_minimum?: boolean };
type ReadinessResponse = { formal_recommendations_allowed?: boolean; headline?: string; market_status?: MarketStatus[]; blocking_reasons?: string[]; required_actions?: string[] };
type MarketStatus = {
market_type: string;
label?: string;
match_count?: number;
bookmaker_count?: number;
odds_rows?: number;
passes_minimum?: boolean;
};
type FormalProviderStatus = {
provider?: string;
status?: string;
api_key_configured?: boolean;
api_key_placeholder?: boolean;
message?: string;
};
type ReadinessSource = {
name?: string;
worker_status?: string;
formal_provider_status?: FormalProviderStatus;
};
type ReadinessResponse = {
status?: string;
mode?: string;
formal_recommendations_allowed?: boolean;
headline?: string;
source?: ReadinessSource;
market_status?: MarketStatus[];
blocking_reasons?: string[];
required_actions?: string[];
warnings?: string[];
};
async function getReadiness(): Promise<ReadinessResponse> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/recommendation-readiness`, { cache: 'no-store' });
@@ -11,21 +43,109 @@ async function getReadiness(): Promise<ReadinessResponse> {
return response.json() as Promise<ReadinessResponse>;
}
function providerLabel(status?: string) {
if (status === 'ok') return '正式 provider 可用';
if (status === 'placeholder_key') return '正式 key 仍是 placeholder';
if (status === 'missing_key') return '正式 key 尚未設定';
if (status === 'empty_events') return '正式 provider 暫無世界盃盤口';
if (status === 'error') return '正式 provider 抓取失敗';
return '正式 provider 狀態未知';
}
export default async function RecommendationReadinessPage() {
let data: ReadinessResponse | null = null;
let error = '';
try { data = await getReadiness(); } catch (err) { error = err instanceof Error ? err.message : '推薦就緒度暫時無法讀取'; }
try {
data = await getReadiness();
} catch (err) {
error = err instanceof Error ? err.message : '推薦就緒度暫時無法讀取';
}
const marketStatus = data?.market_status ?? [];
const blockers = data?.blocking_reasons ?? [];
const actions = data?.required_actions ?? [];
const warnings = data?.warnings ?? [];
const provider = data?.source?.formal_provider_status;
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8"><p className="dot-matrix text-sm font-bold text-[#b83822]"></p><h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1><p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">使</p><p className={`mt-5 inline-flex rounded-full px-4 py-2 text-sm font-black ${data?.formal_recommendations_allowed ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>{data?.headline ?? '就緒度同步中'}</p></section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 lg:grid-cols-3">{marketStatus.map((item) => <article key={item.market_type} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]">{item.label ?? item.market_type}</p><p className="mt-3 text-sm text-[#7a5b46]"> {item.match_count ?? 0} {item.bookmaker_count ?? 0} </p><p className="mt-1 text-sm text-[#7a5b46]"> {item.odds_rows ?? 0}</p><p className={`mt-3 rounded-full px-3 py-1 text-xs font-black ${item.passes_minimum ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>{item.passes_minimum ? '通過最低門檻' : '未通過最低門檻'}</p></article>)}</section>
<section className="grid gap-4 lg:grid-cols-2"><article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p><ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{blockers.map((item) => <li key={item}>{item}</li>)}</ul></article><article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p><ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{actions.map((item) => <li key={item}>{item}</li>)}</ul></article></section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
provider 使
</p>
<p className={`mt-5 inline-flex rounded-full px-4 py-2 text-sm font-black ${data?.formal_recommendations_allowed ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>
{data?.headline ?? '就緒度同步中'}
</p>
</section>
{error ? (
<p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p>
) : null}
<section className="grid gap-4 lg:grid-cols-3">
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"> provider</p>
<p className="mt-2 text-2xl font-black text-[#3f2f25]">{providerLabel(provider?.status)}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
{data?.source?.name ?? '尚未回報'}Key {provider?.api_key_configured ? '是' : '否'}Key placeholder{provider?.api_key_placeholder ? '是' : '否'}
</p>
</article>
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-2xl font-black text-[#3f2f25]">{data?.formal_recommendations_allowed ? '允許進入下注前檢查' : '只允許監控候選'}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
{data?.mode ?? 'unknown'}
</p>
</article>
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<p className="mt-2 text-2xl font-black text-[#3f2f25]">{blockers.length} </p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
</p>
</article>
</section>
<section className="grid gap-4 lg:grid-cols-3">
{marketStatus.map((item) => (
<article key={item.market_type} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">{item.label ?? item.market_type}</p>
<p className="mt-3 text-sm text-[#7a5b46]"> {item.match_count ?? 0} {item.bookmaker_count ?? 0} </p>
<p className="mt-1 text-sm text-[#7a5b46]"> {item.odds_rows ?? 0}</p>
<p className={`mt-3 rounded-full px-3 py-1 text-xs font-black ${item.passes_minimum ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>
{item.passes_minimum ? '通過最低門檻' : '未通過最低門檻'}
</p>
</article>
))}
</section>
<section className="grid gap-4 lg:grid-cols-3">
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 lg:col-span-1">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">
{blockers.length ? blockers.map((item) => <li key={item}>{item}</li>) : <li></li>}
</ul>
</article>
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 lg:col-span-1">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">
{warnings.length ? warnings.map((item) => <li key={item}>{item}</li>) : <li></li>}
</ul>
</article>
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 lg:col-span-1">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">
{actions.map((item) => <li key={item}>{item}</li>)}
</ul>
</article>
</section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white">
</Link>
</div>
);
}

View File

@@ -2,8 +2,48 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
type WorkerStatus = { status?: string; run_at?: string; message?: string };
type SourceHealth = { status?: string; odds_rows?: number; matches?: number; finished_matches?: number; venues?: number; high_altitude_venues?: number; stale_unsettled_matches?: number; latest_odds_recorded_at?: string | null; latest_result_synced_at?: string | null; ingestion_status?: WorkerStatus | null; fixtures_status?: WorkerStatus | null; news_status?: WorkerStatus | null };
type WorkerStatus = {
status?: string;
run_at?: string;
message?: string;
source?: string;
primary_provider?: FormalProviderStatus;
};
type FormalProviderStatus = {
provider?: string;
status?: string;
api_key_configured?: boolean;
api_key_placeholder?: boolean;
sport_key?: string;
regions?: string;
markets?: string[];
message?: string;
};
type ProviderRequirements = {
primary_odds_provider?: string;
formal_provider_status?: FormalProviderStatus;
formal_provider_blocker?: string | null;
current_limitation?: string;
};
type SourceHealth = {
status?: string;
odds_coverage_status?: string;
odds_rows?: number;
matches?: number;
finished_matches?: number;
venues?: number;
high_altitude_venues?: number;
stale_unsettled_matches?: number;
latest_odds_recorded_at?: string | null;
latest_result_synced_at?: string | null;
ingestion_status?: WorkerStatus | null;
fixtures_status?: WorkerStatus | null;
news_status?: WorkerStatus | null;
provider_requirements?: ProviderRequirements;
};
async function getSourceHealth(): Promise<SourceHealth> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/source-health`, { cache: 'no-store' });
@@ -13,22 +53,127 @@ async function getSourceHealth(): Promise<SourceHealth> {
function dateText(value?: string | null) {
if (!value) return '尚無時間';
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
return new Intl.DateTimeFormat('zh-TW', {
timeZone: 'Asia/Taipei',
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function providerLabel(status?: string) {
if (status === 'ok') return '正式 provider 已回傳';
if (status === 'placeholder_key') return '正式 key 仍是 placeholder';
if (status === 'missing_key') return '正式 key 尚未設定';
if (status === 'empty_events') return '正式 provider 暫無盤口';
if (status === 'error') return '正式 provider 抓取失敗';
return '正式 provider 狀態未知';
}
function coverageLabel(status?: string) {
if (status === 'full_market') return '正式多來源盤口';
if (status === 'reference_market') return '台灣/單一來源參考盤';
if (status === 'limited_scoreboard_fallback') return '比分備援';
if (status === 'no_upcoming_market') return '未來賽事無盤口';
return '盤口不足';
}
export default async function SourceHealthPage() {
let data: SourceHealth | null = null;
let error = '';
try { data = await getSourceHealth(); } catch (err) { error = err instanceof Error ? err.message : '資料健康暫時無法讀取'; }
const cards = [['賽事總數', data?.matches ?? '-', '目前資料庫可見賽事'], ['已完賽', data?.finished_matches ?? '-', '可用於賽後校準'], ['賠率列數', data?.odds_rows ?? '-', '盤口歷史資料量'], ['逾時賽果', data?.stale_unsettled_matches ?? '-', '不為 0 時不升級正式推薦']];
try {
data = await getSourceHealth();
} catch (err) {
error = err instanceof Error ? err.message : '資料健康暫時無法讀取';
}
const provider = data?.provider_requirements?.formal_provider_status;
const blocker = data?.provider_requirements?.formal_provider_blocker || data?.provider_requirements?.current_limitation;
const cards = [
['賽事總數', data?.matches ?? '-', '目前資料庫可見賽事'],
['已完賽', data?.finished_matches ?? '-', '可用於賽後校準'],
['賠率列數', data?.odds_rows ?? '-', '盤口歷史資料量'],
['逾時賽果', data?.stale_unsettled_matches ?? '-', '不為 0 時不升級正式推薦'],
];
const workerCards = [
[
'賠率同步',
data?.ingestion_status?.status ?? data?.status,
data?.ingestion_status?.message ?? `最近更新:${dateText(data?.latest_odds_recorded_at)}`,
],
[
'賽程同步',
data?.fixtures_status?.status ?? 'unknown',
data?.fixtures_status?.message ?? `最近排程:${dateText(data?.fixtures_status?.run_at)}`,
],
[
'新聞同步',
data?.news_status?.status ?? 'unknown',
data?.news_status?.message ?? `最近排程:${dateText(data?.news_status?.run_at)}`,
],
];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8"><p className="dot-matrix text-sm font-bold text-[#b83822]"></p><h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1><p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]"></p></section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 md:grid-cols-4">{cards.map(([label, value, helper]) => <article key={String(label)} className="panel-glow rounded-2xl p-5"><p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{helper}</p><p className="mt-3 text-3xl font-black text-[#7d2a15]">{value}</p><p className="mt-1 text-sm text-[#6f4f3c]">{label}</p></article>)}</section>
<section className="grid gap-4 lg:grid-cols-3">{[['賠率同步', data?.ingestion_status?.status ?? data?.status, data?.ingestion_status?.message ?? `最近更新:${dateText(data?.latest_odds_recorded_at)}`], ['賽程同步', data?.fixtures_status?.status ?? 'unknown', data?.fixtures_status?.message ?? `最近排程:${dateText(data?.fixtures_status?.run_at)}`], ['新聞同步', data?.news_status?.status ?? 'unknown', data?.news_status?.message ?? `最近排程:${dateText(data?.news_status?.run_at)}`]].map(([title, status, detail]) => <article key={String(title)} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]">{title}</p><p className="mt-2 text-2xl font-black text-[#3f2f25]">{status || 'unknown'}</p><p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p></article>)}</section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
</p>
</section>
{error ? (
<p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p>
) : null}
<section className="rounded-3xl border border-[#e7c89b] bg-white/80 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"> Provider</p>
<div className="mt-3 grid gap-4 lg:grid-cols-[1.1fr_1.4fr]">
<div>
<p className="text-3xl font-black text-[#3f2f25]">{providerLabel(provider?.status)}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
{coverageLabel(data?.odds_coverage_status)} provider
</p>
</div>
<div className="rounded-2xl border border-[#eadcb9] bg-[#fff8e6] p-4 text-sm leading-7 text-[#5f4330]">
<p>Provider{provider?.provider ?? data?.provider_requirements?.primary_odds_provider ?? 'the-odds-api'}</p>
<p>Key {provider?.api_key_configured ? '是' : '否'}</p>
<p>Key placeholder{provider?.api_key_placeholder ? '是,目前不能當正式盤口' : '否'}</p>
<p>Sport Key{provider?.sport_key ?? '尚未回報'}</p>
<p>Regions{provider?.regions ?? '尚未回報'}</p>
</div>
</div>
{blocker ? (
<p className="mt-4 rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold leading-7 text-[#b83822]">
{blocker}
</p>
) : null}
</section>
<section className="grid gap-4 md:grid-cols-4">
{cards.map(([label, value, helper]) => (
<article key={String(label)} className="panel-glow rounded-2xl p-5">
<p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{helper}</p>
<p className="mt-3 text-3xl font-black text-[#7d2a15]">{value}</p>
<p className="mt-1 text-sm text-[#6f4f3c]">{label}</p>
</article>
))}
</section>
<section className="grid gap-4 lg:grid-cols-3">
{workerCards.map(([title, status, detail]) => (
<article key={String(title)} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">{title}</p>
<p className="mt-2 text-2xl font-black text-[#3f2f25]">{status || 'unknown'}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
</article>
))}
</section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white">
</Link>
</div>
);
}