# -*- coding: utf-8 -*- """ 租户管理后台 — 维客线索管理路由。 端点清单: - GET /api/tenant/customers/search — 客户搜索(keyword + site_id) - GET /api/tenant/customers/{member_id}/clues — 线索列表(source / is_hidden 筛选) - PATCH /api/tenant/clues/{id} — 编辑线索 - DELETE /api/tenant/clues/{id} — 物理删除线索 - PATCH /api/tenant/clues/{id}/visibility — 切换隐藏/显示 需求: 9.1-9.4, 10.1, 11.1-11.3, 12.2-12.3, 13.1-13.4 AI_CHANGELOG - 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:_get_clue_with_site_check 签名改为接受 admin: CurrentTenantAdmin;search_customers 用 get_effective_site_ids;list_customer_clues 用 site_filter_clause(admin=admin);三个调用点改传 admin | Verify:维客线索管理覆盖新建店铺 """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from app.auth.tenant_admins import ( CurrentTenantAdmin, get_effective_site_ids, require_tenant_admin, site_filter_clause, ) from app.database import get_connection, get_etl_readonly_connection from app.schemas.tenant_clues import ( ClueEditRequest, ClueListItem, ClueVisibilityRequest, CustomerSearchItem, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/tenant", tags=["维客线索管理"]) def _mask_mobile(mobile: str | None) -> str | None: """手机号脱敏:中间 4 位替换为 ****,如 138****1234。""" if not mobile or len(mobile) < 7: return mobile return mobile[:3] + "****" + mobile[7:] def _get_clue_with_site_check(clue_id: int, admin: CurrentTenantAdmin): """ 查询线索并校验 site_id 是否在管辖范围内。 不在管辖范围或不存在均返回 404(避免泄露线索存在性)。 返回 (id, site_id, member_id, category, summary, detail, recorded_by_name, source, recorded_at, is_hidden)。 """ site_sql, site_params = site_filter_clause(admin=admin) conn = get_connection() try: with conn.cursor() as cur: cur.execute( f""" SELECT id, site_id, member_id, category, summary, detail, recorded_by_name, source, recorded_at::text, is_hidden FROM public.member_retention_clue WHERE id = %s AND {site_sql} """, (clue_id, *site_params), ) row = cur.fetchone() finally: conn.close() if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线索不存在") return row # ── GET /api/tenant/customers/search ────────────────────── @router.get("/customers/search") async def search_customers( keyword: str = Query(..., min_length=1, description="搜索关键词(姓名模糊/手机号精确)"), site_id: Optional[int] = Query(None, description="指定门店 ID 筛选"), admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """ 客户搜索:在管辖门店范围内搜索 v_dim_member。 nickname 模糊匹配 OR mobile 精确匹配,scd2_is_current=1。 手机号脱敏返回。 """ # 确定要搜索的门店列表 # [CHANGE P20260323-210000] intent: 使用 get_effective_site_ids 统一获取有效 site_ids effective_ids = get_effective_site_ids(admin) if site_id is not None: if site_id not in effective_ids: return {"items": []} search_site_ids = [site_id] else: search_site_ids = effective_ids if not search_site_ids: return {"items": []} # 逐 site_id 查询 FDW(RLS 要求逐个设置 current_site_id) all_items: list[dict] = [] for sid in search_site_ids: try: etl_conn = get_etl_readonly_connection(sid) try: with etl_conn.cursor() as cur: cur.execute( """ SELECT member_id, nickname, mobile FROM fdw_etl.v_dim_member WHERE scd2_is_current = 1 AND (nickname ILIKE %s OR mobile = %s) LIMIT 50 """, (f"%{keyword}%", keyword), ) for row in cur.fetchall(): all_items.append( CustomerSearchItem( member_id=row[0], nickname=row[1], mobile_masked=_mask_mobile(row[2]), site_id=sid, ).model_dump(by_alias=True) ) finally: etl_conn.close() except Exception: logger.warning("v_dim_member 搜索失败(site_id=%s)", sid, exc_info=True) # 补充 site_name if all_items: site_ids_set = list({item.get("siteId") for item in all_items if item.get("siteId")}) if site_ids_set: conn = get_connection() try: with conn.cursor() as cur: placeholders = ", ".join(["%s"] * len(site_ids_set)) cur.execute( f"SELECT site_id, site_name FROM biz.sites WHERE site_id IN ({placeholders})", tuple(site_ids_set), ) site_name_map = {r[0]: r[1] for r in cur.fetchall()} finally: conn.close() for item in all_items: sid_val = item.get("siteId") if sid_val and sid_val in site_name_map: item["siteName"] = site_name_map[sid_val] return {"items": all_items} # ── GET /api/tenant/customers/{member_id}/clues ─────────── @router.get("/customers/{member_id}/clues") async def list_customer_clues( member_id: int, source: Optional[str] = Query(None, description="按来源筛选:manual/ai_consumption/ai_note"), is_hidden: Optional[bool] = Query(None, description="按隐藏状态筛选"), admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """返回该客户在管辖门店范围内的全部线索,支持 source 和 is_hidden 筛选。""" site_sql, site_params = site_filter_clause(admin=admin) conn = get_connection() try: with conn.cursor() as cur: where_parts = [f"{site_sql}", "member_id = %s"] params: list = list(site_params) + [member_id] if source is not None: where_parts.append("source = %s") params.append(source) if is_hidden is not None: where_parts.append("is_hidden = %s") params.append(is_hidden) where_clause = " AND ".join(where_parts) cur.execute( f""" SELECT id, category, summary, detail, recorded_by_name, source, recorded_at::text, is_hidden FROM public.member_retention_clue WHERE {where_clause} ORDER BY recorded_at DESC """, tuple(params), ) rows = cur.fetchall() finally: conn.close() items = [ ClueListItem( id=r[0], category=r[1], summary=r[2], detail=r[3], recorded_by_name=r[4], source=r[5], recorded_at=r[6], is_hidden=r[7], ).model_dump(by_alias=True) for r in rows ] return {"items": items} # ── PATCH /api/tenant/clues/{id} ────────────────────────── @router.patch("/clues/{clue_id}") async def edit_clue( clue_id: int, body: ClueEditRequest, admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """编辑线索 category/summary/detail。校验 category 枚举和 summary 长度。""" # 先校验线索存在且在管辖范围内 _get_clue_with_site_check(clue_id, admin) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE public.member_retention_clue SET category = %s, summary = %s, detail = %s WHERE id = %s """, (body.category.value, body.summary, body.detail, clue_id), ) conn.commit() except Exception: conn.rollback() logger.error("编辑线索失败(clue_id=%s)", clue_id, exc_info=True) raise HTTPException(status_code=500, detail="编辑操作失败") finally: conn.close() return {"message": "更新成功"} # ── DELETE /api/tenant/clues/{id} ───────────────────────── @router.delete("/clues/{clue_id}") async def delete_clue( clue_id: int, admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """物理删除线索。线索不存在或不在管辖范围返回 404。""" _get_clue_with_site_check(clue_id, admin) conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM public.member_retention_clue WHERE id = %s", (clue_id,), ) conn.commit() except Exception: conn.rollback() logger.error("删除线索失败(clue_id=%s)", clue_id, exc_info=True) raise HTTPException(status_code=500, detail="删除操作失败") finally: conn.close() return {"message": "删除成功"} # ── PATCH /api/tenant/clues/{id}/visibility ─────────────── @router.patch("/clues/{clue_id}/visibility") async def toggle_clue_visibility( clue_id: int, body: ClueVisibilityRequest, admin: CurrentTenantAdmin = Depends(require_tenant_admin), ): """切换线索 is_hidden 状态。""" _get_clue_with_site_check(clue_id, admin) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE public.member_retention_clue SET is_hidden = %s WHERE id = %s """, (body.is_hidden, clue_id), ) conn.commit() except Exception: conn.rollback() logger.error("切换线索可见性失败(clue_id=%s)", clue_id, exc_info=True) raise HTTPException(status_code=500, detail="操作失败") finally: conn.close() return {"message": "更新成功"}