Files
Neo-ZQYY/.kiro/specs/rns1-customer-coach-api/design.md

40 KiB
Raw Blame History

技术设计文档 — RNS1.2:客户与助教接口

概述

RNS1.2 实现客户详情CUST-1、客户服务记录CUST-2、助教详情COACH-13 个接口,覆盖 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.pycoach_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 已完成:ResponseWrapperMiddlewareCamelModel、前端 request() 解包
  • 现有代码:fdw_queries.py(已有 6 个查询函数)、task_manager.pynote_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 }

组件与接口

组件 1xcx_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)

组件 2xcx_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)

组件 3customer_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 → aiInsightcache_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 → notestarget_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
    """

组件 4coach_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 口径
    """

组件 5fdw_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_amountitems_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 规则 1totalAmount 使用 ledger_amount。
    ⚠️ DWD-DOC 规则 2coaches 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_logCOUNT 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_logCOUNT(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 规则 1consume 使用 ledger_amountitems_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。
    """

组件 6Pydantic 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_amountitems_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_moneycourseType"激励课" 的记录其 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 列表长度应 ≤ 20COACH-1 响应中的 topCustomers 列表长度应 ≤ 20COACH-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 关系指数值 relationIndex0.0 到 1.0 之间的浮点数):

  • CUST-1 favoriteCoachesrelationIndex >= 0.7emoji 应为 "💖"relationIndex < 0.7 时应为 "💛"
  • COACH-1 topCustomersscore >= 0.7heartEmoji 应为 "❤️"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 应等于请求路径中的 customerIdFor any COACH-1 响应中的 serviceRecords,每条记录的 assistant_id 应等于请求路径中的 coachId

Validates: Requirements 14.3, 14.4

Property 13: 分页与 hasMore 正确性

For any CUST-2 记录集合和分页参数 (page, pageSize),返回的记录数应 ≤ pageSizehasMoretrue 当且仅当总记录数 > page * pageSize。记录应按 create_time 倒序排列。

Validates: Requirements 7.9

Property 14: 幂等性

For any 相同参数的 CUST-2 请求(相同 customerIdyearmonth),在数据未变更的情况下,两次请求应返回相同的 monthCountmonthHours 值。即 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 查询集成、边界条件、优雅降级

属性测试配置

  • 测试库HypothesisPython项目已使用
  • 测试位置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 ≤ 20topCustomers ≤ 20
P7: 月度汇总聚合 test_monthly_summary_aggregation st.lists(st.fixed_dictionaries({hours: st.floats(0,10), income: st.floats(0,1e4)})) count=lenhours=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→visibleinactive→hiddenabandoned→abandoned无交集
P12: 数据隔离 test_data_isolation st.integers(1, 1000) 生成 customerId/coachId coachTasks 每条 member_id=customerIdserviceRecords 每条 assistant_id=coachId
P13: 分页与 hasMore test_pagination_has_more st.integers(1,100) total + st.integers(1,10) page/pageSize 记录数 ≤ pageSizehasMore = (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 含 reasonnotes 关联
权限校验 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"