34 KiB
技术设计文档 — RNS1.1:任务与绩效接口
概述
RNS1.1 实现助教日常使用频率最高的 4 个核心接口及配套前端适配,覆盖 6 个任务:
- T1-1 扩展 TASK-1:任务列表
performance从 4 字段扩展到 15+ 字段;enrichTask补充lastVisitDays/balance/aiSuggestion - T1-2 实现 TASK-2:任务详情完整版(维客线索、话术、服务记录、AI 分析、备注)
- T1-3 实现 PERF-1:绩效概览(DateGroup 分组、收入档位、新客/常客列表)
- T1-4 实现 PERF-2:绩效明细(按月分页、DateGroup 分组)
- T1-5 pin/unpin:已有端点的响应格式对齐契约
- 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_idJOINdim_member - 契约驱动:响应结构严格遵循
API-contract.md定义
依赖
- RNS1.0 已完成:
ResponseWrapperMiddleware、CamelModel、前端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 }
组件与接口
组件 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
@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
职责:绩效概览和绩效明细两个端点。
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)
组件 3:fdw_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 用)。"""
组件 4:task_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 逻辑:
_get_assistant_id()获取assistant_id- 查询
biz.coach_tasks带分页(LIMIT/OFFSET+COUNT(*)) - 调用
fdw_queries批量获取会员信息、余额、lastVisitDays - 调用
fdw_queries.get_salary_calc()获取绩效概览 - 查询
biz.ai_cache获取 aiSuggestion - 组装
TaskListResponse
get_task_detail 逻辑:
_get_assistant_id()+_verify_task_ownership()权限校验- 查询
biz.coach_tasks基础信息 - 查询
public.member_retention_clue维客线索 - 查询
biz.ai_cache(app5_talking_points→ talkingPoints,app4_analysis→ aiAnalysis) - 调用
fdw_queries.get_service_records_for_task()获取服务记录(最多 20 条) - 查询
biz.notes获取备注(最多 20 条) - 组装
TaskDetailResponse
组件 5:performance_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 }
"""
组件 6:Pydantic 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=1;avatarChar/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 两个非负收入值 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)采用优雅降级策略:
# 单个扩展字段查询失败不影响整体响应
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(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 |
测试执行命令
# 属性测试(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"