1116 lines
42 KiB
Markdown
1116 lines
42 KiB
Markdown
# 设计文档:小程序核心业务模块(miniapp-core-business)
|
||
|
||
## 概述
|
||
|
||
本设计在 P1(miniapp-db-foundation)、P2(etl-dws-miniapp-extensions)、P3(miniapp-auth-system)基础上,实现小程序的核心业务逻辑:
|
||
|
||
1. **助教任务系统**:基于 WBI/NCI/RS 指数自动生成 4 种类型任务,支持状态流转(active → inactive/completed/abandoned)、类型变更追溯、48 小时回访滞留机制
|
||
2. **备注系统**:统一备注 CRUD,支持普通备注/回访备注/放弃原因三种类型,含星星评分(再次服务意愿 + 再来店可能性,各 1-5)
|
||
3. **召回完成检测与备注回溯**:ETL 数据更新后自动检测助教服务记录,匹配活跃任务标记完成,回溯重分类普通备注为回访备注
|
||
4. **触发器调度框架**:统一的 cron/interval/event 三种触发方式调度引擎,驱动任务生成、有效期检查、召回检测、备注回溯
|
||
|
||
**环境变量依赖**:
|
||
|
||
| 环境变量 | 用途 | 来源 |
|
||
|---------|------|------|
|
||
| `APP_DB_DSN` / `DB_HOST` 等 | 业务库连接(`test_zqyy_app`) | 根 `.env` |
|
||
| `PG_DSN` / `ETL_DB_HOST` 等 | ETL 库连接(FDW 读取指数) | 根 `.env` |
|
||
| `JWT_SECRET_KEY` | JWT 签名密钥 | `.env.local` |
|
||
|
||
**整体数据流向**:
|
||
|
||
```
|
||
ETL 库(test_etl_feiqiu) 业务库(test_zqyy_app)
|
||
┌──────────────────────────┐ ┌──────────────────────────────┐
|
||
│ dws.dws_member_winback_ │ │ auth Schema │
|
||
│ index (WBI) │ │ └ user_assistant_binding │
|
||
│ dws.dws_member_newconv_ │ │ │
|
||
│ index (NCI) │ FDW 读取 │ biz Schema │
|
||
│ dws.dws_member_assistant_│ ◄────────────► │ ├ coach_tasks │
|
||
│ relation_index (RS) │ │ ├ coach_task_history │
|
||
│ dwd.dwd_assistant_ │ │ ├ notes │
|
||
│ service_log │ │ └ trigger_jobs │
|
||
│ │ │ │
|
||
│ app Schema (RLS 视图) │ │ fdw_etl Schema (外部表) │
|
||
│ └ v_dws_*, v_dwd_* │ │ └ v_dws_*, v_dwd_* │
|
||
└──────────────────────────┘ │ │
|
||
│ public Schema │
|
||
│ └ member_retention_clue │
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
## 架构
|
||
|
||
### 分层架构
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "小程序端"
|
||
MP["微信小程序<br/>任务列表 / 备注 / 评分"]
|
||
end
|
||
|
||
subgraph "FastAPI 后端(apps/backend/)"
|
||
subgraph "路由层"
|
||
XCX_TASK["routers/xcx_tasks.py<br/>任务列表 / 置顶 / 放弃"]
|
||
XCX_NOTE["routers/xcx_notes.py<br/>备注 CRUD / 星星评分"]
|
||
end
|
||
|
||
subgraph "中间件层"
|
||
PERM_MW["middleware/permission.py<br/>require_approved()"]
|
||
end
|
||
|
||
subgraph "服务层"
|
||
TASK_GEN["services/task_generator.py<br/>任务生成器"]
|
||
TASK_MGR["services/task_manager.py<br/>任务 CRUD + 状态流转"]
|
||
EXPIRY["services/task_expiry.py<br/>有效期轮询"]
|
||
RECALL["services/recall_detector.py<br/>召回完成检测"]
|
||
RECLASS["services/note_reclassifier.py<br/>备注回溯重分类"]
|
||
NOTE_SVC["services/note_service.py<br/>备注 CRUD"]
|
||
TRIGGER["services/trigger_scheduler.py<br/>触发器调度框架"]
|
||
end
|
||
|
||
DB["database.py<br/>get_connection() / get_etl_readonly_connection()"]
|
||
end
|
||
|
||
subgraph "数据库"
|
||
BIZ["biz Schema<br/>coach_tasks / notes / trigger_jobs"]
|
||
AUTH["auth Schema<br/>user_assistant_binding"]
|
||
FDW["fdw_etl Schema<br/>v_dws_* / v_dwd_*(只读)"]
|
||
PUB["public Schema<br/>member_retention_clue"]
|
||
end
|
||
|
||
MP --> XCX_TASK
|
||
MP --> XCX_NOTE
|
||
XCX_TASK --> PERM_MW
|
||
XCX_NOTE --> PERM_MW
|
||
PERM_MW --> TASK_MGR
|
||
PERM_MW --> NOTE_SVC
|
||
TRIGGER --> TASK_GEN
|
||
TRIGGER --> EXPIRY
|
||
TRIGGER --> RECALL
|
||
RECALL --> RECLASS
|
||
TASK_GEN --> DB
|
||
TASK_MGR --> DB
|
||
EXPIRY --> DB
|
||
RECALL --> DB
|
||
RECLASS --> DB
|
||
NOTE_SVC --> DB
|
||
DB --> BIZ
|
||
DB --> AUTH
|
||
DB --> FDW
|
||
DB --> PUB
|
||
```
|
||
|
||
### 触发器调度流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant TS as TriggerScheduler
|
||
participant DB as PostgreSQL
|
||
participant TG as TaskGenerator
|
||
participant EC as TaskExpiryChecker
|
||
participant RD as RecallDetector
|
||
participant NR as NoteReclassifier
|
||
|
||
Note over TS: 每 30 秒轮询 trigger_jobs
|
||
|
||
TS->>DB: SELECT * FROM biz.trigger_jobs<br/>WHERE status='enabled' AND next_run_at <= NOW()
|
||
|
||
alt cron: task_generator(每日 04:00)
|
||
TS->>TG: run()
|
||
TG->>DB: 读取 FDW 指数数据(WBI/NCI/RS)
|
||
TG->>DB: 读取 auth.user_assistant_binding
|
||
TG->>DB: INSERT/UPDATE biz.coach_tasks
|
||
TG->>DB: INSERT biz.coach_task_history
|
||
TG->>DB: UPDATE trigger_jobs.last_run_at
|
||
end
|
||
|
||
alt interval: task_expiry_check(每小时)
|
||
TS->>EC: run()
|
||
EC->>DB: SELECT coach_tasks WHERE expires_at < NOW()
|
||
EC->>DB: UPDATE status='inactive'
|
||
EC->>DB: UPDATE trigger_jobs.last_run_at
|
||
end
|
||
|
||
alt event: recall_completion_check(ETL 更新后)
|
||
TS->>RD: run()
|
||
RD->>DB: 读取 FDW dwd_assistant_service_log
|
||
RD->>DB: 匹配 active 任务 → completed
|
||
RD->>DB: fire_event('recall_completed', payload)
|
||
end
|
||
|
||
alt event: note_reclassify_backfill(召回完成时)
|
||
TS->>NR: run(payload)
|
||
NR->>DB: 查找 normal 备注 → 更新为 follow_up
|
||
NR->>DB: 触发 AI 应用 6 接口(P5 实现)
|
||
end
|
||
```
|
||
|
||
### 任务状态机
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> active: 任务生成器创建
|
||
active --> inactive: 类型变更(旧任务关闭)
|
||
active --> inactive: expires_at 到期(轮询检查)
|
||
active --> completed: 召回完成检测
|
||
active --> abandoned: 助教放弃(需填原因)
|
||
abandoned --> active: 助教取消放弃
|
||
active --> active: 置顶/取消置顶(is_pinned 变更)
|
||
|
||
note right of inactive: expires_at 机制:\n生成时 NULL(无限期)\n条件不满足时填充 created_at+48h\n轮询检查超期后标记 inactive
|
||
```
|
||
|
||
|
||
## 组件与接口
|
||
|
||
### 组件 1:触发器调度框架(services/trigger_scheduler.py)
|
||
|
||
**职责**:统一管理 cron/interval/event 三种触发方式,驱动后台任务执行。
|
||
|
||
**设计决策**:
|
||
- 复用现有 `Scheduler` 的轮询模式(每 30 秒检查 `trigger_jobs` 表),但 `trigger_jobs` 是独立于 `scheduled_tasks` 的新表,专门服务于业务触发器
|
||
- cron/interval 类型通过轮询 `next_run_at` 触发;event 类型通过 `fire_event()` 方法直接触发
|
||
- 每个 job 对应一个 Python 可调用对象,通过 `job_type` 映射到具体的服务方法
|
||
|
||
```python
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Callable
|
||
|
||
from app.database import get_connection
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# job_type → 执行函数的注册表
|
||
_JOB_REGISTRY: dict[str, Callable] = {}
|
||
|
||
|
||
def register_job(job_type: str, handler: Callable) -> None:
|
||
"""注册 job_type 对应的执行函数。"""
|
||
_JOB_REGISTRY[job_type] = handler
|
||
|
||
|
||
def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||
"""
|
||
触发事件驱动型任务。
|
||
|
||
查找 trigger_condition='event' 且 trigger_config.event_name 匹配的 enabled job,
|
||
立即执行对应的 handler。
|
||
|
||
返回: 执行的 job 数量
|
||
"""
|
||
conn = get_connection()
|
||
executed = 0
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT id, job_type, job_name
|
||
FROM biz.trigger_jobs
|
||
WHERE status = 'enabled'
|
||
AND trigger_condition = 'event'
|
||
AND trigger_config->>'event_name' = %s
|
||
""",
|
||
(event_name,),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
for job_id, job_type, job_name in rows:
|
||
handler = _JOB_REGISTRY.get(job_type)
|
||
if not handler:
|
||
logger.warning("未注册的 job_type: %s (job_name=%s)", job_type, job_name)
|
||
continue
|
||
try:
|
||
handler(payload=payload)
|
||
executed += 1
|
||
# 更新 last_run_at
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||
(job_id,),
|
||
)
|
||
conn.commit()
|
||
except Exception:
|
||
logger.exception("触发器 %s 执行失败", job_name)
|
||
conn.rollback()
|
||
|
||
finally:
|
||
conn.close()
|
||
return executed
|
||
|
||
|
||
def check_scheduled_jobs() -> int:
|
||
"""
|
||
检查 cron/interval 类型的到期 job 并执行。
|
||
|
||
由 Scheduler 后台循环调用。
|
||
返回: 执行的 job 数量
|
||
"""
|
||
conn = get_connection()
|
||
executed = 0
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT id, job_type, job_name, trigger_condition, trigger_config
|
||
FROM biz.trigger_jobs
|
||
WHERE status = 'enabled'
|
||
AND trigger_condition IN ('cron', 'interval')
|
||
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||
ORDER BY next_run_at ASC NULLS FIRST
|
||
""",
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
for job_id, job_type, job_name, trigger_condition, trigger_config in rows:
|
||
handler = _JOB_REGISTRY.get(job_type)
|
||
if not handler:
|
||
logger.warning("未注册的 job_type: %s", job_type)
|
||
continue
|
||
try:
|
||
handler()
|
||
executed += 1
|
||
# 计算 next_run_at 并更新
|
||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
UPDATE biz.trigger_jobs
|
||
SET last_run_at = NOW(), next_run_at = %s
|
||
WHERE id = %s
|
||
""",
|
||
(next_run, job_id),
|
||
)
|
||
conn.commit()
|
||
except Exception:
|
||
logger.exception("触发器 %s 执行失败", job_name)
|
||
conn.rollback()
|
||
|
||
finally:
|
||
conn.close()
|
||
return executed
|
||
|
||
|
||
def _calculate_next_run(trigger_condition: str, trigger_config: dict) -> datetime | None:
|
||
"""根据触发条件和配置计算下次运行时间。"""
|
||
now = datetime.now(timezone.utc)
|
||
if trigger_condition == "interval":
|
||
seconds = trigger_config.get("interval_seconds", 3600)
|
||
from datetime import timedelta
|
||
return now + timedelta(seconds=seconds)
|
||
elif trigger_condition == "cron":
|
||
# 复用现有 scheduler._parse_simple_cron
|
||
from app.services.scheduler import _parse_simple_cron
|
||
return _parse_simple_cron(trigger_config.get("cron_expression", "0 4 * * *"), now)
|
||
return None # event 类型无 next_run_at
|
||
```
|
||
|
||
### 组件 2:任务生成器(services/task_generator.py)
|
||
|
||
**职责**:每日 4:00 运行,基于 WBI/NCI/RS 指数为每个助教生成/更新任务。
|
||
|
||
**核心逻辑**(纯函数,可独立测试):
|
||
|
||
```python
|
||
from decimal import Decimal
|
||
from dataclasses import dataclass
|
||
from enum import IntEnum
|
||
|
||
|
||
class TaskPriority(IntEnum):
|
||
"""任务类型优先级,数值越小优先级越高。"""
|
||
HIGH_PRIORITY_RECALL = 0
|
||
PRIORITY_RECALL = 0
|
||
FOLLOW_UP_VISIT = 1
|
||
RELATIONSHIP_BUILDING = 2
|
||
|
||
|
||
TASK_TYPE_PRIORITY = {
|
||
"high_priority_recall": TaskPriority.HIGH_PRIORITY_RECALL,
|
||
"priority_recall": TaskPriority.PRIORITY_RECALL,
|
||
"follow_up_visit": TaskPriority.FOLLOW_UP_VISIT,
|
||
"relationship_building": TaskPriority.RELATIONSHIP_BUILDING,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class IndexData:
|
||
"""某客户-助教对的指数数据。"""
|
||
site_id: int
|
||
assistant_id: int
|
||
member_id: int
|
||
wbi: Decimal # 流失回赢指数
|
||
nci: Decimal # 新客转化指数
|
||
rs: Decimal # 关系强度指数
|
||
has_active_recall: bool # 是否有活跃召回任务
|
||
has_follow_up_note: bool # 召回完成后是否有回访备注
|
||
|
||
|
||
def determine_task_type(index_data: IndexData) -> str | None:
|
||
"""
|
||
根据指数数据确定应生成的任务类型。
|
||
|
||
优先级规则(高 → 低):
|
||
1. max(WBI, NCI) > 7 → high_priority_recall
|
||
2. max(WBI, NCI) > 5 → priority_recall
|
||
3. 完成召回后无回访备注 → follow_up_visit
|
||
4. RS < 6 → relationship_building
|
||
5. 不满足任何条件 → None(不生成任务)
|
||
|
||
返回: task_type 字符串或 None
|
||
"""
|
||
priority_score = max(index_data.wbi, index_data.nci)
|
||
|
||
if priority_score > 7:
|
||
return "high_priority_recall"
|
||
if priority_score > 5:
|
||
return "priority_recall"
|
||
if index_data.has_active_recall is False and index_data.has_follow_up_note is False:
|
||
# 完成召回后无回访备注 → 回访任务
|
||
# 注意:此条件需要外部传入是否"已完成召回"的状态
|
||
pass
|
||
if index_data.rs < 6:
|
||
return "relationship_building"
|
||
return None
|
||
|
||
|
||
def should_replace_task(
|
||
existing_type: str, new_type: str
|
||
) -> bool:
|
||
"""
|
||
判断新任务类型是否应替换现有任务类型。
|
||
|
||
规则:高优先级任务覆盖低优先级任务。
|
||
同优先级(如 high_priority_recall 和 priority_recall 都是 0)时,
|
||
类型不同则替换。
|
||
"""
|
||
if existing_type == new_type:
|
||
return False
|
||
return True # 类型不同即替换
|
||
```
|
||
|
||
**执行流程**:
|
||
|
||
```python
|
||
def run(self) -> dict:
|
||
"""
|
||
任务生成器主流程。
|
||
|
||
1. 通过 auth.user_assistant_binding 获取所有已绑定助教
|
||
2. 对每个助教,通过 FDW 读取 WBI/NCI/RS 指数
|
||
3. 调用 determine_task_type() 确定任务类型
|
||
4. 检查是否已存在相同 (site_id, assistant_id, member_id, task_type) 的 active 任务
|
||
- 存在 → 跳过
|
||
5. 检查是否已存在相同 (site_id, assistant_id, member_id) 但不同 task_type 的 active 任务
|
||
- 存在 → 关闭旧任务 + 创建新任务 + 记录 history
|
||
6. 不存在 → 创建新任务
|
||
7. 更新 trigger_jobs 时间戳
|
||
|
||
返回: {"created": int, "replaced": int, "skipped": int}
|
||
"""
|
||
...
|
||
```
|
||
|
||
### 组件 3:任务管理服务(services/task_manager.py)
|
||
|
||
**职责**:任务 CRUD、置顶、放弃、取消操作。
|
||
|
||
```python
|
||
async def get_task_list(
|
||
user_id: int, site_id: int
|
||
) -> list[dict]:
|
||
"""
|
||
获取助教的活跃任务列表。
|
||
|
||
1. 通过 auth.user_assistant_binding 获取 assistant_id
|
||
2. 查询 biz.coach_tasks WHERE site_id=? AND assistant_id=? AND status='active'
|
||
3. JOIN fdw_etl.v_dim_member 获取客户基本信息
|
||
4. JOIN fdw_etl.v_dws_member_assistant_relation_index 获取 RS 指数
|
||
5. 计算爱心 icon 档位:💖>8.5 / 🧡>7 / 💛>5 / 💙<5
|
||
6. 排序:is_pinned DESC, priority_score DESC, created_at ASC
|
||
|
||
注意:FDW 查询需要 SET LOCAL app.current_site_id。
|
||
"""
|
||
...
|
||
|
||
|
||
async def pin_task(task_id: int, user_id: int, site_id: int) -> dict:
|
||
"""置顶任务。验证任务归属后设置 is_pinned=TRUE,记录 history。"""
|
||
...
|
||
|
||
|
||
async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict:
|
||
"""取消置顶。验证任务归属后设置 is_pinned=FALSE。"""
|
||
...
|
||
|
||
|
||
async def abandon_task(
|
||
task_id: int, user_id: int, site_id: int, reason: str
|
||
) -> dict:
|
||
"""
|
||
放弃任务。
|
||
|
||
1. 验证 reason 非空(空则 422)
|
||
2. 验证任务归属和 status='active'
|
||
3. 设置 status='abandoned', abandon_reason=reason
|
||
4. 记录 coach_task_history
|
||
"""
|
||
...
|
||
|
||
|
||
async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||
"""
|
||
取消放弃。
|
||
|
||
1. 验证任务归属和 status='abandoned'
|
||
2. 恢复 status='active', 清空 abandon_reason
|
||
3. 记录 coach_task_history
|
||
"""
|
||
...
|
||
|
||
|
||
def _record_history(
|
||
conn, task_id: int, action: str,
|
||
old_status: str = None, new_status: str = None,
|
||
old_task_type: str = None, new_task_type: str = None,
|
||
detail: dict = None,
|
||
) -> None:
|
||
"""在 coach_task_history 中记录变更。"""
|
||
...
|
||
```
|
||
|
||
### 组件 4:有效期轮询器(services/task_expiry.py)
|
||
|
||
**职责**:每小时检查 `expires_at` 不为 NULL 且已过期的任务,标记为 inactive。
|
||
|
||
```python
|
||
def run() -> dict:
|
||
"""
|
||
有效期轮询主流程。
|
||
|
||
1. SELECT id FROM biz.coach_tasks
|
||
WHERE expires_at IS NOT NULL AND expires_at < NOW() AND status = 'active'
|
||
2. UPDATE status = 'inactive'
|
||
3. INSERT coach_task_history (action='expired')
|
||
|
||
返回: {"expired_count": int}
|
||
"""
|
||
...
|
||
```
|
||
|
||
### 组件 5:召回完成检测器(services/recall_detector.py)
|
||
|
||
**职责**:ETL 数据更新后,检测助教服务记录并匹配活跃任务。
|
||
|
||
```python
|
||
def run(payload: dict | None = None) -> dict:
|
||
"""
|
||
召回完成检测主流程。
|
||
|
||
1. 通过 FDW 读取 fdw_etl.v_dwd_assistant_service_log 中的新增服务记录
|
||
(基于 last_run_at 过滤增量)
|
||
2. 对每条服务记录,查找 biz.coach_tasks 中匹配的
|
||
(site_id, assistant_id, member_id) 且 status='active' 的任务
|
||
3. 将匹配任务标记为 completed:
|
||
- status = 'completed'
|
||
- completed_at = 服务时间
|
||
- completed_task_type = 当前 task_type(快照)
|
||
4. 记录 coach_task_history
|
||
5. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
|
||
|
||
返回: {"completed_count": int}
|
||
"""
|
||
...
|
||
```
|
||
|
||
### 组件 6:备注回溯重分类器(services/note_reclassifier.py)
|
||
|
||
**职责**:召回完成后,回溯检查是否有普通备注需重分类为回访备注。
|
||
|
||
```python
|
||
def run(payload: dict | None = None) -> dict:
|
||
"""
|
||
备注回溯主流程。
|
||
|
||
payload 包含: {site_id, assistant_id, member_id, service_time}
|
||
|
||
1. 查找 biz.notes 中该 (site_id, target_type='member', target_id=member_id)
|
||
在 service_time 之后提交的第一条 type='normal' 的备注
|
||
2. 将该备注 type 从 'normal' 更新为 'follow_up'
|
||
3. 触发 AI 应用 6 接口(P5 实现,本 SPEC 仅定义触发接口):
|
||
- 调用 ai_analyze_note(note_id) → 返回 ai_score
|
||
4. 若 ai_score >= 6:
|
||
- 生成 follow_up_visit 任务,status='completed'(回溯完成)
|
||
5. 若 ai_score < 6:
|
||
- 生成 follow_up_visit 任务,status='active'(需助教重新备注)
|
||
|
||
返回: {"reclassified_count": int, "tasks_created": int}
|
||
"""
|
||
...
|
||
|
||
|
||
def ai_analyze_note(note_id: int) -> int | None:
|
||
"""
|
||
AI 应用 6 备注分析接口(占位)。
|
||
|
||
P5 AI 集成层实现后替换此占位函数。
|
||
当前返回 None 表示 AI 未就绪,跳过评分逻辑。
|
||
"""
|
||
return None
|
||
```
|
||
|
||
### 组件 7:备注服务(services/note_service.py)
|
||
|
||
**职责**:备注 CRUD、星星评分存储与读取。
|
||
|
||
```python
|
||
async def create_note(
|
||
site_id: int,
|
||
user_id: int,
|
||
target_type: str, # 'member'
|
||
target_id: int, # member_id
|
||
content: str,
|
||
task_id: int | None = None,
|
||
rating_service_willingness: int | None = None, # 1-5
|
||
rating_revisit_likelihood: int | None = None, # 1-5
|
||
) -> dict:
|
||
"""
|
||
创建备注。
|
||
|
||
1. 验证评分范围(1-5 或 NULL),不合法则 422
|
||
2. 确定 note type:
|
||
- 若 task_id 关联的任务 task_type='follow_up_visit' → type='follow_up'
|
||
- 否则 → type='normal'
|
||
3. INSERT INTO biz.notes
|
||
4. 若 type='follow_up':
|
||
- 触发 AI 应用 6 分析(P5 实现)
|
||
- 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||
5. 返回创建的备注记录
|
||
|
||
注意:星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
|
||
"""
|
||
...
|
||
|
||
|
||
async def get_notes(
|
||
site_id: int, target_type: str, target_id: int
|
||
) -> list[dict]:
|
||
"""
|
||
查询某目标的备注列表。
|
||
|
||
按 created_at DESC 排序,包含星星评分和 AI 评分。
|
||
"""
|
||
...
|
||
|
||
|
||
async def delete_note(note_id: int, user_id: int, site_id: int) -> dict:
|
||
"""
|
||
删除备注。
|
||
|
||
验证备注归属后执行硬删除。
|
||
"""
|
||
...
|
||
```
|
||
|
||
### 组件 8:路由端点
|
||
|
||
#### 8.1 小程序任务路由(routers/xcx_tasks.py)
|
||
|
||
| 方法 | 路径 | 说明 | 认证要求 |
|
||
|------|------|------|---------|
|
||
| GET | `/api/xcx/tasks` | 获取任务列表 | JWT(approved) |
|
||
| POST | `/api/xcx/tasks/{id}/pin` | 置顶任务 | JWT(approved) |
|
||
| POST | `/api/xcx/tasks/{id}/unpin` | 取消置顶 | JWT(approved) |
|
||
| POST | `/api/xcx/tasks/{id}/abandon` | 放弃任务 | JWT(approved) |
|
||
| POST | `/api/xcx/tasks/{id}/cancel-abandon` | 取消放弃 | JWT(approved) |
|
||
|
||
#### 8.2 小程序备注路由(routers/xcx_notes.py)
|
||
|
||
| 方法 | 路径 | 说明 | 认证要求 |
|
||
|------|------|------|---------|
|
||
| POST | `/api/xcx/notes` | 创建备注 | JWT(approved) |
|
||
| GET | `/api/xcx/notes` | 查询备注列表(query: target_type, target_id) | JWT(approved) |
|
||
| DELETE | `/api/xcx/notes/{id}` | 删除备注 | JWT(approved) |
|
||
|
||
### 组件 9:Pydantic 模型
|
||
|
||
```python
|
||
# schemas/xcx_tasks.py
|
||
from pydantic import BaseModel, Field
|
||
|
||
class TaskListItem(BaseModel):
|
||
id: int
|
||
task_type: str
|
||
status: str
|
||
priority_score: float | None
|
||
is_pinned: bool
|
||
expires_at: str | None
|
||
created_at: str
|
||
# 客户信息(FDW 读取)
|
||
member_id: int
|
||
member_name: str | None
|
||
member_phone: str | None
|
||
# RS 指数 + 爱心 icon
|
||
rs_score: float | None
|
||
heart_icon: str # 💖 / 🧡 / 💛 / 💙
|
||
|
||
class AbandonRequest(BaseModel):
|
||
reason: str = Field(..., min_length=1, description="放弃原因(必填)")
|
||
|
||
|
||
# schemas/xcx_notes.py
|
||
from pydantic import BaseModel, Field
|
||
|
||
class NoteCreateRequest(BaseModel):
|
||
target_type: str = Field(default="member")
|
||
target_id: int
|
||
content: str = Field(..., min_length=1)
|
||
task_id: int | None = None
|
||
rating_service_willingness: int | None = Field(None, ge=1, le=5)
|
||
rating_revisit_likelihood: int | None = Field(None, ge=1, le=5)
|
||
|
||
class NoteOut(BaseModel):
|
||
id: int
|
||
type: str
|
||
content: str
|
||
rating_service_willingness: int | None
|
||
rating_revisit_likelihood: int | None
|
||
ai_score: int | None
|
||
ai_analysis: str | None
|
||
task_id: int | None
|
||
created_at: str
|
||
updated_at: str
|
||
```
|
||
|
||
|
||
## 数据模型
|
||
|
||
### ER 图
|
||
|
||
```mermaid
|
||
erDiagram
|
||
coach_tasks {
|
||
bigserial id PK
|
||
bigint site_id "NOT NULL"
|
||
bigint assistant_id "NOT NULL"
|
||
bigint member_id "NOT NULL"
|
||
varchar task_type "NOT NULL (4种)"
|
||
varchar status "NOT NULL DEFAULT 'active'"
|
||
numeric_5_2 priority_score "max(WBI,NCI) 快照"
|
||
timestamptz expires_at "可空,有效期"
|
||
boolean is_pinned "DEFAULT FALSE"
|
||
text abandon_reason "可空"
|
||
timestamptz completed_at "可空"
|
||
varchar completed_task_type "可空,完成时类型快照"
|
||
bigint parent_task_id "可空,FK → coach_tasks"
|
||
timestamptz created_at "DEFAULT NOW()"
|
||
timestamptz updated_at "DEFAULT NOW()"
|
||
}
|
||
|
||
coach_task_history {
|
||
bigserial id PK
|
||
bigint task_id "FK → coach_tasks"
|
||
varchar action "NOT NULL"
|
||
varchar old_status "可空"
|
||
varchar new_status "可空"
|
||
varchar old_task_type "可空"
|
||
varchar new_task_type "可空"
|
||
jsonb detail "可空"
|
||
timestamptz created_at "DEFAULT NOW()"
|
||
}
|
||
|
||
notes {
|
||
bigserial id PK
|
||
bigint site_id "NOT NULL"
|
||
integer user_id "NOT NULL"
|
||
varchar target_type "NOT NULL"
|
||
bigint target_id "NOT NULL"
|
||
varchar type "NOT NULL DEFAULT 'normal'"
|
||
text content "NOT NULL"
|
||
smallint rating_service_willingness "可空 CHECK 1-5"
|
||
smallint rating_revisit_likelihood "可空 CHECK 1-5"
|
||
bigint task_id "可空 FK → coach_tasks"
|
||
smallint ai_score "可空"
|
||
text ai_analysis "可空"
|
||
timestamptz created_at "DEFAULT NOW()"
|
||
timestamptz updated_at "DEFAULT NOW()"
|
||
}
|
||
|
||
trigger_jobs {
|
||
serial id PK
|
||
varchar job_type "NOT NULL"
|
||
varchar job_name "NOT NULL UNIQUE"
|
||
varchar trigger_condition "NOT NULL (cron/interval/event)"
|
||
jsonb trigger_config "NOT NULL"
|
||
timestamptz last_run_at "可空"
|
||
timestamptz next_run_at "可空"
|
||
varchar status "NOT NULL DEFAULT 'enabled'"
|
||
timestamptz created_at "DEFAULT NOW()"
|
||
}
|
||
|
||
coach_tasks ||--o{ coach_task_history : "变更记录"
|
||
coach_tasks ||--o{ notes : "关联备注"
|
||
coach_tasks ||--o| coach_tasks : "parent_task_id"
|
||
```
|
||
|
||
### 表 DDL
|
||
|
||
所有表在 `biz` Schema 下,迁移脚本位于 `db/zqyy_app/migrations/`。
|
||
|
||
#### biz.coach_tasks
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS biz.coach_tasks (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
site_id BIGINT NOT NULL,
|
||
assistant_id BIGINT NOT NULL,
|
||
member_id BIGINT NOT NULL,
|
||
task_type VARCHAR(50) NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||
priority_score NUMERIC(5,2),
|
||
expires_at TIMESTAMPTZ,
|
||
is_pinned BOOLEAN DEFAULT FALSE,
|
||
abandon_reason TEXT,
|
||
completed_at TIMESTAMPTZ,
|
||
completed_task_type VARCHAR(50),
|
||
parent_task_id BIGINT REFERENCES biz.coach_tasks(id),
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
-- 部分唯一索引:同一 (site_id, assistant_id, member_id, task_type) 下 active 任务最多一条
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_coach_tasks_site_assistant_member_type
|
||
ON biz.coach_tasks (site_id, assistant_id, member_id, task_type)
|
||
WHERE status = 'active';
|
||
|
||
-- 助教任务列表查询索引
|
||
CREATE INDEX IF NOT EXISTS idx_coach_tasks_assistant_status
|
||
ON biz.coach_tasks (site_id, assistant_id, status);
|
||
```
|
||
|
||
#### biz.coach_task_history
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS biz.coach_task_history (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
task_id BIGINT NOT NULL REFERENCES biz.coach_tasks(id),
|
||
action VARCHAR(50) NOT NULL,
|
||
old_status VARCHAR(20),
|
||
new_status VARCHAR(20),
|
||
old_task_type VARCHAR(50),
|
||
new_task_type VARCHAR(50),
|
||
detail JSONB,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
```
|
||
|
||
#### biz.notes
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS biz.notes (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
site_id BIGINT NOT NULL,
|
||
user_id INTEGER NOT NULL,
|
||
target_type VARCHAR(50) NOT NULL,
|
||
target_id BIGINT NOT NULL,
|
||
type VARCHAR(20) NOT NULL DEFAULT 'normal',
|
||
content TEXT NOT NULL,
|
||
rating_service_willingness SMALLINT CHECK (rating_service_willingness BETWEEN 1 AND 5),
|
||
rating_revisit_likelihood SMALLINT CHECK (rating_revisit_likelihood BETWEEN 1 AND 5),
|
||
task_id BIGINT REFERENCES biz.coach_tasks(id),
|
||
ai_score SMALLINT,
|
||
ai_analysis TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
-- 按目标查询备注索引
|
||
CREATE INDEX IF NOT EXISTS idx_notes_target
|
||
ON biz.notes (site_id, target_type, target_id);
|
||
```
|
||
|
||
#### biz.trigger_jobs
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS biz.trigger_jobs (
|
||
id SERIAL PRIMARY KEY,
|
||
job_type VARCHAR(100) NOT NULL,
|
||
job_name VARCHAR(100) NOT NULL UNIQUE,
|
||
trigger_condition VARCHAR(20) NOT NULL,
|
||
trigger_config JSONB NOT NULL,
|
||
last_run_at TIMESTAMPTZ,
|
||
next_run_at TIMESTAMPTZ,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'enabled',
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
```
|
||
|
||
### 种子数据
|
||
|
||
```sql
|
||
-- 预置触发器配置
|
||
INSERT INTO biz.trigger_jobs (job_type, job_name, trigger_condition, trigger_config, next_run_at)
|
||
VALUES
|
||
('task_generator', 'task_generator', 'cron',
|
||
'{"cron_expression": "0 4 * * *"}',
|
||
(CURRENT_DATE + 1) + INTERVAL '4 hours'),
|
||
|
||
('task_expiry_check', 'task_expiry_check', 'interval',
|
||
'{"interval_seconds": 3600}',
|
||
NOW() + INTERVAL '1 hour'),
|
||
|
||
('recall_completion_check', 'recall_completion_check', 'event',
|
||
'{"event_name": "etl_data_updated"}',
|
||
NULL),
|
||
|
||
('note_reclassify_backfill', 'note_reclassify_backfill', 'event',
|
||
'{"event_name": "recall_completed"}',
|
||
NULL)
|
||
ON CONFLICT (job_name) DO NOTHING;
|
||
```
|
||
|
||
### 迁移脚本清单
|
||
|
||
| 序号 | 文件名 | 内容 |
|
||
|------|--------|------|
|
||
| 1 | `YYYY-MM-DD__p4_create_biz_tables.sql` | 创建 coach_tasks + coach_task_history + notes + trigger_jobs 表及索引 |
|
||
| 2 | `YYYY-MM-DD__p4_seed_trigger_jobs.sql` | 预置 4 条触发器配置种子数据 |
|
||
|
||
### FDW 数据依赖
|
||
|
||
任务生成器和召回检测器通过 `fdw_etl` Schema 读取以下外部表(P1 已建立映射):
|
||
|
||
| 外部表 | 用途 |
|
||
|--------|------|
|
||
| `fdw_etl.v_dws_member_winback_index` | WBI 流失回赢指数 |
|
||
| `fdw_etl.v_dws_member_newconv_index` | NCI 新客转化指数 |
|
||
| `fdw_etl.v_dws_member_assistant_relation_index` | RS 关系强度指数 |
|
||
| `fdw_etl.v_dwd_assistant_service_log` | 助教服务记录(召回检测) |
|
||
| `fdw_etl.v_dim_member` | 客户基本信息(任务列表展示) |
|
||
|
||
**FDW 查询模式**:所有 FDW 查询通过业务库连接(`get_connection()`),在事务中 `SET LOCAL app.current_site_id = %s` 设置 RLS 隔离后查询 `fdw_etl.*` 外部表。
|
||
|
||
|
||
## 正确性属性(Correctness Properties)
|
||
|
||
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||
|
||
### Property 1:任务类型确定正确性
|
||
|
||
*For any* 指数数据组合(WBI ≥ 0, NCI ≥ 0, RS ≥ 0),`determine_task_type()` 应满足:
|
||
- 当 `max(WBI, NCI) > 7` 时返回 `high_priority_recall`
|
||
- 当 `5 < max(WBI, NCI) ≤ 7` 时返回 `priority_recall`
|
||
- 当 `RS < 6` 且不满足上述条件时返回 `relationship_building`
|
||
- 且 `priority_score` 始终等于 `max(WBI, NCI)`
|
||
|
||
**Validates: Requirements 3.1, 3.2, 3.3, 3.5**
|
||
|
||
### Property 2:活跃任务唯一性不变量
|
||
|
||
*For any* `(site_id, assistant_id, member_id, task_type)` 组合,`biz.coach_tasks` 中 `status = 'active'` 的记录最多只有一条。任何创建或状态变更操作都不应违反此约束。
|
||
|
||
**Validates: Requirements 1.5, 3.6, 14.1**
|
||
|
||
### Property 3:任务类型变更状态机
|
||
|
||
*For any* 已存在 `(site_id, assistant_id, member_id)` 的 active 任务,当新任务类型与现有类型不同时,执行类型变更后:旧任务 `status` 变为 `inactive`,新任务 `status` 为 `active`,且 `coach_task_history` 中存在对应的变更记录(包含 `old_task_type` 和 `new_task_type`)。
|
||
|
||
**Validates: Requirements 3.7, 5.1, 5.4, 14.2**
|
||
|
||
### Property 4:48 小时滞留机制
|
||
|
||
*For any* `follow_up_visit` 类型任务:
|
||
- 生成时 `expires_at` 为 NULL,`status` 为 `active`
|
||
- 当触发条件不再满足时,`expires_at` 被填充为 `created_at + 48 小时`
|
||
- 当 `expires_at` 不为 NULL 且当前时间超过 `expires_at` 时,轮询后 `status` 变为 `inactive`
|
||
- 当新 `follow_up_visit` 任务顶替有 `expires_at` 的旧任务时,旧任务变为 `inactive`,新任务 `expires_at` 为 NULL
|
||
|
||
**Validates: Requirements 4.1, 4.2, 4.3, 4.4, 14.3**
|
||
|
||
### Property 5:放弃与取消放弃往返
|
||
|
||
*For any* `status = 'active'` 的任务和非空的放弃原因字符串:
|
||
- 放弃操作后 `status = 'abandoned'` 且 `abandon_reason` 等于提供的原因
|
||
- 取消放弃后 `status = 'active'` 且 `abandon_reason` 为空
|
||
- 放弃时若 `abandon_reason` 为空字符串或纯空白,应返回 422 错误且任务状态不变
|
||
|
||
**Validates: Requirements 8.4, 8.6, 8.7, 14.4**
|
||
|
||
### Property 6:召回完成检测与类型快照
|
||
|
||
*For any* `(site_id, assistant_id, member_id)` 组合,当检测到助教为该客户提供了服务时,所有匹配的 `status = 'active'` 任务应变为 `completed`,且 `completed_at` 记录服务时间,`completed_task_type` 记录完成时的 `task_type` 值(快照不变量)。
|
||
|
||
**Validates: Requirements 6.2, 6.3, 14.6**
|
||
|
||
### Property 7:备注回溯重分类
|
||
|
||
*For any* 召回完成事件(包含 site_id, assistant_id, member_id, service_time),若在 service_time 之后存在该客户的 `type = 'normal'` 备注,回溯操作后该备注的 `type` 应变为 `follow_up`。若不存在符合条件的备注,则不做任何修改。
|
||
|
||
**Validates: Requirements 7.1, 7.2, 14.7**
|
||
|
||
### Property 8:备注类型自动设置
|
||
|
||
*For any* 备注创建操作:
|
||
- 若关联的 `task_id` 对应任务的 `task_type = 'follow_up_visit'`,则备注 `type` 应为 `follow_up`
|
||
- 若关联的 `task_id` 对应任务的 `task_type` 不是 `follow_up_visit`,则备注 `type` 应为 `normal`
|
||
- 若未关联 `task_id`,则备注 `type` 应为 `normal`
|
||
|
||
**Validates: Requirements 9.2, 9.3**
|
||
|
||
### Property 9:星星评分范围约束
|
||
|
||
*For any* 备注创建操作,`rating_service_willingness` 和 `rating_revisit_likelihood` 的值应为 NULL 或在 1-5 范围内。范围外的值应被拒绝(422 错误),且备注不被创建。
|
||
|
||
**Validates: Requirements 9.8, 14.5**
|
||
|
||
### Property 10:任务列表排序正确性
|
||
|
||
*For any* 助教的活跃任务列表,返回结果应按 `is_pinned DESC, priority_score DESC, created_at ASC` 排序。即:置顶任务在前,同置顶状态下高优先级在前,同优先级下先创建的在前。
|
||
|
||
**Validates: Requirements 8.1**
|
||
|
||
### Property 11:爱心 icon 档位计算
|
||
|
||
*For any* RS 指数值,爱心 icon 应满足:
|
||
- RS > 8.5 → 💖
|
||
- 7 < RS ≤ 8.5 → 🧡
|
||
- 5 < RS ≤ 7 → 💛
|
||
- RS ≤ 5 → 💙
|
||
|
||
**Validates: Requirements 8.2**
|
||
|
||
### Property 12:触发器 next_run_at 计算
|
||
|
||
*For any* cron 类型触发器配置和当前时间,计算的 `next_run_at` 应大于当前时间。*For any* interval 类型触发器配置(interval_seconds > 0),计算的 `next_run_at` 应等于当前时间加上 interval_seconds 秒。
|
||
|
||
**Validates: Requirements 10.1, 10.2**
|
||
|
||
### Property 13:迁移脚本幂等性
|
||
|
||
*For any* 本次新增的迁移脚本(DDL + 种子数据),连续执行两次的结果应与执行一次相同——第二次执行不应产生错误,且数据库状态不变。
|
||
|
||
**Validates: Requirements 1.8, 2.5, 11.4, 11.5**
|
||
|
||
### Property 14:AI 评分驱动的任务完成判定
|
||
|
||
*For any* `type = 'follow_up'` 的备注和关联的 `follow_up_visit` 任务:
|
||
- 当 AI 应用 6 返回评分 ≥ 6 且任务 `status = 'active'` 时,任务应标记为 `completed`
|
||
- 当 AI 应用 6 返回评分 < 6 时,任务 `status` 保持 `active`
|
||
|
||
**Validates: Requirements 7.4, 7.5, 9.5**
|
||
|
||
### Property 15:状态变更历史完整性
|
||
|
||
*For any* 任务状态变更操作(类型变更、放弃、取消放弃、完成、过期),`coach_task_history` 中应存在对应记录,包含正确的 `action`、`old_status`、`new_status` 字段。
|
||
|
||
**Validates: Requirements 5.4, 8.3**
|
||
|
||
|
||
## 错误处理
|
||
|
||
### API 错误码规范
|
||
|
||
| HTTP 状态码 | 场景 | 响应体 |
|
||
|------------|------|--------|
|
||
| 401 | JWT 无效/过期 | `{"detail": "无效的令牌"}` |
|
||
| 403 | 用户未 approved、操作非自己的任务 | `{"detail": "权限不足"}` |
|
||
| 404 | 任务/备注不存在 | `{"detail": "资源不存在"}` |
|
||
| 409 | 任务状态不允许操作(如对 inactive 任务置顶) | `{"detail": "任务状态不允许此操作"}` |
|
||
| 422 | 放弃原因为空、星星评分超范围、请求体校验失败 | Pydantic 标准错误格式 |
|
||
| 500 | 数据库连接失败、FDW 查询异常 | `{"detail": "服务器内部错误"}` |
|
||
|
||
### 后台任务错误处理
|
||
|
||
| 场景 | 处理方式 |
|
||
|------|---------|
|
||
| FDW 查询失败(ETL 库不可用) | 记录错误日志,跳过本次执行,不影响其他触发器 |
|
||
| 单个任务生成失败 | 捕获异常,记录日志,继续处理下一个助教-客户对 |
|
||
| 触发器执行异常 | 记录错误日志,不中断其他触发器执行(需求 11.7) |
|
||
| 数据库写入冲突(唯一索引) | 捕获 `UniqueViolation`,视为"已存在"跳过 |
|
||
| AI 应用 6 接口不可用 | 跳过评分逻辑,备注正常创建,ai_score 保持 NULL |
|
||
| expires_at 计算溢出 | 防御性检查,确保 expires_at > created_at |
|
||
|
||
### 数据库事务策略
|
||
|
||
| 操作 | 事务范围 |
|
||
|------|---------|
|
||
| 任务生成器(批量) | 每个助教-客户对独立事务,失败不影响其他 |
|
||
| 任务状态变更 | 单任务事务:UPDATE task + INSERT history 在同一事务 |
|
||
| 备注创建 + 任务完成 | 同一事务:INSERT note + UPDATE task(如触发完成) |
|
||
| 触发器调度 | 每个 job 独立事务 |
|
||
| FDW 查询 | 只读事务,`SET LOCAL app.current_site_id` 设置 RLS |
|
||
|
||
### 环境变量缺失处理
|
||
|
||
| 变量 | 缺失时行为 |
|
||
|------|-----------|
|
||
| `DB_HOST` 等数据库参数 | 数据库连接失败,返回 500 |
|
||
| `ETL_DB_HOST` 等 ETL 参数 | FDW 查询失败,任务生成器/召回检测器跳过,记录日志 |
|
||
| `JWT_SECRET_KEY` | JWT 签发/验证使用空密钥(仅开发环境) |
|
||
|
||
## 测试策略
|
||
|
||
### 属性测试(Property-Based Testing)
|
||
|
||
使用 Python `hypothesis` 框架,测试目录:`tests/`(Monorepo 级属性测试目录)。
|
||
|
||
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
|
||
|
||
标注格式:`# Feature: 04-miniapp-core-business, Property N: <属性标题>`
|
||
|
||
**核心纯函数(可直接属性测试,不依赖数据库)**:
|
||
|
||
| 属性 | 测试文件 | 测试方法 | 生成器 |
|
||
|------|---------|---------|--------|
|
||
| P1 任务类型确定 | `tests/test_core_business_properties.py` | 生成随机 WBI/NCI/RS 值,验证 `determine_task_type()` 返回值 | `hypothesis.strategies.decimals(min_value=0, max_value=10, places=2)` |
|
||
| P9 评分范围约束 | `tests/test_core_business_properties.py` | 生成随机整数,验证 Pydantic 模型校验 | `hypothesis.strategies.integers(min_value=-100, max_value=100)` |
|
||
| P10 任务列表排序 | `tests/test_core_business_properties.py` | 生成随机任务列表(不同 is_pinned/priority_score/created_at),验证排序 | 自定义 strategy 生成任务列表 |
|
||
| P11 爱心 icon 档位 | `tests/test_core_business_properties.py` | 生成随机 RS 值,验证 icon 映射 | `hypothesis.strategies.decimals(min_value=0, max_value=10, places=1)` |
|
||
| P12 next_run_at 计算 | `tests/test_core_business_properties.py` | 生成随机 cron/interval 配置和当前时间,验证计算结果 | 自定义 strategy 生成触发器配置 |
|
||
|
||
**状态机属性(需要 FakeDB 模拟数据库状态)**:
|
||
|
||
| 属性 | 测试文件 | 测试方法 | 生成器 |
|
||
|------|---------|---------|--------|
|
||
| P2 唯一性不变量 | `tests/test_core_business_properties.py` | 生成随机 (site_id, assistant_id, member_id, task_type) 组合,模拟插入,验证唯一性 | 自定义 strategy 生成任务组合 |
|
||
| P3 类型变更状态机 | `tests/test_core_business_properties.py` | 生成随机现有任务+新任务类型,执行变更,验证旧任务 inactive + 新任务 active + history | 自定义 strategy |
|
||
| P4 48h 滞留机制 | `tests/test_core_business_properties.py` | 生成随机 follow_up_visit 任务+时间偏移,验证 expires_at 填充和过期逻辑 | `hypothesis.strategies.datetimes` + `timedeltas` |
|
||
| P5 放弃往返 | `tests/test_core_business_properties.py` | 生成随机任务+放弃原因,执行放弃→取消放弃,验证状态恢复 | `hypothesis.strategies.text(min_size=1)` |
|
||
| P6 召回完成+快照 | `tests/test_core_business_properties.py` | 生成随机 active 任务+服务记录,执行完成检测,验证 completed_task_type | 自定义 strategy |
|
||
| P7 备注回溯 | `tests/test_core_business_properties.py` | 生成随机备注列表+service_time,执行回溯,验证 type 变更 | 自定义 strategy |
|
||
| P8 备注类型自动设置 | `tests/test_core_business_properties.py` | 生成随机 task_type + 备注创建,验证 note.type | `hypothesis.strategies.sampled_from(task_types)` |
|
||
| P14 AI 评分判定 | `tests/test_core_business_properties.py` | 生成随机 ai_score + 任务状态,验证完成判定 | `hypothesis.strategies.integers(min_value=0, max_value=10)` |
|
||
| P15 历史完整性 | `tests/test_core_business_properties.py` | 生成随机状态变更操作序列,验证 history 记录数量和内容 | 自定义 strategy |
|
||
|
||
**注意**:P13(迁移幂等性)作为集成测试在测试库中执行,不使用 hypothesis。
|
||
|
||
### 单元测试
|
||
|
||
单元测试位于 `apps/backend/tests/`,聚焦于:
|
||
|
||
- `test_task_generator.py`:任务生成器的边界情况(无指数数据、全部跳过、全部替换)
|
||
- `test_task_manager.py`:任务 CRUD 的边界情况(操作非自己的任务、状态不允许的操作)
|
||
- `test_note_service.py`:备注服务的边界情况(无关联任务、AI 接口不可用)
|
||
- `test_task_expiry.py`:有效期轮询的边界情况(无过期任务、批量过期)
|
||
- `test_recall_detector.py`:召回检测的边界情况(无匹配任务、多任务匹配)
|
||
- `test_note_reclassifier.py`:备注回溯的边界情况(无符合条件备注、多条备注取第一条)
|
||
- `test_trigger_scheduler.py`:触发器调度的边界情况(disabled job、未注册 handler)
|
||
|
||
### 集成测试
|
||
|
||
集成测试通过以下方式验证:
|
||
|
||
1. **迁移脚本幂等性**:在 `test_zqyy_app` 中连续执行两次迁移脚本,验证无错误
|
||
2. **种子数据完整性**:验证 4 条触发器配置正确插入
|
||
3. **端到端流程**:任务生成 → 任务列表 → 置顶/放弃 → 备注创建 → 召回完成 → 备注回溯
|
||
|
||
### 测试配置
|
||
|
||
- 属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v`
|
||
- 后端单元测试:`cd apps/backend && pytest tests/ -v`
|
||
- 每个属性测试标注 `@settings(max_examples=200)`
|
||
- 每个属性测试注释引用设计文档 Property 编号
|
||
- 属性测试库:`hypothesis`(已在项目依赖中)
|
||
- 数据库测试使用 `test_zqyy_app`(禁止连正式库)
|