Files
Neo-ZQYY/.kiro/specs/rns1-board-apis/design.md

32 KiB
Raw Blame History

设计文档 — RNS1.3:三看板接口

概述

本设计覆盖 RNS1.3 的全部后端接口BOARD-1/2/3、CONFIG-1和前端筛选修复。核心挑战在于 BOARD-3 财务看板的复杂度6 板块、200+ 字段、60+ 环比数据点)以及跨多个 ETL RLS 视图的数据聚合。

设计遵循已有架构模式:

  • 路由层(routers/):参数校验 + 权限检查,委托 service 层
  • 服务层(services/):业务逻辑编排,调用 fdw_queries 查询 ETL 数据
  • Schema 层(schemas/CamelModel 基类,自动 camelCase 转换
  • 中间件:ResponseWrapperMiddleware 统一包装 { code: 0, data: ... }
  • FDW 查询:_fdw_context 上下文管理器,直连 ETL 库 + SET LOCAL app.current_site_id

设计决策

  1. BOARD-1 扁平返回 vs 按维度返回:选择扁平返回(所有维度字段一次性返回),前端切换维度无需重新请求,减少网络往返。代价是单次响应略大,但助教数量有限(通常 < 50可接受。
  2. BOARD-2 按维度返回:选择按 dimension 参数仅返回对应维度字段。客户数量可达数千,分页 + 维度专属字段可显著减少传输量和查询开销。
  3. BOARD-3 单接口 6 板块:单个 GET /api/xcx/board/finance 返回全部 6 个板块。各板块独立查询、独立降级,某板块失败不影响其他板块。recharge 板块在 area≠all 时返回 null
  4. 环比计算后端统一处理compare=1 时后端计算所有环比字段,compare=0 时完全不返回环比字段(非返回 null而是字段不存在减少 JSON 体积。
  5. FDW 查询集中封装:所有新增 ETL 查询函数统一添加到 fdw_queries.py,保持 DWD-DOC 规则在单一模块实施。

架构

请求流程

sequenceDiagram
    participant MP as 小程序
    participant MW as ResponseWrapperMiddleware
    participant R as Router (xcx_board / xcx_config)
    participant P as Permission (require_permission)
    participant S as Service (board_service / config_service)
    participant FDW as fdw_queries (_fdw_context)
    participant ETL as ETL DB (app.v_*)
    participant BIZ as App DB (biz.*)

    MP->>MW: GET /api/xcx/board/coaches?sort=perf_desc&skill=all&time=month
    MW->>R: 透传请求
    R->>P: require_permission("view_board_coach")
    P-->>R: CurrentUser (site_id)
    R->>S: get_coach_board(params, site_id)
    S->>FDW: 查询助教列表 + 绩效 + 客户
    FDW->>ETL: SET LOCAL app.current_site_id; SELECT FROM app.v_*
    ETL-->>FDW: 结果集
    S->>BIZ: 查询任务完成数 (biz.coach_tasks)
    BIZ-->>S: 结果集
    S-->>R: 组装响应 dict
    R-->>MW: JSON 响应
    MW-->>MP: { code: 0, data: {...} }

模块结构

apps/backend/app/
├── routers/
│   ├── xcx_board.py          # 新增BOARD-1/2/3 三个看板端点
│   └── xcx_config.py         # 新增CONFIG-1 技能类型端点
├── schemas/
│   ├── xcx_board.py          # 新增:三看板请求参数 + 响应 schema
│   └── xcx_config.py         # 新增:技能类型响应 schema
├── services/
│   ├── board_service.py      # 新增看板业务逻辑3 个看板的编排函数)
│   └── fdw_queries.py        # 扩展:新增看板相关 FDW 查询函数
└── main.py                   # 扩展:注册 xcx_board / xcx_config 路由

apps/miniprogram/miniprogram/
├── pages/board-coach/         # 修改:筛选事件 → loadData()
├── pages/board-customer/      # 修改:筛选事件 → loadData() + 分页
├── pages/board-finance/       # 修改:筛选事件 → loadData() + 环比开关
└── services/api.ts            # 修改:函数签名扩展

组件与接口

3.1 路由层 — xcx_board.py

三个端点共用一个路由文件,前缀 /api/xcx/board

# GET /api/xcx/board/coaches
@router.get("/coaches", response_model=CoachBoardResponse)
async def get_coach_board(
    sort: CoachSortEnum = Query(default="perf_desc"),
    skill: SkillFilterEnum = Query(default="all"),
    time: BoardTimeEnum = Query(default="month"),
    user: CurrentUser = Depends(require_permission("view_board_coach")),
): ...

# GET /api/xcx/board/customers
@router.get("/customers", response_model=CustomerBoardResponse)
async def get_customer_board(
    dimension: CustomerDimensionEnum = Query(default="recall"),
    project: ProjectFilterEnum = Query(default="all"),
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=20, ge=1, le=100),
    user: CurrentUser = Depends(require_permission("view_board_customer")),
): ...

# GET /api/xcx/board/finance
@router.get("/finance", response_model=FinanceBoardResponse)
async def get_finance_board(
    time: FinanceTimeEnum = Query(default="month"),
    area: AreaFilterEnum = Query(default="all"),
    compare: int = Query(default=0, ge=0, le=1),
    user: CurrentUser = Depends(require_permission("view_board_finance")),
): ...

3.2 路由层 — xcx_config.py

# GET /api/xcx/config/skill-types
@router.get("/skill-types", response_model=list[SkillTypeItem])
async def get_skill_types(
    user: CurrentUser = Depends(require_approved()),
): ...

3.3 服务层 — board_service.py

三个核心编排函数,各自独立处理参数解析、日期范围计算、数据查询和响应组装:

async def get_coach_board(sort, skill, time, site_id) -> dict:
    """BOARD-1助教看板。扁平返回所有维度字段。"""

async def get_customer_board(dimension, project, page, page_size, site_id) -> dict:
    """BOARD-2客户看板。按维度返回专属字段 + 分页。"""

async def get_finance_board(time, area, compare, site_id) -> dict:
    """BOARD-3财务看板。6 板块独立查询、独立降级。"""

3.4 FDW 查询层扩展 — fdw_queries.py

新增函数(遵循已有 _fdw_context 模式):

函数 数据源视图 用途
get_all_assistants v_dim_assistant BOARD-1 助教列表
get_salary_calc_batch v_dws_assistant_salary_calc BOARD-1 批量绩效(基于已有 get_salary_calc SQL 模式扩展为批量查询)
get_top_customers_for_coaches v_dws_member_assistant_relation_index + v_dim_member BOARD-1 Top 客户(基于已有 get_relation_index 扩展为按助教批量查询)
get_coach_sv_data v_dws_assistant_recharge_commission BOARD-1 sv 维度(助教储值提成明细,取 recharge_amount + commission_amount
get_customer_board_recall v_dws_member_winback_index + v_dim_member BOARD-2 recall 维度ETL 已计算 WBI 指数)
get_customer_board_potential v_dws_member_spending_power_index BOARD-2 potential 维度ETL 已计算 SPI 指数)
get_customer_board_balance v_dim_member_card_account + v_dim_member BOARD-2 balance 维度
get_customer_board_recharge v_dwd_recharge_order + v_dim_member_card_account BOARD-2 recharge 维度(充值记录 + 当前余额)
get_customer_board_recent v_dws_member_visit_detail + v_dim_member BOARD-2 recent 维度ETL 已计算到店明细)
get_customer_board_spend60 v_dws_member_consumption_summary BOARD-2 spend60 维度items_sum_60d 已在汇总表中)
get_customer_board_freq60 v_dws_member_consumption_summary BOARD-2 freq60 维度visit_count_60d 已在汇总表中)
get_customer_board_loyal v_dws_member_assistant_relation_index BOARD-2 loyal 维度
get_finance_overview v_dws_finance_daily_summary BOARD-3 经营一览(按日期范围聚合财务日报)
get_finance_recharge v_dws_finance_recharge_summary BOARD-3 预收资产
get_finance_revenue v_dws_finance_income_structure + v_dws_finance_discount_detail BOARD-3 应计收入
get_finance_cashflow v_dws_finance_daily_summary BOARD-3 现金流入(复用财务日报中的收款字段)
get_finance_expense v_dws_finance_expense_summary + v_dws_platform_settlement BOARD-3 现金流出
get_finance_coach_analysis v_dws_assistant_salary_calc BOARD-3 助教分析
get_skill_types ETL cfg 表 CONFIG-1 技能类型

3.5 日期范围计算

统一工具函数 _calc_date_range(time_enum) -> (start_date, end_date),返回 date 对象:

time 参数 当期范围 上期范围compare=1 时)
month 当月 1 日 ~ 末日 上月 1 日 ~ 末日
lastMonth / last_month 上月 1 日 ~ 末日 上上月
week 本周一 ~ 本周日 上周一 ~ 上周日
lastWeek 上周一 ~ 上周日 上上周
quarter 本季度首日 ~ 末日 上季度
lastQuarter / last_quarter 上季度 上上季度
quarter3 / last_3m 前 3 个月(不含本月) 再前 3 个月
half6 / last_6m 前 6 个月(不含本月) 再前 6 个月

3.6 环比计算工具

def calc_compare(current: Decimal, previous: Decimal) -> dict:
    """
    统一环比计算。
    
    返回:
      - compare: str  — "12.5%" / "新增" / "持平"
      - is_down: bool — 是否下降
      - is_flat: bool — 是否持平
    
    规则:
      - previous=0, current≠0 → "新增", is_down=False, is_flat=False
      - previous=0, current=0 → "持平", is_down=False, is_flat=True
      - 正常计算: (current - previous) / previous × 100%
      - 正值 → is_down=False; 负值 → is_down=True; 零 → is_flat=True
    """

数据模型

4.1 请求参数枚举

# BOARD-1 排序维度
class CoachSortEnum(str, Enum):
    perf_desc = "perf_desc"
    perf_asc = "perf_asc"
    salary_desc = "salary_desc"
    salary_asc = "salary_asc"
    sv_desc = "sv_desc"
    task_desc = "task_desc"

# BOARD-1 技能筛选
class SkillFilterEnum(str, Enum):
    all = "all"
    chinese = "chinese"
    snooker = "snooker"
    mahjong = "mahjong"
    karaoke = "karaoke"

# BOARD-1 时间范围
class BoardTimeEnum(str, Enum):
    month = "month"
    quarter = "quarter"
    last_month = "last_month"
    last_3m = "last_3m"
    last_quarter = "last_quarter"
    last_6m = "last_6m"

# BOARD-2 客户维度
class CustomerDimensionEnum(str, Enum):
    recall = "recall"
    potential = "potential"
    balance = "balance"
    recharge = "recharge"
    recent = "recent"
    spend60 = "spend60"
    freq60 = "freq60"
    loyal = "loyal"

# BOARD-2 项目筛选
class ProjectFilterEnum(str, Enum):
    all = "all"
    chinese = "chinese"
    snooker = "snooker"
    mahjong = "mahjong"
    karaoke = "karaoke"

# BOARD-3 时间范围
class FinanceTimeEnum(str, Enum):
    month = "month"
    lastMonth = "lastMonth"
    week = "week"
    lastWeek = "lastWeek"
    quarter3 = "quarter3"
    quarter = "quarter"
    lastQuarter = "lastQuarter"
    half6 = "half6"

# BOARD-3 区域筛选
class AreaFilterEnum(str, Enum):
    all = "all"
    hall = "hall"
    hallA = "hallA"
    hallB = "hallB"
    hallC = "hallC"
    mahjong = "mahjong"
    teamBuilding = "teamBuilding"

4.2 BOARD-1 响应 Schema

class CoachSkillItem(CamelModel):
    text: str
    cls: str

class CoachBoardItem(CamelModel):
    """助教看板单条记录(扁平结构,包含所有维度字段)。"""
    # 基础字段(所有维度共享)
    id: int
    name: str
    initial: str
    avatar_gradient: str
    level: str                              # star/senior/middle/junior
    skills: list[CoachSkillItem]
    top_customers: list[str]                # ["💖 王先生", "💛 李女士"]

    # perf 维度
    perf_hours: float = 0.0
    perf_hours_before: float | None = None
    perf_gap: str | None = None             # "距升档 13.8h" 或 None
    perf_reached: bool = False

    # salary 维度
    salary: float = 0.0
    salary_perf_hours: float = 0.0
    salary_perf_before: float | None = None

    # sv 维度
    sv_amount: float = 0.0
    sv_customer_count: int = 0
    sv_consume: float = 0.0

    # task 维度
    task_recall: int = 0
    task_callback: int = 0

class CoachBoardResponse(CamelModel):
    items: list[CoachBoardItem]
    dim_type: str                           # perf/salary/sv/task

4.3 BOARD-2 响应 Schema

class CustomerAssistant(CamelModel):
    name: str
    cls: str
    heart_score: float
    badge: str | None = None
    badge_cls: str | None = None

class CustomerBoardItemBase(CamelModel):
    """客户看板基础字段(所有维度共享)。"""
    id: int
    name: str
    initial: str
    avatar_cls: str
    assistants: list[CustomerAssistant]

# 各维度专属字段通过继承扩展
class RecallItem(CustomerBoardItemBase):
    ideal_days: int
    elapsed_days: int
    overdue_days: int
    visits_30d: int
    balance: str
    recall_index: float

class PotentialTag(CamelModel):
    text: str
    theme: str

class PotentialItem(CustomerBoardItemBase):
    potential_tags: list[PotentialTag]
    spend_30d: float
    avg_visits: float
    avg_spend: float

class BalanceItem(CustomerBoardItemBase):
    balance: str
    last_visit: str                         # "3天前"
    monthly_consume: float
    available_months: str                   # "约0.8个月"

class RechargeItem(CustomerBoardItemBase):
    last_recharge: str
    recharge_amount: float
    recharges_60d: int
    current_balance: str

class RecentItem(CustomerBoardItemBase):
    days_ago: int
    visit_freq: str                         # "6.2次/月"
    ideal_days: int
    visits_30d: int
    avg_spend: float

class Spend60Item(CustomerBoardItemBase):
    spend_60d: float
    visits_60d: int
    high_spend_tag: bool
    avg_spend: float

class WeeklyVisit(CamelModel):
    val: int
    pct: int                                # 0-100

class Freq60Item(CustomerBoardItemBase):
    visits_60d: int
    avg_interval: str                       # "5.0天"
    weekly_visits: list[WeeklyVisit]        # 固定长度 8
    spend_60d: float

class CoachDetail(CamelModel):
    name: str
    cls: str
    heart_score: float
    badge: str | None = None
    avg_duration: str
    service_count: int
    coach_spend: float
    relation_idx: float

class LoyalItem(CustomerBoardItemBase):
    intimacy: float
    top_coach_name: str
    top_coach_heart: float
    top_coach_score: float
    coach_name: str
    coach_ratio: str                        # "78%"
    coach_details: list[CoachDetail]

class CustomerBoardResponse(CamelModel):
    items: list[dict]                       # 实际类型取决于 dimension
    total: int
    page: int
    page_size: int

4.4 BOARD-3 响应 Schema

class CompareField(CamelModel):
    """环比字段三元组(仅 compare=1 时出现)。"""
    compare: str                            # "12.5%" / "新增" / "持平"
    down: bool
    flat: bool

class OverviewPanel(CamelModel):
    occurrence: float
    discount: float                         # 负值
    discount_rate: float
    confirmed_revenue: float
    cash_in: float
    cash_out: float
    cash_balance: float
    balance_rate: float
    # 环比字段compare=1 时存在compare=0 时整个字段不出现)
    occurrence_compare: str | None = None
    occurrence_down: bool | None = None
    occurrence_flat: bool | None = None
    # ... 其余 7 项指标各 3 个环比字段,结构相同

class GiftCell(CamelModel):
    value: float
    compare: str | None = None
    down: bool | None = None
    flat: bool | None = None

class GiftRow(CamelModel):
    """赠送卡矩阵一行:合计 / 酒水卡 / 台费卡 / 抵用券。"""
    label: str                              # "新增" / "消费" / "余额"
    total: GiftCell
    liquor: GiftCell
    table_fee: GiftCell
    voucher: GiftCell

class RechargePanel(CamelModel):
    actual_income: float
    first_charge: float
    renew_charge: float
    consumed: float
    card_balance: float
    gift_rows: list[GiftRow]                # 3 行
    all_card_balance: float
    # 各项环比字段(同 overview 模式)

class RevenueStructureRow(CamelModel):
    id: str
    name: str
    desc: str | None = None
    is_sub: bool = False
    amount: float
    discount: float
    booked: float
    booked_compare: str | None = None

class RevenueItem(CamelModel):
    label: str
    amount: float

class ChannelItem(CamelModel):
    label: str
    amount: float

class RevenuePanel(CamelModel):
    structure_rows: list[RevenueStructureRow]
    price_items: list[RevenueItem]          # 4 项
    total_occurrence: float
    discount_items: list[RevenueItem]       # 4 项
    confirmed_total: float
    channel_items: list[ChannelItem]        # 3 项

class CashflowItem(CamelModel):
    label: str
    amount: float

class CashflowPanel(CamelModel):
    consume_items: list[CashflowItem]       # 3 项
    recharge_items: list[CashflowItem]      # 1 项
    total: float

class ExpenseItem(CamelModel):
    label: str
    amount: float
    compare: str | None = None
    down: bool | None = None
    flat: bool | None = None

class ExpensePanel(CamelModel):
    operation_items: list[ExpenseItem]      # 3 项
    fixed_items: list[ExpenseItem]          # 4 项
    coach_items: list[ExpenseItem]          # 4 项
    platform_items: list[ExpenseItem]       # 3 项
    total: float
    total_compare: str | None = None
    total_down: bool | None = None
    total_flat: bool | None = None

class CoachAnalysisRow(CamelModel):
    level: str
    pay: float
    share: float
    hourly: float
    pay_compare: str | None = None
    pay_down: bool | None = None
    share_compare: str | None = None
    share_down: bool | None = None
    hourly_compare: str | None = None
    hourly_flat: bool | None = None

class CoachAnalysisTable(CamelModel):
    total_pay: float
    total_share: float
    avg_hourly: float
    total_pay_compare: str | None = None
    total_pay_down: bool | None = None
    total_share_compare: str | None = None
    total_share_down: bool | None = None
    avg_hourly_compare: str | None = None
    avg_hourly_flat: bool | None = None
    rows: list[CoachAnalysisRow]            # 4 行:初级/中级/高级/星级

class CoachAnalysisPanel(CamelModel):
    basic: CoachAnalysisTable               # 基础课/陪打
    incentive: CoachAnalysisTable           # 激励课/超休

class FinanceBoardResponse(CamelModel):
    overview: OverviewPanel
    recharge: RechargePanel | None          # area≠all 时为 null
    revenue: RevenuePanel
    cashflow: CashflowPanel
    expense: ExpensePanel
    coach_analysis: CoachAnalysisPanel

4.5 CONFIG-1 响应 Schema

class SkillTypeItem(CamelModel):
    key: str                                # chinese/snooker/mahjong/karaoke
    label: str                              # 中文标签
    emoji: str                              # 表情符号
    cls: str                                # 前端样式类

4.6 数据库查询模式

所有 FDW 查询遵循已有模式:

def get_finance_overview(conn, site_id, start_date, end_date):
    """查询经营一览 8 指标(从财务日报聚合)。"""
    with _fdw_context(conn, site_id) as cur:
        cur.execute("""
            SELECT SUM(occurrence) AS occurrence,
                   SUM(discount) AS discount,
                   ...
            FROM app.v_dws_finance_daily_summary
            WHERE stat_date >= %s AND stat_date < %s
        """, (start_date, end_date))
        ...

⚠️ 已有函数复用说明:

  • get_salary_calc_batch 基于已有 get_salary_calc() 的 SQL 模式,扩展为 WHERE assistant_id = ANY(%s) 批量查询
  • get_top_customers_for_coaches 基于已有 get_relation_index() 的 SQL 模式,扩展为按助教维度批量查询 + JOIN v_dim_member
  • get_coach_sv_data 使用 v_dws_assistant_recharge_commission(助教储值提成明细表,取 recharge_amount + commission_amountv_dws_assistant_monthly_summary 口径错误M15 修复)
  • BOARD-2 recall/potential/recent/recharge 维度使用 ETL 已计算好的专用指数视图,避免在后端重复计算

关键 SQL 模式:

  • items_sum 口径:所有金额字段使用 ledger_amount(对应 items_sum),禁止 consume_money
  • 助教费用拆分base_income(对应 assistant_pd_money+ bonus_income(对应 assistant_cx_money),禁止 service_fee
  • 会员信息 DQ-6LEFT JOIN app.v_dim_member ON tenant_member_id = member_id AND scd2_is_current = 1
  • 会员卡 DQ-7LEFT JOIN app.v_dim_member_card_account ON tenant_member_id AND scd2_is_current = 1
  • 废单排除WHERE is_delete = 0RLS 视图使用 is_delete
  • 正向交易WHERE settle_type IN (1, 3)
  • 支付渠道恒等式balance_amount = recharge_card_amount + gift_card_amount
  • 现金流互斥platform_settlement_amountgroupbuy_pay_amount 互斥

4.7 BOARD-2 分页策略

# SQL 层面使用 LIMIT/OFFSET
# 先执行 COUNT(*) 获取 total再执行分页查询
# pageSize 上限 100默认 20

def get_customer_board_recall(conn, site_id, project, page, page_size):
    with _fdw_context(conn, site_id) as cur:
        # 1. 总数
        cur.execute("SELECT COUNT(*) FROM ... WHERE ...", params)
        total = cur.fetchone()[0]
        
        # 2. 分页数据
        offset = (page - 1) * page_size
        cur.execute("SELECT ... LIMIT %s OFFSET %s", 
                    (*params, page_size, offset))
        items = cur.fetchall()
    
    return {"items": items, "total": total, "page": page, "page_size": page_size}

4.8 BOARD-3 环比字段条件输出

compare=0 时,响应 JSON 中不包含任何环比字段。实现方式Schema 中环比字段设为 Optionalmodel_dump(exclude_none=True) 输出时自动排除 None 值。

class OverviewPanel(CamelModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
        # exclude_none 在序列化时排除 None 字段
    )
    
    occurrence: float
    occurrence_compare: str | None = None   # compare=0 时为 None → 不输出
    occurrence_down: bool | None = None
    occurrence_flat: bool | None = None

路由层序列化时使用 response_model_exclude_none=True

@router.get("/finance", response_model=FinanceBoardResponse,
            response_model_exclude_none=True)
async def get_finance_board(...): ...

正确性属性

属性Property是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。

Property 1: 日期范围计算正确性

对于任意当前日期和任意 time 枚举值BOARD-1 的 6 种 + BOARD-3 的 8 种),_calc_date_range(time) 返回的 (start_date, end_date) 应满足:start_date <= end_date,且当 compare=1 时计算的上期范围 (prev_start, prev_end) 应满足 prev_end <= start_date 且上期长度等于当期长度。

Validates: Requirements 1.3, 3.2, 3.3

Property 2: BOARD-1 排序不变量

对于任意 BOARD-1 响应列表和任意 sort 参数,列表中相邻两项的排序字段值应满足排序方向约束:perf_descitems[i].perfHours >= items[i+1].perfHourssalary_ascitems[i].salary <= items[i+1].salary,以此类推。

Validates: Requirements 1.15, 9.1, 9.2

Property 3: BOARD-2 分页不变量

对于任意相同筛选参数(dimensionproject)的 BOARD-2 请求,(a) items.length <= pageSize(b) 不同 page 值返回的 total 相同,(c) page=1page=2 返回的 items 无交集(按 id 判断)。

Validates: Requirements 2.2, 9.3, 9.4

Property 4: 亲密度 emoji 四级映射

对于任意 rs_display 浮点数值0-10 范围),亲密度 emoji 映射函数应返回确定的结果:> 8.5💖> 7🧡> 5💛≤ 5💙,且映射结果与阈值边界一致(如 rs_display=8.5 应返回 🧡 而非 💖)。

Validates: Requirements 1.6

Property 5: 环比计算公式正确性

对于任意两个非负 Decimal(current, previous)calc_compare(current, previous) 应满足:(a) previous > 0compare 字符串等于 abs((current - previous) / previous * 100) 格式化为百分比,(b) current > previousis_down=False(c) current < previousis_down=True(d) current == previousis_flat=True(e) previous=0, current>0 时返回 "新增"(f) previous=0, current=0 时返回 "持平"

Validates: Requirements 8.11, 8.12, 8.13, 8.14

Property 6: 环比开关一致性

对于任意 BOARD-3 请求参数,当 compare=0 时,响应 JSON 序列化后的字符串中不应包含任何以 CompareDownFlat 结尾的 keycamelCase 格式)。

Validates: Requirements 3.4, 9.8

Property 7: 预收资产区域约束

对于任意 BOARD-3 请求,当 area 不等于 all 时,响应中 recharge 字段应为 null

Validates: Requirements 3.11, 9.7

Property 8: 经营一览收入确认恒等式

对于任意 BOARD-3 响应中的 overview 数据,confirmedRevenue 应近似等于 occurrence - abs(discount)(在 ±0.01 浮点精度范围内)。

Validates: Requirements 9.5

Property 9: 经营一览现金结余恒等式

对于任意 BOARD-3 响应中的 overview 数据,cashBalance 应近似等于 cashIn - cashOut(在 ±0.01 浮点精度范围内)。

Validates: Requirements 9.6

Property 10: 支付渠道恒等式

对于任意涉及支付渠道的数据记录,balance_amount 应精确等于 recharge_card_amount + gift_card_amountDWD-DOC 强制规则 3

Validates: Requirements 8.7, 9.9

Property 11: 参数互斥约束

对于任意 BOARD-1 请求,当 time=last_6msort=sv_descAPI 应返回 HTTP 400 状态码,响应体包含错误信息。

Validates: Requirements 1.2, 9.11

Property 12: BOARD-3 幂等性

对于任意相同参数的 BOARD-3 请求(相同 timeareacompare),在底层数据未变更的情况下,两次请求返回的 overview.occurrenceoverview.cashBalance 值应完全相同。

Validates: Requirements 9.10

Property 13: weeklyVisits 百分比范围

对于任意 BOARD-2 freq60 维度响应中的 weeklyVisits 数组,(a) 数组长度固定为 8(b) 每个元素的 pct 值在 0-100 范围内,(c) 如果存在非零 val,则 max(pct) 应等于 100。

Validates: Requirements 2.20

Property 14: 优雅降级不变量

对于任意 BOARD-3 请求当某个板块overview/recharge/revenue/cashflow/expense/coachAnalysis的数据源查询抛出异常时整体响应仍应返回 HTTP 200失败板块返回空默认值空对象或空数组其他板块数据不受影响。

Validates: Requirements 8.9, 8.10

错误处理

6.1 HTTP 错误码

场景 状态码 响应体
未认证(无 JWT / JWT 过期) 401 { code: 401, message: "无效的令牌" }
未审核status ≠ approved 403 { code: 403, message: "用户未通过审核" }
权限不足 403 { code: 403, message: "权限不足" }
参数互斥last_6m + sv_desc 400 { code: 400, message: "最近6个月不支持客源储值排序" }
无效枚举值 422 FastAPI 自动验证
服务端异常 500 { code: 500, message: "Internal Server Error" }

6.2 优雅降级策略

BOARD-3 财务看板采用板块级降级:

async def get_finance_board(time, area, compare, site_id):
    conn = _get_biz_connection()
    try:
        # 每个板块独立 try/except
        try:
            overview = _build_overview(conn, site_id, date_range, compare)
        except Exception:
            logger.warning("overview 查询失败,降级为空", exc_info=True)
            overview = _empty_overview()

        try:
            recharge = _build_recharge(conn, site_id, date_range, compare) if area == "all" else None
        except Exception:
            logger.warning("recharge 查询失败,降级为 null", exc_info=True)
            recharge = None

        # ... 其余板块同理
        
        return { "overview": overview, "recharge": recharge, ... }
    finally:
        conn.close()

BOARD-1 和 BOARD-2 采用整体降级:核心查询失败直接返回 500扩展字段如 topCustomers失败降级为空。

CONFIG-1 采用空数组降级ETL cfg 表查询失败返回 [],前端使用硬编码回退。

6.3 FDW 查询异常处理

所有 _fdw_context 内的查询异常由调用方捕获。fdw_queries.py 中的函数不做异常吞没,让 service 层决定降级策略。

测试策略

7.1 测试框架

  • 属性测试hypothesisPython已在项目中使用.hypothesis/ 目录和 test_site_isolation_properties.py
  • 单元测试pytestmock 数据库交互
  • 集成测试FastAPI TestClientmock FDW 连接

7.2 属性测试配置

每个属性测试使用 @settings(max_examples=100) 配置最少 100 次迭代。

每个测试函数的 docstring 中标注对应的设计属性:

@settings(max_examples=100)
@given(...)
def test_compare_formula(current, previous):
    """Feature: rns1-board-apis, Property 5: 环比计算公式正确性"""
    ...

7.3 属性测试清单

Property 测试文件 测试策略
P1 日期范围 test_board_properties.py 生成随机日期 + time 枚举,验证 start <= end 和上下期关系
P2 排序不变量 test_board_properties.py 生成随机助教列表,调用排序函数,验证相邻元素顺序
P3 分页不变量 test_board_properties.py 生成随机客户列表 + page/pageSize验证分页约束
P4 emoji 映射 test_board_properties.py 生成 0-10 范围浮点数,验证映射结果
P5 环比公式 test_board_properties.py 生成非负 Decimal 对,验证公式和边界条件
P6 环比开关 test_board_properties.py 生成 BOARD-3 mock 数据 + compare=0验证 JSON 无环比 key
P7 区域约束 test_board_properties.py 生成 area≠all 的请求,验证 recharge=null
P8 收入恒等式 test_board_properties.py 生成 overview 数据,验证 confirmedRevenue ≈ occurrence -
P9 现金结余 test_board_properties.py 生成 overview 数据,验证 cashBalance ≈ cashIn - cashOut
P10 支付渠道 test_board_properties.py 生成支付渠道数据,验证恒等式
P11 参数互斥 test_board_properties.py 固定 time=last_6m + sort=sv_desc验证 400
P12 幂等性 test_board_properties.py 相同参数两次调用,验证结果一致
P13 pct 范围 test_board_properties.py 生成 8 周到店数据,验证 pct 范围和最大值
P14 优雅降级 test_board_properties.py mock 板块查询抛异常,验证整体 200 + 空默认值

7.4 单元测试清单

测试目标 测试文件 覆盖内容
日期范围边界 test_board_unit.py 月末、跨年、闰年等边界
环比 "新增"/"持平" test_board_unit.py previous=0 的两种情况
BOARD-1 扁平结构 test_board_unit.py 验证所有维度字段都存在
BOARD-2 各维度排序 test_board_unit.py 8 个维度各一个排序示例
BOARD-3 recharge null test_board_unit.py area=hallA 时 recharge=null
CONFIG-1 空数组降级 test_board_unit.py cfg 表查询失败返回 []
权限检查 test_board_unit.py 无权限用户访问各看板返回 403

7.5 测试文件位置

apps/backend/tests/
├── test_board_properties.py    # 属性测试14 个 property
└── unit/
    └── test_board_unit.py      # 单元测试