# 技术设计文档 — RNS1.2:客户与助教接口
## 概述
RNS1.2 实现客户详情(CUST-1)、客户服务记录(CUST-2)、助教详情(COACH-1)3 个接口,覆盖 6 个任务:
1. **T2-1 CUST-1 客户详情**:Banner 概览 + AI 洞察 + 维客线索 + 消费记录嵌套结构 + 备注
2. **T2-2 CUST-1 coachTasks**:关联助教任务列表(近 60 天统计)
3. **T2-3 CUST-1 favoriteCoaches**:最亲密助教(关系指数排序)
4. **T2-4 CUST-2 客户服务记录**:按月查询 + 月度统计汇总
5. **T2-5 COACH-1 助教详情**:绩效 + 收入 + 档位 + TOP 客户 + 任务分组 + 备注
6. **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_id` JOIN `v_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 可并行:无直接依赖
## 架构
### 模块交互
```mermaid
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
CUST-1, CUST-2 — 新增]
F[routers/xcx_coaches.py
COACH-1 — 新增]
G[services/customer_service.py
客户查询 — 新增]
H[services/coach_service.py
助教查询 — 新增]
I[services/fdw_queries.py
FDW 查询封装 — 扩展]
J[services/note_service.py
备注查询 — 已有]
end
subgraph "数据库"
K[(zqyy_app
biz.coach_tasks
biz.ai_cache
biz.notes
public.member_retention_clue)]
L[(etl_feiqiu 直连
app.v_dwd_assistant_service_log
app.v_dws_assistant_salary_calc
app.v_dws_member_assistant_relation_index
app.v_dws_member_consumption_summary
app.v_dim_member
app.v_dim_member_card_account
app.v_dim_assistant
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 为例)
```mermaid
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`
**职责**:客户详情和客户服务记录两个端点。
```python
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`):
```python
from app.routers import xcx_customers
app.include_router(xcx_customers.router)
```
### 组件 2:xcx_coaches Router(新增)
**位置**:`apps/backend/app/routers/xcx_coaches.py`
**职责**:助教详情端点。
```python
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`):
```python
from app.routers import xcx_coaches
app.include_router(xcx_coaches.router)
```
### 组件 3:customer_service(新增)
**位置**:`apps/backend/app/services/customer_service.py`
**核心函数**:
```python
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`
**核心函数**:
```python
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`(在现有模块上新增函数)
**新增函数**:
```python
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
```python
# ── 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
```python
# ── 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` 已有的统一模式:
```python
# 通过 _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)
```sql
-- ⚠️ 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)
```sql
-- 按关系指数降序排列
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)
```sql
-- 主查询:结算单级别
-- ⚠️ 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)
```sql
-- ⚠️ 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)
```sql
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)
```sql
-- ⚠️ 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)
```sql
-- 多月绩效数据批量查询
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)
```sql
-- 特定助教对特定客户的近 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)采用优雅降级:
```python
# 每个扩展模块独立 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](https://hypothesis.readthedocs.io/)(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 失败→空列表 |
### 测试执行命令
```bash
# 属性测试(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"
```