Files
Neo-ZQYY/.kiro/specs/rns1-task-performance-api/design.md

34 KiB
Raw Blame History

技术设计文档 — RNS1.1:任务与绩效接口

概述

RNS1.1 实现助教日常使用频率最高的 4 个核心接口及配套前端适配,覆盖 6 个任务:

  1. T1-1 扩展 TASK-1:任务列表 performance 从 4 字段扩展到 15+ 字段;enrichTask 补充 lastVisitDays/balance/aiSuggestion
  2. T1-2 实现 TASK-2任务详情完整版维客线索、话术、服务记录、AI 分析、备注)
  3. T1-3 实现 PERF-1绩效概览DateGroup 分组、收入档位、新客/常客列表)
  4. T1-4 实现 PERF-2绩效明细按月分页、DateGroup 分组)
  5. T1-5 pin/unpin:已有端点的响应格式对齐契约
  6. T1-6 前端适配createNote 补 score、月份切换、avatarChar/avatarColor 前端计算

设计原则

  • 增量扩展:在现有 xcx_tasks.py 路由和 task_manager.py 服务基础上扩展,不重写
  • FDW 查询集中化:所有 FDW 查询封装在 service 层,路由层不直接操作数据库
  • DWD-DOC 强制规则:金额 items_sum 口径、助教费用 assistant_pd_money + assistant_cx_money 拆分、会员信息通过 member_id JOIN dim_member
  • 契约驱动:响应结构严格遵循 API-contract.md 定义

依赖

  • RNS1.0 已完成:ResponseWrapperMiddlewareCamelModel、前端 request() 解包
  • 现有代码:xcx_tasks.py(路由)、task_manager.py(服务)、xcx_notes.py(备注路由)

架构

模块交互

graph TB
    subgraph "微信小程序"
        A[task-list 页面] --> B[services/api.ts]
        C[task-detail 页面] --> B
        D[performance 页面] --> B
        E[performance-records 页面] --> B
    end

    subgraph "FastAPI 后端"
        F[routers/xcx_tasks.py<br/>TASK-1/2, PIN/UNPIN]
        G[routers/xcx_performance.py<br/>PERF-1/2 — 新增]
        H[services/task_manager.py<br/>任务 CRUD + 扩展]
        I[services/performance_service.py<br/>绩效查询 — 新增]
        J[services/fdw_queries.py<br/>FDW 查询封装 — 新增]
    end

    subgraph "数据库"
        K[(zqyy_app<br/>biz.coach_tasks<br/>biz.ai_cache<br/>biz.notes<br/>auth.user_assistant_binding<br/>public.member_retention_clue)]
        L[(etl_feiqiu via FDW<br/>fdw_etl.v_dws_assistant_salary_calc<br/>fdw_etl.v_dwd_assistant_service_log<br/>fdw_etl.v_dim_member<br/>fdw_etl.v_dim_member_card_account)]
    end

    B -->|HTTP JSON| F
    B -->|HTTP JSON| G
    F --> H
    G --> I
    H --> J
    I --> J
    H --> K
    I --> K
    J -->|FDW + SET LOCAL| L

    style G fill:#9f9,stroke:#333
    style I fill:#9f9,stroke:#333
    style J fill:#9f9,stroke:#333

请求流程(以 TASK-1 扩展为例)

sequenceDiagram
    participant MP as 小程序
    participant R as xcx_tasks Router
    participant TM as task_manager
    participant FDW as fdw_queries
    participant BIZ as zqyy_app
    participant ETL as etl via FDW

    MP->>R: GET /api/xcx/tasks?status=pending&page=1
    R->>TM: get_task_list(user_id, site_id, status, page, pageSize)
    TM->>BIZ: SELECT assistant_id FROM user_assistant_binding
    TM->>BIZ: SELECT tasks FROM coach_tasks
    TM->>FDW: enrich_tasks(member_ids, assistant_id, site_id)
    FDW->>ETL: SET LOCAL app.current_site_id
    FDW->>ETL: SELECT dim_member (姓名)
    FDW->>ETL: SELECT dim_member_card_account (余额)
    FDW->>ETL: SELECT dwd_settlement_head (lastVisitDays)
    FDW-->>TM: enriched data
    TM->>FDW: get_performance_summary(assistant_id, site_id)
    FDW->>ETL: SELECT v_dws_assistant_salary_calc (档位/收入)
    FDW-->>TM: PerformanceSummary
    TM->>BIZ: SELECT ai_cache (aiSuggestion)
    TM-->>R: TaskListResponse
    R-->>MP: { code: 0, data: TaskListResponse }

组件与接口

组件 1xcx_tasks Router 扩展

位置apps/backend/app/routers/xcx_tasks.py

改动

  • GET /api/xcx/tasks — 响应从 list[TaskListItem] 改为 TaskListResponse(含 items + performance + 分页)
  • 新增 GET /api/xcx/tasks/{taskId} — 任务详情端点
  • POST .../pinPOST .../unpin — 响应对齐契约格式 { isPinned: bool }
  • 新增查询参数:status(筛选)、pagepageSize
@router.get("", response_model=TaskListResponse)
async def get_tasks(
    status: str = Query("pending", regex="^(pending|completed|abandoned)$"),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    user: CurrentUser = Depends(require_approved()),
):
    """获取任务列表 + 绩效概览。"""
    return await task_manager.get_task_list_v2(
        user.user_id, user.site_id, status, page, page_size
    )

@router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task_detail(
    task_id: int,
    user: CurrentUser = Depends(require_approved()),
):
    """获取任务详情完整版。"""
    return await task_manager.get_task_detail(
        task_id, user.user_id, user.site_id
    )

组件 2xcx_performance Router新增

位置apps/backend/app/routers/xcx_performance.py

职责:绩效概览和绩效明细两个端点。

router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])

@router.get("", response_model=PerformanceOverviewResponse)
async def get_performance_overview(
    year: int = Query(...),
    month: int = Query(..., ge=1, le=12),
    user: CurrentUser = Depends(require_approved()),
):
    """绩效概览PERF-1。"""
    return await performance_service.get_overview(
        user.user_id, user.site_id, year, month
    )

@router.get("/records", response_model=PerformanceRecordsResponse)
async def get_performance_records(
    year: int = Query(...),
    month: int = Query(..., ge=1, le=12),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    user: CurrentUser = Depends(require_approved()),
):
    """绩效明细PERF-2。"""
    return await performance_service.get_records(
        user.user_id, user.site_id, year, month, page, page_size
    )

注册main.py

from app.routers import xcx_performance
app.include_router(xcx_performance.router)

组件 3fdw_queries 服务(新增)

位置apps/backend/app/services/fdw_queries.py

职责:封装所有 FDW 查询,统一 SET LOCAL app.current_site_id 隔离。所有 FDW 查询集中在此模块,避免 service 层散落 SQL。

核心函数

def _fdw_context(conn, site_id: int):
    """上下文管理器BEGIN + SET LOCAL app.current_site_id。"""
    ...

def get_member_info(conn, site_id: int, member_ids: list[int]) -> dict[int, MemberInfo]:
    """
    批量查询会员信息。
    ⚠️ DQ-6通过 member_id JOIN fdw_etl.v_dim_member 获取 nickname/mobile
    禁止使用 settlement_head.member_phone/member_name。
    """
    # SELECT member_id, nickname, mobile
    # FROM fdw_etl.v_dim_member
    # WHERE member_id = ANY(%s) AND scd2_is_current = 1

def get_member_balance(conn, site_id: int, member_ids: list[int]) -> dict[int, Decimal]:
    """
    批量查询会员储值卡余额。
    ⚠️ DQ-7通过 member_id JOIN fdw_etl.v_dim_member_card_account。
    """

def get_last_visit_days(conn, site_id: int, member_ids: list[int]) -> dict[int, int | None]:
    """
    批量查询客户距上次到店天数。
    来源fdw_etl.v_dwd_assistant_service_log 或 dwd_settlement_head。
    """

def get_salary_calc(conn, site_id: int, assistant_id: int, year: int, month: int) -> dict | None:
    """
    查询助教绩效/档位/收入数据。
    来源fdw_etl.v_dws_assistant_salary_calc。
    ⚠️ DWD-DOC 规则 1收入使用 items_sum 口径。
    ⚠️ DWD-DOC 规则 2费用使用 assistant_pd_money + assistant_cx_money。
    """

def get_service_records(
    conn, site_id: int, assistant_id: int,
    year: int, month: int, limit: int, offset: int
) -> list[dict]:
    """
    查询助教服务记录明细。
    来源fdw_etl.v_dwd_assistant_service_log。
    ⚠️ 废单排除WHERE is_trash = false。
    ⚠️ DQ-6客户姓名通过 member_id JOIN dim_member。
    """

def get_service_records_for_task(
    conn, site_id: int, assistant_id: int, member_id: int, limit: int
) -> list[dict]:
    """查询特定客户的服务记录TASK-2 用)。"""

组件 4task_manager 服务扩展

位置apps/backend/app/services/task_manager.py

新增函数

async def get_task_list_v2(
    user_id: int, site_id: int,
    status: str, page: int, page_size: int
) -> dict:
    """
    扩展版任务列表TASK-1    返回 { items, total, page, pageSize, performance }。
    """

async def get_task_detail(
    task_id: int, user_id: int, site_id: int
) -> dict:
    """
    任务详情完整版TASK-2    返回基础信息 + retentionClues + talkingPoints + serviceSummary
    + serviceRecords + aiAnalysis + notes + customerId。
    """

get_task_list_v2 逻辑

  1. _get_assistant_id() 获取 assistant_id
  2. 查询 biz.coach_tasks 带分页(LIMIT/OFFSET + COUNT(*)
  3. 调用 fdw_queries 批量获取会员信息、余额、lastVisitDays
  4. 调用 fdw_queries.get_salary_calc() 获取绩效概览
  5. 查询 biz.ai_cache 获取 aiSuggestion
  6. 组装 TaskListResponse

get_task_detail 逻辑

  1. _get_assistant_id() + _verify_task_ownership() 权限校验
  2. 查询 biz.coach_tasks 基础信息
  3. 查询 public.member_retention_clue 维客线索
  4. 查询 biz.ai_cacheapp5_talking_points → talkingPointsapp4_analysis → aiAnalysis
  5. 调用 fdw_queries.get_service_records_for_task() 获取服务记录(最多 20 条)
  6. 查询 biz.notes 获取备注(最多 20 条)
  7. 组装 TaskDetailResponse

组件 5performance_service新增

位置apps/backend/app/services/performance_service.py

核心函数

async def get_overview(
    user_id: int, site_id: int, year: int, month: int
) -> dict:
    """
    绩效概览PERF-1    1. 获取 assistant_id
    2. fdw_queries.get_salary_calc() → 档位/收入/费率
    3. fdw_queries.get_service_records() → 按日期分组为 DateGroup
    4. 聚合新客/常客列表
    5. 计算 incomeItems含 desc 费率描述)
    6. 查询上月收入 lastMonthIncome
    """

async def get_records(
    user_id: int, site_id: int,
    year: int, month: int, page: int, page_size: int
) -> dict:
    """
    绩效明细PERF-2    1. 获取 assistant_id
    2. fdw_queries.get_service_records() 带分页
    3. 按日期分组为 dateGroups
    4. 计算 summary 汇总
    5. 返回 { summary, dateGroups, hasMore }
    """

组件 6Pydantic Schema新增/扩展)

位置apps/backend/app/schemas/xcx_tasks.py(扩展)+ apps/backend/app/schemas/xcx_performance.py(新增)

任务相关 Schema 扩展

class PerformanceSummary(CamelModel):
    """绩效概览(附带在任务列表响应中)。"""
    total_hours: float
    total_income: float
    total_customers: int
    month_label: str
    tier_nodes: list[float]
    basic_hours: float
    bonus_hours: float
    current_tier: int
    next_tier_hours: float
    tier_completed: bool
    bonus_money: float
    income_trend: str
    income_trend_dir: str  # 'up' | 'down'
    prev_month: str
    current_tier_label: str

class TaskItem(CamelModel):
    """任务列表项(扩展版)。"""
    id: int
    customer_name: str
    customer_avatar: str
    task_type: str
    task_type_label: str
    deadline: str | None
    heart_score: float
    hobbies: list[str]
    is_pinned: bool
    has_note: bool
    status: str
    last_visit_days: int | None = None
    balance: float | None = None
    ai_suggestion: str | None = None

class TaskListResponse(CamelModel):
    """TASK-1 响应。"""
    items: list[TaskItem]
    total: int
    page: int
    page_size: int
    performance: PerformanceSummary

任务详情 Schema

class RetentionClue(CamelModel):
    tag: str
    tag_color: str
    emoji: str
    text: str
    source: str  # 'manual' | 'ai_consumption' | 'ai_note'
    desc: str | None = None

class ServiceRecord(CamelModel):
    table: str | None = None
    type: str
    type_class: str  # 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
    record_type: str | None = None  # 'course' | 'recharge'
    duration: float
    duration_raw: float | None = None
    income: float
    is_estimate: bool | None = None
    drinks: str | None = None
    date: str

class AiAnalysis(CamelModel):
    summary: str
    suggestions: list[str]

class NoteItem(CamelModel):
    id: int
    content: str
    tag_type: str
    tag_label: str
    created_at: str
    score: int | None = None

class ServiceSummary(CamelModel):
    total_hours: float
    total_income: float
    avg_income: float

class TaskDetailResponse(CamelModel):
    """TASK-2 响应。"""
    # 基础信息
    id: int
    customer_name: str
    customer_avatar: str
    task_type: str
    task_type_label: str
    deadline: str | None
    heart_score: float
    hobbies: list[str]
    is_pinned: bool
    has_note: bool
    status: str
    customer_id: int
    # 扩展模块
    retention_clues: list[RetentionClue]
    talking_points: list[str]
    service_summary: ServiceSummary
    service_records: list[ServiceRecord]
    ai_analysis: AiAnalysis
    notes: list[NoteItem]

绩效 Schema

class DateGroupRecord(CamelModel):
    customer_name: str
    avatar_char: str | None = None   # PERF-1 返回PERF-2 不返回
    avatar_color: str | None = None  # PERF-1 返回PERF-2 不返回
    time_range: str
    hours: str
    course_type: str
    course_type_class: str  # 'basic' | 'vip' | 'tip'
    location: str
    income: str

class DateGroup(CamelModel):
    date: str
    total_hours: str
    total_income: str
    records: list[DateGroupRecord]

class TierInfo(CamelModel):
    basic_rate: float
    incentive_rate: float

class IncomeItem(CamelModel):
    icon: str
    label: str
    desc: str
    value: str

class CustomerSummary(CamelModel):
    name: str
    avatar_char: str
    avatar_color: str

class NewCustomer(CustomerSummary):
    last_service: str
    count: int

class RegularCustomer(CustomerSummary):
    hours: float
    income: str
    count: int

class PerformanceOverviewResponse(CamelModel):
    """PERF-1 响应。"""
    coach_name: str
    coach_role: str
    store_name: str
    monthly_income: str
    last_month_income: str
    current_tier: TierInfo
    next_tier: TierInfo
    upgrade_hours_needed: float
    upgrade_bonus: float
    income_items: list[IncomeItem]
    monthly_total: str
    this_month_records: list[DateGroup]
    new_customers: list[NewCustomer]
    regular_customers: list[RegularCustomer]

class RecordsSummary(CamelModel):
    total_count: int
    total_hours: float
    total_hours_raw: float
    total_income: float

class PerformanceRecordsResponse(CamelModel):
    """PERF-2 响应。"""
    summary: RecordsSummary
    date_groups: list[DateGroup]
    has_more: bool

组件 7前端适配

涉及文件与改动

文件 改动
services/api.ts 新增 pinTask()unpinTask()createNote() 增加 score 参数;新增 fetchTaskDetail()fetchPerformanceOverview(year, month)fetchPerformanceRecords(year, month, page)
pages/task-list/task-list.ts 消费 TaskListResponse 新结构;buildPerfData() 使用 15+ 字段绩效数据
pages/task-detail/task-detail.ts 调用 fetchTaskDetail()storageLevel/relationLevel 前端本地计算
pages/performance/performance.ts 添加月份切换控件;切换时重新调用 API
pages/performance-records/performance-records.ts 月份切换时重置 page=1avatarChar/avatarColor 使用 nameToAvatarColor() 前端计算

数据模型

FDW 查询模式

所有 FDW 查询遵循统一模式:

# 1. 使用业务库连接get_connection() → zqyy_app
conn = get_connection()

# 2. 开启事务 + 设置门店隔离
with conn.cursor() as cur:
    cur.execute("BEGIN")
    cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))

    # 3. 通过 fdw_etl schema 访问 ETL 视图
    cur.execute("""
        SELECT ...
        FROM fdw_etl.v_dws_assistant_salary_calc
        WHERE assistant_id = %s AND calc_month = %s
    """, (assistant_id, f"{year}-{month:02d}"))

conn.commit()

核心 SQL 查询设计

Q1: 绩效概览查询TASK-1 performance + PERF-1

-- ⚠️ DWD-DOC 规则 1: 收入使用 items_sum 口径
-- ⚠️ DWD-DOC 规则 2: 费用使用 assistant_pd_money + assistant_cx_money
SELECT
    calc_month,
    coach_level,                          -- 当前档位名称
    tier_index,                           -- 当前档位索引
    tier_nodes,                           -- 档位节点数组 (JSON)
    basic_hours,                          -- 基础课时 (assistant_pd_money 对应)
    bonus_hours,                          -- 激励课时 (assistant_cx_money 对应)
    total_hours,                          -- 总工时
    total_income,                         -- 总收入 (items_sum 口径)
    total_customers,                      -- 服务客户数
    basic_rate,                           -- 基础课时费率
    incentive_rate,                       -- 激励课时费率
    next_tier_basic_rate,                 -- 下一档基础课时费率
    next_tier_incentive_rate,             -- 下一档激励课时费率
    next_tier_hours,                      -- 下一档工时阈值
    tier_completed,                       -- 是否已达最高档
    bonus_money,                          -- 升档奖金
    assistant_pd_money_total,             -- 基础课总费用
    assistant_cx_money_total              -- 激励课总费用
FROM fdw_etl.v_dws_assistant_salary_calc
WHERE assistant_id = %s
  AND calc_month = %s

Q2: 收入趋势计算TASK-1 performance

-- 当月 vs 上月收入对比
WITH months AS (
    SELECT total_income, calc_month
    FROM fdw_etl.v_dws_assistant_salary_calc
    WHERE assistant_id = %s
      AND calc_month IN (%s, %s)  -- 当月, 上月
)
SELECT * FROM months ORDER BY calc_month

后端计算逻辑:

diff = current_income - prev_income
income_trend = f"{'↑' if diff >= 0 else '↓'}{abs(diff):.0f}"
income_trend_dir = "up" if diff >= 0 else "down"

Q3: 服务记录查询PERF-1 thisMonthRecords / PERF-2 dateGroups / TASK-2 serviceRecords

-- ⚠️ DWD-DOC 规则 DQ-6: 客户姓名通过 member_id JOIN dim_member
-- ⚠️ 废单排除: is_trash = false
SELECT
    sl.id,
    dm.nickname AS customer_name,
    sl.settle_time,
    sl.start_time,
    sl.end_time,
    sl.service_hours,                     -- 折算工时
    sl.service_hours_raw,                 -- 原始工时
    sl.course_type,                       -- 课程类型
    sl.table_name,                        -- 台桌名
    sl.items_sum AS income,               -- ⚠️ items_sum 口径
    sl.is_estimate
FROM fdw_etl.v_dwd_assistant_service_log sl
LEFT JOIN fdw_etl.v_dim_member dm
    ON sl.member_id = dm.member_id
    AND dm.scd2_is_current = 1            -- ⚠️ SCD2 当前记录
WHERE sl.assistant_id = %s
  AND sl.is_trash = false                 -- ⚠️ 废单排除
  AND sl.settle_time >= %s                -- 月份起始
  AND sl.settle_time < %s                 -- 月份结束
ORDER BY sl.settle_time DESC
LIMIT %s OFFSET %s

Q4: 任务项扩展字段查询

-- lastVisitDays: 距上次到店天数
SELECT member_id,
       CURRENT_DATE - MAX(settle_time::date) AS days_since_visit
FROM fdw_etl.v_dwd_assistant_service_log
WHERE member_id = ANY(%s)
  AND is_trash = false
GROUP BY member_id

-- balance: 客户储值卡余额
-- ⚠️ DQ-7: 通过 member_id JOIN dim_member_card_account
SELECT tenant_member_id AS member_id,
       balance
FROM fdw_etl.v_dim_member_card_account
WHERE tenant_member_id = ANY(%s)
  AND scd2_is_current = 1

Q5: 维客线索查询TASK-2

SELECT id, tag, tag_color, emoji, text, source, description
FROM public.member_retention_clue
WHERE member_id = %s
  AND site_id = %s
ORDER BY created_at DESC

Q6: AI 缓存查询TASK-2

-- aiAnalysis: cache_type = 'app4_analysis'
-- talkingPoints: cache_type = 'app5_talking_points'
SELECT cache_type, cache_value
FROM biz.ai_cache
WHERE target_id = %s
  AND site_id = %s
  AND cache_type IN ('app4_analysis', 'app5_talking_points')

Q7: 新客/常客列表PERF-1

-- 新客:本月首次服务的客户
-- ⚠️ DQ-6: 客户姓名通过 member_id JOIN dim_member
WITH month_records AS (
    SELECT sl.member_id,
           dm.nickname AS customer_name,
           COUNT(*) AS service_count,
           MAX(sl.settle_time) AS last_service
    FROM fdw_etl.v_dwd_assistant_service_log sl
    LEFT JOIN fdw_etl.v_dim_member dm
        ON sl.member_id = dm.member_id AND dm.scd2_is_current = 1
    WHERE sl.assistant_id = %s
      AND sl.is_trash = false
      AND sl.settle_time >= %s AND sl.settle_time < %s
    GROUP BY sl.member_id, dm.nickname
),
historical AS (
    SELECT DISTINCT member_id
    FROM fdw_etl.v_dwd_assistant_service_log
    WHERE assistant_id = %s
      AND is_trash = false
      AND settle_time < %s  -- 本月之前
)
SELECT mr.member_id, mr.customer_name, mr.service_count, mr.last_service
FROM month_records mr
LEFT JOIN historical h ON mr.member_id = h.member_id
WHERE h.member_id IS NULL  -- 新客:历史无记录
ORDER BY mr.last_service DESC

-- 常客:本月服务 ≥ 2 次的客户
SELECT mr.member_id, mr.customer_name,
       SUM(sl.service_hours) AS total_hours,
       SUM(sl.items_sum) AS total_income,  -- ⚠️ items_sum 口径
       COUNT(*) AS service_count
FROM month_records mr
JOIN fdw_etl.v_dwd_assistant_service_log sl
    ON mr.member_id = sl.member_id
WHERE mr.service_count >= 2
GROUP BY mr.member_id, mr.customer_name
ORDER BY total_income DESC

courseTypeClass 枚举映射

后端根据 v_dwd_assistant_service_log.course_type 映射:

原始值 courseTypeClass courseType 中文
basic / 陪打 / 基础课 basic 基础课
vip / 包厢 / 包厢课 vip 包厢课
tip / 超休 / 激励课 tip 激励课
recharge / 充值 recharge 充值
incentive / 激励 incentive 激励

统一不带 tag- 前缀(契约要求)。

DWD-DOC 强制规则实施位置

规则 实施位置 具体措施
规则 1: items_sum 口径 fdw_queries.py 所有金额查询 SQL 中使用 items_sum 字段,禁止 consume_money
规则 2: 助教费用拆分 fdw_queries.get_salary_calc() 使用 assistant_pd_money + assistant_cx_money,禁止 service_fee
DQ-6: 会员信息 JOIN fdw_queries.get_member_info() + 所有含客户姓名的查询 member_id JOIN fdw_etl.v_dim_member WHERE scd2_is_current=1
DQ-7: 会员卡 JOIN fdw_queries.get_member_balance() member_id JOIN fdw_etl.v_dim_member_card_account WHERE scd2_is_current=1
废单排除 fdw_queries 所有服务记录查询 WHERE is_trash = false,禁止引用 dwd_assistant_trash_event

正确性属性

属性Property是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。

Property 1: 收入趋势计算正确性

For any 两个非负收入值 currentIncomeprevIncome,计算 incomeTrendincomeTrendDir 时:

  • incomeTrendDir 应为 "up"currentIncome >= prevIncome,否则为 "down"
  • incomeTrend 应包含绝对差值 abs(currentIncome - prevIncome) 的整数表示
  • incomeTrend 应以 "↑""↓" 开头,与 incomeTrendDir 一致

Validates: Requirements 1.4

Property 2: lastVisitDays 计算正确性

For any 有效日期 lastVisitDate(不晚于今天),lastVisitDays 应等于 (current_date - lastVisitDate).days,且结果为非负整数。

Validates: Requirements 1.7

Property 3: 维客线索 tag 净化与 source 枚举

For any 维客线索记录,经过后端处理后:

  • tag 字段不应包含换行符 \n(多行标签使用空格分隔)
  • source 字段必须是 manualai_consumptionai_note 三者之一

Validates: Requirements 2.4, 2.5

Property 4: courseTypeClass 枚举映射

For any 服务记录的原始课程类型值,经过 map_course_type_class() 映射后:

  • 结果必须是 basicviptiprechargeincentive 五者之一
  • 结果不应包含 tag- 前缀

Validates: Requirements 2.9, 4.4

Property 5: 列表分页与排序

For any 记录集合和分页参数 (page, pageSize)

  • 返回的记录数 ≤ pageSize
  • hasMoretrue 当且仅当总记录数 > page * pageSize
  • 服务记录在每个 DateGroup 内按时间倒序排列
  • 备注列表按 created_at 倒序排列

Validates: Requirements 2.3, 4.2

Property 6: DateGroup 分组正确性

For any 服务记录集合,按日期分组后:

  • 每个 DateGroup 的 date 在结果中唯一
  • 每个 DateGroup 内的所有记录的日期部分相同
  • totalHours 等于该组内所有记录 hours 之和
  • totalIncome 等于该组内所有记录 income 之和
  • DateGroup 列表按日期倒序排列

Validates: Requirements 3.3, 4.3

Property 7: incomeItems desc 格式化

For any 费率 rate(正数)和工时 hours(非负数),生成的 desc 字段应包含费率值和工时值,格式为 "{rate}元/h × {hours}h"

Validates: Requirements 3.9

Property 8: 月度汇总聚合正确性

For any 服务记录集合,summary 的聚合应满足:

  • totalCount 等于记录总数
  • totalHours 等于所有记录折算工时之和
  • totalHoursRaw 等于所有记录原始工时之和
  • totalIncome 等于所有记录收入之和(items_sum 口径)

Validates: Requirements 4.6

Property 9: Pin/Unpin 状态往返

For any 有效任务,执行 pinisPinned 应为 true,再执行 unpinisPinned 应恢复为 false。即 unpin(pin(task))isPinned 状态等于原始状态(假设原始为 false)。

Validates: Requirements 5.1, 5.2, 5.3

Property 10: 权限与数据隔离

For any 请求,若用户状态非 approved、缺少 view_tasks 权限、或在 user_assistant_binding 中无绑定记录,则所有 RNS1.1 端点应返回 HTTP 403。若请求的 taskId 不属于当前助教的 assistant_id,也应返回 HTTP 403。

Validates: Requirements 2.13, 8.1, 8.2, 8.4

Property 11: 前端派生字段计算

For any 客户姓名字符串 name(非空),nameToAvatarColor(name) 应返回:

  • avatarChar 等于 name 的第一个字符
  • avatarColor 为预定义颜色集合中的一个值
  • 相同 name 输入始终产生相同输出(确定性)

For any 余额值 balance(非负数),computeStorageLevel(balance) 应返回预定义等级之一(如 "非常多"/"较多"/"一般"/"较少"),且等级随余额单调递增。

For any 亲密度分数 heartScore0-10computeRelationLevel(heartScore) 应返回预定义等级之一,且等级随分数单调递增。

Validates: Requirements 6.4, 6.5, 7.6

Property 12: 备注 score 输入验证

For any 整数 score,若 score 在 1-5 范围内,POST /api/xcx/notes 应接受并存储;若 score 超出范围(<1 或 >5应返回 422 错误。scorenull 时应正常创建备注score 可选)。

Validates: Requirements 6.3

错误处理

后端错误处理

错误场景 HTTP 状态码 响应 触发位置
用户未通过审核 403 { code: 403, message: "用户未通过审核,无法访问此资源" } require_approved()
用户无 view_tasks 权限 403 { code: 403, message: "权限不足" } require_permission("view_tasks")
用户未绑定助教身份 403 { code: 403, message: "未绑定助教身份" } _get_assistant_id()
任务不属于当前助教 403 { code: 403, message: "无权访问该任务" } _verify_task_ownership()
任务不存在 404 { code: 404, message: "任务不存在" } get_task_detail() / pin_task()
score 超出 1-5 范围 422 { code: 422, message: "评分必须在 1-5 范围内" } create_note()
无效的 status 参数 422 Pydantic 验证错误 FastAPI 参数校验
year/month 参数无效 422 Pydantic 验证错误 FastAPI 参数校验
FDW 查询超时/失败 500 { code: 500, message: "Internal Server Error" } unhandled_exception_handler
数据库连接失败 500 { code: 500, message: "Internal Server Error" } unhandled_exception_handler

FDW 查询容错策略

任务列表扩展字段(lastVisitDaysbalanceaiSuggestion)采用优雅降级策略:

# 单个扩展字段查询失败不影响整体响应
try:
    last_visit_map = fdw_queries.get_last_visit_days(conn, site_id, member_ids)
except Exception:
    logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
    last_visit_map = {}

核心字段(绩效概览、服务记录)查询失败则直接抛出 500。

前端错误处理

场景 处理方式
API 返回 403 显示"权限不足"提示,不跳转
API 返回 404 显示"任务不存在"提示,返回列表页
API 返回 500 显示"服务器错误"toast
月份切换期间网络失败 恢复上一月份状态,显示重试提示
扩展字段为 null 前端隐藏对应 UI 元素(如不显示余额标签)

测试策略

双轨测试方法

RNS1.1 采用属性测试Property-Based Testing+ 单元测试Unit Testing双轨并行

  • 属性测试:验证计算逻辑、枚举映射、分页排序、聚合等通用规则在所有输入上的正确性
  • 单元测试:验证具体的 API 端点行为、权限校验、FDW 查询集成、边界条件

属性测试配置

  • 测试库HypothesisPython项目已使用
  • 测试位置tests/ 目录Monorepo 级属性测试)
  • 最小迭代次数:每个属性测试 100 次(@settings(max_examples=100)
  • 标签格式:每个测试函数的 docstring 中标注 Feature: rns1-task-performance-api, Property {N}: {property_text}
  • 每个正确性属性由一个属性测试实现

属性测试清单

Property 测试函数 生成器 验证逻辑
P1: 收入趋势计算 test_income_trend_computation st.floats(min_value=0, max_value=1e6) × 2 方向正确、差值正确、前缀符号一致
P2: lastVisitDays 计算 test_last_visit_days_computation st.dates(max_value=date.today()) 天数差 = (today - date).days非负
P3: 维客线索净化 test_retention_clue_sanitization st.text() 生成含 \n 的 tag + st.sampled_from source tag 无换行、source 在枚举内
P4: courseTypeClass 映射 test_course_type_class_mapping st.sampled_from(ALL_COURSE_TYPES) 结果在枚举内、无 tag- 前缀
P5: 分页与排序 test_pagination_and_ordering st.lists(st.fixed_dictionaries(...)) + st.integers(1,10) page/pageSize 记录数 ≤ pageSize、hasMore 正确、排序正确
P6: DateGroup 分组 test_date_group_correctness st.lists(st.fixed_dictionaries({date, hours, income})) 日期唯一、组内日期一致、汇总正确
P7: incomeItems desc 格式化 test_income_item_desc_format st.floats(min_value=0.01) rate × st.floats(min_value=0) hours 包含费率和工时值
P8: 月度汇总聚合 test_monthly_summary_aggregation st.lists(st.fixed_dictionaries({hours, hours_raw, income})) count/hours/hours_raw/income 聚合正确
P9: Pin/Unpin 往返 test_pin_unpin_roundtrip st.booleans() 初始状态 pin→true、unpin→false、往返恢复
P10: 权限隔离 test_authorization_enforcement st.sampled_from(INVALID_USER_SCENARIOS) 所有场景返回 403
P11: 前端派生字段 test_frontend_derived_fields st.text(min_size=1) name + st.floats(0, 1e6) balance + st.floats(0, 10) heartScore avatarChar=name[0]、确定性、单调性
P12: score 输入验证 test_note_score_validation st.integers() 1-5 接受、超范围拒绝、null 接受

单元测试清单

测试目标 测试文件 关键用例
TASK-1 端点 tests/unit/test_xcx_tasks_v2.py 正常返回 TaskListResponse 结构performance 含 15+ 字段;扩展字段 null 降级
TASK-2 端点 tests/unit/test_task_detail.py 完整详情返回customerId 正确serviceRecords ≤ 20 条notes ≤ 20 条
PERF-1 端点 tests/unit/test_performance_overview.py DateGroup 结构正确;收入档位数据完整;新客/常客列表
PERF-2 端点 tests/unit/test_performance_records.py 分页正确summary 聚合正确hasMore 标记
PIN/UNPIN tests/unit/test_pin_unpin.py 响应格式 { isPinned: bool }404 不存在403 非本人任务
权限校验 tests/unit/test_auth_rns11.py 未审核用户 403无绑定 403无权限 403
FDW 查询 tests/unit/test_fdw_queries.py DQ-6 JOIN 正确is_trash 排除items_sum 口径
AI 缓存降级 tests/unit/test_ai_cache_fallback.py 无缓存返回空cache_type 映射正确
tierCompleted 边界 tests/unit/test_tier_completed.py tierCompleted=true 时 bonusMoney=0

测试执行命令

# 属性测试Hypothesis
cd C:\NeoZQYY && pytest tests/ -v -k "rns1_task_performance"

# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "xcx_tasks_v2 or task_detail or performance or pin_unpin or auth_rns11 or fdw_queries"