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

931 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术设计文档 — 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<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 扩展为例)
```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 }
```
## 组件与接口
### 组件 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 .../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
)
```
### 组件 2xcx_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)
```
### 组件 3fdw_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 用)。"""
```
### 组件 4task_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`
### 组件 5performance_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 }
"""
```
### 组件 6Pydantic 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"
```