32 KiB
设计文档 — 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
设计决策
- BOARD-1 扁平返回 vs 按维度返回:选择扁平返回(所有维度字段一次性返回),前端切换维度无需重新请求,减少网络往返。代价是单次响应略大,但助教数量有限(通常 < 50),可接受。
- BOARD-2 按维度返回:选择按
dimension参数仅返回对应维度字段。客户数量可达数千,分页 + 维度专属字段可显著减少传输量和查询开销。 - BOARD-3 单接口 6 板块:单个
GET /api/xcx/board/finance返回全部 6 个板块。各板块独立查询、独立降级,某板块失败不影响其他板块。recharge板块在area≠all时返回null。 - 环比计算后端统一处理:
compare=1时后端计算所有环比字段,compare=0时完全不返回环比字段(非返回 null,而是字段不存在),减少 JSON 体积。 - 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_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 环比计算工具
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_memberget_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 分页策略
# 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 值。
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_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 财务看板采用板块级降级:
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 中标注对应的设计属性:
@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 # 单元测试