# 设计文档 — 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 规则在单一模块实施。 ## 架构 ### 请求流程 ```mermaid 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`。 ```python # 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` ```python # 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` 三个核心编排函数,各自独立处理参数解析、日期范围计算、数据查询和响应组装: ```python 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_monthly_summary` | BOARD-1 sv 维度(助教月度储值汇总,已按助教预聚合) | | `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 环比计算工具 ```python 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 请求参数枚举 ```python # 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 ```python 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 ```python 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 ```python 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 ```python class SkillTypeItem(CamelModel): key: str # chinese/snooker/mahjong/karaoke label: str # 中文标签 emoji: str # 表情符号 cls: str # 前端样式类 ``` ### 4.6 数据库查询模式 所有 FDW 查询遵循已有模式: ```python 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_monthly_summary`(已按助教预聚合),无需从 `v_dws_member_consumption_summary` 手动聚合 - 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-6**:`LEFT JOIN app.v_dim_member ON tenant_member_id = member_id AND scd2_is_current = 1` - **会员卡 DQ-7**:`LEFT JOIN app.v_dim_member_card_account ON tenant_member_id AND scd2_is_current = 1` - **废单排除**:`WHERE is_delete = 0`(RLS 视图使用 `is_delete`) - **正向交易**:`WHERE settle_type IN (1, 3)` - **支付渠道恒等式**:`balance_amount = recharge_card_amount + gift_card_amount` - **现金流互斥**:`platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥 ### 4.7 BOARD-2 分页策略 ```python # 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 中环比字段设为 `Optional`,`model_dump(exclude_none=True)` 输出时自动排除 `None` 值。 ```python 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`: ```python @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_desc` → `items[i].perfHours >= items[i+1].perfHours`,`salary_asc` → `items[i].salary <= items[i+1].salary`,以此类推。 **Validates: Requirements 1.15, 9.1, 9.2** ### Property 3: BOARD-2 分页不变量 *对于任意*相同筛选参数(`dimension`、`project`)的 BOARD-2 请求,(a) `items.length <= pageSize`,(b) 不同 `page` 值返回的 `total` 相同,(c) `page=1` 和 `page=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 > 0` 时 `compare` 字符串等于 `abs((current - previous) / previous * 100)` 格式化为百分比,(b) `current > previous` 时 `is_down=False`,(c) `current < previous` 时 `is_down=True`,(d) `current == previous` 时 `is_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 序列化后的字符串中不应包含任何以 `Compare`、`Down`、`Flat` 结尾的 key(camelCase 格式)。 **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_amount`(DWD-DOC 强制规则 3)。 **Validates: Requirements 8.7, 9.9** ### Property 11: 参数互斥约束 *对于任意* BOARD-1 请求,当 `time=last_6m` 且 `sort=sv_desc` 时,API 应返回 HTTP 400 状态码,响应体包含错误信息。 **Validates: Requirements 1.2, 9.11** ### Property 12: BOARD-3 幂等性 *对于任意*相同参数的 BOARD-3 请求(相同 `time`、`area`、`compare`),在底层数据未变更的情况下,两次请求返回的 `overview.occurrence` 和 `overview.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 财务看板采用板块级降级: ```python 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 测试框架 - **属性测试**:`hypothesis`(Python),已在项目中使用(见 `.hypothesis/` 目录和 `test_site_isolation_properties.py`) - **单元测试**:`pytest`,mock 数据库交互 - **集成测试**:`FastAPI TestClient`,mock FDW 连接 ### 7.2 属性测试配置 每个属性测试使用 `@settings(max_examples=100)` 配置最少 100 次迭代。 每个测试函数的 docstring 中标注对应的设计属性: ```python @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 - |discount| | | 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 # 单元测试 ```