Files
Neo-ZQYY/.kiro/specs/04-miniapp-core-business/design.md

42 KiB
Raw Blame History

设计文档小程序核心业务模块miniapp-core-business

概述

本设计在 P1miniapp-db-foundation、P2etl-dws-miniapp-extensions、P3miniapp-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    │
                                            └──────────────────────────────┘

架构

分层架构

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

触发器调度流程

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_checkETL 更新后)
        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

任务状态机

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 映射到具体的服务方法
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 指数为每个助教生成/更新任务。

核心逻辑(纯函数,可独立测试):

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  # 类型不同即替换

执行流程

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、置顶、放弃、取消操作。

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。

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 数据更新后,检测助教服务记录并匹配活跃任务。

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

职责:召回完成后,回溯检查是否有普通备注需重分类为回访备注。

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、星星评分存储与读取。

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 获取任务列表 JWTapproved
POST /api/xcx/tasks/{id}/pin 置顶任务 JWTapproved
POST /api/xcx/tasks/{id}/unpin 取消置顶 JWTapproved
POST /api/xcx/tasks/{id}/abandon 放弃任务 JWTapproved
POST /api/xcx/tasks/{id}/cancel-abandon 取消放弃 JWTapproved

8.2 小程序备注路由routers/xcx_notes.py

方法 路径 说明 认证要求
POST /api/xcx/notes 创建备注 JWTapproved
GET /api/xcx/notes 查询备注列表query: target_type, target_id JWTapproved
DELETE /api/xcx/notes/{id} 删除备注 JWTapproved

组件 9Pydantic 模型

# 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 图

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

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

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

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

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()
);

种子数据

-- 预置触发器配置
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 ≥ 0determine_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_tasksstatus = 'active' 的记录最多只有一条。任何创建或状态变更操作都不应违反此约束。

Validates: Requirements 1.5, 3.6, 14.1

Property 3任务类型变更状态机

For any 已存在 (site_id, assistant_id, member_id) 的 active 任务,当新任务类型与现有类型不同时,执行类型变更后:旧任务 status 变为 inactive,新任务 statusactive,且 coach_task_history 中存在对应的变更记录(包含 old_task_typenew_task_type)。

Validates: Requirements 3.7, 5.1, 5.4, 14.2

Property 448 小时滞留机制

For any follow_up_visit 类型任务:

  • 生成时 expires_at 为 NULLstatusactive
  • 当触发条件不再满足时,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_willingnessrating_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 14AI 评分驱动的任务完成判定

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 中应存在对应记录,包含正确的 actionold_statusnew_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(禁止连正式库)