Selaa lähdekoodia

feat: 后端接通真实 PG + 默认单日取最新可用日 + FastAPI 可选静态托管

- session.py 挂 search_path=ads(连真实 ads.* 两表);config 加 db_schema
- 默认单日改最新可用日(省略 snapshot_dt → 后端 MAX(dt)),避数据滞后致缺失
- main.py 加 STATIC_DIR 条件静态托管(单进程同源 SPA+/api),本地/测试不挂载

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tianyu.chu 1 kuukausi sitten
vanhempi
commit
666f7b6e1b

+ 12 - 0
CHANGELOG.md

@@ -2,6 +2,18 @@
 
 > 记录本项目每次改动。新条目追加到顶部。格式:`日期 — 改动概述`,正文按 `新增 / 变更 / 修复 / 移除` 分类。链接 PR / commit / 相关文档(如有)。
 
+## 2026-06-26
+
+### 变更
+- **后端接通真实 PG**(`apps/api`):由假数据切到真实拼团漏斗预聚合两表(`ads.ads_trd_group_funnel_daily` / `_rolling`)。
+  - `config.py` 加 `db_schema`(默认 `ads`);`session.py` 给真实连接挂 `search_path=ads`(asyncpg `server_settings`)——ORM 表名保持无 schema,SQLite 测试不受影响,模型零改动。
+  - 真实连接串 + `USE_FAKE_DATA=false` 放进 **gitignored** 的 `apps/api/.env`(密码不入库)。
+  - 端到端实测通过:`{"period":"day"}` 取最新可用日(dt=20260621)`ready`;`last_7d`/`last_30d` 取 rolling 行。
+  - 已验证部署服务器(100.64.0.62)能连通 PG(100.64.0.10:25432);后端落点(隔离 conda/Py3.11)为后续。
+- **默认单日改为「最新可用日」**(`apps/web`):数据 T+1 且可能滞后(当前最新仅到 6-21),默认不再硬编码昨日。
+  - `FunnelPage` 默认 `snapshotDt=null` → 查询省略 `snapshot_dt`,后端取 `MAX(dt)`,打开即见最新一天,不再「数据缺失」。
+  - `TimePeriodSelect`:单日按钮无选中时显示「最新」;日历 popover 顶部加「最新可用日」快捷项可一键回到默认;日历仍可选历史具体日。
+
 ## 2026-06-25
 
 ### 新增

+ 5 - 0
apps/api/app/config.py

@@ -22,6 +22,11 @@ class Settings(BaseSettings):
     # Async SQLAlchemy URL. asyncpg driver is required for the async engine.
     database_url: str = "postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata"
 
+    # PostgreSQL schema holding the拼团 funnel tables. Real source is `ads`
+    # (tables ads.ads_trd_group_funnel_*); set via connection search_path so the
+    # schema-less ORM table names resolve there. Empty = leave default (public).
+    db_schema: str = "ads"
+
     # When true, the funnel API serves a realistic in-memory snapshot instead of
     # querying Postgres. Default ON so `uvicorn app.main:app` works with zero
     # infrastructure. Set USE_FAKE_DATA=false to read the real wide table.

+ 12 - 2
apps/api/app/db/session.py

@@ -20,9 +20,19 @@ from app.config import get_settings
 
 @lru_cache
 def get_engine() -> AsyncEngine:
-    """Return a lazily-created, cached async engine."""
+    """Return a lazily-created, cached async engine.
+
+    The real funnel tables live in the ``ads`` schema; the ORM models are
+    schema-less, so we point every connection's ``search_path`` at the
+    configured schema (asyncpg ``server_settings``). Skipped when blank.
+    """
     settings = get_settings()
-    return create_async_engine(settings.database_url, pool_pre_ping=True)
+    connect_args: dict = {}
+    if settings.db_schema:
+        connect_args["server_settings"] = {"search_path": settings.db_schema}
+    return create_async_engine(
+        settings.database_url, pool_pre_ping=True, connect_args=connect_args
+    )
 
 
 @lru_cache

+ 24 - 0
apps/api/app/main.py

@@ -7,7 +7,11 @@ offline.
 
 from __future__ import annotations
 
+import os
+
 from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
 
 from app.api.funnels import router as funnels_router
 
@@ -27,3 +31,23 @@ app.include_router(funnels_router)
 async def health() -> dict[str, str]:
     """Liveness probe. Does not touch the database."""
     return {"status": "ok"}
+
+
+# Optional single-origin static hosting: when STATIC_DIR points at a built SPA
+# (apps/web/dist), this process serves the frontend alongside /api on one port,
+# so the deployed page can call the real API same-origin (no CORS, no proxy).
+# Unset locally / in tests → api-only, Vite serves the frontend. Registered
+# AFTER the router so /api and /health keep priority; the GET catch-all does
+# SPA fallback (real files served, unknown routes → index.html).
+_STATIC_DIR = os.environ.get("STATIC_DIR")
+if _STATIC_DIR and os.path.isdir(_STATIC_DIR):
+    _assets = os.path.join(_STATIC_DIR, "assets")
+    if os.path.isdir(_assets):
+        app.mount("/assets", StaticFiles(directory=_assets), name="assets")
+
+    @app.get("/{full_path:path}", include_in_schema=False)
+    async def spa_fallback(full_path: str) -> FileResponse:
+        candidate = os.path.join(_STATIC_DIR, full_path)
+        if full_path and os.path.isfile(candidate):
+            return FileResponse(candidate)
+        return FileResponse(os.path.join(_STATIC_DIR, "index.html"))

+ 14 - 15
apps/web/src/modules/funnel/FunnelPage.tsx

@@ -6,12 +6,7 @@ import type {
   FunnelQueryRequest,
   FunnelQueryResponse,
 } from '../../api/types';
-import {
-  DEFAULT_PERIOD,
-  funnelRangeText,
-  toSnapshotParam,
-  yesterday,
-} from './period';
+import { DEFAULT_PERIOD, funnelRangeText, toSnapshotParam } from './period';
 import { TimePeriodSelect } from './components/TimePeriodSelect';
 import { FunnelResult } from './components/FunnelResult';
 import { Badge } from '@/components/ui/badge';
@@ -24,8 +19,10 @@ import { Badge } from '@/components/ui/badge';
  */
 export function FunnelPage() {
   const [period, setPeriod] = useState<FunnelPeriod>(DEFAULT_PERIOD);
-  // Selected calendar day for `period: 'day'`; defaults to yesterday (max).
-  const [snapshotDt, setSnapshotDt] = useState<Date>(() => yesterday());
+  // Selected calendar day for `period: 'day'`. `null` = latest available day
+  // (data is T+1 and may lag, so the default queries MAX(dt) server-side
+  // rather than hard-coding yesterday); a picked date overrides it.
+  const [snapshotDt, setSnapshotDt] = useState<Date | null>(null);
 
   const mutation = useMutation<FunnelQueryResponse, Error, FunnelQueryRequest>({
     mutationFn: queryFunnel,
@@ -34,14 +31,12 @@ export function FunnelPage() {
   const { mutate } = mutation;
 
   // Query on mount and whenever the period — or, for single-day, the chosen
-  // date — changes. Send snapshot_dt ONLY for `day`; omit it for 7d/30d.
-  const dayParam = period === 'day' ? toSnapshotParam(snapshotDt) : undefined;
+  // date — changes. Send snapshot_dt ONLY for `day` with a picked date; omit it
+  // for latest single-day and for 7d/30d (the server returns the latest row).
+  const dayParam =
+    period === 'day' && snapshotDt ? toSnapshotParam(snapshotDt) : undefined;
   useEffect(() => {
-    mutate(
-      period === 'day'
-        ? { period, snapshot_dt: dayParam }
-        : { period },
-    );
+    mutate(dayParam ? { period: 'day', snapshot_dt: dayParam } : { period });
   }, [period, dayParam, mutate]);
 
   return (
@@ -67,6 +62,10 @@ export function FunnelPage() {
               setSnapshotDt(d);
               setPeriod('day');
             }}
+            onPickLatest={() => {
+              setSnapshotDt(null);
+              setPeriod('day');
+            }}
             onPickRolling={(p) => setPeriod(p)}
           />
           {/* 滚动周期显示日期范围;单日不显示。查询 pending 时不渲染,

+ 28 - 18
apps/web/src/modules/funnel/__tests__/FunnelPage.test.tsx

@@ -101,22 +101,14 @@ describe('FunnelPage — fixed funnel + period selection', () => {
     return lastReq()?.period;
   }
 
-  /** Yesterday as "yyyy-MM-dd" (the picker default + request snapshot_dt). */
-  function yesterdayParam(): string {
-    const d = new Date();
-    d.setDate(d.getDate() - 1);
-    const y = d.getFullYear();
-    const m = String(d.getMonth() + 1).padStart(2, '0');
-    const day = String(d.getDate()).padStart(2, '0');
-    return `${y}-${m}-${day}`;
-  }
-
-  it('auto-queries with default 单日 (day = yesterday) on first render', async () => {
+  it('auto-queries with default 单日 (latest available day) on first render', async () => {
     queryFunnelMock.mockResolvedValue(readyResponse());
     renderPage();
 
+    // Data is T+1 and may lag, so the default omits snapshot_dt and the server
+    // returns MAX(dt) — no hard-coded yesterday that could be "数据缺失".
     await waitFor(() => expect(lastPeriod()).toBe('day'));
-    expect(lastReq()).toEqual({ period: 'day', snapshot_dt: yesterdayParam() });
+    expect(lastReq()).toEqual({ period: 'day' });
     expect(queryFunnelMock).toHaveBeenCalledTimes(1);
   });
 
@@ -188,26 +180,44 @@ describe('FunnelPage — fixed funnel + period selection', () => {
     await waitFor(() => expect(lastPeriod()).toBe('day'));
   });
 
-  it('单日 segment defaults to yesterday', async () => {
+  it('单日 segment defaults to 最新 (latest available day)', async () => {
     queryFunnelMock.mockResolvedValue(readyResponse());
     renderPage();
 
     await waitFor(() => expect(lastPeriod()).toBe('day'));
     const picker = screen.getByRole('button', { name: '选择历史日期' });
     expect(picker).toBeInTheDocument();
-    expect(picker).toHaveTextContent(yesterdayParam());
+    expect(picker).toHaveTextContent('最新');
   });
 
-  it('default day sends snapshot_dt; rolling periods omit it', async () => {
+  it('default day omits snapshot_dt; a picked date sends it, then 最新 clears it', async () => {
     queryFunnelMock.mockResolvedValue(readyResponse());
     const user = userEvent.setup();
     renderPage();
 
-    // default 单日 sends snapshot_dt = yesterday
+    // default 单日 (latest) omits snapshot_dt
     await waitFor(() => expect(lastPeriod()).toBe('day'));
-    expect(lastReq()).toEqual({ period: 'day', snapshot_dt: yesterdayParam() });
+    expect(lastReq()).toEqual({ period: 'day' });
+
+    // picking a historical date sends that snapshot_dt
+    await pickFirstOfMonth(user, await openCalendar(user));
+    await waitFor(() => {
+      const req = lastReq();
+      expect(req?.period).toBe('day');
+      expect(req?.snapshot_dt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+    });
+
+    // 最新可用日 quick-pick clears snapshot_dt again
+    await user.click(screen.getByRole('button', { name: '选择历史日期' }));
+    await user.click(await screen.findByRole('button', { name: '最新可用日' }));
+    await waitFor(() => expect(lastReq()).toEqual({ period: 'day' }));
+  });
+
+  it('rolling periods omit snapshot_dt', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    const user = userEvent.setup();
+    renderPage();
 
-    // rolling periods omit snapshot_dt
     await user.click(screen.getByRole('button', { name: '近 7 天' }));
     await waitFor(() => expect(lastPeriod()).toBe('last_7d'));
     expect(lastReq()).toEqual({ period: 'last_7d' });

+ 32 - 9
apps/web/src/modules/funnel/components/TimePeriodSelect.tsx

@@ -1,3 +1,4 @@
+import { useState } from 'react';
 import { CalendarIcon } from 'lucide-react';
 import type { FunnelPeriod } from '../../../api/types';
 import { toSnapshotParam, yesterday } from '../period';
@@ -11,10 +12,12 @@ import { cn } from '@/lib/utils';
 
 interface Props {
   period: FunnelPeriod;
-  /** Selected day for `period: 'day'`. */
-  snapshotDt: Date;
+  /** Selected day for `period: 'day'`; `null` = latest available day. */
+  snapshotDt: Date | null;
   /** Pick a historical day → switch to single-day mode. */
   onPickDate: (d: Date) => void;
+  /** Reset single-day back to the latest available day. */
+  onPickLatest: () => void;
   /** Pick a rolling window. */
   onPickRolling: (p: 'last_7d' | 'last_30d') => void;
 }
@@ -34,14 +37,16 @@ export function TimePeriodSelect({
   period,
   snapshotDt,
   onPickDate,
+  onPickLatest,
   onPickRolling,
 }: Props) {
   const max = yesterday();
+  const [open, setOpen] = useState(false);
 
   return (
     <div className="inline-flex items-center gap-1 rounded-lg border border-border bg-muted/60 p-1">
-      {/* 单日 —— 日历 popover */}
-      <Popover>
+      {/* 单日 —— 日历 popover;未选具体日 = 最新可用日 */}
+      <Popover open={open} onOpenChange={setOpen}>
         <PopoverTrigger asChild>
           <button
             type="button"
@@ -52,19 +57,37 @@ export function TimePeriodSelect({
             <CalendarIcon className="size-4" />
             <span>单日</span>
             <span className="tabular-nums opacity-90">
-              {toSnapshotParam(snapshotDt)}
+              {snapshotDt ? toSnapshotParam(snapshotDt) : '最新'}
             </span>
           </button>
         </PopoverTrigger>
         <PopoverContent className="w-auto p-0" align="start">
+          {/* 最新可用日快捷项:回到默认(后端取 MAX(dt)) */}
+          <button
+            type="button"
+            onClick={() => {
+              onPickLatest();
+              setOpen(false);
+            }}
+            className={cn(
+              'w-full px-3 py-2 text-left text-sm border-b border-border transition-colors',
+              !snapshotDt
+                ? 'text-primary font-medium'
+                : 'text-foreground hover:bg-muted',
+            )}
+          >
+            最新可用日
+          </button>
           <Calendar
             mode="single"
-            selected={snapshotDt}
-            defaultMonth={snapshotDt}
+            selected={snapshotDt ?? undefined}
+            defaultMonth={snapshotDt ?? max}
             disabled={{ after: max }}
-            required
             onSelect={(d) => {
-              if (d) onPickDate(d);
+              if (d) {
+                onPickDate(d);
+                setOpen(false);
+              }
             }}
           />
         </PopoverContent>