# 技术设计文档 — 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 已完成:`ResponseWrapperMiddleware`、`CamelModel`、前端 `request()` 解包 - 现有代码:`xcx_tasks.py`(路由)、`task_manager.py`(服务)、`xcx_notes.py`(备注路由) ## 架构 ### 模块交互 ```mermaid 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
TASK-1/2, PIN/UNPIN] G[routers/xcx_performance.py
PERF-1/2 — 新增] H[services/task_manager.py
任务 CRUD + 扩展] I[services/performance_service.py
绩效查询 — 新增] J[services/fdw_queries.py
FDW 查询封装 — 新增] end subgraph "数据库" K[(zqyy_app
biz.coach_tasks
biz.ai_cache
biz.notes
auth.user_assistant_binding
public.member_retention_clue)] L[(etl_feiqiu via FDW
fdw_etl.v_dws_assistant_salary_calc
fdw_etl.v_dwd_assistant_service_log
fdw_etl.v_dim_member
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 扩展为例) ```mermaid 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 } ``` ## 组件与接口 ### 组件 1:xcx_tasks Router 扩展 **位置**:`apps/backend/app/routers/xcx_tasks.py` **改动**: - `GET /api/xcx/tasks` — 响应从 `list[TaskListItem]` 改为 `TaskListResponse`(含 `items` + `performance` + 分页) - 新增 `GET /api/xcx/tasks/{taskId}` — 任务详情端点 - `POST .../pin` 和 `POST .../unpin` — 响应对齐契约格式 `{ isPinned: bool }` - 新增查询参数:`status`(筛选)、`page`、`pageSize` ```python @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 ) ``` ### 组件 2:xcx_performance Router(新增) **位置**:`apps/backend/app/routers/xcx_performance.py` **职责**:绩效概览和绩效明细两个端点。 ```python 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`): ```python from app.routers import xcx_performance app.include_router(xcx_performance.router) ``` ### 组件 3:fdw_queries 服务(新增) **位置**:`apps/backend/app/services/fdw_queries.py` **职责**:封装所有 FDW 查询,统一 `SET LOCAL app.current_site_id` 隔离。所有 FDW 查询集中在此模块,避免 service 层散落 SQL。 **核心函数**: ```python 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 用)。""" ``` ### 组件 4:task_manager 服务扩展 **位置**:`apps/backend/app/services/task_manager.py` **新增函数**: ```python 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_cache`(`app5_talking_points` → talkingPoints,`app4_analysis` → aiAnalysis) 5. 调用 `fdw_queries.get_service_records_for_task()` 获取服务记录(最多 20 条) 6. 查询 `biz.notes` 获取备注(最多 20 条) 7. 组装 `TaskDetailResponse` ### 组件 5:performance_service(新增) **位置**:`apps/backend/app/services/performance_service.py` **核心函数**: ```python 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 } """ ``` ### 组件 6:Pydantic Schema(新增/扩展) **位置**:`apps/backend/app/schemas/xcx_tasks.py`(扩展)+ `apps/backend/app/schemas/xcx_performance.py`(新增) #### 任务相关 Schema 扩展 ```python 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 ```python 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 ```python 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=1`;`avatarChar`/`avatarColor` 使用 `nameToAvatarColor()` 前端计算 | ## 数据模型 ### FDW 查询模式 所有 FDW 查询遵循统一模式: ```python # 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) ```sql -- ⚠️ 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) ```sql -- 当月 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 ``` 后端计算逻辑: ```python 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) ```sql -- ⚠️ 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: 任务项扩展字段查询 ```sql -- 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) ```sql 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) ```sql -- 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) ```sql -- 新客:本月首次服务的客户 -- ⚠️ 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* 两个非负收入值 `currentIncome` 和 `prevIncome`,计算 `incomeTrend` 和 `incomeTrendDir` 时: - `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` 字段必须是 `manual`、`ai_consumption`、`ai_note` 三者之一 **Validates: Requirements 2.4, 2.5** ### Property 4: courseTypeClass 枚举映射 *For any* 服务记录的原始课程类型值,经过 `map_course_type_class()` 映射后: - 结果必须是 `basic`、`vip`、`tip`、`recharge`、`incentive` 五者之一 - 结果不应包含 `tag-` 前缀 **Validates: Requirements 2.9, 4.4** ### Property 5: 列表分页与排序 *For any* 记录集合和分页参数 `(page, pageSize)`: - 返回的记录数 ≤ `pageSize` - `hasMore` 为 `true` 当且仅当总记录数 > `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* 有效任务,执行 `pin` 后 `isPinned` 应为 `true`,再执行 `unpin` 后 `isPinned` 应恢复为 `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* 亲密度分数 `heartScore`(0-10),`computeRelationLevel(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 错误。`score` 为 `null` 时应正常创建备注(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 查询容错策略 任务列表扩展字段(`lastVisitDays`、`balance`、`aiSuggestion`)采用**优雅降级**策略: ```python # 单个扩展字段查询失败不影响整体响应 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 查询集成、边界条件 ### 属性测试配置 - **测试库**:[Hypothesis](https://hypothesis.readthedocs.io/)(Python,项目已使用) - **测试位置**:`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 | ### 测试执行命令 ```bash # 属性测试(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" ```