From a3feb1463ccf804027b6d2098c4c9471d88a012d Mon Sep 17 00:00:00 2001 From: mark <2483178594@qq.com> Date: Wed, 30 Sep 2026 23:17:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7/=E4=BC=9A=E8=AF=9D/=E8=81=8A=E5=A4=A9=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=A0=B8=E5=BF=83=20API=20=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 挂载 health/sessions/customers/orders/tickets 五组路由,共 11 个接口 - 新增 POST /api/sessions/{id}/messages,打通聊天记录写入 + 事件落库 - 统一 {data, meta, error} 响应、request_id、写接口限流、脱敏、UTC/+08:00 时间 --- app/api/__init__.py | 1 + app/api/routes/__init__.py | 18 + app/api/routes/customers.py | 43 +++ app/api/routes/deprecated_agent.py | 87 +++++ app/api/routes/health.py | 42 +++ app/api/routes/orders.py | 34 ++ app/api/routes/risk.py | 31 ++ app/api/routes/sessions.py | 84 +++++ app/api/routes/tickets.py | 36 ++ app/core/config.py | 96 ++++++ app/core/deps.py | 35 ++ app/core/envelope.py | 59 ++++ app/core/errors.py | 66 ++++ app/core/events.py | 69 ++++ app/core/masking.py | 82 +++++ app/core/middleware.py | 93 +++++ app/core/time_utils.py | 196 +++++++++++ app/db/__init__.py | 5 + app/db/baseline_data.py | 527 +++++++++++++++++++++++++++++ app/db/excel_import.py | 520 ++++++++++++++++++++++++++++ app/db/seed.py | 205 +++++++++++ app/main.py | 94 ++++- app/schemas/api.py | 123 +++++++ app/services/__init__.py | 1 + app/services/consumer_service.py | 47 +++ app/services/order_service.py | 50 +++ app/services/risk_service.py | 253 ++++++++++++++ app/services/serializers.py | 280 +++++++++++++++ app/services/session_service.py | 299 ++++++++++++++++ app/services/ticket_service.py | 96 ++++++ app/services/timeline_service.py | 45 +++ data/sqlite_demo.db | Bin 155648 -> 176128 bytes data/sqlite_demo.db-shm | Bin 0 -> 32768 bytes data/sqlite_demo.db-wal | Bin 0 -> 185432 bytes err.txt | 0 out.txt | 0 pyproject.toml | 6 +- scripts/demo_reset.py | 74 ++-- scripts/import_excel.py | 109 +++--- tests/conftest.py | 70 ++++ tests/test_core_api.py | 187 ++++++++++ tests/test_health.py | 47 ++- 42 files changed, 4016 insertions(+), 94 deletions(-) create mode 100644 app/api/__init__.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/customers.py create mode 100644 app/api/routes/deprecated_agent.py create mode 100644 app/api/routes/health.py create mode 100644 app/api/routes/orders.py create mode 100644 app/api/routes/risk.py create mode 100644 app/api/routes/sessions.py create mode 100644 app/api/routes/tickets.py create mode 100644 app/core/config.py create mode 100644 app/core/deps.py create mode 100644 app/core/envelope.py create mode 100644 app/core/errors.py create mode 100644 app/core/events.py create mode 100644 app/core/masking.py create mode 100644 app/core/middleware.py create mode 100644 app/core/time_utils.py create mode 100644 app/db/__init__.py create mode 100644 app/db/baseline_data.py create mode 100644 app/db/excel_import.py create mode 100644 app/db/seed.py create mode 100644 app/schemas/api.py create mode 100644 app/services/__init__.py create mode 100644 app/services/consumer_service.py create mode 100644 app/services/order_service.py create mode 100644 app/services/risk_service.py create mode 100644 app/services/serializers.py create mode 100644 app/services/session_service.py create mode 100644 app/services/ticket_service.py create mode 100644 app/services/timeline_service.py create mode 100644 data/sqlite_demo.db-shm create mode 100644 data/sqlite_demo.db-wal delete mode 100644 err.txt delete mode 100644 out.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_core_api.py diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..f6f494b --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API 层:路由、请求校验与统一响应封装 diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..344f5cc --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,18 @@ +# 路由聚合:main.py 以 /api 前缀统一挂载 +# +# 当前开发阶段仅挂载核心数据查询与管理接口: +# 会话/消息(sessions)、客户(customers)、订单(orders)、工单(tickets)、健康检查(health)。 +# 已下线(代码保留,未挂载): +# 风险队列 app/api/routes/risk.py;Agent 副驾与 SSE app/api/routes/deprecated_agent.py。 +# 恢复方式:在下方重新 include_router 对应 router 即可。 + +from fastapi import APIRouter + +from app.api.routes import customers, health, orders, sessions, tickets + +api_router = APIRouter() +api_router.include_router(health.router) +api_router.include_router(sessions.router) +api_router.include_router(customers.router) +api_router.include_router(orders.router) +api_router.include_router(tickets.router) diff --git a/app/api/routes/customers.py b/app/api/routes/customers.py new file mode 100644 index 0000000..23e0854 --- /dev/null +++ b/app/api/routes/customers.py @@ -0,0 +1,43 @@ +# 消费者路由:基础信息查询与跨会话/订单/工单的统一时间线 + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session as DbSession + +from app.core.deps import get_db +from app.core.envelope import ok +from app.services import consumer_service, timeline_service + +router = APIRouter(prefix="/customers", tags=["customers"]) + + +@router.get("/{customer_id}") +def get_customer(request: Request, customer_id: str, db: Annotated[DbSession, Depends(get_db)]) -> dict: + """客户基础信息与统计(会话数、订单数、工单数、未闭环工单数)。""" + return ok(request, consumer_service.get_customer(db, customer_id)) + + +@router.get("/{customer_id}/timeline") +def get_timeline( + request: Request, + customer_id: str, + db: Annotated[DbSession, Depends(get_db)], + from_time: str | None = Query(None, alias="from", description="RFC3339 起始时间"), + to_time: str | None = Query(None, alias="to", description="RFC3339 结束时间"), + event_type: str | None = Query(None, max_length=200, description="事件类型,逗号分隔多值"), + order: Literal["asc", "desc"] = Query("desc", description="默认倒序(主管看板)"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> dict: + items, total = timeline_service.get_timeline( + db, + customer_id, + from_time=from_time, + to_time=to_time, + event_type=event_type, + order=order, + page=page, + page_size=page_size, + ) + return ok(request, {"items": items}, page=page, page_size=page_size, total=total) diff --git a/app/api/routes/deprecated_agent.py b/app/api/routes/deprecated_agent.py new file mode 100644 index 0000000..7fc5fff --- /dev/null +++ b/app/api/routes/deprecated_agent.py @@ -0,0 +1,87 @@ +# 【已下线】Agent 副驾结论与 SSE 事件流路由——当前开发阶段仅保留核心数据查询与管理 API。 +# +# 下线原因:本阶段聚焦客户/会话/消息/订单/工单基础接口,副驾结论与实时推送不参与联调。 +# 恢复方式:在 app/api/routes/__init__.py 重新 include_router(deprecated_agent.router)。 +# 依赖模块(app/agents/copilot.py、app/core/events.py)均保留完好,可直接恢复挂载。 + +import asyncio +import json +from collections.abc import AsyncIterator +from typing import Annotated, Any, Literal + +from fastapi import APIRouter, Depends, Header, Query, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session as DbSession + +from app.agents import copilot +from app.core import config +from app.core.deps import get_db +from app.core.envelope import ok, request_id_of +from app.core.errors import session_not_found +from app.core.events import event_bus +from app.models import ServiceSession + +router = APIRouter(prefix="/sessions", tags=["deprecated-agent"]) + + +@router.get("/{session_id}/copilot") +def get_session_copilot( + request: Request, + session_id: str, + db: Annotated[DbSession, Depends(get_db)], + mode: Literal["auto", "fast", "reasoning", "vision", "mock"] = Query("auto"), + refresh: bool = Query(False, description="true 时强制重新生成,忽略缓存"), +) -> dict: + """触发或读取当前会话的 Agent 结论(不改变业务状态)。""" + outcome = copilot.generate_copilot(db, session_id, mode=mode, refresh=refresh, request_id=request_id_of(request)) + return ok( + request, + { + "insight": outcome.insight, + "draft_reply": outcome.draft_reply, + "generated_at": outcome.generated_at, + "analysis_id": outcome.analysis_id, + "evidence": outcome.insight.get("evidence", []), + "model_route": outcome.model_route, + "degraded": outcome.degraded, + }, + ) + + +@router.get("/{session_id}/stream") +async def stream_session_events( + request: Request, + session_id: str, + db: Annotated[DbSession, Depends(get_db)], + last_event_id: str | None = Header(None, alias="Last-Event-ID"), +) -> StreamingResponse: + """SSE 推送 Agent 生成、动作执行与承诺状态变化;断线可用 Last-Event-ID 重连。""" + if db.get(ServiceSession, session_id) is None: + raise session_not_found(session_id) + return StreamingResponse( + _event_stream(session_id, last_event_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, + ) + + +async def _event_stream(session_id: str, last_event_id: str | None) -> AsyncIterator[str]: + yield ": connected\n\n" + for record in event_bus.replay_after(session_id, last_event_id): + yield _format_sse(record) + subscription = event_bus.register(session_id) + try: + while True: + try: + record = await asyncio.wait_for(subscription.queue.get(), timeout=config.SSE_KEEPALIVE_SECONDS) + except TimeoutError: + yield ": keep-alive\n\n" + continue + yield _format_sse(record) + finally: + event_bus.unregister(session_id, subscription) + + +def _format_sse(record: dict[str, Any]) -> str: + data = json.dumps(record["data"], ensure_ascii=False) + return f"id: {record['id']}\nevent: {record['event']}\ndata: {data}\n\n" diff --git a/app/api/routes/health.py b/app/api/routes/health.py new file mode 100644 index 0000000..2061010 --- /dev/null +++ b/app/api/routes/health.py @@ -0,0 +1,42 @@ +# 健康检查:服务、数据库与模型网关状态(GET /api/health) +# +# 数据库不可用时返回 HTTP 503 DATABASE_UNAVAILABLE(见接口文档 4.1)。 + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session as DbSession + +from app.core import config +from app.core.deps import get_db +from app.core.envelope import ok +from app.core.errors import ApiError, ErrorCode +from app.core.time_utils import to_api_time, utc_now + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def health(request: Request, db: Annotated[DbSession, Depends(get_db)]) -> dict: + try: + db.execute(text("SELECT 1")) + except SQLAlchemyError as exc: + raise ApiError( + 503, + ErrorCode.DATABASE_UNAVAILABLE, + "数据库不可用", + {"reason": type(exc).__name__}, + ) from exc + + return ok( + request, + { + "status": "ok", + "database": "ok", + "model_provider": config.model_provider_name(), + "version": config.APP_VERSION, + "server_time": to_api_time(utc_now()), + }, + ) diff --git a/app/api/routes/orders.py b/app/api/routes/orders.py new file mode 100644 index 0000000..95a6ea2 --- /dev/null +++ b/app/api/routes/orders.py @@ -0,0 +1,34 @@ +# 订单路由:订单事实查询与条件检索 + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session as DbSession + +from app.core.deps import get_db +from app.core.envelope import ok +from app.services import order_service + +router = APIRouter(prefix="/orders", tags=["orders"]) + + +@router.get("") +def list_orders( + request: Request, + db: Annotated[DbSession, Depends(get_db)], + order_no: str | None = Query(None, max_length=64, description="订单号精确匹配"), + customer_id: str | None = Query(None, max_length=64, description="按消费者筛选"), + session_id: str | None = Query(None, max_length=64, description="按关联会话筛选"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> dict: + """按订单号或关联条件(消费者/会话)检索订单列表。""" + items, total = order_service.list_orders( + db, order_no=order_no, customer_id=customer_id, session_id=session_id, page=page, page_size=page_size + ) + return ok(request, {"items": items}, page=page, page_size=page_size, total=total) + + +@router.get("/{order_id}") +def get_order(request: Request, order_id: str, db: Annotated[DbSession, Depends(get_db)]) -> dict: + return ok(request, order_service.get_order(db, order_id)) diff --git a/app/api/routes/risk.py b/app/api/routes/risk.py new file mode 100644 index 0000000..4657d53 --- /dev/null +++ b/app/api/routes/risk.py @@ -0,0 +1,31 @@ +# 风险队列路由:主管视角的会话、工单与承诺聚合 +# +# 【当前阶段已下线】未在 app/api/routes/__init__.py 挂载(聚焦核心数据查询与管理 API)。 +# 恢复方式:重新 include_router(risk.router);依赖 app/services/risk_service.py 保留完好。 + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session as DbSession + +from app.core.deps import get_db +from app.core.envelope import ok +from app.services import risk_service + +router = APIRouter(prefix="/risk-queue", tags=["risk"]) + + +@router.get("") +def get_risk_queue( + request: Request, + db: Annotated[DbSession, Depends(get_db)], + risk_level: Literal["L0", "L1", "L2", "L3"] | None = Query(None), + ticket_type: str | None = Query(None, max_length=50), + status: Literal["open", "closed", "pending"] | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> dict: + items, total = risk_service.build_risk_queue( + db, risk_level=risk_level, ticket_type=ticket_type, status=status, page=page, page_size=page_size + ) + return ok(request, {"items": items}, page=page, page_size=page_size, total=total) diff --git a/app/api/routes/sessions.py b/app/api/routes/sessions.py new file mode 100644 index 0000000..4a54441 --- /dev/null +++ b/app/api/routes/sessions.py @@ -0,0 +1,84 @@ +# 会话路由:队列列表、聚合详情与聊天消息检索 +# +# 当前开发阶段已下线:Agent 副驾结论(GET /sessions/{id}/copilot)与 SSE 事件流 +# (GET /sessions/{id}/stream),代码迁移至 app/api/routes/deprecated_agent.py 保留。 + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session as DbSession + +from app.core.deps import get_db +from app.core.envelope import ok +from app.services import session_service +from fastapi import Header + +from app.schemas.api import SessionMessageCreateRequest + +router = APIRouter(prefix="/sessions", tags=["sessions"]) + + +@router.get("") +def list_sessions( + request: Request, + db: Annotated[DbSession, Depends(get_db)], + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + q: str | None = Query(None, max_length=100, description="脱敏昵称、会话 ID、场景关键词"), + risk_level: Literal["L0", "L1", "L2", "L3"] | None = Query(None), + status: Literal["open", "closed", "pending"] | None = Query(None), + scene_major: str | None = Query(None, max_length=50), + customer_id: str | None = Query(None, max_length=64, description="按消费者筛选"), + sort: Literal["last_message_at", "risk"] | None = Query(None, description="默认风险优先视图"), +) -> dict: + items, total = session_service.list_sessions( + db, + q=q, + risk_level=risk_level, + status=status, + scene_major=scene_major, + customer_id=customer_id, + sort=sort or "risk", + page=page, + page_size=page_size, + ) + return ok(request, {"items": items}, page=page, page_size=page_size, total=total) + + +@router.get("/{session_id}") +def get_session_detail( + request: Request, + session_id: str, + db: Annotated[DbSession, Depends(get_db)], + include: str | None = Query(None, description="可选:events,orders,tickets"), +) -> dict: + return ok(request, session_service.get_session_detail(db, session_id, include=include)) + + +@router.get("/{session_id}/messages") +def get_session_messages( + request: Request, + session_id: str, + db: Annotated[DbSession, Depends(get_db)], + order: Literal["asc", "desc"] = Query("asc", description="默认按 seq_no 升序(聊天读取顺序)"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +) -> dict: + """按会话 ID 分页获取聊天消息记录。""" + items, total = session_service.list_messages(db, session_id, order=order, page=page, page_size=page_size) + return ok(request, {"items": items}, page=page, page_size=page_size, total=total) + + +@router.post("/{session_id}/messages") +def create_session_message( + request: Request, + session_id: str, + payload: SessionMessageCreateRequest, + db: Annotated[DbSession, Depends(get_db)], + x_operator_id: str | None = Header(None, alias="X-Operator-ID", description="操作人标识,写入审计事件"), +) -> dict: + """模拟客服发送消息(send=true)或保存草稿(send=false)。""" + result = session_service.append_message( + db, session_id, payload=payload, operator_id=x_operator_id + ) + return ok(request, result) \ No newline at end of file diff --git a/app/api/routes/tickets.py b/app/api/routes/tickets.py new file mode 100644 index 0000000..7704218 --- /dev/null +++ b/app/api/routes/tickets.py @@ -0,0 +1,36 @@ +# 工单路由:统一核心字段 + 白名单 detail + 字段式更新(PATCH) + +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, Query, Request +from sqlalchemy.orm import Session as DbSession + +from app.core.deps import get_db +from app.core.envelope import ok +from app.schemas.api import TicketUpdateRequest +from app.services import ticket_service + +router = APIRouter(prefix="/tickets", tags=["tickets"]) + + +@router.get("/{ticket_id}") +def get_ticket( + request: Request, + ticket_id: str, + db: Annotated[DbSession, Depends(get_db)], + include: str | None = Query(None, description="可选:events"), +) -> dict: + include_events = bool(include and "events" in {part.strip() for part in include.split(",")}) + return ok(request, ticket_service.get_ticket(db, ticket_id, include_events=include_events)) + + +@router.patch("/{ticket_id}") +def update_ticket( + request: Request, + ticket_id: str, + payload: TicketUpdateRequest, + db: Annotated[DbSession, Depends(get_db)], + x_operator_id: str | None = Header(None, alias="X-Operator-ID", description="操作人标识,写入审计事件"), +) -> dict: + """更新工单状态/优先级/处理人/备注;状态变化自动写入 service_event 审计与时间线。""" + return ok(request, ticket_service.update_ticket(db, ticket_id, payload=payload, operator_id=x_operator_id)) diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..9f7ddf1 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,96 @@ +# 运行时配置:环境变量读取与 Demo 常量 +# +# 百炼(DashScope)环境变量约定(见开发文档 2.3 节): +# DASHSCOPE_API_KEY 必填,缺失时自动降级为 Mock 路由 +# DASHSCOPE_BASE_URL 可选,默认 OpenAI 兼容模式地址 +# MODEL_FAST / MODEL_REASONING / MODEL_VISION 可选,模型名不写死在代码中 + +import os +from pathlib import Path + +# ---------------------------------------------------------------- 基础信息 + +APP_NAME = "知微客服副驾" +APP_VERSION = "0.1.0" +API_PREFIX = "/api" + +# 运行环境:development / test / production +APP_ENV = os.environ.get("APP_ENV", "development").strip().lower() + +# 项目根目录(app/core/config.py -> 项目根) +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +# ---------------------------------------------------------------- .env 加载 +# 标准库实现的极简 .env 读取,避免引入 python-dotenv 依赖。 + + +def _load_dotenv(path: Path) -> None: + if not path.is_file(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +_load_dotenv(PROJECT_ROOT / ".env") + +# ---------------------------------------------------------------- 模型网关 + +DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "").strip() +DASHSCOPE_BASE_URL = os.environ.get("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1").strip() +MODEL_FAST = os.environ.get("MODEL_FAST", "").strip() +MODEL_REASONING = os.environ.get("MODEL_REASONING", "").strip() +MODEL_VISION = os.environ.get("MODEL_VISION", "").strip() +MODEL_EMBEDDING = os.environ.get("MODEL_EMBEDDING", "").strip() + +MODEL_TIMEOUT_SECONDS = float(os.environ.get("MODEL_TIMEOUT_SECONDS", "20")) +MODEL_MAX_RETRIES = 1 # JSON 解析失败时仅重试当前节点一次 + + +def model_provider_name() -> str: + """当前模型网关对外展示的名称。""" + return "bailian" if DASHSCOPE_API_KEY else "mock" + + +def route_model_name(route: str) -> str: + """按路由返回配置的模型名,未配置返回空字符串。""" + return { + "fast": MODEL_FAST, + "reasoning": MODEL_REASONING, + "vision": MODEL_VISION, + }.get(route, "") + + +# ---------------------------------------------------------------- 业务常量 + +# 承诺履约雷达 +PROMISE_DUE_SOON_HOURS = float(os.environ.get("PROMISE_DUE_SOON_HOURS", "24")) + +# 动作预览有效期(分钟) +ACTION_PREVIEW_TTL_MINUTES = int(os.environ.get("ACTION_PREVIEW_TTL_MINUTES", "10")) + +# 节假日日历(Demo 阶段使用常量配置,后续迁移到配置表) +# 仅影响“N 个工作日”计算,周六/周日自动跳过。 +HOLIDAYS: set[str] = {d.strip() for d in os.environ.get("APP_HOLIDAYS", "").split(",") if d.strip()} + +# 客户端伪造 now 仅允许在非生产环境使用(承诺扫描接口) +ALLOW_CLIENT_NOW = APP_ENV in {"development", "test"} + +# 写接口简易限流:窗口 60 秒内的最大写请求数 +WRITE_RATE_LIMIT_PER_MINUTE = int(os.environ.get("WRITE_RATE_LIMIT_PER_MINUTE", "120")) + +# CORS 允许来源 +CORS_ALLOW_ORIGINS = [ + origin.strip() + for origin in os.environ.get("CORS_ALLOW_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",") + if origin.strip() +] + +# SSE keep-alive 间隔(秒) +SSE_KEEPALIVE_SECONDS = int(os.environ.get("SSE_KEEPALIVE_SECONDS", "15")) diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..0cfa382 --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,35 @@ +# FastAPI 依赖:数据库会话、操作员占位鉴权 + +from collections.abc import Iterator + +from fastapi import Request +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.core.errors import ApiError, ErrorCode + + +def get_db() -> Iterator[Session]: + """请求级数据库会话;测试通过 dependency_overrides 替换。""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_request_id(request: Request) -> str: + return getattr(request.state, "request_id", "req_unknown") + + +def require_operator(request: Request) -> str: + """写接口最小鉴权占位:必须携带 X-Operator-ID。""" + operator_id = (request.headers.get("X-Operator-ID") or "").strip() + if not operator_id: + raise ApiError(401, ErrorCode.UNAUTHORIZED, "缺少操作员标识 X-Operator-ID") + return operator_id + + +def optional_operator(request: Request) -> str | None: + operator_id = (request.headers.get("X-Operator-ID") or "").strip() + return operator_id or None diff --git a/app/core/envelope.py b/app/core/envelope.py new file mode 100644 index 0000000..6dd01b1 --- /dev/null +++ b/app/core/envelope.py @@ -0,0 +1,59 @@ +# 统一响应封装:{data, meta, error} +# +# 成功:{"data": ..., "meta": {"request_id": ..., "page": ..., "total": ...}, "error": null} +# 失败:{"data": null, "meta": {"request_id": ...}, "error": {"code": ..., "message": ..., "details": {}}} + +from typing import Any + +from fastapi import Request +from fastapi.responses import JSONResponse + + +def request_id_of(request: Request) -> str: + return getattr(request.state, "request_id", "req_unknown") + + +def ok( + request: Request, + data: Any, + *, + page: int | None = None, + page_size: int | None = None, + total: int | None = None, + meta_extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """构造成功响应体(由路由直接返回,FastAPI 序列化为 JSON)。""" + meta: dict[str, Any] = {"request_id": request_id_of(request)} + if total is not None: + meta["page"] = page + meta["page_size"] = page_size + meta["total"] = total + if meta_extra: + meta.update(meta_extra) + return {"data": data, "meta": meta, "error": None} + + +def error_body( + request_id: str, + code: str, + message: str, + details: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "data": None, + "meta": {"request_id": request_id}, + "error": {"code": code, "message": message, "details": details or {}}, + } + + +def error_response( + status_code: int, + request_id: str, + code: str, + message: str, + details: dict[str, Any] | None = None, +) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content=error_body(request_id, code, message, details), + ) diff --git a/app/core/errors.py b/app/core/errors.py new file mode 100644 index 0000000..5124e4a --- /dev/null +++ b/app/core/errors.py @@ -0,0 +1,66 @@ +# 统一错误码与业务异常(对应接口设计文档第 5 节) + +from typing import Any + + +class ErrorCode: + INVALID_PARAMETER = "INVALID_PARAMETER" + UNAUTHORIZED = "UNAUTHORIZED" + FORBIDDEN = "FORBIDDEN" + SESSION_NOT_FOUND = "SESSION_NOT_FOUND" + CUSTOMER_NOT_FOUND = "CUSTOMER_NOT_FOUND" + ORDER_NOT_FOUND = "ORDER_NOT_FOUND" + TICKET_NOT_FOUND = "TICKET_NOT_FOUND" + PROMISE_NOT_FOUND = "PROMISE_NOT_FOUND" + VERSION_CONFLICT = "VERSION_CONFLICT" + ACTION_PREVIEW_REQUIRED = "ACTION_PREVIEW_REQUIRED" + PROMISE_TRANSITION_INVALID = "PROMISE_TRANSITION_INVALID" + IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT" + ACTION_PAYLOAD_INVALID = "ACTION_PAYLOAD_INVALID" + MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD" + RATE_LIMITED = "RATE_LIMITED" + DATABASE_UNAVAILABLE = "DATABASE_UNAVAILABLE" + MODEL_UNAVAILABLE = "MODEL_UNAVAILABLE" + # 扩展错误码:履约验证失败(见接口文档 4.3 MARK_FULFILLED 证据校验) + VERIFICATION_FAILED = "VERIFICATION_FAILED" + # 框架级错误码(HTTP 语义;资源类 404 使用各自错误码) + NOT_FOUND = "NOT_FOUND" + METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED" + INTERNAL_ERROR = "INTERNAL_ERROR" + + +class ApiError(Exception): + """业务异常,由全局异常处理器转换为统一错误响应。""" + + def __init__( + self, + status_code: int, + code: str, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + self.details = details or {} + + +def session_not_found(session_id: str) -> ApiError: + return ApiError(404, ErrorCode.SESSION_NOT_FOUND, "会话不存在", {"session_id": session_id}) + + +def customer_not_found(customer_id: str) -> ApiError: + return ApiError(404, ErrorCode.CUSTOMER_NOT_FOUND, "客户不存在", {"customer_id": customer_id}) + + +def order_not_found(order_id: str) -> ApiError: + return ApiError(404, ErrorCode.ORDER_NOT_FOUND, "订单不存在", {"order_id": order_id}) + + +def ticket_not_found(ticket_id: str) -> ApiError: + return ApiError(404, ErrorCode.TICKET_NOT_FOUND, "工单不存在", {"ticket_id": ticket_id}) + + +def promise_not_found(promise_id: str) -> ApiError: + return ApiError(404, ErrorCode.PROMISE_NOT_FOUND, "承诺不存在", {"promise_id": promise_id}) diff --git a/app/core/events.py b/app/core/events.py new file mode 100644 index 0000000..ecf78d6 --- /dev/null +++ b/app/core/events.py @@ -0,0 +1,69 @@ +# 进程内事件总线:支撑 SSE 推送(分析、动作、承诺状态变化) +# +# 【当前阶段已下线】仅 Agent 副驾/SSE 使用,路由未挂载;代码保留待恢复。 +# Demo 阶段单进程运行,使用内存缓冲 + asyncio 队列: +# - 每个会话保留最近 N 条事件,支持前端按 Last-Event-ID 断线重连补发。 +# - publish 可能发生在同步路由的线程池中,订阅者按登记的事件循环跨线程投递。 + +import asyncio +from collections import defaultdict, deque +from typing import Any, NamedTuple + +_MAX_BUFFER_PER_SESSION = 100 + + +class Subscription(NamedTuple): + loop: asyncio.AbstractEventLoop + queue: asyncio.Queue + + +class SessionEventBus: + def __init__(self) -> None: + self._buffers: dict[str, deque[dict[str, Any]]] = defaultdict(lambda: deque(maxlen=_MAX_BUFFER_PER_SESSION)) + self._seq: dict[str, int] = defaultdict(int) + self._subscribers: dict[str, list[Subscription]] = defaultdict(list) + + def publish(self, session_id: str, event: str, data: dict[str, Any]) -> dict[str, Any]: + """发布事件:event 形如 analysis.started / promise.updated。""" + self._seq[session_id] += 1 + record = {"id": str(self._seq[session_id]), "event": event, "data": {"session_id": session_id, **data}} + self._buffers[session_id].append(record) + try: + running_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + for subscription in self._subscribers.get(session_id, []): + if running_loop is subscription.loop: + subscription.queue.put_nowait(record) + else: + subscription.loop.call_soon_threadsafe(subscription.queue.put_nowait, record) + return record + + def register(self, session_id: str) -> Subscription: + """登记订阅(须在事件循环内调用);调用方负责 unregister。""" + subscription = Subscription(loop=asyncio.get_running_loop(), queue=asyncio.Queue()) + self._subscribers[session_id].append(subscription) + return subscription + + def unregister(self, session_id: str, subscription: Subscription) -> None: + subscribers = self._subscribers.get(session_id) + if not subscribers: + return + if subscription in subscribers: + subscribers.remove(subscription) + if not subscribers: + self._subscribers.pop(session_id, None) + + def replay_after(self, session_id: str, last_event_id: str | None) -> list[dict[str, Any]]: + """按 Last-Event-ID 返回未送达的事件。""" + if not last_event_id: + return [] + try: + last_id = int(last_event_id) + except ValueError: + return [] + return [rec for rec in self._buffers.get(session_id, []) if int(rec["id"]) > last_id] + + +# 全局单例 +event_bus = SessionEventBus() diff --git a/app/core/masking.py b/app/core/masking.py new file mode 100644 index 0000000..bdba490 --- /dev/null +++ b/app/core/masking.py @@ -0,0 +1,82 @@ +# 脱敏工具:API 不返回支付宝账号、手机号、详细地址、物流号原值 +# +# 原则:运行库只保存掩码值;本模块负责导入阶段的掩码计算与引用文本的安全处理。 + +import hashlib +import re + +# 物流号掩码:保留前 3 后 4 +_TRACKING_RE = re.compile(r"^([A-Za-z0-9]{3})[A-Za-z0-9]+([A-Za-z0-9]{4})$") +# 手机号:11 位中国大陆号码 +_PHONE_RE = re.compile(r"(? str: + """规范化昵称哈希,用于跨会话归并,不可反推原值。""" + normalized = (nickname or "").strip() + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def mask_display_name(name: str) -> str: + """展示名掩码:保留首字符,如“魏h**”仅由官方脱敏源直接提供。 + + 对于新数据:保留首字符并补齐两位掩码。 + """ + if not name: + return "用户**" + head = name[0] + return f"{head}**" + + +def mask_tracking_no(value: str | None) -> str: + if not value: + return HIDDEN + compact = value.strip() + match = _TRACKING_RE.match(compact) + if match: + return f"{match.group(1)}****{match.group(2)}" + if len(compact) <= 4: + return compact[0] + "*" * max(len(compact) - 1, 1) + return compact[:2] + "*" * 4 + compact[-2:] + + +def mask_phone(value: str | None) -> str: + if not value: + return HIDDEN + compact = value.strip() + if len(compact) >= 11: + return compact[:3] + "****" + compact[-4:] + return compact[:2] + "****" + + +def mask_alipay_account(value: str | None) -> str: + if not value: + return HIDDEN + compact = value.strip() + if len(compact) <= 4: + return compact[0] + "***" + return compact[:2] + "***" + compact[-2:] + + +def redact_sensitive_text(text: str | None) -> str | None: + """兜底清洗:引用文本中如混入手机号或邮箱,替换为掩码。""" + if not text: + return text + cleaned = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-4:], text) + cleaned = _EMAIL_RE.sub("***@***", cleaned) + return cleaned + + +def mask_quote(text: str | None, max_len: int = 40) -> str | None: + """证据短引文:压缩空白、截断并做兜底脱敏。""" + if not text: + return None + compact = re.sub(r"\s+", " ", text).strip() + redacted = redact_sensitive_text(compact) or compact + if len(redacted) > max_len: + return redacted[: max_len - 1] + "…" + return redacted diff --git a/app/core/middleware.py b/app/core/middleware.py new file mode 100644 index 0000000..e4d98d3 --- /dev/null +++ b/app/core/middleware.py @@ -0,0 +1,93 @@ +# 请求上下文中间件:request_id 注入、访问日志、写接口简易限流 +# +# - 使用纯 ASGI 实现,避免包装层破坏 SSE 流式响应。 +# - 日志只记录 request_id、路由、状态码、耗时、operator_id,不记录请求体。 + +import logging +import time +import uuid +from collections import defaultdict, deque +from typing import Any + +from app.core import config + +logger = logging.getLogger("app.request") + +_HEADER_REQUEST_ID = b"x-request-id" +_HEADER_OPERATOR_ID = b"x-operator-id" +_WRITE_METHODS = {b"POST", b"PUT", b"PATCH", b"DELETE"} + + +class RequestContextMiddleware: + def __init__(self, app: Any) -> None: + self.app = app + self._write_hits: dict[str, deque[float]] = defaultdict(deque) + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = dict(scope.get("headers") or []) + request_id = (headers.get(_HEADER_REQUEST_ID) or b"").decode() or f"req_{uuid.uuid4().hex[:12]}" + operator_id = (headers.get(_HEADER_OPERATOR_ID) or b"").decode() or None + + state = scope.setdefault("state", {}) + state["request_id"] = request_id + state["operator_id"] = operator_id + + path = scope.get("path", "") + method = scope.get("method", "GET") + + # 写接口简易限流:按 operator 或客户端地址,60 秒滑动窗口 + if method.encode() in _WRITE_METHODS and path.startswith(config.API_PREFIX): + limited = self._check_rate_limit(scope, operator_id) + if limited is not None: + from app.core.envelope import error_response + from app.core.errors import ErrorCode + + response = error_response( + 429, + request_id, + ErrorCode.RATE_LIMITED, + "请求过于频繁,请稍后重试", + ) + await response(scope, receive, send) + return + + started = time.perf_counter() + status_holder = {"status": 500} + + async def send_wrapper(message: dict) -> None: + if message["type"] == "http.response.start": + status_holder["status"] = message["status"] + raw_headers = list(message.get("headers") or []) + raw_headers.append((b"x-request-id", request_id.encode())) + message["headers"] = raw_headers + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + elapsed_ms = (time.perf_counter() - started) * 1000 + logger.info( + "%s %s -> %s %.1fms request_id=%s operator_id=%s", + method, + path, + status_holder["status"], + elapsed_ms, + request_id, + operator_id or "-", + ) + + def _check_rate_limit(self, scope: dict, operator_id: str | None) -> bool | None: + client = scope.get("client") or ("unknown", 0) + key = operator_id or f"ip:{client[0]}" + now = time.monotonic() + hits = self._write_hits[key] + while hits and now - hits[0] > 60: + hits.popleft() + if len(hits) >= config.WRITE_RATE_LIMIT_PER_MINUTE: + return True + hits.append(now) + return None diff --git a/app/core/time_utils.py b/app/core/time_utils.py new file mode 100644 index 0000000..f315bd6 --- /dev/null +++ b/app/core/time_utils.py @@ -0,0 +1,196 @@ +# 时间服务:统一 UTC 存储 / RFC3339 输出 / 工作日计算 / 中文时间表达归一化 +# +# 约定: +# - 数据库统一保存带时区的 UTC ISO 字符串(与 app/models/models.py 的 utc_now 一致)。 +# - API 按 RFC3339 返回,序列化时转换为 Asia/Shanghai(+08:00),与接口文档示例一致。 +# - “N 个工作日”与“N 小时”语义不同:前者跳过周末与配置节假日,后者为自然小时。 + +import re +from datetime import UTC, datetime, time, timedelta, timezone + +from app.core import config + +# 中国无夏令时,使用固定 +08:00 偏移即可,避免 Windows 缺少 tzdata 的兼容问题。 +CHINA_TZ = timezone(timedelta(hours=8), name="Asia/Shanghai") + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def to_iso(dt: datetime) -> str: + """存储格式:带时区的 UTC ISO 字符串。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(UTC).isoformat() + + +def parse_iso(value: str | None) -> datetime | None: + """解析数据库/请求中的 ISO 时间字符串,失败返回 None。""" + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed + + +def to_api_time(value: str | datetime | None) -> str | None: + """API 输出:RFC3339(Asia/Shanghai)。""" + if value is None: + return None + dt = parse_iso(value) if isinstance(value, str) else value + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(CHINA_TZ).isoformat() + + +def seconds_until(due_at: str | datetime | None, now: datetime | None = None) -> int | None: + """剩余秒数:负数表示已超时。""" + due = parse_iso(due_at) if isinstance(due_at, str) else due_at + if due is None: + return None + base = now or utc_now() + return int((due - base).total_seconds()) + + +def add_business_days(start: datetime, days: int) -> datetime: + """自然日叠加后跳过周六、周日与配置节假日,时间点保持不变。""" + current = start + remaining = max(days, 0) + while remaining > 0: + current = current + timedelta(days=1) + if current.weekday() < 5 and current.strftime("%Y-%m-%d") not in config.HOLIDAYS: + remaining -= 1 + return current + + +def _chinese_number_to_int(text: str) -> int | None: + """支持 一到十九、二十、三十等 Demo 常见表达。""" + mapping = { + "一": 1, + "二": 2, + "两": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + "十": 10, + } + if not text: + return None + if text.isdigit(): + return int(text) + if text == "十": + return 10 + if text.startswith("十") and len(text) == 2 and text[1] in mapping: + return 10 + mapping[text[1]] + if len(text) == 2 and text[0] in ("二", "三") and text[1] == "十": + return mapping[text[0]] * 10 + if len(text) == 3 and text[0] in ("二", "三") and text[1] == "十" and text[2] in mapping: + return mapping[text[0]] * 10 + mapping[text[2]] + if len(text) == 1 and text in mapping: + return mapping[text] + return None + + +_NUM = r"(\d{1,2}|[一二两三四五六七八九十]{1,3})" + +# 模糊表达:必须转人工确认,不得虚构截止时间 +_VAGUE_WORDS = ("尽快", "稍后", "晚点", "有空", "抽空", "第一时间", "尽早", "回头", "今天之内尽快") + +# 关键时间表达模式(按优先级匹配) +_PATTERNS: list[tuple[str, str]] = [ + ("business_days", rf"{_NUM}\s*个?\s*工作日(之?内)?"), + ("business_days", rf"{_NUM}\s*个?\s*工作日内"), + ("days", rf"{_NUM}\s*天(之?内)?"), + ("hours", rf"{_NUM}\s*个?\s*小时(之?内)?"), + ("tomorrow_morning", r"明天(上午|早上|一早)"), + ("tomorrow_afternoon", r"明天(下午|中午)"), + ("tomorrow_evening", r"明天(晚上|夜里)"), + ("tomorrow", r"明天"), + ("day_after_tomorrow", r"后天"), + ("today_offwork", r"(今天|今日)(下班前|下班之前)"), + ("tonight", r"(今天|今日)?(晚上|今晚)"), + ("today", r"(今天|今日)(内|之内|之内回复)?"), +] + + +def parse_time_expression(text: str, now: datetime | None = None) -> tuple[datetime | None, str]: + """解析中文时间表达。 + + 返回 (due_at, kind): + - kind = "exact" 明确时间,可直接计算截止时间 + - kind = "vague" 模糊表达(尽快/稍后等),due_at 为 None,需人工确认 + - kind = "none" 未发现时间表达,due_at 为 None + """ + if not text: + return None, "none" + + base = (now or utc_now()).astimezone(CHINA_TZ) + today = base.replace(hour=0, minute=0, second=0, microsecond=0) + + if any(word in text for word in _VAGUE_WORDS): + # 模糊词优先,避免“尽快”被误当作确定性承诺 + return None, "vague" + + for kind, pattern in _PATTERNS: + match = re.search(pattern, text) + if not match: + continue + if kind == "business_days": + amount = _chinese_number_to_int(match.group(1)) + if amount is None: + return None, "vague" + due = add_business_days(base, amount) + return _clamp_end_of_day(due), "exact" + if kind == "days": + amount = _chinese_number_to_int(match.group(1)) + if amount is None: + return None, "vague" + return _clamp_end_of_day(base + timedelta(days=amount)), "exact" + if kind == "hours": + amount = _chinese_number_to_int(match.group(1)) + if amount is None: + return None, "vague" + return base + timedelta(hours=amount), "exact" + if kind == "tomorrow_morning": + return datetime.combine(today + timedelta(days=1), time(12, 0), CHINA_TZ), "exact" + if kind == "tomorrow_afternoon": + return datetime.combine(today + timedelta(days=1), time(15, 0), CHINA_TZ), "exact" + if kind == "tomorrow_evening": + return datetime.combine(today + timedelta(days=1), time(20, 0), CHINA_TZ), "exact" + if kind == "tomorrow": + return datetime.combine(today + timedelta(days=1), time(18, 0), CHINA_TZ), "exact" + if kind == "day_after_tomorrow": + return datetime.combine(today + timedelta(days=2), time(18, 0), CHINA_TZ), "exact" + if kind == "today_offwork": + target = today.replace(hour=18, minute=0) + if target <= base: + target = base + timedelta(hours=2) + return target, "exact" + if kind == "tonight": + target = today.replace(hour=20, minute=0) + if target <= base: + target = base + timedelta(hours=2) + return target, "exact" + if kind == "today": + target = today.replace(hour=23, minute=59) + if target <= base: + target = base + timedelta(hours=2) + return target, "exact" + + return None, "none" + + +def _clamp_end_of_day(dt: datetime) -> datetime: + """“N 天/工作日”类表达统一取当日 23:59 为最晚截止。""" + return dt.replace(hour=23, minute=59, second=0, microsecond=0) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..dbe35b0 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1,5 @@ +# 数据层:基线灌库、Demo 重置与 Excel 导入 + +from app.db.seed import apply_baseline, reset_demo, scenario_session_ids + +__all__ = ["apply_baseline", "reset_demo", "scenario_session_ids"] diff --git a/app/db/baseline_data.py b/app/db/baseline_data.py new file mode 100644 index 0000000..0f5327e --- /dev/null +++ b/app/db/baseline_data.py @@ -0,0 +1,527 @@ +# 演示基线数据(baseline-v1):退款 / 不良反应 / 赠品补发 三场景 +# +# 固定入口(见开发任务文档): +# 退款 -> S00005(消费者 魏h**,含历史会话 S00004 与未完成退款承诺) +# 不良反应 -> S00015(消费者 陈x**,已就医触发 L3,等待建单) +# 赠品补发 -> S00159(消费者 姚b**,48 小时补发承诺进入履约雷达) +# +# 时间均以“灌库时刻”为基准做相对偏移,保证任何时候重置后演示状态一致。 + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +from app.core.masking import mask_tracking_no +from app.core.time_utils import CHINA_TZ, to_iso + +BASELINE_SEED = "baseline-v1" + +SCENARIO_SESSION_IDS: dict[str, list[str]] = { + "refund": ["S00004", "S00005"], + "adverse_reaction": ["S00015"], + "gift_resend": ["S00159"], +} + + +@dataclass +class BaselineData: + consumers: list[dict[str, Any]] = field(default_factory=list) + sessions: list[dict[str, Any]] = field(default_factory=list) + messages: list[dict[str, Any]] = field(default_factory=list) + orders: list[dict[str, Any]] = field(default_factory=list) + tickets: list[dict[str, Any]] = field(default_factory=list) + promises: list[dict[str, Any]] = field(default_factory=list) + events: list[dict[str, Any]] = field(default_factory=list) + + def all_session_ids(self) -> list[str]: + return [s["session_id"] for s in self.sessions] + + +def build_baseline(now: datetime) -> BaselineData: + """按相对时间构建三场景基线数据。""" + local_now = now.astimezone(CHINA_TZ) + data = BaselineData() + + def ts(**delta: float) -> str: + return to_iso(local_now + timedelta(**delta)) + + # ------------------------------------------------------------ 消费者 + data.consumers = [ + { + "consumer_id": "C00005", + "display_name_masked": "魏h**", + "risk_level": "L2", + "risk_note": "存在历史退款承诺未完成,注意重复进线", + }, + { + "consumer_id": "C00015", + "display_name_masked": "陈x**", + "risk_level": "L0", + "risk_note": None, + }, + { + "consumer_id": "C00159", + "display_name_masked": "姚b**", + "risk_level": "L1", + "risk_note": "赠品补发处理中", + }, + ] + + # ------------------------------------------------------------ 会话 + data.sessions = [ + { + "session_id": "S00004", + "consumer_id": "C00005", + "store_name": "知微美妆官方旗舰店", + "scene_major": "售后", + "scene_minor": "退款", + "status": "closed", + "started_at": ts(days=-6, hours=-1), + "ended_at": ts(days=-6), + "last_message_at": ts(days=-6), + "intent_primary": "退款进度咨询", + "intent_secondary": "退款未到账", + "emotion": "concerned", + "risk_level": "L2", + "summary": "消费者咨询退款未到账,客服登记 3 个工作日退款核实承诺。", + "unresolved_count": 1, + }, + { + "session_id": "S00005", + "consumer_id": "C00005", + "store_name": "知微美妆官方旗舰店", + "scene_major": "售后", + "scene_minor": "退款核实", + "status": "open", + "started_at": ts(minutes=-35), + "ended_at": None, + "last_message_at": ts(minutes=-10), + "intent_primary": "退款进度咨询", + "intent_secondary": "历史承诺未履约", + "emotion": "angry", + "risk_level": "L2", + "summary": "消费者再次进线催退款,提及投诉倾向,历史承诺已超时。", + "unresolved_count": 1, + }, + { + "session_id": "S00015", + "consumer_id": "C00015", + "store_name": "知微美妆官方旗舰店", + "scene_major": "售后", + "scene_minor": "不良反应", + "status": "open", + "started_at": ts(hours=-2), + "ended_at": None, + "last_message_at": ts(minutes=-15), + "intent_primary": "过敏不适处理", + "intent_secondary": "已就医求助", + "emotion": "urgent", + "risk_level": "L3", + "summary": "消费者使用精华后出现泛红刺痛,已就医并上传门诊资料。", + "unresolved_count": 1, + }, + { + "session_id": "S00159", + "consumer_id": "C00159", + "store_name": "知微美妆官方旗舰店", + "scene_major": "售后", + "scene_minor": "赠品补发", + "status": "open", + "started_at": ts(hours=-26), + "ended_at": None, + "last_message_at": ts(hours=-24), + "intent_primary": "赠品漏发", + "intent_secondary": "补发进度", + "emotion": "concerned", + "risk_level": "L1", + "summary": "消费者反馈赠品洁面仪漏发,客服承诺 48 小时内补发并同步物流单号。", + "unresolved_count": 1, + }, + ] + + # ------------------------------------------------------------ 消息 + data.messages = [ + # S00004 历史退款会话 + _msg("m-s4-1", "S00004", 1, ts(days=-6, hours=-1), "buyer", "你好,我下单的面霜退款一直没到,麻烦帮我查一下"), + _msg( + "m-s4-2", + "S00004", + 2, + ts(days=-6, hours=-1, minutes=8), + "agent", + "您好,非常抱歉让您久等,我先帮您核实订单的退款进度。", + ), + _msg( + "m-s4-3", + "S00004", + 3, + ts(days=-6, minutes=-30), + "agent", + "已为您登记退款核实,承诺3个工作日内给您明确结果,请放心。", + ), + _msg("m-s4-4", "S00004", 4, ts(days=-6, minutes=-20), "buyer", "好的,那我等你们消息。"), + _msg("m-s4-5", "S00004", 5, ts(days=-6), "system", "[系统] 退款核实承诺已进入履约队列,责任人 G002。"), + # S00005 再次进线 + _msg( + "m-s5-1", "S00005", 1, ts(minutes=-30), "system", "[系统] 消费者再次进线,历史会话 S00004 存在未完成承诺。" + ), + _msg( + "m-s5-2", + "S00005", + 2, + ts(minutes=-12), + "buyer", + "上次答应处理的退款怎么还没到,都一个礼拜了一分没到,急死了", + is_target=True, + ), + _msg("m-s5-3", "S00005", 3, ts(minutes=-10), "buyer", "你们再不给个说法我只能投诉了"), + # S00015 不良反应 + _msg("m-s15-1", "S00015", 1, ts(hours=-2), "buyer", "用了你们家的舒缓修护精华两天,脸上又红又刺痛"), + _msg( + "m-s15-2", + "S00015", + 2, + ts(hours=-1), + "buyer", + "今天去医院看过了,医生说是接触性皮炎,开了药", + is_target=True, + ), + _msg( + "m-s15-3", + "S00015", + 3, + ts(minutes=-50), + "buyer", + "[图片] 医院门诊单据", + content_type="image", + image_path="data/media/S00015_outpatient_note.jpg", + ), + _msg( + "m-s15-4", + "S00015", + 4, + ts(minutes=-15), + "agent", + "非常抱歉给您带来不适,我马上为您升级处理并安排专员跟进,请您先停用该产品。", + ), + # S00159 赠品补发 + _msg( + "m-s159-1", + "S00159", + 1, + ts(hours=-26), + "buyer", + "我收到货了,但是说好的赠品洁面仪没有,只有正装", + is_target=True, + ), + _msg( + "m-s159-2", + "S00159", + 2, + ts(hours=-25), + "agent", + "非常抱歉,我马上为您核实仓库发货情况,48小时内给您完成补发并同步物流单号。", + ), + _msg("m-s159-3", "S00159", 3, ts(hours=-24), "buyer", "行,那我等物流单号。"), + _msg("m-s159-4", "S00159", 4, ts(hours=-24), "system", "[系统] 补发工单 T00159 已创建,等待仓库处理。"), + ] + + # ------------------------------------------------------------ 订单 + data.orders = [ + { + "order_id": "O00004", + "order_no": "O00004", + "session_id": "S00004", + "consumer_id": "C00005", + "store_name": "知微美妆官方旗舰店", + "sku": "SKU-CREAM-50", + "product_name": "光透修护面霜 50ml", + "quantity": 1, + "unit_price_cent": 19800, + "paid_amount_cent": 19800, + "order_status": "refunding", + "ordered_at": ts(days=-7), + "paid_at": ts(days=-7, minutes=5), + "shipped_at": ts(days=-6, hours=-20), + "carrier": "中通快递", + "tracking_no_masked": mask_tracking_no("78912345678901"), + "shipping_province": "浙江省", + "shipping_city": "杭州市", + "gift_description": "小样三件套", + "buyer_note_redacted": "请尽快退款", + }, + { + "order_id": "O00015", + "order_no": "O00015", + "session_id": "S00015", + "consumer_id": "C00015", + "store_name": "知微美妆官方旗舰店", + "sku": "SKU-ESSENCE-30", + "product_name": "舒缓修护精华 30ml", + "quantity": 1, + "unit_price_cent": 23900, + "paid_amount_cent": 23900, + "order_status": "received", + "ordered_at": ts(days=-10), + "paid_at": ts(days=-10, minutes=3), + "shipped_at": ts(days=-9), + "carrier": "圆通速递", + "tracking_no_masked": mask_tracking_no("YT4520128890012"), + "shipping_province": "江苏省", + "shipping_city": "南京市", + "gift_description": "旅行装小样", + "buyer_note_redacted": "敏感肌,请确保正品", + }, + { + "order_id": "O00159", + "order_no": "O00159", + "session_id": "S00159", + "consumer_id": "C00159", + "store_name": "知微美妆官方旗舰店", + "sku": "SKU-CLEAN-SET", + "product_name": "氨基酸洁面仪套装", + "quantity": 1, + "unit_price_cent": 15600, + "paid_amount_cent": 15600, + "order_status": "shipped", + "ordered_at": ts(days=-3), + "paid_at": ts(days=-3, minutes=2), + "shipped_at": ts(days=-2), + "carrier": "韵达快递", + "tracking_no_masked": mask_tracking_no("YD3312987765432"), + "shipping_province": "广东省", + "shipping_city": "深圳市", + "gift_description": "赠品洁面仪 x1", + "buyer_note_redacted": "", + }, + ] + + # ------------------------------------------------------------ 工单 + data.tickets = [ + { + "ticket_id": "T00004", + "ticket_no": "T00004", + "ticket_type": "offline_payment", + "session_id": "S00004", + "consumer_id": "C00005", + "order_id": "O00004", + "reason": "退款迟迟未到账,需核实打款进度", + "priority": "urgent", + "status": "in_progress", + "assignee": "G002", + "created_at": ts(days=-6, hours=-2), + "detail": { + "payment_type": "退款差额", + "refund_reason_type": "退款核实", + "refund_amount_cent": 19800, + "related_tracking_masked": "中通 ****8901", + "transfer_status": "处理中", + "alipay_name_masked": "魏**", + "alipay_account_masked": "13***89", + }, + }, + { + "ticket_id": "T00159", + "ticket_no": "T00159", + "ticket_type": "replenishment_exchange", + "session_id": "S00159", + "consumer_id": "C00159", + "order_id": "O00159", + "reason": "赠品洁面仪漏发,需补发", + "priority": "normal", + "status": "pending", + "assignee": "G001", + "created_at": ts(hours=-24), + "detail": { + "ship_sku": "SKU-GIFT-CM1", + "ship_product_name": "洁面仪(赠品)", + "quantity": 1, + "warehouse": "华南仓", + "replacement_tracking_masked": None, + "expedite": False, + }, + }, + ] + + # ------------------------------------------------------------ 服务承诺 + data.promises = [ + { + "promise_id": "P-BASE-0001", + "consumer_id": "C00005", + "session_id": "S00004", + "source_message_id": "m-s4-3", + "promise_type": "refund", + "statement": "3个工作日内完成退款核实并回复明确结果", + "owner_type": "agent", + "owner_id": "G002", + "due_at": ts(hours=-26), + "status": "active", + "verification_type": "ticket_completed", + "verification_ref": "T00004", + "confirmed_by": "G002", + "confirmed_at": ts(days=-6), + }, + { + "promise_id": "P-BASE-0002", + "consumer_id": "C00159", + "session_id": "S00159", + "source_message_id": "m-s159-2", + "promise_type": "replenishment", + "statement": "48小时内完成赠品补发并同步物流单号", + "owner_type": "agent", + "owner_id": "G001", + "due_at": ts(hours=20), + "status": "active", + "verification_type": "ticket_completed", + "verification_ref": "T00159", + "confirmed_by": "G001", + "confirmed_at": ts(hours=-25), + }, + { + "promise_id": "P-BASE-0003", + "consumer_id": "C00159", + "session_id": "S00159", + "source_message_id": "m-s159-2", + "promise_type": "follow_up", + "statement": "今天18:00前回复仓库核实结果", + "owner_type": "agent", + "owner_id": "G001", + "due_at": ts(days=-1, hours=6), + "status": "fulfilled", + "verification_type": "message_sent", + "verification_ref": "m-s159-2", + "confirmed_by": "G001", + "confirmed_at": ts(days=-1, hours=8), + "fulfilled_at": ts(days=-1, hours=6), + }, + ] + + # ------------------------------------------------------------ 时间线事件 + data.events = [ + _event( + "C00005", + "S00004", + ticket_id="T00004", + event_type="ticket_status", + occurred_at=ts(days=-6, hours=-2), + title="线下打款工单创建", + content="退款核实工单 T00004 已创建,责任人 G002。", + source_type="ticket", + source_id="T00004", + ), + _event( + "C00005", + "S00004", + event_type="promise", + occurred_at=ts(days=-6, minutes=-30), + title="退款核实承诺登记", + content="承诺3个工作日内完成退款核实并回复明确结果。", + source_type="chat", + source_id="m-s4-3", + evidence=["m-s4-3"], + ), + _event( + "C00005", + "S00005", + event_type="message", + occurred_at=ts(minutes=-12), + title="消费者再次进线催办退款", + content="消费者反馈退款仍未到账,存在投诉倾向。", + source_type="chat", + source_id="m-s5-2", + evidence=["m-s5-2", "m-s5-3"], + ), + _event( + "C00015", + "S00015", + event_type="message", + occurred_at=ts(hours=-1), + title="消费者反馈已就医", + content="消费者上传门诊资料图片,等待人工确认处理。", + source_type="chat", + source_id="m-s15-2", + evidence=["m-s15-2", "m-s15-3"], + order_id="O00015", + ), + _event( + "C00159", + "S00159", + ticket_id="T00159", + event_type="ticket_status", + occurred_at=ts(hours=-24), + title="补发工单创建", + content="赠品补发工单 T00159 已创建,等待仓库处理。", + source_type="ticket", + source_id="T00159", + order_id="O00159", + ), + _event( + "C00159", + "S00159", + event_type="promise", + occurred_at=ts(hours=-25), + title="补发承诺登记", + content="承诺48小时内完成赠品补发并同步物流单号。", + source_type="chat", + source_id="m-s159-2", + evidence=["m-s159-2"], + ), + ] + + return data + + +def _msg( + message_id: str, + session_id: str, + seq_no: int, + sent_at: str, + role: str, + text: str, + *, + content_type: str = "text", + image_path: str | None = None, + is_target: bool = False, +) -> dict[str, Any]: + return { + "message_id": message_id, + "session_id": session_id, + "seq_no": seq_no, + "sent_at": sent_at, + "role": role, + "sender_label": {"buyer": "消费者", "agent": "客服", "system": "系统"}.get(role, role), + "content_type": content_type, + "message_text": text, + "image_path": image_path, + "is_target_buyer_message": is_target, + } + + +def _event( + consumer_id: str, + session_id: str, + *, + event_type: str, + occurred_at: str, + title: str, + content: str, + source_type: str, + source_id: str, + evidence: list[str] | None = None, + ticket_id: str | None = None, + order_id: str | None = None, +) -> dict[str, Any]: + return { + "consumer_id": consumer_id, + "session_id": session_id, + "ticket_id": ticket_id, + "order_id": order_id, + "event_type": event_type, + "occurred_at": occurred_at, + "actor_type": "system" if event_type != "message" else "buyer", + "title": title, + "content": content, + "source_type": source_type, + "source_id": source_id, + "evidence_message_ids": evidence or [], + } diff --git a/app/db/excel_import.py b/app/db/excel_import.py new file mode 100644 index 0000000..454dbeb --- /dev/null +++ b/app/db/excel_import.py @@ -0,0 +1,520 @@ +# Excel 导入器:官方脱敏数据 -> 运行库 +# +# 流程(对应数据库设计文档第 6 节): +# Excel -> allowlist 字段读取 -> 规范化/类型校验 -> ID 关联 +# -> 敏感字段掩码 -> upsert 核心表 -> 生成 service_event -> 统计 +# +# 说明: +# - pandas/openpyxl 仅在脚本运行时懒加载,业务 API 不依赖。 +# - 列名映射使用别名表,官方文件列名确认后只需调整 COLUMN_ALIASES。 +# - 使用 --inspect 可打印每个 sheet 的实际列名与样例,用于快速校准映射。 + +import json +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.masking import ( + hash_nickname, + mask_alipay_account, + mask_display_name, + mask_tracking_no, +) +from app.core.time_utils import to_iso, utc_now +from app.models import ( + Consumer, + Message, + SalesOrder, + ServiceEvent, + ServiceSession, + ServiceTicket, +) + +IMPORT_BATCH_PREFIX = "excel-" + +# ---------------------------------------------------------------- 列名别名表 + +COLUMN_ALIASES: dict[str, list[str]] = { + # 通用 + "session_id": ["会话ID", "会话id", "session_id", "会话编号", "sessionId"], + "consumer_nickname": ["客户昵称", "买家昵称", "昵称", "消费者昵称", "nickname", "用户昵称"], + "store_name": ["店铺名称", "店铺", "store", "门店"], + "scene_major": ["一级场景", "场景一级", "scene_major", "一级问题"], + "scene_minor": ["二级场景", "场景二级", "scene_minor", "二级问题"], + "session_status": ["会话状态", "状态", "session_status"], + "started_at": ["会话开始时间", "开始时间", "started_at"], + "ended_at": ["会话结束时间", "结束时间", "ended_at"], + "message_id": ["消息ID", "消息id", "message_id", "msg_id"], + "seq_no": ["序号", "seq", "seq_no", "消息序号"], + "sent_at": ["发送时间", "消息时间", "时间", "sent_at"], + "role": ["发送方", "角色", "role", "发送角色"], + "sender_label": ["发送方名称", "发送人", "sender"], + "content_type": ["消息类型", "内容类型", "content_type"], + "message_text": ["消息内容", "聊天内容", "内容", "message", "text"], + "image_path": ["图片", "图片路径", "image_path", "图片地址"], + "is_target_buyer_message": ["指定买家消息", "目标消息", "is_target_buyer_message"], + "order_id": ["订单ID", "订单id", "订单号", "order_id", "order_no"], + "sku": ["SKU", "sku", "商品编码", "货号"], + "product_name": ["商品名称", "产品名称", "商品", "product_name"], + "quantity": ["数量", "购买数量", "quantity"], + "paid_amount": ["实付金额", "支付金额", "金额", "paid_amount", "实付"], + "order_status": ["订单状态", "order_status"], + "ordered_at": ["下单时间", "ordered_at", "创建时间"], + "paid_at": ["付款时间", "支付时间", "paid_at"], + "shipped_at": ["发货时间", "shipped_at"], + "carrier": ["快递公司", "物流公司", "carrier", "快递"], + "tracking_no": ["物流单号", "快递单号", "运单号", "tracking_no", "物流号", "单号"], + "shipping_province": ["收货省份", "省份", "province", "省"], + "shipping_city": ["收货城市", "城市", "city", "市"], + "gift_description": ["赠品", "赠品信息", "gift"], + "buyer_note": ["买家留言", "买家备注", "备注", "buyer_note"], + # 工单通用 + "ticket_id": ["工单号", "工单ID", "工单id", "ticket_id", "ticket_no"], + "ticket_reason": ["工单原因", "问题描述", "原因", "reason"], + "ticket_priority": ["优先级", "priority"], + "ticket_status": ["工单状态", "处理状态", "status"], + "ticket_assignee": ["处理人", "责任人", "负责人", "assignee"], +} + +# 工单 sheet 名称 -> ticket_type +TICKET_SHEET_KEYWORDS: list[tuple[str, str]] = [ + ("补发", "replenishment_exchange"), + ("换货", "replenishment_exchange"), + ("打款", "offline_payment"), + ("物流", "logistics"), + ("不良反应", "adverse_reaction"), + ("过敏", "adverse_reaction"), + ("退货", "return"), +] + +CHAT_SHEET_KEYWORDS = ("聊天", "消息", "会话记录", "chat", "message") +ORDER_SHEET_KEYWORDS = ("订单", "order") + +# 各工单类型允许的 detail 字段别名(白名单,与原值字段隔离) +TICKET_DETAIL_ALIASES: dict[str, dict[str, list[str]]] = { + "replenishment_exchange": { + "ship_sku": ["补发SKU", "ship_sku"], + "ship_product_name": ["补发商品", "ship_product_name"], + "quantity": ["数量", "quantity"], + "warehouse": ["仓库", "warehouse"], + "replacement_tracking_masked": ["补发物流单号", "replacement_tracking"], + "expedite": ["加急", "expedite"], + }, + "offline_payment": { + "payment_type": ["打款类型", "payment_type"], + "refund_reason_type": ["退款原因类型", "refund_reason_type"], + "refund_amount_cent": ["打款金额", "退款金额", "refund_amount"], + "related_tracking_masked": ["关联物流单号", "related_tracking"], + "transfer_status": ["转账状态", "transfer_status"], + "alipay_name_masked": ["支付宝实名", "支付宝姓名"], + "alipay_account_masked": ["支付宝账号"], + }, + "logistics": { + "problem_type": ["物流问题类型", "problem_type"], + "carrier": ["快递公司", "carrier"], + "package_tracking_masked": ["包裹物流单号", "package_tracking"], + "warehouse": ["仓库", "warehouse"], + "solution": ["处理方案", "solution"], + "abnormal_flag": ["异常标记", "abnormal_flag"], + }, + "adverse_reaction": { + "reaction_type": ["反应类型", "reaction_type"], + "age_band": ["年龄段", "age_band"], + "skin_type": ["肤质", "skin_type"], + "product_name": ["产品名称", "product_name"], + "batch_no_masked": ["批次号", "batch_no"], + "affected_area": ["使用部位", "affected_area"], + "symptom_summary": ["症状描述", "症状", "symptom_summary"], + "onset_after": ["出现时间", "onset_after"], + "stopped_use": ["是否停用", "stopped_use"], + "sought_medical_help": ["是否就医", "sought_medical_help"], + "follow_up_status": ["回访状态", "follow_up_status"], + }, + "return": { + "package_type": ["包裹类型", "package_type"], + "return_reason": ["退货原因", "return_reason"], + "return_tracking_masked": ["退货物流单号", "return_tracking"], + "refund_no_masked": ["退款单号", "refund_no"], + "receipt_advice": ["收货建议", "receipt_advice"], + "abnormal_flag": ["异常标记", "abnormal_flag"], + }, +} + + +@dataclass +class ImportStats: + sessions: int = 0 + messages: int = 0 + orders: int = 0 + tickets: int = 0 + consumers: int = 0 + events: int = 0 + skipped_rows: int = 0 + unlinked_rows: int = 0 + warnings: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "sessions": self.sessions, + "messages": self.messages, + "orders": self.orders, + "tickets": self.tickets, + "consumers": self.consumers, + "events": self.events, + "skipped_rows": self.skipped_rows, + "unlinked_rows": self.unlinked_rows, + "warnings": self.warnings[:20], + } + + +def inspect_workbook(path: Path) -> None: + """打印工作表、列名与样例行,用于校准列名映射。""" + import pandas as pd + + sheets = pd.read_excel(path, sheet_name=None) + for name, frame in sheets.items(): + print(f"\n=== Sheet: {name} | 行数: {len(frame)} ===") + print("列名:", list(frame.columns)) + if len(frame) > 0: + print("样例:", frame.iloc[0].to_dict()) + + +def import_workbook(path: Path, db: Session) -> ImportStats: + """读取 Excel 并 upsert 到运行库;调用方负责事务提交。""" + import pandas as pd + + stats = ImportStats() + batch_id = f"{IMPORT_BATCH_PREFIX}{utc_now().strftime('%Y%m%d%H%M%S')}" + sheets = pd.read_excel(path, sheet_name=None) + + chat_frames: list[Any] = [] + order_frames: list[Any] = [] + ticket_frames: list[tuple[str, Any]] = [] + + for sheet_name, frame in sheets.items(): + matched_ticket_type = _match_ticket_type(sheet_name) + if matched_ticket_type: + ticket_frames.append((matched_ticket_type, frame)) + elif any(key.lower() in sheet_name.lower() for key in CHAT_SHEET_KEYWORDS): + chat_frames.append(frame) + elif any(key.lower() in sheet_name.lower() for key in ORDER_SHEET_KEYWORDS): + order_frames.append(frame) + else: + stats.warnings.append(f"未识别的工作表已跳过: {sheet_name}") + + for frame in chat_frames: + _import_chat(frame, db, batch_id, stats) + for frame in order_frames: + _import_orders(frame, db, batch_id, stats) + for ticket_type, frame in ticket_frames: + _import_tickets(frame, db, batch_id, stats, ticket_type) + + return stats + + +# ---------------------------------------------------------------- 各实体导入 + + +def _import_chat(frame: Any, db: Session, batch_id: str, stats: ImportStats) -> None: + session_cache: dict[str, ServiceSession] = {} + seq_counter: dict[str, int] = {} + + for _, row in frame.iterrows(): + session_id = _text(_pick(row, "session_id")) + message_text = _text(_pick(row, "message_text")) + if not session_id or message_text is None: + stats.skipped_rows += 1 + continue + + session = session_cache.get(session_id) + if session is None: + session = db.scalar(select(ServiceSession).where(ServiceSession.session_id == session_id)) + if session is None: + nickname = _text(_pick(row, "consumer_nickname")) or "" + consumer = _upsert_consumer(db, nickname, batch_id) + session = ServiceSession( + session_id=session_id, + consumer_id=consumer.consumer_id, + store_name=_text(_pick(row, "store_name")), + scene_major=_text(_pick(row, "scene_major")), + scene_minor=_text(_pick(row, "scene_minor")), + status=_map_session_status(_pick(row, "session_status")), + started_at=_time(_pick(row, "started_at")), + ended_at=_time(_pick(row, "ended_at")), + emotion="unknown", + risk_level="L0", + source_import_batch=batch_id, + ) + db.add(session) + stats.sessions += 1 + session_cache[session_id] = session + + seq_no = int(_pick(row, "seq_no") or 0) or seq_counter.get(session_id, 0) + 1 + seq_counter[session_id] = max(seq_counter.get(session_id, 0), seq_no) + + message_id = _text(_pick(row, "message_id")) or f"{session_id}-m{seq_no}" + existing = db.scalar(select(Message.message_id).where(Message.message_id == message_id)) + if existing: + continue + db.add( + Message( + message_id=message_id, + session_id=session_id, + seq_no=seq_no, + sent_at=_time(_pick(row, "sent_at")) or to_iso(utc_now()), + role=_map_role(_pick(row, "role")), + sender_label=_text(_pick(row, "sender_label")), + content_type=_text(_pick(row, "content_type")) or "text", + message_text=message_text, + image_path=_text(_pick(row, "image_path")), + is_target_buyer_message=_bool(_pick(row, "is_target_buyer_message")), + source_sheet="chat", + ) + ) + stats.messages += 1 + sent_at = _time(_pick(row, "sent_at")) + if sent_at and (session.last_message_at is None or sent_at > session.last_message_at): + session.last_message_at = sent_at + + +def _import_orders(frame: Any, db: Session, batch_id: str, stats: ImportStats) -> None: + for _, row in frame.iterrows(): + order_no = _text(_pick(row, "order_id")) + if not order_no: + stats.skipped_rows += 1 + continue + session_id = _text(_pick(row, "session_id")) + session = ( + db.scalar(select(ServiceSession).where(ServiceSession.session_id == session_id)) if session_id else None + ) + if session is None: + stats.unlinked_rows += 1 + continue + order_id = order_no if order_no.startswith("O") else f"O{order_no}" + existing = db.scalar(select(SalesOrder).where(SalesOrder.order_id == order_id)) + order = existing or SalesOrder( + order_id=order_id, + order_no=order_id, + consumer_id=session.consumer_id, + source_import_batch=batch_id, + ) + order.session_id = session.session_id + order.store_name = _text(_pick(row, "store_name")) or session.store_name + order.sku = _text(_pick(row, "sku")) + order.product_name = _text(_pick(row, "product_name")) + order.quantity = int(_pick(row, "quantity") or 1) + amount_cent = _money_to_cent(_pick(row, "paid_amount")) + order.unit_price_cent = amount_cent + order.paid_amount_cent = amount_cent + order.order_status = _text(_pick(row, "order_status")) or "unknown" + order.ordered_at = _time(_pick(row, "ordered_at")) + order.paid_at = _time(_pick(row, "paid_at")) + order.shipped_at = _time(_pick(row, "shipped_at")) + order.carrier = _text(_pick(row, "carrier")) + order.tracking_no_masked = mask_tracking_no(_text(_pick(row, "tracking_no"))) + order.shipping_province = _text(_pick(row, "shipping_province")) + order.shipping_city = _text(_pick(row, "shipping_city")) + order.gift_description = _text(_pick(row, "gift_description")) + order.buyer_note_redacted = _text(_pick(row, "buyer_note")) + if existing is None: + db.add(order) + stats.orders += 1 + + +def _import_tickets(frame: Any, db: Session, batch_id: str, stats: ImportStats, ticket_type: str) -> None: + for _, row in frame.iterrows(): + ticket_no = _text(_pick(row, "ticket_id")) + session_id = _text(_pick(row, "session_id")) + if not ticket_no or not session_id: + stats.skipped_rows += 1 + continue + session = db.scalar(select(ServiceSession).where(ServiceSession.session_id == session_id)) + if session is None: + stats.unlinked_rows += 1 + continue + ticket_id = ticket_no if ticket_no.startswith("T") else f"T{ticket_no}" + detail = _extract_ticket_detail(row, ticket_type) + existing = db.scalar(select(ServiceTicket).where(ServiceTicket.ticket_id == ticket_id)) + ticket = existing or ServiceTicket( + ticket_id=ticket_id, + ticket_no=ticket_id, + ticket_type=ticket_type, + session_id=session_id, + consumer_id=session.consumer_id, + source_sheet=ticket_type, + ) + ticket.order_id = _order_id_of(row, db) + ticket.reason = _text(_pick(row, "ticket_reason")) + ticket.priority = _text(_pick(row, "ticket_priority")) or "normal" + ticket.status = _text(_pick(row, "ticket_status")) or "pending" + ticket.assignee = _text(_pick(row, "ticket_assignee")) + ticket.detail_json = json.dumps(detail, ensure_ascii=False) + if existing is None: + db.add(ticket) + stats.tickets += 1 + db.add( + ServiceEvent( + consumer_id=session.consumer_id, + session_id=session_id, + order_id=ticket.order_id, + ticket_id=ticket_id, + event_type="ticket_status", + occurred_at=to_iso(utc_now()), + actor_type="system", + title=f"工单导入: {ticket_type}", + content=f"工单 {ticket_id} 从官方数据导入。", + source_type="ticket", + source_id=ticket_id, + evidence_message_ids=json.dumps([], ensure_ascii=False), + ) + ) + stats.events += 1 + + +# ---------------------------------------------------------------- 工具函数 + + +def _upsert_consumer(db: Session, nickname: str, batch_id: str) -> Consumer: + nickname_hash = hash_nickname(nickname) + consumer = db.scalar(select(Consumer).where(Consumer.nickname_hash == nickname_hash)) + if consumer: + return consumer + consumer = Consumer( + consumer_id=f"C{nickname_hash[:10].upper()}", + display_name_masked=mask_display_name(nickname), + nickname_hash=nickname_hash, + risk_level="L0", + ) + db.add(consumer) + db.flush() + return consumer + + +def _pick(row: Any, key: str) -> Any: + for alias in COLUMN_ALIASES.get(key, []): + if alias in row.index: + value = row[alias] + if value is not None and str(value).strip() not in ("", "nan", "NaT"): + return value + return None + + +def _extract_ticket_detail(row: Any, ticket_type: str) -> dict[str, Any]: + detail: dict[str, Any] = {} + for field_name, aliases in TICKET_DETAIL_ALIASES.get(ticket_type, {}).items(): + for alias in aliases: + if alias in row.index: + value = row[alias] + if value is None or str(value).strip() in ("", "nan", "NaT"): + continue + detail[field_name] = _sanitize_detail(field_name, value) + break + return detail + + +def _sanitize_detail(field_name: str, value: Any) -> Any: + """敏感字段在内存中即完成掩码,原值不落库。""" + text = _text(value) + if text is None: + return None + if field_name in ("alipay_name_masked", "alipay_account_masked"): + return mask_alipay_account(text) if "account" in field_name else mask_display_name(text) + if field_name.endswith("_tracking_masked"): + return mask_tracking_no(text) + if field_name.endswith("_amount_cent"): + return _money_to_cent(value) + if field_name in ("stopped_use", "sought_medical_help", "expedite", "abnormal_flag"): + return _bool(value) + if field_name == "quantity": + return int(value) if str(value).isdigit() else None + return text + + +def _order_id_of(row: Any, db: Session) -> str | None: + order_no = _text(_pick(row, "order_id")) + if not order_no: + return None + order_id = order_no if order_no.startswith("O") else f"O{order_no}" + exists = db.scalar(select(SalesOrder.order_id).where(SalesOrder.order_id == order_id)) + return order_id if exists else None + + +def _match_ticket_type(sheet_name: str) -> str | None: + for keyword, ticket_type in TICKET_SHEET_KEYWORDS: + if keyword in sheet_name: + return ticket_type + return None + + +def _map_role(value: Any) -> str: + text = str(value or "").strip() + mapping = { + "买家": "buyer", + "客户": "buyer", + "消费者": "buyer", + "buyer": "buyer", + "客服": "agent", + "卖家": "agent", + "agent": "agent", + "系统": "system", + "system": "system", + } + return mapping.get(text, "buyer" if "buyer" in text.lower() else "agent") + + +def _map_session_status(value: Any) -> str: + text = str(value or "").strip() + mapping = { + "进行中": "open", + "已结束": "closed", + "已关闭": "closed", + "待处理": "pending", + "open": "open", + "closed": "closed", + "pending": "pending", + } + return mapping.get(text, "open") + + +def _bool(value: Any) -> bool: + text = str(value or "").strip().lower() + return text in {"1", "true", "yes", "是", "y"} + + +def _money_to_cent(value: Any) -> int: + if value is None: + return 0 + text = str(value).replace("¥", "").replace("元", "").replace(",", "").strip() + try: + return int(round(float(text) * 100)) + except ValueError: + return 0 + + +def _text(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + if text in ("", "nan", "NaT", "None"): + return None + return text + + +def _time(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return to_iso(value) + text = str(value).strip() + if not text or text in ("nan", "NaT"): + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"): + try: + return to_iso(datetime.strptime(text, fmt)) + except ValueError: + continue + return None diff --git a/app/db/seed.py b/app/db/seed.py new file mode 100644 index 0000000..5963c3e --- /dev/null +++ b/app/db/seed.py @@ -0,0 +1,205 @@ +# 基线灌库与 Demo 重置(事务内完成,失败整笔回滚) +# +# reset_demo 行为(对应数据库设计文档第 7 节): +# 1. 清理演示运行数据:动作、分析、承诺、事件、审计。 +# 2. 清理演示期间生成的业务数据(无导入批次标记的会话/消息/订单/工单/消费者)。 +# 3. preserve_import=false 时同时清理 Excel 导入数据(source_import_batch 前缀 excel-)。 +# 4. 恢复 baseline-v1 固定初始状态,最后写入 demo_reset 审计记录。 + +import hashlib +import json +from datetime import datetime +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app.core.time_utils import to_iso, utc_now +from app.db.baseline_data import BASELINE_SEED, SCENARIO_SESSION_IDS, build_baseline +from app.models import ( + ActionExecution, + AIAnalysis, + AuditLog, + Consumer, + Message, + Promise, + SalesOrder, + ServiceEvent, + ServiceSession, + ServiceTicket, +) + + +def apply_baseline(db: Session, scenario: str = "all", now: datetime | None = None) -> dict[str, int]: + """将 baseline-v1 数据 upsert 到数据库(不做清理,供脚本初始化使用)。""" + data = build_baseline(now or utc_now()) + refreshed_at = to_iso(utc_now()) + + # 注意:SQLite 外键立即校验,按依赖顺序 flush 保证父行先落库 + for item in data.consumers: + db.merge( + Consumer( + consumer_id=item["consumer_id"], + display_name_masked=item["display_name_masked"], + nickname_hash=_nickname_hash(item["consumer_id"]), + risk_level=item["risk_level"], + risk_note=item["risk_note"], + updated_at=refreshed_at, + ) + ) + db.flush() + for item in data.sessions: + db.merge(ServiceSession(**item, source_import_batch=BASELINE_SEED, updated_at=refreshed_at)) + db.flush() + for item in data.orders: + db.merge(SalesOrder(**item, source_import_batch=BASELINE_SEED, updated_at=refreshed_at)) + for item in data.tickets: + db.merge(_ticket_from_item(item, refreshed_at)) + db.flush() + for item in data.messages: + db.merge(Message(**item, source_sheet=BASELINE_SEED)) + for item in data.promises: + db.merge(_promise_from_item(item, refreshed_at)) + db.flush() + for item in data.events: + db.merge(_event_from_item(item)) + + db.flush() + return _counts(db) + + +def reset_demo( + db: Session, + scenario: str = "all", + preserve_import: bool = True, + now: datetime | None = None, +) -> dict[str, Any]: + """恢复固定初始数据;在调用方事务中执行,异常时由调用方回滚。""" + started = utc_now() + + # 1. 演示运行数据整表清理(这些表全部由 Demo 流程生成) + db.execute(delete(ActionExecution)) + db.execute(delete(AIAnalysis)) + db.execute(delete(Promise)) + db.execute(delete(ServiceEvent)) + db.execute(delete(AuditLog)) + + # 2. 演示期间生成的业务行(无来源标记)一律清理: + # 例如会话中追加的消息、通过动作创建的工单 + db.execute(delete(Message).where(Message.source_sheet.is_(None))) + db.execute(delete(ServiceTicket).where(ServiceTicket.source_sheet.is_(None))) + + # 3. 业务表:清理演示期间生成的会话(无导入批次标记) + _cleanup_sessions(db, batch_is_null=True) + if not preserve_import: + # 同时清理 Excel 导入数据(source_import_batch 前缀 excel-) + _cleanup_sessions(db, batch_is_null=False) + + # 4. 恢复基线 + counts = apply_baseline(db, scenario=scenario, now=now) + + # 5. 审计记录 + elapsed_ms = int((utc_now() - started).total_seconds() * 1000) + db.add( + AuditLog( + request_id=f"demo_reset_{started.isoformat()}", + operator_id="system", + entity_type="demo", + entity_id=BASELINE_SEED, + operation="demo_reset", + before_json=None, + after_json=json.dumps( + {"scenario": scenario, "counts": counts, "duration_ms": elapsed_ms}, + ensure_ascii=False, + ), + ) + ) + db.flush() + return {"counts": counts, "duration_ms": elapsed_ms, "started_at": started} + + +def _cleanup_sessions(db: Session, batch_is_null: bool) -> None: + """清理指定批次的会话及其业务数据与会话专属消费者。""" + if batch_is_null: + condition = ServiceSession.source_import_batch.is_(None) + else: + condition = ServiceSession.source_import_batch.like("excel-%") + + session_ids = db.scalars(select(ServiceSession.session_id).where(condition)).all() + if not session_ids: + return + consumer_ids = db.scalars( + select(ServiceSession.consumer_id).where(ServiceSession.session_id.in_(session_ids)).distinct() + ).all() + db.execute(delete(Message).where(Message.session_id.in_(session_ids))) + db.execute(delete(SalesOrder).where(SalesOrder.session_id.in_(session_ids))) + db.execute(delete(ServiceTicket).where(ServiceTicket.session_id.in_(session_ids))) + db.execute(delete(ServiceSession).where(ServiceSession.session_id.in_(session_ids))) + for consumer_id in consumer_ids: + remaining = db.scalar( + select(ServiceSession.session_id).where(ServiceSession.consumer_id == consumer_id).limit(1) + ) + if remaining is None: + db.execute(delete(Consumer).where(Consumer.consumer_id == consumer_id)) + + +def scenario_session_ids(scenario: str) -> list[str]: + if scenario == "all": + ids: list[str] = [] + for session_ids in SCENARIO_SESSION_IDS.values(): + ids.extend(session_ids) + return ids + return SCENARIO_SESSION_IDS.get(scenario, []) + + +def _nickname_hash(consumer_id: str) -> str: + from app.core.masking import hash_nickname + + return hash_nickname(f"baseline::{consumer_id}") + + +def _ticket_from_item(item: dict[str, Any], refreshed_at: str) -> ServiceTicket: + payload = dict(item) + detail = payload.pop("detail", {}) or {} + payload.setdefault("source_sheet", BASELINE_SEED) + return ServiceTicket(**payload, detail_json=json.dumps(detail, ensure_ascii=False), updated_at=refreshed_at) + + +def _promise_from_item(item: dict[str, Any], refreshed_at: str) -> Promise: + payload = dict(item) + for key in ("confirmed_at", "fulfilled_at", "cancelled_at"): + if key in payload and payload[key] is None: + payload.pop(key) + payload["updated_at"] = refreshed_at + return Promise(**payload) + + +def _event_from_item(item: dict[str, Any]) -> ServiceEvent: + payload = dict(item) + evidence = payload.pop("evidence_message_ids", []) or [] + metadata = payload.pop("metadata_json", None) + # 事件使用确定性 ID,保证重复灌库幂等 + stable_key = ( + f"{payload.get('consumer_id')}|{payload.get('session_id')}|{payload.get('title')}|{payload.get('occurred_at')}" + ) + payload["event_id"] = "evt-base-" + hashlib.sha1(stable_key.encode("utf-8")).hexdigest()[:16] + return ServiceEvent( + **payload, + evidence_message_ids=json.dumps(evidence, ensure_ascii=False), + metadata_json=metadata, + ) + + +def _counts(db: Session) -> dict[str, int]: + def count(model: Any) -> int: + return int(db.scalar(select(func.count()).select_from(model)) or 0) + + return { + "consumers": count(Consumer), + "sessions": count(ServiceSession), + "messages": count(Message), + "orders": count(SalesOrder), + "tickets": count(ServiceTicket), + "promises": count(Promise), + "events": count(ServiceEvent), + } diff --git a/app/main.py b/app/main.py index 4b13fcd..a8c6396 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,92 @@ -from fastapi import FastAPI -from pydantic import BaseModel +# FastAPI 应用入口:中间件、全局异常处理与路由挂载 +# +# 全局约定(见接口设计文档第 2 节): +# - 成功/失败统一封装 {data, meta, error};Base URL 为 /api +# - 请求日志带 request_id / operator_id,不记录请求体与敏感原文 +# - 模型超时/断网时业务接口仍返回 200 + degraded 结果;仅数据库不可读写才阻断 +import logging -class HealthResponse(BaseModel): - status: str +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException +from app.api.routes import api_router +from app.core import config +from app.core.envelope import error_response, request_id_of +from app.core.errors import ApiError, ErrorCode +from app.core.middleware import RequestContextMiddleware -app = FastAPI(title="InsightCopilot") +logger = logging.getLogger("app.main") +app = FastAPI(title=config.APP_NAME, version=config.APP_VERSION) -@app.get("/health", response_model=HealthResponse) -async def health() -> HealthResponse: - return HealthResponse(status="ok") +# 中间件:后添加的位于更外层;CORS 需要最外层以便直接响应预检请求 +app.add_middleware(RequestContextMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ALLOW_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["X-Request-ID"], +) + +app.include_router(api_router, prefix=config.API_PREFIX) + + +@app.exception_handler(ApiError) +async def handle_api_error(request: Request, exc: ApiError) -> JSONResponse: + return error_response(exc.status_code, request_id_of(request), exc.code, exc.message, exc.details) + + +@app.exception_handler(RequestValidationError) +async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + return error_response( + 400, + request_id_of(request), + ErrorCode.INVALID_PARAMETER, + "请求参数不合法", + _validation_details(exc), + ) + + +@app.exception_handler(StarletteHTTPException) +async def handle_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse: + return error_response(exc.status_code, request_id_of(request), _code_for_status(exc.status_code), str(exc.detail)) + + +@app.exception_handler(Exception) +async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse: + logger.exception("unhandled error request_id=%s", request_id_of(request)) + return error_response(500, request_id_of(request), ErrorCode.INTERNAL_ERROR, "服务内部错误") + + +def _validation_details(exc: RequestValidationError) -> dict: + """只保留定位/类型/描述,不回显请求体原文(可能包含敏感内容)。""" + return { + "errors": [ + { + "loc": [str(part) for part in error.get("loc", ())], + "msg": str(error.get("msg", "")), + "type": str(error.get("type", "")), + } + for error in exc.errors() + ] + } + + +def _code_for_status(status_code: int) -> str: + if status_code == 404: + return ErrorCode.NOT_FOUND + if status_code == 405: + return ErrorCode.METHOD_NOT_ALLOWED + if status_code == 401: + return ErrorCode.UNAUTHORIZED + if status_code == 403: + return ErrorCode.FORBIDDEN + if status_code < 500: + return ErrorCode.INVALID_PARAMETER + return ErrorCode.INTERNAL_ERROR diff --git a/app/schemas/api.py b/app/schemas/api.py new file mode 100644 index 0000000..c950b07 --- /dev/null +++ b/app/schemas/api.py @@ -0,0 +1,123 @@ +# 接口契约模型(对应《FastAPI 接口设计文档》第 3 节) +# +# 字段可扩展,不可删除或改变语义。工单 detail 白名单见 app/schemas/schemas.py。 +# 当前阶段启用:TicketUpdateRequest(工单字段式更新); +# CopilotInsight / Action* / Promise* / MessagePost / PromiseScan / DemoReset 为后续阶段契约(未挂载路由)。 + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + +RiskLevel = Literal["L0", "L1", "L2", "L3"] +PromiseStatus = Literal["pending_confirmation", "active", "due_soon", "overdue", "fulfilled", "changed", "cancelled"] +ActionType = Literal["CREATE_TICKET", "ESCALATE", "FOLLOW_UP", "NUDGE_TICKET", "CREATE_PROMISE", "MARK_FULFILLED"] +# 工单状态与优先级枚举(对应数据库设计文档:service_ticket.status / priority) +TicketStatus = Literal["draft", "pending", "in_progress", "pending_customer", "completed", "cancelled"] +TicketPriority = Literal["normal", "urgent", "critical"] + + +class SessionListItem(BaseModel): + session_id: str + customer_id: str + customer_name_masked: str + store_name: str + scene_major: str | None = None + scene_minor: str | None = None + last_message_at: datetime | None = None + risk_level: RiskLevel = "L0" + emotion: str = "unknown" + unresolved_count: int = 0 + open_ticket_count: int = 0 + active_promise_count: int = 0 + + +class EvidenceRef(BaseModel): + source_type: Literal["chat", "order", "ticket", "rule", "action"] + source_id: str + message_id: str | None = None + quote: str | None = None # 脱敏短引文 + + +class CopilotInsight(BaseModel): + intent_primary: str | None = None + intent_secondary: str | None = None + emotion: str = "unknown" + risk_level: RiskLevel = "L0" + risk_reasons: list[str] = Field(default_factory=list) + missing_fields: list[str] = Field(default_factory=list) + suggested_actions: list[str] = Field(default_factory=list) + evidence: list[EvidenceRef] = Field(default_factory=list) + model_route: Literal["fast", "reasoning", "vision", "mock"] = "mock" + degraded: bool = False + + +class ActionPreviewRequest(BaseModel): + session_id: str + action_type: ActionType + draft_payload: dict[str, Any] = Field(default_factory=dict) + source_message_id: str | None = None + + +class ActionConfirmRequest(BaseModel): + action_id: str + idempotency_key: str = Field(min_length=8, max_length=128) + payload: dict[str, Any] | None = None + + +class PromisePatch(BaseModel): + status: PromiseStatus | None = None + due_at: datetime | None = None + owner_id: str | None = None + verification_type: str | None = None + verification_ref: str | None = None + reason: str | None = None + + +class PromiseExtractRequest(BaseModel): + session_id: str + message_id: str + message_text: str + mode: Literal["model", "mock", "auto"] = "auto" + + +class MessagePostRequest(BaseModel): + """模拟客服最终发送消息或追加系统消息(send=false 仅草稿,不落库)。""" + + role: Literal["agent", "system"] = "agent" + content: str = Field(min_length=1, max_length=4000) + send: bool = True + client_message_id: str | None = None + + +class PromiseScanRequest(BaseModel): + now: datetime | None = None # 仅 Mock/测试环境允许 + dry_run: bool = False + + +class DemoResetRequest(BaseModel): + scenario: Literal["all", "refund", "adverse_reaction", "gift_resend"] = "all" + seed: str = "baseline-v1" + preserve_import: bool = True + + +class TicketUpdateRequest(BaseModel): + """工单字段式更新(PATCH /api/tickets/{ticket_id}):至少提供一个字段。 + + - status 变更时自动维护 completed_at,并写入 service_event 审计与时间线; + - note 仅作为审计事件备注,不落库到工单主表。 + """ + + status: TicketStatus | None = None + priority: TicketPriority | None = None + assignee: str | None = Field(None, max_length=64) + note: str | None = Field(None, max_length=500) + +class SessionMessageCreateRequest(BaseModel): + """模拟客服发送消息 / 保存草稿。""" + + role: Literal["agent", "system", "customer"] = "agent" + content: str = Field(min_length=1, max_length=5000) + send: bool = True # 是否立即发送(否则仅保存草稿) + client_message_id: str | None = Field(default=None, max_length=64) + sender_label: str | None = Field(default=None, max_length=50) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..ae13775 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# 服务层:状态机与事务(路由层只做参数与权限校验) diff --git a/app/services/consumer_service.py b/app/services/consumer_service.py new file mode 100644 index 0000000..bac394b --- /dev/null +++ b/app/services/consumer_service.py @@ -0,0 +1,47 @@ +# 客户服务:基础信息与统计查询(会话/订单/工单维度) +# +# - 统计口径与工作台一致:未闭环工单 = status 不在 CLOSED_TICKET_STATUSES。 +# - 输出统一走 to_api_time 转换为固定 +08:00 展示时间。 + +from sqlalchemy import func, select +from sqlalchemy.orm import Session as DbSession + +from app.core.errors import customer_not_found +from app.core.time_utils import to_api_time +from app.models import Consumer, SalesOrder, ServiceSession, ServiceTicket +from app.services.session_service import CLOSED_TICKET_STATUSES + + +def get_customer(db: DbSession, customer_id: str) -> dict: + """客户基础信息与统计;不存在时抛 404 CUSTOMER_NOT_FOUND。""" + consumer = db.get(Consumer, customer_id) + if consumer is None: + raise customer_not_found(customer_id) + + session_count = db.scalar( + select(func.count()).select_from(ServiceSession).where(ServiceSession.consumer_id == customer_id) + ) + order_count = db.scalar(select(func.count()).select_from(SalesOrder).where(SalesOrder.consumer_id == customer_id)) + ticket_count = db.scalar( + select(func.count()).select_from(ServiceTicket).where(ServiceTicket.consumer_id == customer_id) + ) + open_ticket_count = db.scalar( + select(func.count()) + .select_from(ServiceTicket) + .where(ServiceTicket.consumer_id == customer_id, ServiceTicket.status.not_in(CLOSED_TICKET_STATUSES)) + ) + + return { + "customer_id": consumer.consumer_id, + "display_name_masked": consumer.display_name_masked, + "risk_level": consumer.risk_level, + "risk_note": consumer.risk_note, + "created_at": to_api_time(consumer.created_at), + "updated_at": to_api_time(consumer.updated_at), + "statistics": { + "session_count": int(session_count or 0), + "order_count": int(order_count or 0), + "ticket_count": int(ticket_count or 0), + "open_ticket_count": int(open_ticket_count or 0), + }, + } diff --git a/app/services/order_service.py b/app/services/order_service.py new file mode 100644 index 0000000..668bc2d --- /dev/null +++ b/app/services/order_service.py @@ -0,0 +1,50 @@ +# 订单服务:订单事实查询与条件检索(金额与状态来自数据库,不经过模型改写) + +from sqlalchemy import func, select +from sqlalchemy.orm import Session as DbSession + +from app.core.errors import order_not_found +from app.models import SalesOrder, ServiceTicket +from app.services import serializers + + +def get_order(db: DbSession, order_id: str) -> dict: + order = db.get(SalesOrder, order_id) + if order is None: + raise order_not_found(order_id) + ticket_ids = db.scalars(select(ServiceTicket.ticket_id).where(ServiceTicket.order_id == order_id)).all() + return serializers.order_item(order, list(ticket_ids)) + + +def list_orders( + db: DbSession, + *, + order_no: str | None = None, + customer_id: str | None = None, + session_id: str | None = None, + page: int = 1, + page_size: int = 20, +) -> tuple[list[dict], int]: + """按订单号(精确)或关联条件(消费者/会话)检索订单列表,按下单时间倒序。""" + query = select(SalesOrder) + if order_no: + query = query.where(SalesOrder.order_no == order_no) + if customer_id: + query = query.where(SalesOrder.consumer_id == customer_id) + if session_id: + query = query.where(SalesOrder.session_id == session_id) + + total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 + + orders = db.scalars( + query.order_by(SalesOrder.ordered_at.desc(), SalesOrder.order_id.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + + order_ids = [order.order_id for order in orders] + ticket_ids_by_order: dict[str, list[str]] = {} + if order_ids: + for ticket in db.scalars(select(ServiceTicket).where(ServiceTicket.order_id.in_(order_ids))): + ticket_ids_by_order.setdefault(ticket.order_id or "", []).append(ticket.ticket_id) + return [serializers.order_item(order, ticket_ids_by_order.get(order.order_id, [])) for order in orders], int(total) diff --git a/app/services/risk_service.py b/app/services/risk_service.py new file mode 100644 index 0000000..54641c2 --- /dev/null +++ b/app/services/risk_service.py @@ -0,0 +1,253 @@ +# 风险队列服务:会话、工单与承诺的主管视角聚合 +# +# 【当前阶段已下线】路由未挂载(见 app/api/routes/risk.py);代码保留待恢复。 +# - 风险理由优先复用最近一次 Agent 分析结论(与副驾卡一致); +# 尚无分析记录时用规则引擎实时兜底,保证开箱即用。 +# - 按 session_id 批量聚合子查询,避免逐会话 N+1。 + +import json +from collections import defaultdict +from datetime import datetime + +from sqlalchemy import case, func, select +from sqlalchemy.orm import Session as DbSession + +from app.agents import rules +from app.core.masking import mask_quote +from app.core.time_utils import utc_now +from app.models import AIAnalysis, Consumer, Message, Promise, SalesOrder, ServiceSession, ServiceTicket +from app.services import serializers + +CLOSED_TICKET_STATUSES = ("completed", "cancelled") +OPEN_PROMISE_STATUSES = ("pending_confirmation", "active", "due_soon", "overdue") + +_RISK_CASE = case({"L3": 0, "L2": 1, "L1": 2, "L0": 3}, value=ServiceSession.risk_level, else_=4) + + +def build_risk_queue( + db: DbSession, + *, + risk_level: str | None = None, + ticket_type: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 20, +) -> tuple[list[dict], int]: + base = select(ServiceSession, Consumer).join(Consumer, Consumer.consumer_id == ServiceSession.consumer_id) + if risk_level: + base = base.where(ServiceSession.risk_level == risk_level) + if status: + base = base.where(ServiceSession.status == status) + if ticket_type: + # 消费者维度:退款/补发工单可能登记在历史会话,本次进线也需提示 + base = base.where( + ServiceSession.consumer_id.in_( + select(ServiceTicket.consumer_id).where(ServiceTicket.ticket_type == ticket_type) + ) + ) + + total = db.scalar(select(func.count()).select_from(base.subquery())) or 0 + rows = db.execute( + base.order_by(_RISK_CASE.asc(), ServiceSession.last_message_at.desc(), ServiceSession.session_id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + if not rows: + return [], int(total) + + session_ids = [session.session_id for session, _ in rows] + consumer_ids = sorted({session.consumer_id for session, _ in rows}) + + # 工单/承诺按消费者维度聚合:主管队列需要看到跨会话的未闭环事项 + tickets_by_consumer: dict[str, list[ServiceTicket]] = defaultdict(list) + for ticket in db.scalars(select(ServiceTicket).where(ServiceTicket.consumer_id.in_(consumer_ids))): + tickets_by_consumer[ticket.consumer_id].append(ticket) + + promises_by_consumer: dict[str, list[Promise]] = defaultdict(list) + for promise in db.scalars(select(Promise).where(Promise.consumer_id.in_(consumer_ids))): + promises_by_consumer[promise.consumer_id].append(promise) + + orders_by_session: dict[str, list[SalesOrder]] = defaultdict(list) + for order in db.scalars(select(SalesOrder).where(SalesOrder.session_id.in_(session_ids))): + orders_by_session[order.session_id].append(order) + + messages_by_session: dict[str, list[Message]] = defaultdict(list) + messages_by_id: dict[str, Message] = {} + for message in db.scalars( + select(Message).where(Message.session_id.in_(session_ids)).order_by(Message.sent_at.asc(), Message.seq_no.asc()) + ): + messages_by_session[message.session_id].append(message) + messages_by_id[message.message_id] = message + + analyses: dict[str, AIAnalysis] = {} + for analysis in db.scalars( + select(AIAnalysis).where(AIAnalysis.session_id.in_(session_ids)).order_by(AIAnalysis.created_at.asc()) + ): + analyses[analysis.session_id] = analysis # 升序遍历,最终保留最新一条 + + consumer_session_counts = dict( + db.execute( + select(ServiceSession.consumer_id, func.count()) + .where(ServiceSession.consumer_id.in_(consumer_ids)) + .group_by(ServiceSession.consumer_id) + ).all() + ) + + now = utc_now() + items = [ + _build_item( + session=session, + consumer=consumer, + tickets=tickets_by_consumer.get(session.consumer_id, []), + promises=promises_by_consumer.get(session.consumer_id, []), + orders=orders_by_session.get(session.session_id, []), + messages=messages_by_session.get(session.session_id, []), + messages_by_id=messages_by_id, + analysis=analyses.get(session.session_id), + consumer_session_count=int(consumer_session_counts.get(session.consumer_id, 1)), + now=now, + ) + for session, consumer in rows + ] + return items, int(total) + + +def _build_item( + *, + session: ServiceSession, + consumer: Consumer, + tickets: list[ServiceTicket], + promises: list[Promise], + orders: list[SalesOrder], + messages: list[Message], + messages_by_id: dict[str, Message], + analysis: AIAnalysis | None, + consumer_session_count: int, + now: datetime, +) -> dict: + reasons, evidence_ids = _resolve_reasons( + session=session, + analysis=analysis, + messages=messages, + orders=orders, + tickets=tickets, + promises=promises, + consumer_session_count=consumer_session_count, + now=now, + ) + open_tickets = [ticket for ticket in tickets if ticket.status not in CLOSED_TICKET_STATUSES] + active_promises = [promise for promise in promises if promise.status in OPEN_PROMISE_STATUSES] + + item = serializers.session_brief(session, consumer) + item.update( + { + "risk_reasons": reasons, + "evidence": _evidence_refs(evidence_ids, messages_by_id), + "open_ticket_count": len(open_tickets), + "active_promise_count": len(active_promises), + "tickets": [ + { + "ticket_id": ticket.ticket_id, + "ticket_type": ticket.ticket_type, + "status": ticket.status, + "priority": ticket.priority, + "reason": ticket.reason, + } + for ticket in sorted(tickets, key=lambda t: (t.status in CLOSED_TICKET_STATUSES, t.created_at or "")) + ], + "promises": [ + serializers.promise_item( + promise, session=session, consumer=consumer, now=now, include_verification=True + ) + for promise in sorted(active_promises, key=lambda p: p.due_at or "") + ], + } + ) + return item + + +def _resolve_reasons( + *, + session: ServiceSession, + analysis: AIAnalysis | None, + messages: list[Message], + orders: list[SalesOrder], + tickets: list[ServiceTicket], + promises: list[Promise], + consumer_session_count: int, + now: datetime, +) -> tuple[list[str], list[str]]: + """优先复用 Agent 结论;无分析记录时由规则引擎实时兜底。""" + if analysis is not None: + parsed = _load_json_dict(analysis.intent_json) + reasons = [str(reason) for reason in (parsed.get("risk_reasons") or [])] + if reasons: + evidence = _load_json_list(analysis.evidence_message_ids) + return reasons, evidence + + result = rules.analyze( + session=session, + messages=messages, + orders=orders, + tickets=[ + rules.TicketContext( + ticket_id=ticket.ticket_id, + ticket_type=ticket.ticket_type, + status=ticket.status, + priority=ticket.priority, + ) + for ticket in tickets + ], + promises=[ + rules.PromiseContext( + promise_id=promise.promise_id, + session_id=promise.session_id, + status=promise.status, + statement=promise.statement, + promise_type=promise.promise_type, + due_at=promise.due_at, + verification_type=promise.verification_type, + verification_ref=promise.verification_ref, + ) + for promise in promises + ], + consumer_session_count=consumer_session_count, + now=now, + ) + return result.risk_reasons, result.evidence_message_ids + + +def _evidence_refs(evidence_ids: list[str], messages_by_id: dict[str, Message]) -> list[dict]: + """证据引用:message_id + 脱敏短引文。""" + refs: list[dict] = [] + for message_id in evidence_ids[:5]: + message = messages_by_id.get(message_id) + refs.append( + { + "source_type": "chat", + "source_id": message_id, + "message_id": message_id, + "quote": mask_quote(message.message_text if message is not None else None), + } + ) + return refs + + +def _load_json_dict(value: str | None) -> dict: + if not value: + return {} + try: + loaded = json.loads(value) + except (TypeError, ValueError): + return {} + return loaded if isinstance(loaded, dict) else {} + + +def _load_json_list(value: str | None) -> list[str]: + if not value: + return [] + try: + loaded = json.loads(value) + except (TypeError, ValueError): + return [] + return [str(item) for item in loaded] if isinstance(loaded, list) else [] diff --git a/app/services/serializers.py b/app/services/serializers.py new file mode 100644 index 0000000..9422544 --- /dev/null +++ b/app/services/serializers.py @@ -0,0 +1,280 @@ +# API 序列化层:数据库实体 -> 接口契约字典(统一脱敏与 RFC3339 时间) +# +# 当前阶段已下线:promise_item / copilot_snapshot(承诺与副驾视图,代码保留待恢复)。 + +import json +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ValidationError + +from app.core.masking import mask_alipay_account +from app.core.time_utils import seconds_until, to_api_time +from app.models import AIAnalysis, Consumer, Message, Promise, SalesOrder, ServiceEvent, ServiceSession, ServiceTicket +from app.schemas.schemas import ( + AdverseReactionDetail, + LogisticsDetail, + OfflinePaymentDetail, + ReplenishmentExchangeDetail, + ReturnDetail, +) + +TICKET_DETAIL_MODELS: dict[str, type[BaseModel]] = { + "replenishment_exchange": ReplenishmentExchangeDetail, + "offline_payment": OfflinePaymentDetail, + "logistics": LogisticsDetail, + "adverse_reaction": AdverseReactionDetail, + "return": ReturnDetail, +} + + +def money_display(cent: int | None) -> str | None: + if cent is None: + return None + return f"¥{cent / 100:.2f}" + + +def event_item(event: ServiceEvent) -> dict[str, Any]: + evidence: list[str] = [] + if event.evidence_message_ids: + try: + evidence = json.loads(event.evidence_message_ids) + except (TypeError, ValueError): + evidence = [] + return { + "event_id": event.event_id, + "event_type": event.event_type, + "occurred_at": to_api_time(event.occurred_at), + "title": event.title, + "content": event.content, + "source_type": event.source_type, + "source_id": event.source_id, + "evidence_message_ids": evidence, + "actor_type": event.actor_type, + "actor_id": event.actor_id, + "ticket_id": event.ticket_id, + "order_id": event.order_id, + } + + +def message_item(message: Message) -> dict[str, Any]: + return { + "message_id": message.message_id, + "session_id": message.session_id, + "seq_no": message.seq_no, + "sent_at": to_api_time(message.sent_at), + "role": message.role, + "sender_label": message.sender_label, + "content_type": message.content_type, + "message_text": message.message_text, + "image_path": message.image_path, + "is_target_buyer_message": bool(message.is_target_buyer_message), + } + + +def session_brief(session: ServiceSession, consumer: Consumer | None) -> dict[str, Any]: + return { + "session_id": session.session_id, + "customer_id": session.consumer_id, + "customer_name_masked": consumer.display_name_masked if consumer else "用户**", + "store_name": session.store_name, + "scene_major": session.scene_major, + "scene_minor": session.scene_minor, + "status": session.status, + "started_at": to_api_time(session.started_at), + "last_message_at": to_api_time(session.last_message_at), + "intent_primary": session.intent_primary, + "intent_secondary": session.intent_secondary, + "emotion": session.emotion, + "risk_level": session.risk_level, + "summary": session.summary, + "unresolved_count": session.unresolved_count, + } + + +def order_item(order: SalesOrder, related_ticket_ids: list[str] | None = None) -> dict[str, Any]: + return { + "order_id": order.order_id, + "order_no": order.order_no, + "session_id": order.session_id, + "customer_id": order.consumer_id, + "store_name": order.store_name, + "sku": order.sku, + "product_name": order.product_name, + "quantity": order.quantity, + "unit_price_cent": order.unit_price_cent, + "unit_price_display": money_display(order.unit_price_cent), + "paid_amount_cent": order.paid_amount_cent, + "paid_amount_display": money_display(order.paid_amount_cent), + "order_status": order.order_status, + "ordered_at": to_api_time(order.ordered_at), + "paid_at": to_api_time(order.paid_at), + "shipped_at": to_api_time(order.shipped_at), + "carrier": order.carrier, + "tracking_no_masked": order.tracking_no_masked, + "shipping_province": order.shipping_province, + "shipping_city": order.shipping_city, + "gift_description": order.gift_description, + "buyer_note_redacted": order.buyer_note_redacted, + "related_session_ids": [order.session_id] if order.session_id else [], + "related_ticket_ids": related_ticket_ids or [], + } + + +def ticket_item(ticket: ServiceTicket, events: list[ServiceEvent] | None = None) -> dict[str, Any]: + item: dict[str, Any] = { + "ticket_id": ticket.ticket_id, + "ticket_no": ticket.ticket_no, + "ticket_type": ticket.ticket_type, + "session_id": ticket.session_id, + "customer_id": ticket.consumer_id, + "order_id": ticket.order_id, + "reason": ticket.reason, + "priority": ticket.priority, + "status": ticket.status, + "assignee": ticket.assignee, + "detail": sanitize_ticket_detail(ticket.ticket_type, ticket.detail_json), + "created_at": to_api_time(ticket.created_at), + "completed_at": to_api_time(ticket.completed_at), + "updated_at": to_api_time(ticket.updated_at), + } + if events is not None: + item["events"] = [event_item(e) for e in events] + return item + + +def sanitize_ticket_detail(ticket_type: str, detail_json: str | None) -> dict[str, Any]: + """按 ticket_type 白名单校验 detail_json:未知字段丢弃,隐私字段兜底掩码。""" + parsed: dict[str, Any] = {} + if detail_json: + try: + loaded = json.loads(detail_json) + if isinstance(loaded, dict): + parsed = loaded + except (TypeError, ValueError): + parsed = {} + + model_cls = TICKET_DETAIL_MODELS.get(ticket_type) + if model_cls is not None and parsed: + try: + validated = model_cls.model_validate(parsed) + detail = validated.model_dump(exclude_none=True) + except ValidationError: + known = set(model_cls.model_fields) + detail = {k: v for k, v in parsed.items() if k in known} + else: + detail = parsed + + for key in ("alipay_name_masked", "alipay_account_masked"): + if detail.get(key): + detail[key] = mask_alipay_account(str(detail[key])) if "account" in key else detail[key] + return detail + + +def promise_item( + promise: Promise, + *, + session: ServiceSession | None = None, + consumer: Consumer | None = None, + now: datetime | None = None, + include_verification: bool = False, +) -> dict[str, Any]: + """承诺序列化。【已下线:承诺只读视图暂停输出,恢复时重新接入会话详情】""" + item: dict[str, Any] = { + "promise_id": promise.promise_id, + "session_id": promise.session_id, + "customer_id": promise.consumer_id, + "customer_name_masked": consumer.display_name_masked if consumer else None, + "promise_type": promise.promise_type, + "statement": promise.statement, + "due_at": to_api_time(promise.due_at), + "status": promise.status, + "remaining_seconds": seconds_until(promise.due_at, now), + "owner_type": promise.owner_type, + "owner_id": promise.owner_id, + "source_message_id": promise.source_message_id, + "risk_level": session.risk_level if session else None, + "version": promise.version, + "evidence": [ + { + "source_type": "chat", + "source_id": promise.source_message_id, + "message_id": promise.source_message_id, + } + ], + "confirmed_at": to_api_time(promise.confirmed_at), + "fulfilled_at": to_api_time(promise.fulfilled_at), + "cancelled_at": to_api_time(promise.cancelled_at), + } + if include_verification: + item["verification"] = { + "verification_type": promise.verification_type, + "verification_ref": promise.verification_ref, + "evidence_event_id": promise.evidence_event_id, + } + return item + + +def _load_json_dict(value: str | None) -> dict[str, Any]: + if not value: + return {} + try: + loaded = json.loads(value) + except (TypeError, ValueError): + return {} + return loaded if isinstance(loaded, dict) else {} + + +def copilot_snapshot(analysis: AIAnalysis | None) -> dict[str, Any]: + """会话详情中的 copilot 快照:读取最近一次分析记录(尚无分析时为 None)。 + + intent_json 约定保存完整的结构化结论(意图/风险理由/建议动作)。 + 【已下线:Agent 副驾与详情分析段暂停输出,恢复时重新接入会话详情。】 + """ + if analysis is None: + return { + "insight": None, + "draft_reply": None, + "generated_at": None, + "analysis_id": None, + "model_route": None, + "degraded": False, + } + + intent_json = _load_json_dict(analysis.intent_json) + raw_evidence = _load_json_dict_or_list(analysis.evidence_message_ids) + missing_fields = _load_json_dict_or_list(analysis.missing_fields_json) + + degraded = (analysis.model_route or "mock") == "mock" + insight = { + "intent_primary": intent_json.get("intent_primary"), + "intent_secondary": intent_json.get("intent_secondary"), + "emotion": analysis.emotion or "unknown", + "risk_level": analysis.risk_level or "L0", + "risk_reasons": intent_json.get("risk_reasons") or [], + "missing_fields": missing_fields if isinstance(missing_fields, list) else [], + "suggested_actions": intent_json.get("suggested_actions") or [], + "evidence": [ + {"source_type": "chat", "source_id": str(mid), "message_id": str(mid), "quote": None} + for mid in (raw_evidence if isinstance(raw_evidence, list) else []) + ], + "model_route": analysis.model_route or "mock", + "degraded": degraded, + } + return { + "insight": insight, + "draft_reply": analysis.draft_text, + "generated_at": to_api_time(analysis.created_at), + "analysis_id": analysis.analysis_id, + "model_route": analysis.model_route, + "degraded": degraded, + } + + +def _load_json_dict_or_list(value: str | None) -> Any: + if not value: + return None + try: + return json.loads(value) + except (TypeError, ValueError): + return None diff --git a/app/services/session_service.py b/app/services/session_service.py new file mode 100644 index 0000000..cf77d29 --- /dev/null +++ b/app/services/session_service.py @@ -0,0 +1,299 @@ +# 会话服务:队列查询、聚合详情与聊天消息检索 +# +# - 队列排序:默认风险优先(L3/L2 在前),其次按最近消息倒序。 +# - 详情聚合消息、订单、工单与事件;输出统一走序列化层脱敏。 +# - 当前阶段已下线:承诺只读视图与 Agent 分析快照(代码保留,见文件末尾说明)。 + +import uuid +from sqlalchemy import case, func, or_, select +from sqlalchemy.orm import Session as DbSession + +from app.models import Message, ServiceEvent # 已有 ServiceSession 就合并 +from app.core.time_utils import to_api_time # to_iso / utc_now 已在 +from app.core.time_utils import to_iso, utc_now +from app.schemas.api import SessionMessageCreateRequest +from app.core.errors import session_not_found +from app.models import ( + AIAnalysis, + Consumer, + Message, + Promise, + SalesOrder, + ServiceEvent, + ServiceSession, + ServiceTicket, +) +from app.services import serializers + +# 未完成状态集合(计数与筛选口径) +CLOSED_TICKET_STATUSES = ("completed", "cancelled") +# 承诺未完成状态:当前阶段已下线(保留口径,恢复承诺只读视图时重新启用) +OPEN_PROMISE_STATUSES = ("pending_confirmation", "active", "due_soon", "overdue") + +_RISK_CASE = case({"L3": 0, "L2": 1, "L1": 2, "L0": 3}, value=ServiceSession.risk_level, else_=4) + + +def list_sessions( + db: DbSession, + *, + q: str | None = None, + risk_level: str | None = None, + status: str | None = None, + scene_major: str | None = None, + customer_id: str | None = None, + sort: str = "risk", + page: int = 1, + page_size: int = 20, +) -> tuple[list[dict], int]: + base = select(ServiceSession, Consumer).join(Consumer, Consumer.consumer_id == ServiceSession.consumer_id) + if q: + like = f"%{q}%" + base = base.where( + or_( + ServiceSession.session_id.like(like), + Consumer.display_name_masked.like(like), + ServiceSession.scene_major.like(like), + ServiceSession.scene_minor.like(like), + ) + ) + if risk_level: + base = base.where(ServiceSession.risk_level == risk_level) + if status: + base = base.where(ServiceSession.status == status) + if scene_major: + base = base.where(ServiceSession.scene_major == scene_major) + if customer_id: + base = base.where(ServiceSession.consumer_id == customer_id) + + total = db.scalar(select(func.count()).select_from(base.subquery())) or 0 + + if sort == "last_message_at": + order_by = (ServiceSession.last_message_at.desc(), ServiceSession.session_id.desc()) + else: # risk:工作台默认视图,L3/L2 置顶 + order_by = (_RISK_CASE.asc(), ServiceSession.last_message_at.desc(), ServiceSession.session_id.desc()) + + rows = db.execute(base.order_by(*order_by).offset((page - 1) * page_size).limit(page_size)).all() + return _build_list_items(db, rows), int(total) + + +def _build_list_items(db: DbSession, rows) -> list[dict]: + if not rows: + return [] + # 统计口径为消费者维度:历史会话的未闭环工单需要在新进线时提示 + consumer_ids = sorted({session.consumer_id for session, _ in rows}) + + open_ticket_counts = dict( + db.execute( + select(ServiceTicket.consumer_id, func.count()) + .where( + ServiceTicket.consumer_id.in_(consumer_ids), + ServiceTicket.status.not_in(CLOSED_TICKET_STATUSES), + ) + .group_by(ServiceTicket.consumer_id) + ).all() + ) + + items: list[dict] = [] + for session, consumer in rows: + item = serializers.session_brief(session, consumer) + item["open_ticket_count"] = int(open_ticket_counts.get(session.consumer_id, 0)) + items.append(item) + return items + + +def get_session_detail(db: DbSession, session_id: str, *, include: str | None = None) -> dict: + """聚合会话详情;include 给定时仅额外返回所列分组(events/orders/tickets)。""" + session = db.get(ServiceSession, session_id) + if session is None: + raise session_not_found(session_id) + consumer = db.get(Consumer, session.consumer_id) + + sections = {part.strip() for part in include.split(",") if part.strip()} if include else None + + def wanted(name: str) -> bool: + return sections is None or name in sections + + session_view = serializers.session_brief(session, consumer) + session_view["open_ticket_count"] = _count_open_tickets(db, session.consumer_id) + + messages = db.scalars( + select(Message).where(Message.session_id == session_id).order_by(Message.seq_no.asc(), Message.sent_at.asc()) + ).all() + + detail: dict = { + "session": session_view, + "messages": [serializers.message_item(message) for message in messages], + } + + if wanted("orders"): + # 跨会话轨迹:订单事实可能登记在消费者其他会话(如历史会话创建、本次进线催办) + orders = db.scalars( + select(SalesOrder) + .where(SalesOrder.consumer_id == session.consumer_id) + .order_by(SalesOrder.ordered_at.asc(), SalesOrder.created_at.asc()) + ).all() + order_ids = [order.order_id for order in orders] + ticket_ids_by_order: dict[str, list[str]] = {} + if order_ids: + for ticket in db.scalars(select(ServiceTicket).where(ServiceTicket.order_id.in_(order_ids))): + ticket_ids_by_order.setdefault(ticket.order_id or "", []).append(ticket.ticket_id) + detail["orders"] = [ + serializers.order_item(order, ticket_ids_by_order.get(order.order_id, [])) for order in orders + ] + else: + detail["orders"] = [] + + if wanted("tickets"): + # 跨会话轨迹:退款/补发工单可能登记在历史会话 + tickets = db.scalars( + select(ServiceTicket) + .where(ServiceTicket.consumer_id == session.consumer_id) + .order_by(ServiceTicket.created_at.asc()) + ).all() + detail["tickets"] = [serializers.ticket_item(ticket) for ticket in tickets] + else: + detail["tickets"] = [] + + if wanted("events"): + events = db.scalars( + select(ServiceEvent).where(ServiceEvent.session_id == session_id).order_by(ServiceEvent.occurred_at.asc()) + ).all() + detail["events"] = [serializers.event_item(event) for event in events] + else: + detail["events"] = [] + + return detail + + +def list_messages( + db: DbSession, + session_id: str, + *, + order: str = "asc", + page: int = 1, + page_size: int = 50, +) -> tuple[list[dict], int]: + """按会话 ID 分页检索聊天消息记录(默认 seq_no 升序,即聊天读取顺序)。""" + if db.get(ServiceSession, session_id) is None: + raise session_not_found(session_id) + + query = select(Message).where(Message.session_id == session_id) + total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 + + if order == "asc": + ordering = (Message.seq_no.asc(), Message.sent_at.asc()) + else: + ordering = (Message.seq_no.desc(), Message.sent_at.desc()) + messages = db.scalars(query.order_by(*ordering).offset((page - 1) * page_size).limit(page_size)).all() + return [serializers.message_item(message) for message in messages], int(total) + +def append_message( + db: DbSession, + session_id: str, + *, + payload: SessionMessageCreateRequest, + operator_id: str | None = None, +) -> dict: + """追加一条会话消息。 + + - send=True:写入 message + service_event,刷新 session.last_message_at + - send=False:仅写入 message(草稿),不写事件、不刷新会话时间 + 返回 message_id / event_id / session_id / seq_no / sent_at。 + """ + session = db.get(ServiceSession, session_id) + if session is None: + raise session_not_found(session_id) + + now_iso = to_iso(utc_now()) + next_seq = ( + db.scalar( + select(func.coalesce(func.max(Message.seq_no), 0)).where(Message.session_id == session_id) + ) + or 0 + ) + 1 + + message_id = f"m-{uuid.uuid4().hex[:12]}" + message = Message( + message_id=message_id, + session_id=session_id, + seq_no=next_seq, + sent_at=now_iso, + role=payload.role, + sender_label=payload.sender_label or ("客服" if payload.role == "agent" else None), + content_type="text", + message_text=payload.content, + is_target_buyer_message=False, + ) + db.add(message) + + event_id: str | None = None + if payload.send: + event_id = f"e-{uuid.uuid4().hex[:12]}" + db.add( + ServiceEvent( + event_id=event_id, + consumer_id=session.consumer_id, + session_id=session_id, + event_type="message_sent" if payload.role == "agent" else "message_received", + occurred_at=now_iso, + actor_type="operator" if payload.role == "agent" else "system", + actor_id=operator_id, + title="客服发送消息" if payload.role == "agent" else "系统消息", + content=payload.content, + source_type="chat", + source_id=message_id, + evidence_message_ids=None, + metadata_json=None, + ) + ) + session.last_message_at = now_iso + + db.commit() + db.refresh(message) + + return { + "message_id": message_id, + "event_id": event_id, + "session_id": session_id, + "seq_no": next_seq, + "sent_at": to_api_time(now_iso), + "send": payload.send, + "promise_extract_job_id": None, # 承诺抽取链路暂未接入 + } + + + + + + +# ---------- 以下为已下线能力(承诺只读视图 / Agent 分析快照),代码保留待恢复 ---------- + + +def latest_analysis(db: DbSession, session_id: str) -> AIAnalysis | None: + """最近一次 Agent 分析记录。【已下线:仅 Agent 副驾/详情分析段使用】""" + return db.scalars( + select(AIAnalysis).where(AIAnalysis.session_id == session_id).order_by(AIAnalysis.created_at.desc()).limit(1) + ).first() + + +def _count_open_tickets(db: DbSession, consumer_id: str) -> int: + return int( + db.scalar( + select(func.count()) + .select_from(ServiceTicket) + .where(ServiceTicket.consumer_id == consumer_id, ServiceTicket.status.not_in(CLOSED_TICKET_STATUSES)) + ) + or 0 + ) + + +def _count_active_promises(db: DbSession, consumer_id: str) -> int: + """未完成承诺计数。【已下线:承诺只读视图暂停输出,恢复时重新接入会话详情】""" + return int( + db.scalar( + select(func.count()) + .select_from(Promise) + .where(Promise.consumer_id == consumer_id, Promise.status.in_(OPEN_PROMISE_STATUSES)) + ) + or 0 + ) diff --git a/app/services/ticket_service.py b/app/services/ticket_service.py new file mode 100644 index 0000000..2efa0b7 --- /dev/null +++ b/app/services/ticket_service.py @@ -0,0 +1,96 @@ +# 工单服务:统一核心字段 + 按 ticket_type 白名单校验后的 detail + 字段式更新(PATCH) + +import json +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session as DbSession + +from app.core.errors import ApiError, ErrorCode, ticket_not_found +from app.core.time_utils import to_iso, utc_now +from app.models import ServiceEvent, ServiceTicket +from app.schemas.api import TicketUpdateRequest +from app.services import serializers + + +def get_ticket(db: DbSession, ticket_id: str, *, include_events: bool = False) -> dict: + ticket = db.get(ServiceTicket, ticket_id) + if ticket is None: + raise ticket_not_found(ticket_id) + + events = None + if include_events: + events = list( + db.scalars( + select(ServiceEvent).where(ServiceEvent.ticket_id == ticket_id).order_by(ServiceEvent.occurred_at.asc()) + ).all() + ) + return serializers.ticket_item(ticket, events) + + +def update_ticket( + db: DbSession, + ticket_id: str, + *, + payload: TicketUpdateRequest, + operator_id: str | None = None, +) -> dict: + """字段式更新工单状态/优先级/处理人;变更写入 service_event 审计与时间线。 + + - 全部字段为空 -> 400 INVALID_PARAMETER; + - 传入值与当前一致且无备注 -> 幂等返回,不写事件也不刷新 updated_at; + - 状态迁移到 completed 时记录 completed_at,从 completed 迁出时清空。 + """ + ticket = db.get(ServiceTicket, ticket_id) + if ticket is None: + raise ticket_not_found(ticket_id) + + if all(field is None for field in (payload.status, payload.priority, payload.assignee, payload.note)): + raise ApiError(400, ErrorCode.INVALID_PARAMETER, "至少提供一个待更新字段(status/priority/assignee/note)") + + changes: dict[str, dict[str, Any]] = {} + if payload.status is not None and payload.status != ticket.status: + changes["status"] = {"from": ticket.status, "to": payload.status} + ticket.status = payload.status + if payload.status == "completed": + ticket.completed_at = to_iso(utc_now()) + elif ticket.completed_at: + ticket.completed_at = None + if payload.priority is not None and payload.priority != ticket.priority: + changes["priority"] = {"from": ticket.priority, "to": payload.priority} + ticket.priority = payload.priority + if payload.assignee is not None and payload.assignee != ticket.assignee: + changes["assignee"] = {"from": ticket.assignee, "to": payload.assignee} + ticket.assignee = payload.assignee + + if not changes and not payload.note: + return serializers.ticket_item(ticket) + + now_iso = to_iso(utc_now()) + ticket.updated_at = now_iso + + status_change = changes.get("status") + if status_change: + title = f"工单状态更新:{status_change['from']} → {status_change['to']}" + else: + title = "工单信息更新" + db.add( + ServiceEvent( + consumer_id=ticket.consumer_id, + session_id=ticket.session_id, + ticket_id=ticket.ticket_id, + order_id=ticket.order_id, + event_type="ticket_status" if status_change else "ticket_update", + occurred_at=now_iso, + actor_type="operator", + actor_id=operator_id, + title=title, + content=payload.note, + source_type="ticket", + source_id=ticket.ticket_id, + metadata_json=json.dumps(changes, ensure_ascii=False) if changes else None, + ) + ) + db.commit() + db.refresh(ticket) + return serializers.ticket_item(ticket) diff --git a/app/services/timeline_service.py b/app/services/timeline_service.py new file mode 100644 index 0000000..0604d85 --- /dev/null +++ b/app/services/timeline_service.py @@ -0,0 +1,45 @@ +# 消费者时间线服务:跨会话、订单和工单的统一事件视图 +# +# - 默认倒序(主管看板),详情页可传 order=asc。 +# - from/to 为 RFC3339 字符串;入库为 UTC ISO,可直接按字符串比较。 + +from sqlalchemy import func, select +from sqlalchemy.orm import Session as DbSession + +from app.core.time_utils import parse_iso, to_iso +from app.models import ServiceEvent +from app.services import serializers + + +def get_timeline( + db: DbSession, + consumer_id: str, + *, + from_time: str | None = None, + to_time: str | None = None, + event_type: str | None = None, + order: str = "desc", + page: int = 1, + page_size: int = 20, +) -> tuple[list[dict], int]: + query = select(ServiceEvent).where(ServiceEvent.consumer_id == consumer_id) + + from_dt = parse_iso(from_time) + if from_dt is not None: + query = query.where(ServiceEvent.occurred_at >= to_iso(from_dt)) + to_dt = parse_iso(to_time) + if to_dt is not None: + query = query.where(ServiceEvent.occurred_at <= to_iso(to_dt)) + + if event_type: + event_types = [part.strip() for part in event_type.split(",") if part.strip()] + if event_types: + query = query.where(ServiceEvent.event_type.in_(event_types)) + + total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 + + ordering = ServiceEvent.occurred_at.asc() if order == "asc" else ServiceEvent.occurred_at.desc() + events = db.scalars( + query.order_by(ordering, ServiceEvent.event_id.asc()).offset((page - 1) * page_size).limit(page_size) + ).all() + return [serializers.event_item(event) for event in events], int(total) diff --git a/data/sqlite_demo.db b/data/sqlite_demo.db index 9e5e50fdaa6a98fb3cadcb316e69bf3398efae3c..ec00261a5d539afdf5ce94bfdf0e566aaa3d234c 100644 GIT binary patch literal 176128 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 W{~!1N7oVPj`~PwO{~fUZ|NjFZ*t&24 delta 111 zcmZp8z}0YobAmK469WT-8W4lQL=AIBri}?p_!qN0FxbrU;E((?0Y(-OLx0l;76p(f lROYY#c18zAW(Ba^q7Mp-J_w*F1#8}Bz{DZ2nd89^c>wDODB%DA diff --git a/data/sqlite_demo.db-shm b/data/sqlite_demo.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..0238332196878445bc331f094b7f64cfc4ec8ef6 GIT binary patch literal 32768 zcmeI*xlIE>6b9e{bJ`r{Hurs)LzF-PBs4&Z$U%hU6o3#CIXR*dN}voU@N5ubBZNSh z)qW%WvS++IGw&FdY8{@%H}}zrVK~2@oJafB*pk1PBlyK!5-N0t5&UAV7cs N0RjXF5Fn5*fmiur87;q=A6fSdY_)|{oU{T-qY20@2&Y-ey73lcONkrmKo^Z*|w%l z!`uG+vv+x)kF0#->%WGV3e>Ovqo;Xmf^7L=DZFSZ`ij9CDs>e90Cw;X2_OL^fCP{L z5YFqGe%Y9z&7M^!{>K;vQ<0r<&*>HTeU!3R` z!$+l8Zb-w!iO?l6GAs^-#Y6pxt0%?5VB*xcIB>XScVL%`cU3$1I)|(7Dc)W0s;PIm z*SnqYuVx+3*YmvkyhrCeyPkA&0ItWbnwT3ABNP6XUH(Apu0Z2vr*tipn7WbZ=n@C| z<|jg_cT5H6kB!Bmud}zlck?ANJSL6yNj-Ck$w;M8TPa=|kcO{`-CgXm;*D$Kp_inK z*z247oXNAs?L<9VkdtMe(RtKR9Z7x?vcd|hoduhqrPKgFkY z5vtvF?0n}IF3s1u&Tb2?P4NZ5z!$a)@{LXRC3+@7Q!z56=1V{GR(#>SQ>Yhe>-p;S z)zvj_S?0vt0qMv9Ty?&qQ#`XjeyCf#enq@=S$rufUG5Zn&+ln{Ca|l0vs(mzeBLT-`6d^6G7- z`URtkYRw82RX$0TUzqBAM~8G_RysK?hL2}b73w{%T)mcA+L_g>+4WdY6<-F;$He|_ zao`H;ebVUb;^pypWWu-Wnby6VT?$&UyHC1s3f>lv%!!>B*i`_hG;l~9=#_epBqn>r zj#=^0Vaix_Mc|#d)&<@9Hly8>mqcr~d%a%gI+zeP$Q_m!Xop=iTK|rgwm_pYfP|-b zL1(b5&!-2F&SU}a*E^q$J!Mz`9=ep6Qq3AF=t=uzG*sq`_^Z)G_)?|2GB*9XczrxJ zGb#;_ry3`_3p45M$up>6OF2_OL^ zfCP{L57t_PaDWzlc&koNpOi;V{;^c1dsp{ zKmter2_OL^fCP{L5Iy2_OL^fCP{L5)<;%g}wZ7gQ1X;@a}X3s%u^DMxRhy>uP}LNEqQ}c_`)@;yxNh$3hU>1242o zo#({y85SuQn~uOC3p5f(jwix%`WLGcJZ=_97_47bwhE-O+slIxC-bcZWUzwRsPHRRr;w&)cQe_!g=BBovp3MHM9op@6Q^DR z(dIi(OQFLc;1Xjv)zURvlW4)~=9dzV?s>;ZrstWb)tiV*Qy_$D|>RZK;*?7-^ZIux6JOAn! z3)F?NFZG2ReeJ#tI|Gf)z76d9+gtXu@AkDf!(6s~S4(?f{g-w>vlLrxDq^;R4rkMJ zI0qeDpnx*Am;%}H;#vjh(2qrX#WUx{{wR|_K6(&hnpw|-SK>n_pyxsVllsRKXD6hN zv+>u$@y>qQ;cj+7vqazIQZ#tj0vfO(0~)AJKt~K{AdeWTz~V*>^aqe5Hghr-yK z(IFCgIev36HW!w9&#*x2*z_4tl6Iv1(=c_210fo2JrH6M*Go~Ls?cU`kEyzfhxHU* z+fzVL_+3n!SwKh7>0Su0j$a&t*m*WwfpsdDx1&FHBb1msDqT4Ra~FV~-!~JR?f_-t zXKqRdN2!ITJH$}8(m?7Mmo7$^sMVk3gI1R0jeCs-f3o*zpZ+26JZt>sylX3WLv;)} z?c~WQ@z~AR5|hU&X%FRvO7Y@Rar7LGLQmdL79GK?o^>BKC(T2jK+08$2o4YX5aod= z*LRbgnq&u8)97|c`p`AbqUPt1o)AOBbSzArjzz%&k;=y)k;D?G(v>E896X_spFTeU zB;}^T4*cgb7iDmh0+$N=did&slo0rExi1bKvb?i9PDo=5-*AOR$R1dsp{Kmter2_OL^ za3>QeG1>F-*fKD)O+J7{xu#{R1CFQ;jMie4-9q03%}V7Ha9}D?9dN~}17nf=7HDQs zPQcQwLe+t>KxxH$^3x{-vsrauGN}$YPIX{37J*e&Q4CL3 z-mi3$9Q7Ju3|G)`Ec*o>{?Y1dsp{Kmter2_OL^fCP{L5k*d zppJo}A^{|T1dsp{Kmter2_OL^fCP}h9YKJ8ng0X$1y)gbvR~k{EB|55124UJkk%t8 zu(TS8&3bp~JteD(*A{JrL;OPmNB{{S0VIF~kidtZK&P|7_IW$}t?{l}5qR8gH|+7& z^0n@!nx-a)Tc`^-1ZS<^;j0#W4o^*OU1L)toO1W&+Z%8|@dAHMqtESf3)Qs3lrx#k-RapG zXlsM?+uPfq?t^@lTnnC|UUsXmy`{Bfx1%wzvxSws5eE*>cLZ4?bR}_jMoMy4`o{mP zuU(r^OZNk7cn@EjDu&aXU$%*=QsK25oI$R9RTb#+e7l1t#C8aE4L&~L6PkEWLm=R6 zP?Or-+Oo5`Eg*jYU6if2aGGVRXH{2NqI6cPhwZD1w4})vg$D71KlvQU_s!;{kW%Vw%8?H@^9?RJ|=SAV0gk+1VN3QdB?)yVT|T^H^Z zIcl4$wHK2`fmkIVR>tN3t5^{0m8^$uT~O!FJX2Cv8U(7d(rrO!*iUs{HCjC@YxDVw1AX%oA?TsvrOQx7M5(So%l6zn$!eq|uI`s! zd6iX9fn@Wkx=4`-P=bW{i7-^`*dIUC4cX-tb30^fJ6!iZRLKnQa4*8{U*&A6KEA^6 z#MjF09u)$oyUFiqa=SeKy4uDjhJa^f81$51xK@U`goN06LF_%ADi?4ql$g4a=;&f} zQmN^qeYBE9=u%>80w|F#y`^zbDnqeZch`;3WBH3S>2@87zPy-uh5_N zL3_QBiButu$Q;xVka`BC3$yBBuF+7b36`uPqN)j&Rf$6x;?Q#8Ar3e8yXnnc_6z)E zL*1U|-uyRD)Aa%cq~1X4A%5U(a+Y|>bEJp1#6Ki}1dsp{Kmter2_OL^fCP{L5kh-qB{tv|P56}Krbr&}~%b2ihFXx5y~bh(){XEVJ~oaSt%x5l`V+hkFs z%Jgi2C0A2x%6g@0beC9~+BB zUuSQn8Q{WW(r6#cpjIi=R*IJfq~U8~ch{{`BGPP)JDPU|9M3xySs_uStKR9Z7x?vc zd|hod&z398=QUXpokF#{j-Bt^!ln5-*V%2swJE-Umj06H^eo6XHrA}a zW`&9>pQOq!OjVstE|V%ybJe@t>tR{CvM^n1X=hF5d3HV4Q^l9hB__wj{%&#L3hR9| zFX83!cx1x2>zUTQn_UXpj0A`5Du7cOI3y1AN~+|Al(FiHz&mlR3%c`dM!P33iPmoS zdcDqdFd=M^J1j4-pS~CDuuKX6j+VASql}GxcXnlAQ=iwH3>G?{jh(%sB!W!O8Y<{X z`(!j!<_nfmwbES~n|>Yg55{IjrQz{Z3RVZ@fnDZ{0+H`83!pN{6hjr00|%gB!C2v01`j~NB{{S0VJ?w0&7e*Z{Fs#qc2D8 z5ff|Ymi-aAoNF9w=TfzU@+sNwBIEt=xt7gJJL(_N^M8Oo)CHjU1^#@?Q&&1Re6o}I z1)ipP2cITiC&4A_jc-5#NB{{S0VIF~kN^@u0!RP}AOR$B=Mq?JD);88mxJVrGB9Pe z%;^!(YpL%ONah{PUN@1$FHlU)B*zi_<>blA`d`2C0`m(zsnjF5b4`hwBLO6U1dsp{ zKmter2_OL^fCP{L5?BHOIrm{kK103l07^iefl`fFHmM2GLVhdw$iauuH=#8y+x-jzqQmCzECio|Lyz& zbA{(_QTkzsrEJ0HrBFQB>4*s zCLfJgW5Isyffw4P&U51U42wyNO-JC6h4hFc#}nZ>{foH;p_YZm1((}&qzY8my4;OE zp|;l5z@##3FC^5~^VRFCt83hHY~b%9m3*-}!Q)OzRllrk6|l0~%YzUo1hDR-u-af+ zatJJ3(=9?XV$ne{GRPtZ7ZLpAtgYiyQIf$5Vxz*ZSe-I-T6Q;+om)uuhCO=&txD7!l|FIm zB@k`C^RyH?30~l&J%8-DI6W=( zUY0KQfRG^J{P_zsDl*v{rDPOY3Ma(4PMwwZb&1_qp`}o(#HhxjuhNbjhIb$O{o+;C zmUyHTbhS`R1mQjbJt=JpC(~7}s$O_$wk1G4Y3Q05x&)fUBHf@yY^GZ}I+>W9f^iss zZGUEb?v+#0DCHs!ybe3k#nXw?T?>VWs+DLiDXU=fM({a}!IDD(VrrG4kh-O2MRVT5 zUMcR=6VkD979^W(L9y>ZJaRS_JbUAs819k!Uye-=iieLTuAhJ)PYSZeLnyc1O4Dvr#? zdk$=?6km$YzdANQ4AS+&(2@GWjlOo@hMj@NX5R*O{p~G#+IRcfn_-6CzN@7@u>MQC zpIM5nHWe{jL5H(xI-G-!El@xiTTFrM5K*lHbm+&Tz2cekVt}IORbX)= z2KocY5t}&~i(U}J*XR%ly&S(e7@G@Ay=PdUb!_?!C`mig{%M#x#DNeEw;l+wi0h>& zP*rHNv)R|DlHbF65U=e)de5Ia24IqR_rU;x@dAOel=vG*U?zYuo!B?S0+&I@rO3IZ zpe!Y4@MEg3;$c07*Y*^>bX_`vPWMU|PQ@<{LF_ymuE07K%iGZ(yAevv9hI(}g1HMo z&+nUwO?QAY@iRB2gQL_!(;Z@{TWKJ5j7t}zOVsL5@D3vs*&Zq zYb$p{bqqP}YenxkT|k!?ErUd?6vcGS3L9aVjV? zJ(W+dr)1pJfT8;7R}C2?W?jIoj&X;?80_XJ9qo^w3df^^((zFW9qwd9v*O&oB`Wlo zQBi2EMxo@)&kM@T&lGaQcWQ0Ug$#w1=X&+yRU&*IUXR`Af@eUg7R*3!%bgCxi|jco z5gvnEn=j9;oSn7@uWg~S>nc!oo%yL>xlcmAZEu~f1RgeG@Kmter2_OL^fCP{L5j&_pN_kN|5Bx6jnZ3APsf{tI=FK~40Cx$)| z`_lwlFL2gCen;LXe@}i1s|J2SehM`U-yz?F_<^^{nT`-IUNxG2Il=jfHLs>85~g z^6AD*HzvB_=*Gx#G?A2HCB-KD1@0aFi~sqtA3j)vet{2fXF|=801`j~NB{{S0VIF~ zkN^@u0!ZKkM}U5h|3mo&R#6(VU*In;{QR-=FTVO;Xgz|$LRc?gv))~LPsys{wM82( zt?&Z=Aps^S`B@_kFT~iE2rdxgOEv+rP9gTsVEv$NtIBcy#&z2Nz_sG?4&rBx;M zfi=8`mrL|C=a+4wR;lpX4bC7}zN!k$@_f64Cd76Kbqzi~;1im7PeUN!Yyh=j9X5-% z-QC)ESV$Bwe{Xq zwlLSd+hkE_@d#z1Fg1rKRru%2W~!^*zJ}>)x7$>@`Wtw-FW=9!W@gMC*R zHU^8!E9z7_H#PeGP0oheKuwcS(;!ftl@SO!!+vTU%6cxYu!uO&H$M@A!6sh13{^yw z>I$@M&&`vpMoQx9e(9B0S@jf1HlM1C6p4t}$01>UA`BHf_Qwx(Lw0$^+z#2=4%fX8 zRWidn+>5aLS2A>dgV20i5$u9cxKAt82N z5POfO$^~2tC8lm9I=UF0RBHNYAFU)o>-|gsg;Z^c-Xn?09c6Rtb zr9C9DTZHnvjp+ftF<4q|zelA#?{az?>KbdB0u6jqwbM^KKuUWdbAab4mB0qLx+FsO zFwd-wz{a}zJtCC;896SV=~EX>NGlJO8ov-K?TJt^anU-uNN|rD#Q_)gBk)-&!BW+7CJC3os}&3rO{BS36`uPqN)j& zRf$6x;?Q#8Ar80oyXmc6_6z*eFF(36w0xwDt`{gIYYe2GtbquEx5-)JCC`x_+7|zi z01`j~NB{{S0VIF~kN^@u0!RP}+(`r;;66`_5NQ3-h3qA@Keu2Nw<#~DTQpyDHq()4 z)|}0BxtTO)Grduq=4__7#<-H(WKpEb^lX3~S5sTcet~c8dH26g{qz5RCg~Tjo-#l+ z!t>-s@}%`U)>GEs5X>=nCv|+(6$u~#B!C2v01`j~NB{{S0VMFpKwzB-@=3C)0IGE- zx=X(bbGFT1TU8*p%}6Dww`wUrM_XlCk<;yT*RW+pX1Ogamr=>9yBf-*wACg-)LT}< zrQ2$9AnN5QG(HH-bn*`BEm;5)S5h;}et{h=T|2(s1j0!RP}AOR$R z1dsp{Kmter2_S)gO#;d9!z@Ha^*#F*egVCjqcF}%;?6$f60$DQL!Y@GEF4ixg zZR^SroWcA8*==+91?~a!u9N)&!BwqK4cs^BV(SIoHjqhTBYx6jeb@RntR1NN*X#=T z*+>8hAOR$R1dsp{Kmter2_OL^fCO|2RGAFkyal}mI<0j41)5g5@}9621T0O5{JFWZ zgqSr~%#~xql+{Xi)qtket^EQl-+>}sjuZ#Lvw$*{{Q}Q(j*=hjc=HU+J7_BUioqHx zbrk;qcJL1gAOR$R1dsp{Kmter3EX}Ho!d-z+3kdMimcA3pw_(8T`%zK>-f6bYMw1umd|Ul zBszs^cO5(5xrIyfb*{79f@@QJ0WJL{(dk)`Z*00R(K88}ijg5TU;3GPKRv2sa(e3F z7gBfKdfx4J@dA@6F?T>ZG5}Yd@91QC0n^C^FL#Q)=l8Te1BpU6yA{f@nYsATx%ib? zv9BKtE*^-)`!7jvjKm{jVki6Ew*;N3iG;l~9=#^B-3n^pO6@ho+S{HQZ+l+QkUJ|X{?)7?|>tI6I zAa__^U_X5?*kPFx{v9oCfkqh{`|j+@!lpj2HyJE+J{voGMM(sio;6g^llIAIsLU5E zrD~tOvGm( zKJqu@GUgkkjPMT$AOR$R1dsp{Kmter2_OL^fCP}hk_oIa*}Qq1(~drHTI%^BOspMv z(OlIIzRm&8H(lo%$J)76?R4Em#{1!OEt{2g)IXy4xu6eq0VsZf?$5un^JiZ181oA} zP4f;uO}1p@D3)wv~>RawU%x?=3oQ`K_hC@P&fu{BP$Mm@7;# za&K}E8i$}6{(VRZ1TD*LYwda774~wE!H_hxcc-I<4+ss_)vj89t&i0TDOjij`)G_9 z3$SYsywJYoE52u*3A9#9R}Us8k3e{n6r5b>62awVvMi6MWpVrAeKpm-hT6tjCQH8h zLJ*r(UGJ$`FVwl*b=54QEkhQm^PD(7!(!56(-AmiAwA;A@kDq||6*=IsAU%mF1P7O z6{xOtxf^{#ZLO<;NoCevC_A;u?;(|Zu{y!yPDxe2tZWt7&2BFbLZA>B?mhz;t_`Lo zhrq%$O|n3_e3or{+u8#=<8MUc(G%2WTiNcm%2YVy^>MN9NF~d%Qc2_aLL*`{0vzKP zd&JqUSaeW~46=y9MFc-NYwP$_lw`1i*r@O;R+lqFr)76D*|~*eZ`iXp(5ghuQRx$> zUINkPJ5NiY!ysV1=e%?^2nvV;*Ow?=vo(nphD5MA1FegRXH<))SK=na*7L`Xi__Cm z?`7#?4+seo&Y!(p6kUzgZ@6DV|6lFhcD*mocvIhzWey>U$p_elLO$EF9x!$%X>Pe2eTyd^Pto!YM} zBnC$zdNwgR9)q_G2A9}5Hx}EHx~^XFsNreVuy@dr1F9+`hpGy(zFAKXT6;C`1l6~S zBeU_I1KTRam!k8pj?E8)biFWiq`q*Yuidv{XP~j!w}D-Md&{2o-M;o_nC-XkYH1Iw z|I+SfmSU?-Ma)*v;cS`?=b&Q?6i~(%Qy@D;RI30T`mt!Qc;>v=A7%2#M-M_wGwXTq zN_^-9^gQT)QvZ13?1a>DHvU>T-q}w(+|3SXmgt*YiUto`Km#^pKm)Z2=!gLgBlQ31Cbo_RX-sWzcac za&9RoOUW7hn5wIISWn@#Jw-2FmyV#*z0!qK@ry$cJI{tIuujGDcJ#+?gc5T{r7Ndk z?gG&B`(|R(9iUA7%uVUwD7DaZhZyQs8b}@E(#7Z!wfd8M(8`j$aj((fPxc;dhH|}X zWI6BJ%H2>MLryz+GDj}u}a!Qd7)CgcvKucM}wl1_mf3OFsoSB5ut22pF`joVaVlMD zlE=Xl8u{t-6F^dK8ccqEfti#}MC|rPyN>)A!p1av{#~MYpW#^d2fmOFUYTcv)HoHC znV!Ycd$Q!qxTyg{_0z8!GDyt2fLk5o4v8_?%}+YoA3qh2M+c?jqZB&a$%badxqVAi z=rNc^`@_&mHGyU_*DfK)A*f#8-q9flX# zb5SXPw+x`b#FR+}H z7|3c;LR{n+IZV39Ub2I1C!ZsqB)=x_lAlA2!MDg9nIh-u74Q!UAOR$R1dsp{Kmter z2_OL^fCP}h2b(|{w>D4vTj+B3lKLmfGWL@C_dr_-w+i0Kev4bCI&c%!fy=5oaF!Nx z73@6@p(MTUE>7>eiqiW|OA%+M?ZInXsO-85lwD_jY8TvbW@T3}DZ6e?*>xE$oZSK! z6C6&kn4MZp_6y{hKYRaA{?q+unP0$aAip9B@+0zH@)o&D&XEx^Ksw1D@+^6PR1hoq zB6)&5Lg1HV{6hjr00|%gB!C2v01`j~NB{{S0VMFjCs1Ot=jE}*UIoil2l-{H1G7y& zfJM2cWvT;?s1A(QVw2rMuLjLZHSS(yc<(fw4eo#e4G8 zCj_%ubzm~74meJAU^EtiU>3F*25k0GYB$+0U_0;&@89`06(s!vB_js%4w)b?kWZ79 z*7vM$SqH66)>>;(>5og^FZpK4NaNyX?|Jm)^ez3ja#k>X|^e?7ZWtTY$>fdlB=~|RHD)P z5HOLyYsyS$c&J{OQmJz3jF=_}waE&gGnM3OtrvsF73Xf9y;|N_q==m%iJsV&^rf}I zG|RCd%x02pdNu)l@yZf z7f48d_szRMGFpRvfe%5~K^2ey5<2fPRJlgZc$lQAo01 zp!k#j+_~=)(%rNkfu%q)5S#Vx(tAo)6|XJYXlX4x3oqdx5DtxRhcrpDI)-@OVlA zf52bocltfNE8tp;0(xtT7gRvWcAb&On^m!4s;CoFsZ~jTpsKFTrwVjbf#L=Jnns`7 zRHtlmMEQ75MukPA}y)% zELTrLVUJrEX<-IS<_L3by*HID%ysWJSrl44LRlzG&EZKE{`s<*>T0*IVY=GwHkGda zMqeXe=Wi661dpqc=heDCk<^=&^J#DPKNo1<-q!AG-_s_4Ij; zp9n+6j{WgN-H=^gF}Fjuw!?MrLzT?%4)-GL{#DMF>fF7sTGkqX%lARs?PiYSc>=vQ?Zex0YZw!`}+wW0n&%2zShPuX@ra%MV zRPFTB4v^Ab$QYnqXECJax|MCbh;esGxX-{s0H<^+G06g*YN}P(wiK z8I&%}LId6|2A$S|&1-#@r(nDM09}++UNB{{S0VIF~kN^@u0!RP}Ab}4r0a|fD z>ovEqm(+gfLiUo{pIfks+mx5nEt;=6o9Re2YtCl6+)SFYncgT)b2igkV_eB?vM5qz zdN#m{tEnYrzrf}vD~&5&+;TnX7qIRzKrO=a?7;qCaS84^GO zNB{{S0VIF~kN^@u0!ZLrioiM(WRzqJOH^w~bl0OaAMJ&(T&{R^)U$-8D=Gv)nc#!=m1GO-fsB5=6aaC0x3#CI_Nkoz*F8}XAK>$}#sttVjhK+PYdN8m+~01`j~NB{{S z0VIF~kN^@u0!RP}=n<$g8N7K5`VO*M>3R)xTIu);G_7*wJz*;dSeg!5x?EX8%$h6a b%CTX}YNfksK-22hegT&6K#?v-ii7_L3q+h^ literal 0 HcmV?d00001 diff --git a/err.txt b/err.txt deleted file mode 100644 index e69de29..0000000 diff --git a/out.txt b/out.txt deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index a16eaaf..af5c86c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,4 +41,8 @@ pythonpath = ["."] [tool.mypy] python_version = "3.12" -ignore_missing_imports = true \ No newline at end of file +ignore_missing_imports = true +# 现有 models.py 使用旧式 Column 声明,使用侧被推断为 Column[str] 而非 str, +# 会产生大量与运行行为无关的类型噪声(arg-type/assignment/index 等)。 +# 解方思路:后续把 models.py 迁移为 SQLAlchemy 2.0 Mapped 标注后,可移除本项。 +disable_error_code = ["arg-type", "assignment", "index", "var-annotated", "call-overload"] \ No newline at end of file diff --git a/scripts/demo_reset.py b/scripts/demo_reset.py index f4328d4..d5aa0c6 100644 --- a/scripts/demo_reset.py +++ b/scripts/demo_reset.py @@ -1,38 +1,62 @@ -# Demo演示一键重置逻辑 +# Demo 演示一键重置:恢复 baseline-v1 固定初始数据(退款/不良反应/赠品补发) +# +# 用法(在项目根目录执行): +# py scripts/demo_reset.py # 恢复全部场景,保留 Excel 导入数据 +# py scripts/demo_reset.py --no-preserve # 同时清理 Excel 导入数据 +# py scripts/demo_reset.py --scenario all # 与 Demo 接口 POST /api/demo/reset 行为一致 -from database import SessionLocal -from models import ActionExecution, AIAnalysis, AuditLog, Promise +import argparse +import os +import sys +import time +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core.database import SessionLocal # noqa: E402 +from app.db import reset_demo # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description="恢复 Demo 基线数据") + parser.add_argument( + "--scenario", + default="all", + choices=["all", "refund", "adverse_reaction", "gift_resend"], + help="演示场景(默认 all)", + ) + parser.add_argument( + "--no-preserve", + action="store_true", + help="同时清理 Excel 导入数据(默认保留)", + ) + args = parser.parse_args() -def reset_demo_state(): - """3秒内完成演示环境重置""" db = SessionLocal() + started = time.perf_counter() try: - # 开启事务清理演示过程中产生的动态数据 - db.query(ActionExecution).delete() - db.query(Promise).delete() - db.query(AIAnalysis).delete() - - # 写入一条审计记录 - audit = AuditLog( - request_id="reset_req_001", - operator_id="system_admin", - entity_type="system", - entity_id="demo_db", - operation="demo_reset", - before_json="{}", - after_json='{"status": "reset_completed"}', + result = reset_demo( + db, + scenario=args.scenario, + preserve_import=not args.no_preserve, ) - db.add(audit) - db.commit() - print("【Demo 重置成功】已清空本次演示产生的临时操作与承诺,数据恢复为初始基线状态!") - except Exception as e: + elapsed_ms = int((time.perf_counter() - started) * 1000) + counts = result["counts"] + print("【Demo 重置成功】已恢复 baseline-v1 初始状态") + print(f" 场景: {args.scenario} | 耗时: {elapsed_ms}ms") + print( + " 数据量: " + f"消费者 {counts['consumers']} / 会话 {counts['sessions']} / 消息 {counts['messages']} / " + f"订单 {counts['orders']} / 工单 {counts['tickets']} / 承诺 {counts['promises']} / 事件 {counts['events']}" + ) + return 0 + except Exception as exc: # noqa: BLE001 - 脚本层兜底,事务整笔回滚 db.rollback() - print(f"重置失败, 事务已整笔回滚: {e}") + print(f"重置失败,事务已整笔回滚: {exc}") + return 1 finally: db.close() if __name__ == "__main__": - reset_demo_state() + raise SystemExit(main()) diff --git a/scripts/import_excel.py b/scripts/import_excel.py index 7bec44d..ff23eba 100644 --- a/scripts/import_excel.py +++ b/scripts/import_excel.py @@ -1,72 +1,71 @@ -# 模拟数据导入与脱敏工具 +# 官方 Excel 数据导入与脱敏工具 +# +# 用法(在项目根目录执行): +# py scripts/import_excel.py --inspect # 查看工作表与列名(校准映射用) +# py scripts/import_excel.py # 导入默认路径数据文件 +# py scripts/import_excel.py "data/你的文件.xlsx" # 导入指定文件 +# +# 导入默认保留基线数据(baseline-v1),重复运行按业务主键幂等 upsert。 -import hashlib +import argparse +import os +import sys +from pathlib import Path -from database import SessionLocal -from models import ( - Consumer, - ServiceEvent, - ServiceSession, - utc_now, -) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from app.core.database import SessionLocal # noqa: E402 +from app.db.excel_import import import_workbook, inspect_workbook # noqa: E402 -def hash_nickname(nickname: str) -> str: - """对原始昵称求哈希,原始值不写库""" - return hashlib.sha256(nickname.encode("utf-8")).hexdigest() +DEFAULT_EXCEL_PATH = Path("data/赛题 1:数据共情者-业务数据.xlsx") -def mask_display_name(name: str) -> str: - """脱敏展示名称,如:魏h**""" - if not name: - return "用户**" - return name[0] + "**" +def main() -> int: + parser = argparse.ArgumentParser(description="导入官方 Excel 数据") + parser.add_argument( + "path", + nargs="?", + default=str(DEFAULT_EXCEL_PATH), + help=f"Excel 文件路径(默认 {DEFAULT_EXCEL_PATH})", + ) + parser.add_argument("--inspect", action="store_true", help="仅打印工作表与列名,不写库") + args = parser.parse_args() + excel_path = Path(args.path) + if not excel_path.is_file(): + print(f"未找到 Excel 文件: {excel_path}") + print("请将官方数据文件放入 data/ 目录,或通过参数指定路径;") + print("如需先体验 Demo,可执行: py scripts/demo_reset.py 生成基线数据。") + return 1 + + if args.inspect: + inspect_workbook(excel_path) + return 0 -def seed_baseline_data(): db = SessionLocal() try: - # 1. 创建测试消费者 - c1 = Consumer( - consumer_id="C00015", - display_name_masked=mask_display_name("魏海波"), - nickname_hash=hash_nickname("魏海波_raw_nick"), - risk_level="L0", - ) - db.merge(c1) - - # 2. 创建测试会话 S00015 - s1 = ServiceSession( - session_id="S00015", - consumer_id="C00015", - store_name="官方旗舰店", - scene_major="售后", - scene_minor="补发", - status="open", - last_message_at=utc_now().isoformat(), - ) - db.merge(s1) - - # 3. 创建时间线事件 - evt = ServiceEvent( - consumer_id="C00015", - session_id="S00015", - event_type="message", - occurred_at=utc_now().isoformat(), - actor_type="buyer", - title="会话初始化", - content="消费者进入会话 S00015", - ) - db.add(evt) - + stats = import_workbook(excel_path, db) db.commit() - print("模拟初始化基线数据成功!") - except Exception as e: + print("【导入完成】") + print( + " 会话 {sessions} / 消息 {messages} / 订单 {orders} / 工单 {tickets} / 消费者 {consumers} / 事件 {events}".format( + **stats.as_dict() + ) + ) + if stats.skipped_rows: + print(f" 跳过行数: {stats.skipped_rows}") + if stats.unlinked_rows: + print(f" 未关联行数: {stats.unlinked_rows}") + for warning in stats.warnings: + print(f" [警告] {warning}") + return 0 + except Exception as exc: # noqa: BLE001 - 脚本层兜底,事务整笔回滚 db.rollback() - print(f"数据插入失败: {e}") + print(f"导入失败,事务已整笔回滚: {exc}") + return 1 finally: db.close() if __name__ == "__main__": - seed_baseline_data() + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a6c20b8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +# 测试基础设施:独立临时 SQLite 库 + get_db 依赖覆盖 +# +# 测试不触碰 data/sqlite_demo.db:每个用例在 tmp_path 下建库, +# 通过 dependency_overrides 注入请求级会话。 + +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +import app.models # noqa: F401 # 确保模型已注册到 Base.metadata +from app.core import database as db_module +from app.core.deps import get_db +from app.db import apply_baseline +from app.main import app + + +@pytest.fixture() +def db_session(tmp_path: Path) -> Iterator[Session]: + engine = create_engine( + f"sqlite:///{tmp_path / 'test.db'}", + connect_args={"check_same_thread": False}, + ) + db_module.Base.metadata.create_all(engine) + testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine) + session = testing_session_local() + try: + yield session + finally: + session.close() + engine.dispose() + + +@pytest.fixture() +def client(db_session: Session) -> Iterator[TestClient]: + def override_get_db() -> Iterator[Session]: + yield db_session + + app.dependency_overrides[get_db] = override_get_db + try: + with TestClient(app) as test_client: + yield test_client + finally: + app.dependency_overrides.clear() + + +@pytest.fixture() +def seeded_db_session(db_session: Session) -> Session: + """在临时库中灌入 baseline-v1 演示数据(退款/不良反应/补发三场景固定入口)。""" + apply_baseline(db_session) + db_session.commit() + return db_session + + +@pytest.fixture() +def seeded_client(seeded_db_session: Session) -> Iterator[TestClient]: + """带演示数据的 TestClient:与真实 demo 库隔离,逐用例独立建库。""" + + def override_get_db() -> Iterator[Session]: + yield seeded_db_session + + app.dependency_overrides[get_db] = override_get_db + try: + with TestClient(app) as test_client: + yield test_client + finally: + app.dependency_overrides.clear() diff --git a/tests/test_core_api.py b/tests/test_core_api.py new file mode 100644 index 0000000..016432c --- /dev/null +++ b/tests/test_core_api.py @@ -0,0 +1,187 @@ +# 核心业务 API 契约测试:客户/会话/消息/订单/工单(含 PATCH 更新) +# +# 临时库灌入 baseline-v1 等价数据(见 tests/conftest.py seeded_client): +# C00005(会话 S00004+S00005、订单 O00004、工单 T00004)、C00015(S00015/O00015)、C00159(S00159/O00159/T00159)。 + +from fastapi.testclient import TestClient + + +# ------------------------------------------------------------ 客户 +def test_customer_detail(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/customers/C00005") + assert response.status_code == 200 + + data = response.json()["data"] + assert data["customer_id"] == "C00005" + assert data["display_name_masked"] == "魏h**" + assert data["risk_level"] == "L2" + assert data["statistics"] == { + "session_count": 2, + "order_count": 1, + "ticket_count": 1, + "open_ticket_count": 1, + } + assert data["created_at"].endswith("+08:00") + + +def test_customer_not_found(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/customers/C99999") + assert response.status_code == 404 + + error = response.json()["error"] + assert error["code"] == "CUSTOMER_NOT_FOUND" + assert error["details"]["customer_id"] == "C99999" + + +def test_customer_timeline(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/customers/C00005/timeline") + assert response.status_code == 200 + + body = response.json() + assert body["meta"]["total"] == 3 + assert all(item["event_id"] for item in body["data"]["items"]) + + +# ------------------------------------------------------------ 会话 +def test_session_list_default_and_customer_filter(seeded_client: TestClient) -> None: + body = seeded_client.get("/api/sessions").json() + assert body["meta"]["total"] == 4 + + filtered = seeded_client.get("/api/sessions", params={"customer_id": "C00005"}).json() + assert filtered["meta"]["total"] == 2 + items = filtered["data"]["items"] + assert {item["session_id"] for item in items} == {"S00004", "S00005"} + # 承诺只读数据已下线:列表项不再返回承诺计数,保留未闭环工单计数 + assert all("active_promise_count" not in item for item in items) + assert all("open_ticket_count" in item for item in items) + + +def test_session_detail_includes(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/sessions/S00005", params={"include": "events,orders,tickets"}) + assert response.status_code == 200 + + data = response.json()["data"] + assert data["session"]["session_id"] == "S00005" + assert data["session"]["open_ticket_count"] == 1 + assert len(data["messages"]) >= 1 + # 承诺与副驾数据已下线:详情不再返回 promises / copilot + assert "promises" not in data + assert "copilot" not in data + assert {order["order_id"] for order in data["orders"]} == {"O00004"} + assert {ticket["ticket_id"] for ticket in data["tickets"]} == {"T00004"} + assert {event["title"] for event in data["events"]} == {"消费者再次进线催办退款"} + + +# ------------------------------------------------------------ 聊天记录检索 +def test_session_messages_asc_and_desc(seeded_client: TestClient) -> None: + body = seeded_client.get("/api/sessions/S00159/messages").json() + assert body["meta"]["total"] == 4 + assert [item["seq_no"] for item in body["data"]["items"]] == [1, 2, 3, 4] + + desc = seeded_client.get("/api/sessions/S00159/messages", params={"order": "desc", "page_size": 2}).json() + assert desc["meta"]["total"] == 4 + assert [item["seq_no"] for item in desc["data"]["items"]] == [4, 3] + + +def test_session_messages_not_found(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/sessions/S99999/messages") + assert response.status_code == 404 + assert response.json()["error"]["code"] == "SESSION_NOT_FOUND" + + +# ------------------------------------------------------------ 订单 +def test_orders_by_order_no(seeded_client: TestClient) -> None: + body = seeded_client.get("/api/orders", params={"order_no": "O00004"}).json() + assert body["meta"]["total"] == 1 + + order = body["data"]["items"][0] + assert order["order_id"] == "O00004" + assert order["paid_amount_display"] == "¥198.00" + assert order["related_ticket_ids"] == ["T00004"] + + +def test_orders_by_customer_and_session(seeded_client: TestClient) -> None: + assert seeded_client.get("/api/orders").json()["meta"]["total"] == 3 + assert seeded_client.get("/api/orders", params={"customer_id": "C00159"}).json()["meta"]["total"] == 1 + assert seeded_client.get("/api/orders", params={"session_id": "S00015"}).json()["meta"]["total"] == 1 + + +def test_order_detail_not_found(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/orders/O99999") + assert response.status_code == 404 + assert response.json()["error"]["code"] == "ORDER_NOT_FOUND" + + +# ------------------------------------------------------------ 工单 +def test_ticket_detail_with_events(seeded_client: TestClient) -> None: + response = seeded_client.get("/api/tickets/T00004", params={"include": "events"}) + assert response.status_code == 200 + + data = response.json()["data"] + assert data["status"] == "in_progress" + assert data["detail"]["transfer_status"] == "处理中" + assert {event["event_type"] for event in data["events"]} == {"ticket_status"} + + +def test_ticket_patch_status_writes_audit(seeded_client: TestClient) -> None: + response = seeded_client.patch( + "/api/tickets/T00159", + json={"status": "completed", "note": "补发完成,物流单号已同步"}, + headers={"X-Operator-ID": "G001"}, + ) + assert response.status_code == 200 + + data = response.json()["data"] + assert data["status"] == "completed" + assert data["completed_at"] is not None + + detail = seeded_client.get("/api/tickets/T00159", params={"include": "events"}).json()["data"] + audit = [event for event in detail["events"] if event["actor_type"] == "operator"] + assert len(audit) == 1 + assert audit[0]["actor_id"] == "G001" + assert audit[0]["content"] == "补发完成,物流单号已同步" + assert "pending" in audit[0]["title"] and "completed" in audit[0]["title"] + + # 统计联动:C00159 未闭环工单数降为 0 + customer = seeded_client.get("/api/customers/C00159").json()["data"] + assert customer["statistics"]["open_ticket_count"] == 0 + + +def test_ticket_patch_priority_and_assignee(seeded_client: TestClient) -> None: + response = seeded_client.patch("/api/tickets/T00004", json={"priority": "critical", "assignee": "G009"}) + assert response.status_code == 200 + + data = response.json()["data"] + assert data["priority"] == "critical" + assert data["assignee"] == "G009" + + detail = seeded_client.get("/api/tickets/T00004", params={"include": "events"}).json()["data"] + assert len([event for event in detail["events"] if event["event_type"] == "ticket_update"]) == 1 + + +def test_ticket_patch_no_change_is_idempotent(seeded_client: TestClient) -> None: + response = seeded_client.patch("/api/tickets/T00004", json={"status": "in_progress"}) + assert response.status_code == 200 + assert response.json()["data"]["status"] == "in_progress" + + detail = seeded_client.get("/api/tickets/T00004", params={"include": "events"}).json()["data"] + assert [event for event in detail["events"] if event["actor_type"] == "operator"] == [] + + +def test_ticket_patch_requires_at_least_one_field(seeded_client: TestClient) -> None: + response = seeded_client.patch("/api/tickets/T00004", json={}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "INVALID_PARAMETER" + + +def test_ticket_patch_not_found(seeded_client: TestClient) -> None: + response = seeded_client.patch("/api/tickets/T99999", json={"status": "completed"}) + assert response.status_code == 404 + assert response.json()["error"]["code"] == "TICKET_NOT_FOUND" + + +# ------------------------------------------------------------ 范围外模块已下线(未挂载) +def test_deprecated_routes_unmounted(seeded_client: TestClient) -> None: + assert seeded_client.get("/api/sessions/S00005/copilot").status_code == 404 + assert seeded_client.get("/api/sessions/S00005/stream").status_code == 404 + assert seeded_client.get("/api/risk-queue").status_code == 404 diff --git a/tests/test_health.py b/tests/test_health.py index 010f2bf..6225983 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,11 +1,48 @@ +# 健康检查与统一响应封装的基础契约测试 + from fastapi.testclient import TestClient -from app.main import app -client = TestClient(app) +def test_health(client: TestClient) -> None: + response = client.get("/api/health") + assert response.status_code == 200 + + body = response.json() + assert body["error"] is None + + data = body["data"] + assert data["status"] == "ok" + assert data["database"] == "ok" + assert data["model_provider"] in {"bailian", "mock"} + assert data["version"] + assert data["server_time"].endswith("+08:00") + + assert body["meta"]["request_id"] + assert response.headers["x-request-id"] -def test_health() -> None: - response = client.get("/health") +def test_health_echoes_request_id(client: TestClient) -> None: + response = client.get("/api/health", headers={"X-Request-ID": "req_test_001"}) assert response.status_code == 200 - assert response.json() == {"status": "ok"} + assert response.json()["meta"]["request_id"] == "req_test_001" + assert response.headers["x-request-id"] == "req_test_001" + + +def test_unknown_route_uses_envelope(client: TestClient) -> None: + response = client.get("/api/not-exist") + assert response.status_code == 404 + + body = response.json() + assert body["data"] is None + assert body["error"]["code"] == "NOT_FOUND" + assert body["meta"]["request_id"] + + +def test_session_not_found_shape(client: TestClient) -> None: + response = client.get("/api/sessions/S99999") + assert response.status_code == 404 + + body = response.json() + assert body["data"] is None + assert body["error"]["code"] == "SESSION_NOT_FOUND" + assert body["error"]["details"]["session_id"] == "S99999" From 3302c9c77527699daf5dffff7c8aa05a388daa9a Mon Sep 17 00:00:00 2001 From: mark <2483178594@qq.com> Date: Sun, 4 Oct 2026 20:41:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?style:=20=E4=BD=BF=E7=94=A8=20ruff=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=85=A5=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=92=8C=E9=87=8D=E5=A4=8D=E5=AE=9A=E4=B9=89=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/sessions.py | 6 ++---- app/services/session_service.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/api/routes/sessions.py b/app/api/routes/sessions.py index 4a54441..a7853bd 100644 --- a/app/api/routes/sessions.py +++ b/app/api/routes/sessions.py @@ -5,15 +5,13 @@ from typing import Annotated, Literal -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Header, Query, Request from sqlalchemy.orm import Session as DbSession from app.core.deps import get_db from app.core.envelope import ok -from app.services import session_service -from fastapi import Header - from app.schemas.api import SessionMessageCreateRequest +from app.services import session_service router = APIRouter(prefix="/sessions", tags=["sessions"]) diff --git a/app/services/session_service.py b/app/services/session_service.py index cf77d29..3ad8c71 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -5,15 +5,17 @@ # - 当前阶段已下线:承诺只读视图与 Agent 分析快照(代码保留,见文件末尾说明)。 import uuid + from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session as DbSession -from app.models import Message, ServiceEvent # 已有 ServiceSession 就合并 -from app.core.time_utils import to_api_time # to_iso / utc_now 已在 -from app.core.time_utils import to_iso, utc_now -from app.schemas.api import SessionMessageCreateRequest from app.core.errors import session_not_found -from app.models import ( +from app.core.time_utils import ( + to_api_time, # to_iso / utc_now 已在 + to_iso, + utc_now, +) +from app.models import ( # 已有 ServiceSession 就合并 AIAnalysis, Consumer, Message, @@ -23,6 +25,7 @@ ServiceSession, ServiceTicket, ) +from app.schemas.api import SessionMessageCreateRequest from app.services import serializers # 未完成状态集合(计数与筛选口径) From 437f5c67be03b629c379b4a119d19c678b75a4ab Mon Sep 17 00:00:00 2001 From: mark <2483178594@qq.com> Date: Sun, 4 Oct 2026 20:45:17 +0800 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=E5=BA=94=E7=94=A8=20ruff=20format?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/sessions.py | 6 ++---- app/schemas/api.py | 1 + app/services/session_service.py | 10 ++-------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/api/routes/sessions.py b/app/api/routes/sessions.py index a7853bd..062fa0b 100644 --- a/app/api/routes/sessions.py +++ b/app/api/routes/sessions.py @@ -76,7 +76,5 @@ def create_session_message( x_operator_id: str | None = Header(None, alias="X-Operator-ID", description="操作人标识,写入审计事件"), ) -> dict: """模拟客服发送消息(send=true)或保存草稿(send=false)。""" - result = session_service.append_message( - db, session_id, payload=payload, operator_id=x_operator_id - ) - return ok(request, result) \ No newline at end of file + result = session_service.append_message(db, session_id, payload=payload, operator_id=x_operator_id) + return ok(request, result) diff --git a/app/schemas/api.py b/app/schemas/api.py index c950b07..a08e98e 100644 --- a/app/schemas/api.py +++ b/app/schemas/api.py @@ -113,6 +113,7 @@ class TicketUpdateRequest(BaseModel): assignee: str | None = Field(None, max_length=64) note: str | None = Field(None, max_length=500) + class SessionMessageCreateRequest(BaseModel): """模拟客服发送消息 / 保存草稿。""" diff --git a/app/services/session_service.py b/app/services/session_service.py index 3ad8c71..36502c9 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -190,6 +190,7 @@ def list_messages( messages = db.scalars(query.order_by(*ordering).offset((page - 1) * page_size).limit(page_size)).all() return [serializers.message_item(message) for message in messages], int(total) + def append_message( db: DbSession, session_id: str, @@ -209,10 +210,7 @@ def append_message( now_iso = to_iso(utc_now()) next_seq = ( - db.scalar( - select(func.coalesce(func.max(Message.seq_no), 0)).where(Message.session_id == session_id) - ) - or 0 + db.scalar(select(func.coalesce(func.max(Message.seq_no), 0)).where(Message.session_id == session_id)) or 0 ) + 1 message_id = f"m-{uuid.uuid4().hex[:12]}" @@ -265,10 +263,6 @@ def append_message( } - - - - # ---------- 以下为已下线能力(承诺只读视图 / Agent 分析快照),代码保留待恢复 ----------