40 KiB
技术设计文档 — RNS1.2:客户与助教接口
概述
RNS1.2 实现客户详情(CUST-1)、客户服务记录(CUST-2)、助教详情(COACH-1)3 个接口,覆盖 6 个任务:
- T2-1 CUST-1 客户详情:Banner 概览 + AI 洞察 + 维客线索 + 消费记录嵌套结构 + 备注
- T2-2 CUST-1 coachTasks:关联助教任务列表(近 60 天统计)
- T2-3 CUST-1 favoriteCoaches:最亲密助教(关系指数排序)
- T2-4 CUST-2 客户服务记录:按月查询 + 月度统计汇总
- T2-5 COACH-1 助教详情:绩效 + 收入 + 档位 + TOP 客户 + 任务分组 + 备注
- T2-6 COACH-1 historyMonths:历史月份统计(最近 5+ 个月)
设计原则
- 增量扩展:在现有
fdw_queries.py基础上新增查询方法,新建customer_service.py和coach_service.py - FDW 查询集中化:所有 ETL 查询封装在
fdw_queries.py,路由层不直接操作数据库 - DWD-DOC 强制规则:金额
items_sum口径、助教费用assistant_pd_money+assistant_cx_money拆分、会员信息通过member_idJOINv_dim_member - 契约驱动:响应结构严格遵循
API-contract.md中 CUST-1、CUST-2、COACH-1 定义 - 优雅降级:扩展模块查询失败返回空默认值,不影响核心响应
依赖
- RNS1.0 已完成:
ResponseWrapperMiddleware、CamelModel、前端request()解包 - 现有代码:
fdw_queries.py(已有 6 个查询函数)、task_manager.py、note_service.py - RNS1.1 可并行:无直接依赖
架构
模块交互
graph TB
subgraph "微信小程序"
A[customer-detail 页面] --> B[services/api.ts]
C[customer-service-records 页面] --> B
D[coach-detail 页面] --> B
end
subgraph "FastAPI 后端"
E[routers/xcx_customers.py<br/>CUST-1, CUST-2 — 新增]
F[routers/xcx_coaches.py<br/>COACH-1 — 新增]
G[services/customer_service.py<br/>客户查询 — 新增]
H[services/coach_service.py<br/>助教查询 — 新增]
I[services/fdw_queries.py<br/>FDW 查询封装 — 扩展]
J[services/note_service.py<br/>备注查询 — 已有]
end
subgraph "数据库"
K[(zqyy_app<br/>biz.coach_tasks<br/>biz.ai_cache<br/>biz.notes<br/>public.member_retention_clue)]
L[(etl_feiqiu 直连<br/>app.v_dwd_assistant_service_log<br/>app.v_dws_assistant_salary_calc<br/>app.v_dws_member_assistant_relation_index<br/>app.v_dws_member_consumption_summary<br/>app.v_dim_member<br/>app.v_dim_member_card_account<br/>app.v_dim_assistant<br/>app.v_dwd_table_fee_log)]
end
B -->|HTTP JSON| E
B -->|HTTP JSON| F
E --> G
F --> H
G --> I
H --> I
G --> K
H --> K
G --> J
H --> J
I -->|直连 ETL + SET LOCAL| L
style E fill:#9f9,stroke:#333
style F fill:#9f9,stroke:#333
style G fill:#9f9,stroke:#333
style H fill:#9f9,stroke:#333
请求流程(以 CUST-1 为例)
sequenceDiagram
participant MP as 小程序
participant R as xcx_customers Router
participant CS as customer_service
participant FDW as fdw_queries
participant BIZ as zqyy_app
participant ETL as etl_feiqiu 直连
MP->>R: GET /api/xcx/customers/{customerId}
R->>CS: get_customer_detail(customerId, site_id)
par 基础信息 + Banner
CS->>FDW: get_member_info([customerId])
FDW->>ETL: SET LOCAL + SELECT v_dim_member
CS->>FDW: get_member_balance([customerId])
FDW->>ETL: SELECT v_dim_member_card_account
CS->>FDW: get_last_visit_days([customerId])
FDW->>ETL: SELECT v_dwd_assistant_service_log
CS->>FDW: get_consumption_60d(customerId)
FDW->>ETL: SELECT v_dws_member_consumption_summary
end
par 扩展模块(优雅降级)
CS->>BIZ: SELECT ai_cache (aiInsight)
CS->>BIZ: SELECT member_retention_clue (retentionClues)
CS->>BIZ: SELECT notes (备注)
CS->>FDW: get_consumption_records(customerId)
FDW->>ETL: SELECT v_dwd_assistant_service_log + JOIN
CS->>BIZ: SELECT coach_tasks (coachTasks)
CS->>FDW: get_favorite_coaches(customerId)
FDW->>ETL: SELECT v_dws_member_assistant_relation_index
end
CS-->>R: CustomerDetailResponse
R-->>MP: { code: 0, data: CustomerDetailResponse }
组件与接口
组件 1:xcx_customers Router(新增)
位置:apps/backend/app/routers/xcx_customers.py
职责:客户详情和客户服务记录两个端点。
router = APIRouter(prefix="/api/xcx/customers", tags=["小程序客户"])
@router.get("/{customer_id}", response_model=CustomerDetailResponse)
async def get_customer_detail(
customer_id: int,
user: CurrentUser = Depends(require_approved()),
):
"""客户详情(CUST-1)。"""
return await customer_service.get_customer_detail(
customer_id, user.site_id
)
@router.get("/{customer_id}/records", response_model=CustomerRecordsResponse)
async def get_customer_records(
customer_id: int,
year: int = Query(...),
month: int = Query(..., ge=1, le=12),
table: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
):
"""客户服务记录(CUST-2)。"""
return await customer_service.get_customer_records(
customer_id, user.site_id, year, month, table, page, page_size
)
注册(main.py):
from app.routers import xcx_customers
app.include_router(xcx_customers.router)
组件 2:xcx_coaches Router(新增)
位置:apps/backend/app/routers/xcx_coaches.py
职责:助教详情端点。
router = APIRouter(prefix="/api/xcx/coaches", tags=["小程序助教"])
@router.get("/{coach_id}", response_model=CoachDetailResponse)
async def get_coach_detail(
coach_id: int,
user: CurrentUser = Depends(require_approved()),
):
"""助教详情(COACH-1)。"""
return await coach_service.get_coach_detail(
coach_id, user.site_id
)
注册(main.py):
from app.routers import xcx_coaches
app.include_router(xcx_coaches.router)
组件 3:customer_service(新增)
位置:apps/backend/app/services/customer_service.py
核心函数:
async def get_customer_detail(customer_id: int, site_id: int) -> dict:
"""
客户详情(CUST-1)。
1. fdw_queries.get_member_info() → 基础信息(name/phone/avatar/memberLevel)
2. fdw_queries.get_member_balance() → balance
3. fdw_queries.get_consumption_60d() → consumption60d
4. fdw_queries.get_last_visit_days() → daysSinceVisit
5. biz.ai_cache → aiInsight(cache_type='app4_analysis')
6. public.member_retention_clue → retentionClues
7. biz.coach_tasks + FDW 聚合 → coachTasks
8. fdw_queries.get_favorite_coaches() → favoriteCoaches
9. fdw_queries.get_consumption_records() → consumptionRecords
10. biz.notes → notes(target_type='member',最多 20 条)
扩展模块(5-10)查询失败返回空默认值。
"""
async def get_customer_records(
customer_id: int, site_id: int,
year: int, month: int,
table: str | None,
page: int, page_size: int,
) -> dict:
"""
客户服务记录(CUST-2)。
1. fdw_queries.get_member_info() → customerName/customerPhone
2. fdw_queries.get_customer_service_records() → 按月分页记录
3. 聚合 monthCount/monthHours
4. fdw_queries.get_total_service_count() → totalServiceCount(跨月)
5. 返回 { customerName, records, monthCount, monthHours, totalServiceCount, hasMore }
"""
def _build_coach_tasks(customer_id: int, site_id: int, conn) -> list[dict]:
"""
构建 coachTasks 模块。
1. 查询 biz.coach_tasks WHERE member_id=customer_id
2. 对每位助教:fdw_queries 获取等级、近 60 天统计
3. 映射 levelColor/taskColor/bgClass
"""
def _build_favorite_coaches(customer_id: int, site_id: int, conn) -> list[dict]:
"""
构建 favoriteCoaches 模块。
1. fdw_queries.get_relation_index() → 关系指数列表
2. 按关系指数降序排列
3. 映射 emoji(≥0.7→💖,<0.7→💛)、indexColor、bgClass
4. fdw_queries 聚合 stats(基础课时/激励课时/上课次数/充值金额)
"""
def _build_consumption_records(customer_id: int, site_id: int, conn) -> list[dict]:
"""
构建 consumptionRecords 模块。
1. fdw_queries.get_consumption_records() → 结算单列表
2. 对每条结算单:关联 v_dwd_assistant_service_log 获取 coaches 子数组
3. coaches[].fee 使用 assistant_pd_money / assistant_cx_money 拆分
4. totalAmount 使用 items_sum 口径
5. 过滤 settle_type IN (1, 3),排除 is_delete != 0
"""
组件 4:coach_service(新增)
位置:apps/backend/app/services/coach_service.py
核心函数:
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
"""
助教详情(COACH-1)。
1. fdw_queries.get_assistant_info() → 基础信息
2. fdw_queries.get_salary_calc() → 绩效/收入/档位(当月+上月)
3. fdw_queries.get_member_balance() → customerBalance(该助教所有客户余额合计)
4. biz.coach_tasks → tasksCompleted + 任务分组
5. fdw_queries.get_top_customers() → topCustomers
6. fdw_queries.get_service_records() → serviceRecords
7. _build_history_months() → historyMonths
8. biz.notes → notes(最多 20 条)
扩展模块查询失败返回空默认值。
"""
def _build_income(salary_this: dict, salary_last: dict) -> dict:
"""
构建 income 模块。
thisMonth/lastMonth 各含 4 项:
- 基础课时费(assistant_pd_money / base_income)
- 激励课时费(assistant_cx_money / bonus_income)
- 充值提成(从 salary_calc 获取)
- 酒水提成(从 salary_calc 获取)
"""
def _build_task_groups(coach_id: int, site_id: int, conn) -> dict:
"""
构建任务分组。
1. 查询 biz.coach_tasks WHERE assistant_id=coach_id
2. 按 status 分组:active→visibleTasks, inactive→hiddenTasks, abandoned→abandonedTasks
3. 对 visible/hidden:关联 biz.notes 获取备注列表
4. 对 abandoned:取 abandon_reason
"""
def _build_history_months(coach_id: int, site_id: int, conn) -> list[dict]:
"""
构建 historyMonths 模块。
1. fdw_queries.get_salary_calc_multi_months() → 最近 6 个月工时/工资
2. fdw_queries.get_monthly_customer_count() → 各月客户数
3. biz.coach_tasks → 各月回访/召回完成数
4. 本月 estimated=True,历史月份 estimated=False
5. 格式化:customers→"22人",hours→"87.5h",salary→"¥6,950"
"""
def _build_top_customers(coach_id: int, site_id: int, conn) -> list[dict]:
"""
构建 topCustomers 模块(最多 20 条)。
1. fdw_queries.get_coach_top_customers() → 按服务次数/消费排序
2. fdw_queries.get_member_info() → 客户姓名(DQ-6)
3. fdw_queries.get_member_balance() → 余额(DQ-7)
4. 映射 heartEmoji(≥0.7→❤️,0.3-0.7→💛,<0.3→🤍)
5. consume 使用 items_sum 口径
"""
组件 5:fdw_queries 扩展
位置:apps/backend/app/services/fdw_queries.py(在现有模块上新增函数)
新增函数:
def get_consumption_60d(conn, site_id: int, member_id: int) -> Decimal | None:
"""
查询客户近 60 天消费金额。
来源:app.v_dwd_assistant_service_log。
⚠️ DWD-DOC 规则 1:使用 ledger_amount(items_sum 口径)。
⚠️ 废单排除:is_delete = 0。
"""
def get_relation_index(
conn, site_id: int, member_id: int
) -> list[dict]:
"""
查询客户与助教的关系指数列表。
来源:app.v_dws_member_assistant_relation_index。
返回 [{assistant_id, relation_index, ...}],按 relation_index 降序。
"""
def get_consumption_records(
conn, site_id: int, member_id: int, limit: int, offset: int
) -> list[dict]:
"""
查询客户消费记录(嵌套结构用)。
来源:app.v_dwd_assistant_service_log + v_dwd_table_fee_log。
⚠️ DWD-DOC 规则 1:totalAmount 使用 ledger_amount。
⚠️ DWD-DOC 规则 2:coaches fee 使用 assistant_pd_money / assistant_cx_money。
⚠️ 废单排除:is_delete = 0。
⚠️ 正向交易:settle_type IN (1, 3)。
"""
def get_customer_service_records(
conn, site_id: int, member_id: int,
year: int, month: int,
table: str | None, limit: int, offset: int
) -> tuple[list[dict], int]:
"""
查询客户按月服务记录(CUST-2 用)。
来源:app.v_dwd_assistant_service_log。
返回 (records, total_count)。
⚠️ DQ-6:助教姓名通过 site_assistant_id JOIN v_dim_assistant。
⚠️ 废单排除:is_delete = 0。
"""
def get_total_service_count(conn, site_id: int, member_id: int) -> int:
"""
查询客户累计服务总次数(跨所有月份)。
来源:app.v_dwd_assistant_service_log,COUNT WHERE is_delete = 0。
"""
def get_assistant_info(conn, site_id: int, assistant_id: int) -> dict | None:
"""
查询助教基本信息。
来源:app.v_dim_assistant。
返回 {name, avatar, skills, work_years, hire_date}。
"""
def get_salary_calc_multi_months(
conn, site_id: int, assistant_id: int, months: list[str]
) -> dict[str, dict]:
"""
批量查询多个月份的绩效数据。
来源:app.v_dws_assistant_salary_calc。
返回 {month_str: salary_data}。
"""
def get_monthly_customer_count(
conn, site_id: int, assistant_id: int, months: list[str]
) -> dict[str, int]:
"""
批量查询各月不重复客户数。
来源:app.v_dwd_assistant_service_log,COUNT(DISTINCT tenant_member_id)。
⚠️ 废单排除:is_delete = 0。
"""
def get_coach_top_customers(
conn, site_id: int, assistant_id: int, limit: int = 20
) -> list[dict]:
"""
查询助教 TOP 客户(按服务次数降序)。
来源:app.v_dwd_assistant_service_log + v_dim_member + v_dim_member_card_account。
⚠️ DQ-6:客户姓名通过 tenant_member_id JOIN v_dim_member。
⚠️ DQ-7:余额通过 tenant_member_id JOIN v_dim_member_card_account。
⚠️ DWD-DOC 规则 1:consume 使用 ledger_amount(items_sum 口径)。
⚠️ 废单排除:is_delete = 0。
"""
def get_coach_60d_stats(
conn, site_id: int, assistant_id: int, member_id: int
) -> dict:
"""
查询特定助教对特定客户的近 60 天统计。
返回 {service_count, total_hours, avg_hours}。
来源:app.v_dwd_assistant_service_log。
⚠️ 废单排除:is_delete = 0。
"""
组件 6:Pydantic Schema(新增)
位置:apps/backend/app/schemas/xcx_customers.py(新增)+ apps/backend/app/schemas/xcx_coaches.py(新增)
客户相关 Schema
# ── xcx_customers.py ──
class AiInsight(CamelModel):
summary: str = ""
strategies: list[AiStrategy] = []
class AiStrategy(CamelModel):
color: str
text: str
class MetricItem(CamelModel):
label: str
value: str
color: str | None = None
class CoachTask(CamelModel):
name: str
level: str # star / senior / middle / junior
level_color: str
task_type: str
task_color: str
bg_class: str
status: str
last_service: str | None = None
metrics: list[MetricItem] = []
class FavoriteCoach(CamelModel):
emoji: str # 💖 / 💛
name: str
relation_index: str
index_color: str
bg_class: str
stats: list[MetricItem] = []
class CoachServiceItem(CamelModel):
name: str
level: str
level_color: str
course_type: str # "基础课" / "激励课"
hours: float
perf_hours: float | None = None
fee: float
class ConsumptionRecord(CamelModel):
id: str
type: str # table / shop / recharge
date: str
table_name: str | None = None
start_time: str | None = None
end_time: str | None = None
duration: int | None = None
table_fee: float | None = None
table_orig_price: float | None = None
coaches: list[CoachServiceItem] = []
food_amount: float | None = None
food_orig_price: float | None = None
total_amount: float
total_orig_price: float
pay_method: str
recharge_amount: float | None = None
class RetentionClue(CamelModel):
type: str
text: str
class CustomerNote(CamelModel):
id: int
tag_label: str
created_at: str
content: str
class CustomerDetailResponse(CamelModel):
"""CUST-1 响应。"""
# 基础信息
id: int
name: str
phone: str
phone_full: str
avatar: str
member_level: str
relation_index: str
tags: list[str] = []
# Banner 概览
balance: float | None = None
consumption_60d: float | None = None
ideal_interval: int | None = None
days_since_visit: int | None = None
# 扩展模块
ai_insight: AiInsight = AiInsight()
coach_tasks: list[CoachTask] = []
favorite_coaches: list[FavoriteCoach] = []
retention_clues: list[RetentionClue] = []
consumption_records: list[ConsumptionRecord] = []
notes: list[CustomerNote] = []
class ServiceRecordItem(CamelModel):
id: str
date: str
time_range: str | None = None
table: str | None = None
type: str
type_class: str
record_type: str | None = None # course / recharge
duration: float
duration_raw: float | None = None
income: float
is_estimate: bool = False
drinks: str | None = None
class CustomerRecordsResponse(CamelModel):
"""CUST-2 响应。"""
customer_name: str
customer_phone: str
customer_phone_full: str
relation_index: str
tables: list[dict] = []
total_service_count: int
month_count: int
month_hours: float
records: list[ServiceRecordItem] = []
has_more: bool = False
助教相关 Schema
# ── xcx_coaches.py ──
class PerformanceMetrics(CamelModel):
monthly_hours: float
monthly_salary: float
customer_balance: float
tasks_completed: int
perf_current: float
perf_target: float
class IncomeItem(CamelModel):
label: str
amount: str
color: str
class IncomeSection(CamelModel):
this_month: list[IncomeItem] = []
last_month: list[IncomeItem] = []
class CoachTaskItem(CamelModel):
type_label: str
type_class: str
customer_name: str
customer_id: int | None = None
note_count: int = 0
pinned: bool = False
notes: list[dict] | None = None
class AbandonedTask(CamelModel):
customer_name: str
reason: str
class TopCustomer(CamelModel):
id: int
name: str
initial: str
avatar_gradient: str
heart_emoji: str # ❤️ / 💛 / 🤍
score: str
score_color: str
service_count: int
balance: str
consume: str
class CoachServiceRecord(CamelModel):
customer_id: int | None = None
customer_name: str
initial: str
avatar_gradient: str
type: str
type_class: str
table: str | None = None
duration: str
income: str
date: str
perf_hours: str | None = None
class HistoryMonth(CamelModel):
month: str
estimated: bool
customers: str
hours: str
salary: str
callback_done: int
recall_done: int
class CoachNoteItem(CamelModel):
id: int
content: str
timestamp: str
score: int | None = None
customer_name: str
tag_label: str
created_at: str
class CoachDetailResponse(CamelModel):
"""COACH-1 响应。"""
# 基础信息
id: int
name: str
avatar: str
level: str
skills: list[str] = []
work_years: float = 0
customer_count: int = 0
hire_date: str | None = None
# 绩效
performance: PerformanceMetrics
# 收入
income: IncomeSection
# 档位
tier_nodes: list[float] = []
# 任务分组
visible_tasks: list[CoachTaskItem] = []
hidden_tasks: list[CoachTaskItem] = []
abandoned_tasks: list[AbandonedTask] = []
# TOP 客户
top_customers: list[TopCustomer] = []
# 近期服务记录
service_records: list[CoachServiceRecord] = []
# 历史月份
history_months: list[HistoryMonth] = []
# 备注
notes: list[CoachNoteItem] = []
数据模型
FDW 查询模式
所有 FDW 查询遵循 fdw_queries.py 已有的统一模式:
# 通过 _fdw_context() 上下文管理器:
# 1. 获取 ETL 直连(get_etl_readonly_connection(site_id))
# 2. BEGIN + SET LOCAL app.current_site_id = site_id
# 3. 执行查询
# 4. COMMIT + 关闭连接
with _fdw_context(conn, site_id) as cur:
cur.execute("SELECT ... FROM app.v_* WHERE ...", params)
核心 SQL 查询设计
Q1: 客户近 60 天消费(CUST-1 Banner)
-- ⚠️ DWD-DOC 规则 1: 使用 ledger_amount(items_sum 口径)
-- ⚠️ 废单排除: is_delete = 0
SELECT COALESCE(SUM(ledger_amount), 0) AS consumption_60d
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
Q2: 关系指数查询(CUST-1 favoriteCoaches)
-- 按关系指数降序排列
SELECT site_assistant_id AS assistant_id,
relation_index,
service_count,
total_hours,
total_income
FROM app.v_dws_member_assistant_relation_index
WHERE tenant_member_id = %s
ORDER BY relation_index DESC
Q3: 消费记录嵌套查询(CUST-1 consumptionRecords)
-- 主查询:结算单级别
-- ⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount
-- ⚠️ 废单排除: is_delete = 0
SELECT sl.assistant_service_id AS id,
sl.create_time AS settle_time,
sl.start_use_time AS start_time,
sl.last_use_time AS end_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.ledger_amount AS total_amount,
sl.skill_name AS course_type,
sl.site_table_id AS table_id,
sl.site_assistant_id AS assistant_id,
da.assistant_name,
da.assistant_level_name AS level
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id
WHERE sl.tenant_member_id = %s
AND sl.is_delete = 0
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
Q4: 客户按月服务记录(CUST-2)
-- ⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取
-- ⚠️ DWD-DOC 规则 1: income 使用 ledger_amount
-- ⚠️ 废单排除: is_delete = 0
SELECT sl.assistant_service_id AS id,
sl.create_time,
sl.start_use_time,
sl.last_use_time,
sl.income_seconds / 3600.0 AS service_hours,
sl.real_use_seconds / 3600.0 AS service_hours_raw,
sl.ledger_amount AS income,
sl.skill_name AS course_type,
sl.site_table_id AS table_id,
da.assistant_name
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da
ON sl.site_assistant_id = da.assistant_id
WHERE sl.tenant_member_id = %s
AND sl.is_delete = 0
AND sl.create_time >= %s::timestamptz
AND sl.create_time < %s::timestamptz
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
-- 月度统计汇总
SELECT COUNT(*) AS month_count,
COALESCE(SUM(income_seconds / 3600.0), 0) AS month_hours
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= %s::timestamptz
AND create_time < %s::timestamptz
-- 累计服务总次数
SELECT COUNT(*) AS total_service_count
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s AND is_delete = 0
Q5: 助教基本信息(COACH-1)
SELECT assistant_id,
assistant_name AS name,
assistant_level_name AS level,
-- skills/work_years/hire_date 视图中可能不存在,使用默认值
created_at AS hire_date
FROM app.v_dim_assistant
WHERE assistant_id = %s
Q6: 助教 TOP 客户(COACH-1)
-- ⚠️ DQ-6: 客户姓名通过 tenant_member_id JOIN v_dim_member
-- ⚠️ DQ-7: 余额通过 tenant_member_id JOIN v_dim_member_card_account
-- ⚠️ DWD-DOC 规则 1: consume 使用 ledger_amount
-- ⚠️ 废单排除: is_delete = 0
SELECT sl.tenant_member_id AS member_id,
dm.nickname AS customer_name,
COUNT(*) AS service_count,
COALESCE(SUM(sl.ledger_amount), 0) AS total_consume,
mca.balance AS customer_balance
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm
ON sl.tenant_member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dim_member_card_account mca
ON sl.tenant_member_id = mca.tenant_member_id AND mca.scd2_is_current = 1
WHERE sl.site_assistant_id = %s
AND sl.is_delete = 0
GROUP BY sl.tenant_member_id, dm.nickname, mca.balance
ORDER BY service_count DESC
LIMIT %s
Q7: 历史月份统计(COACH-1 historyMonths)
-- 多月绩效数据批量查询
SELECT salary_month,
effective_hours,
gross_salary,
base_income,
bonus_income
FROM app.v_dws_assistant_salary_calc
WHERE assistant_id = %s
AND salary_month = ANY(%s::date[])
ORDER BY salary_month DESC
-- 各月不重复客户数
SELECT DATE_TRUNC('month', create_time)::date AS month,
COUNT(DISTINCT tenant_member_id) AS customer_count
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
AND is_delete = 0
AND create_time >= %s::timestamptz
AND create_time < %s::timestamptz
GROUP BY DATE_TRUNC('month', create_time)::date
Q8: 助教近 60 天统计(CUST-1 coachTasks metrics)
-- 特定助教对特定客户的近 60 天统计
-- ⚠️ 废单排除: is_delete = 0
SELECT COUNT(*) AS service_count,
COALESCE(SUM(income_seconds / 3600.0), 0) AS total_hours,
CASE WHEN COUNT(*) > 0
THEN SUM(income_seconds / 3600.0) / COUNT(*)
ELSE 0 END AS avg_hours
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
AND tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
列名映射汇总
| 代码语义 | 实际视图列名 | 视图 | 说明 |
|---|---|---|---|
| member_id | tenant_member_id |
v_dwd_assistant_service_log | 会员 ID |
| assistant_id (WHERE) | site_assistant_id |
v_dwd_assistant_service_log | 助教 ID |
| settle_time | create_time |
v_dwd_assistant_service_log | 结算时间 |
| income | ledger_amount |
v_dwd_assistant_service_log | items_sum 口径 |
| service_hours | income_seconds / 3600.0 |
v_dwd_assistant_service_log | 折算工时 |
| is_trash=false | is_delete = 0 |
v_dwd_assistant_service_log | 废单排除 |
| course_type | skill_name |
v_dwd_assistant_service_log | 课程类型 |
| calc_month | salary_month |
v_dws_assistant_salary_calc | 月份(date) |
| coach_level | assistant_level_name |
v_dws_assistant_salary_calc | 档位名称 |
| total_hours | effective_hours |
v_dws_assistant_salary_calc | 有效总工时 |
| total_income | gross_salary |
v_dws_assistant_salary_calc | 总收入 |
| pd_money_total | base_income |
v_dws_assistant_salary_calc | 基础课总收入 |
| cx_money_total | bonus_income |
v_dws_assistant_salary_calc | 激励课总收入 |
DWD-DOC 强制规则实施位置
| 规则 | 实施位置 | 具体措施 |
|---|---|---|
规则 1: items_sum 口径 |
fdw_queries.py 所有金额查询 |
SQL 中使用 ledger_amount,禁止 consume_money |
| 规则 2: 助教费用拆分 | fdw_queries.py + customer_service.py |
使用 base_income/bonus_income,禁止 service_fee |
| DQ-6: 会员信息 JOIN | fdw_queries.py 所有含客户姓名的查询 |
tenant_member_id JOIN v_dim_member WHERE scd2_is_current=1 |
| DQ-7: 会员卡 JOIN | fdw_queries.py 余额查询 |
tenant_member_id JOIN v_dim_member_card_account WHERE scd2_is_current=1 |
| 废单排除 | fdw_queries.py 所有服务记录查询 |
WHERE is_delete = 0,禁止引用 dwd_assistant_trash_event |
正确性属性
属性(Property)是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。
Property 1: 消费记录金额拆分不变量
For any 消费记录,totalAmount 应等于 tableFee + foodAmount + SUM(coaches[].fee)(在浮点精度 0.01 范围内)。这验证了 items_sum 口径拆分的一致性——各子项之和必须等于总额。
Validates: Requirements 14.1, 4.4
Property 2: 废单排除一致性
For any 服务记录查询(CUST-1 consumptionRecords、CUST-2 records、COACH-1 serviceRecords/topCustomers),返回的记录集合中不应包含 is_delete != 0 的记录。即对所有返回记录,is_delete 字段值必须为 0。
Validates: Requirements 14.8, 4.6, 7.8, 10.7
Property 3: 助教费用拆分正确性
For any 消费记录中的 coaches 子数组,courseType 为 "基础课" 的记录其 fee 应对应 assistant_pd_money,courseType 为 "激励课" 的记录其 fee 应对应 assistant_cx_money。两者之和应等于该结算单的助教费用总额。
Validates: Requirements 14.2, 4.3, 9.2
Property 4: favoriteCoaches 排序不变量
For any CUST-1 响应中的 favoriteCoaches 列表,列表应按 relationIndex 降序排列——即对于相邻两项 favoriteCoaches[i] 和 favoriteCoaches[i+1],float(favoriteCoaches[i].relationIndex) >= float(favoriteCoaches[i+1].relationIndex) 恒成立。
Validates: Requirements 14.5, 6.1
Property 5: historyMonths 排序与预估标记
For any COACH-1 响应中的 historyMonths 列表,列表应按月份降序排列(最近月份在前),且第一条的 estimated 必须为 true(本月为预估数据),其余条目的 estimated 必须为 false。
Validates: Requirements 14.6, 12.5
Property 6: 列表上限约束
For any CUST-1 响应中的 notes 列表长度应 ≤ 20,COACH-1 响应中的 topCustomers 列表长度应 ≤ 20,COACH-1 响应中的 notes 列表长度应 ≤ 20。即所有带上限的列表返回数量不超过其指定上限。
Validates: Requirements 3.2, 10.1, 11.5
Property 7: 月度汇总聚合正确性
For any 服务记录集合,CUST-2 的 monthCount 应等于当月返回的记录总数,monthHours 应等于当月所有记录 service_hours 之和(在浮点精度 0.01 范围内)。同理,CUST-1 coachTasks 的 metrics 中近 60 天统计的 service_count 应等于实际记录数,total_hours 应等于各记录工时之和。
Validates: Requirements 7.6, 5.3
Property 8: daysSinceVisit 计算正确性
For any 有效日期 lastVisitDate(不晚于今天),daysSinceVisit 应等于 (current_date - lastVisitDate).days,且结果为非负整数。无服务记录时应返回 null。
Validates: Requirements 1.6
Property 9: emoji 映射正确性
For any 关系指数值 relationIndex(0.0 到 1.0 之间的浮点数):
- CUST-1 favoriteCoaches:
relationIndex >= 0.7时emoji应为"💖",relationIndex < 0.7时应为"💛" - COACH-1 topCustomers:
score >= 0.7时heartEmoji应为"❤️",0.3 <= score < 0.7时应为"💛",score < 0.3时应为"🤍"
映射函数应对相同输入始终产生相同输出(确定性)。
Validates: Requirements 6.4
Property 10: 优雅降级
For any 扩展模块(aiInsight/coachTasks/favoriteCoaches/consumptionRecords/historyMonths)的数据源查询失败时,该模块应返回空默认值(空数组或空对象),不影响其他模块和整体响应的 HTTP 200 状态码。
Validates: Requirements 1.7, 13.8
Property 11: 任务分组正确性
For any 助教的任务集合,按 status 分组后:active 状态任务应全部出现在 visibleTasks 中,inactive 状态任务应全部出现在 hiddenTasks 中,abandoned 状态任务应全部出现在 abandonedTasks 中。三组之间无交集,且三组之并等于原始任务集合(不含 completed 状态)。
Validates: Requirements 11.1
Property 12: 数据隔离不变量
For any CUST-1 响应中的 coachTasks,每条任务的 member_id 应等于请求路径中的 customerId。For any COACH-1 响应中的 serviceRecords,每条记录的 assistant_id 应等于请求路径中的 coachId。
Validates: Requirements 14.3, 14.4
Property 13: 分页与 hasMore 正确性
For any CUST-2 记录集合和分页参数 (page, pageSize),返回的记录数应 ≤ pageSize,hasMore 为 true 当且仅当总记录数 > page * pageSize。记录应按 create_time 倒序排列。
Validates: Requirements 7.9
Property 14: 幂等性
For any 相同参数的 CUST-2 请求(相同 customerId、year、month),在数据未变更的情况下,两次请求应返回相同的 monthCount 和 monthHours 值。即 f(x) == f(x) 对所有合法输入成立。
Validates: Requirements 14.7
错误处理
后端错误处理
| 错误场景 | HTTP 状态码 | 响应 | 触发位置 |
|---|---|---|---|
| 用户未通过审核 | 403 | { code: 403, message: "用户未通过审核,无法访问此资源" } |
require_approved() |
| 客户不存在 | 404 | { code: 404, message: "客户不存在" } |
customer_service.get_customer_detail() |
| 助教不存在 | 404 | { code: 404, message: "助教不存在" } |
coach_service.get_coach_detail() |
| year/month 参数无效 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
| page/pageSize 参数无效 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
| FDW 查询超时/失败(核心字段) | 500 | { code: 500, message: "Internal Server Error" } |
unhandled_exception_handler |
| FDW 查询失败(扩展模块) | 200 | 该模块返回空默认值 | 各 _build_* 函数 |
| 数据库连接失败 | 500 | { code: 500, message: "Internal Server Error" } |
unhandled_exception_handler |
FDW 查询容错策略
核心字段(基础信息、Banner 概览)查询失败直接抛出 500。
扩展模块(aiInsight/coachTasks/favoriteCoaches/consumptionRecords/historyMonths)采用优雅降级:
# 每个扩展模块独立 try/except
try:
ai_insight = _build_ai_insight(customer_id, site_id, conn)
except Exception:
logger.warning("构建 aiInsight 失败,降级为空", exc_info=True)
ai_insight = {"summary": "", "strategies": []}
try:
coach_tasks = _build_coach_tasks(customer_id, site_id, conn)
except Exception:
logger.warning("构建 coachTasks 失败,降级为空列表", exc_info=True)
coach_tasks = []
# ... 其他扩展模块同理
前端错误处理
| 场景 | 处理方式 |
|---|---|
| API 返回 403 | 显示"权限不足"提示 |
| API 返回 404 | 显示"数据不存在"提示,返回上一页 |
| API 返回 500 | 显示"服务器错误"toast |
| 扩展模块字段为空数组/空对象 | 前端隐藏对应 UI 区域 |
| 月份切换期间网络失败 | 恢复上一月份状态,显示重试提示 |
| Banner 字段为 null | 显示 "--" 占位符 |
测试策略
双轨测试方法
RNS1.2 采用属性测试(Property-Based Testing)+ 单元测试(Unit Testing)双轨并行:
- 属性测试:验证金额拆分、排序不变量、聚合正确性、映射函数、分页逻辑等通用规则在所有输入上的正确性
- 单元测试:验证具体的 API 端点行为、权限校验、FDW 查询集成、边界条件、优雅降级
属性测试配置
- 测试库:Hypothesis(Python,项目已使用)
- 测试位置:
tests/目录(Monorepo 级属性测试) - 最小迭代次数:每个属性测试 100 次(
@settings(max_examples=100)) - 标签格式:每个测试函数的 docstring 中标注
Feature: rns1-customer-coach-api, Property {N}: {property_text} - 每个正确性属性由一个属性测试实现
属性测试清单
| Property | 测试函数 | 生成器 | 验证逻辑 |
|---|---|---|---|
| P1: 金额拆分不变量 | test_consumption_amount_split |
st.floats(min_value=0, max_value=1e5) 生成 tableFee/foodAmount/coachFees |
abs(totalAmount - (tableFee + foodAmount + sum(fees))) < 0.01 |
| P2: 废单排除一致性 | test_deleted_records_excluded |
st.lists(st.fixed_dictionaries({is_delete: st.integers(0,2), ...})) |
过滤后结果中所有 is_delete == 0 |
| P3: 助教费用拆分 | test_coach_fee_split |
st.floats 生成 pd_money/cx_money + st.sampled_from(["基础课","激励课"]) |
基础课→pd_money,激励课→cx_money,两者之和=总额 |
| P4: favoriteCoaches 排序 | test_favorite_coaches_ordering |
st.lists(st.floats(0, 1)) 生成 relationIndex 列表 |
排序后每项 ≥ 下一项 |
| P5: historyMonths 排序与标记 | test_history_months_ordering |
st.lists(st.dates(), min_size=1) 生成月份列表 |
降序排列,首项 estimated=True,其余 False |
| P6: 列表上限 | test_list_size_limits |
st.integers(0, 100) 生成记录数 |
notes ≤ 20,topCustomers ≤ 20 |
| P7: 月度汇总聚合 | test_monthly_summary_aggregation |
st.lists(st.fixed_dictionaries({hours: st.floats(0,10), income: st.floats(0,1e4)})) |
count=len,hours=sum(hours),monthHours=sum(hours) |
| P8: daysSinceVisit 计算 | test_days_since_visit |
st.dates(max_value=date.today()) |
days = (today - date).days,非负 |
| P9: emoji 映射 | test_emoji_mapping |
st.floats(0, 1) 生成 relationIndex |
≥0.7→💖,<0.7→💛;heartEmoji 三级映射正确 |
| P10: 优雅降级 | test_graceful_degradation |
st.sampled_from(MODULES) 选择失败模块 |
失败模块返回空默认值,其他模块正常 |
| P11: 任务分组 | test_task_grouping |
st.lists(st.fixed_dictionaries({status: st.sampled_from(STATUSES)})) |
active→visible,inactive→hidden,abandoned→abandoned,无交集 |
| P12: 数据隔离 | test_data_isolation |
st.integers(1, 1000) 生成 customerId/coachId |
coachTasks 每条 member_id=customerId,serviceRecords 每条 assistant_id=coachId |
| P13: 分页与 hasMore | test_pagination_has_more |
st.integers(1,100) total + st.integers(1,10) page/pageSize |
记录数 ≤ pageSize,hasMore = (total > page*pageSize) |
| P14: 幂等性 | test_idempotent_query |
st.integers(1,12) month + st.integers(2020,2026) year |
f(x) == f(x) 对 monthCount/monthHours |
单元测试清单
| 测试目标 | 测试文件 | 关键用例 |
|---|---|---|
| CUST-1 端点 | tests/unit/test_customer_detail.py |
完整响应结构;Banner 字段正确;aiInsight 无缓存降级;consumptionRecords 嵌套结构 |
| CUST-1 coachTasks | tests/unit/test_customer_coach_tasks.py |
关联助教列表;metrics 近 60 天统计;levelColor 映射 |
| CUST-1 favoriteCoaches | tests/unit/test_customer_favorites.py |
关系指数排序;emoji 映射;stats 4 项指标 |
| CUST-2 端点 | tests/unit/test_customer_records.py |
按月查询;monthCount/monthHours 汇总;totalServiceCount 跨月;hasMore 分页 |
| COACH-1 端点 | tests/unit/test_coach_detail.py |
完整响应结构;performance 6 指标;income thisMonth/lastMonth 各 4 项 |
| COACH-1 topCustomers | tests/unit/test_coach_top_customers.py |
最多 20 条;heartEmoji 三级映射;consume items_sum 口径 |
| COACH-1 historyMonths | tests/unit/test_coach_history.py |
最近 5+ 个月;本月 estimated=true;回访/召回完成数 |
| COACH-1 任务分组 | tests/unit/test_coach_task_groups.py |
三组分组正确;abandonedTasks 含 reason;notes 关联 |
| 权限校验 | tests/unit/test_auth_rns12.py |
未审核用户 403;客户不存在 404;助教不存在 404 |
| FDW 查询扩展 | tests/unit/test_fdw_queries_rns12.py |
DQ-6 JOIN 正确;DQ-7 余额查询;is_delete=0 排除;items_sum 口径 |
| 优雅降级 | tests/unit/test_degradation_rns12.py |
aiInsight 失败→空;coachTasks 失败→空列表;favoriteCoaches 失败→空列表 |
测试执行命令
# 属性测试(Hypothesis)
cd C:\NeoZQYY && pytest tests/ -v -k "rns1_customer_coach"
# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "customer_detail or customer_records or coach_detail or coach_top or coach_history or coach_task_groups or auth_rns12 or fdw_queries_rns12 or degradation_rns12"