微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
1
apps/backend/app/ai/__init__.py
Normal file
1
apps/backend/app/ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AI 集成模块:百炼 API 封装、8 个 AI 应用、事件调度、缓存管理
|
||||
1
apps/backend/app/ai/apps/__init__.py
Normal file
1
apps/backend/app/ai/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AI 应用子模块:app1_chat ~ app8_consolidation
|
||||
273
apps/backend/app/ai/bailian_client.py
Normal file
273
apps/backend/app/ai/bailian_client.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""百炼 API 统一封装层。
|
||||
|
||||
使用 openai Python SDK(百炼兼容 OpenAI 协议),提供流式和非流式两种调用模式。
|
||||
所有 AI 应用通过此客户端统一调用阿里云通义千问。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import openai
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 异常类 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BailianApiError(Exception):
|
||||
"""百炼 API 调用失败(重试耗尽后)。"""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class BailianJsonParseError(Exception):
|
||||
"""百炼 API 返回的 JSON 解析失败。"""
|
||||
|
||||
def __init__(self, message: str, raw_content: str = ""):
|
||||
super().__init__(message)
|
||||
self.raw_content = raw_content
|
||||
|
||||
|
||||
class BailianAuthError(BailianApiError):
|
||||
"""百炼 API Key 无效(HTTP 401)。"""
|
||||
|
||||
def __init__(self, message: str = "API Key 无效或已过期"):
|
||||
super().__init__(message, status_code=401)
|
||||
|
||||
|
||||
# ── 客户端 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BailianClient:
|
||||
"""百炼 API 统一封装层。
|
||||
|
||||
使用 openai.AsyncOpenAI 客户端,base_url 指向百炼端点。
|
||||
提供流式(chat_stream)和非流式(chat_json)两种调用模式。
|
||||
"""
|
||||
|
||||
# 重试配置
|
||||
MAX_RETRIES = 3
|
||||
BASE_INTERVAL = 1 # 秒
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model: str):
|
||||
"""初始化百炼客户端。
|
||||
|
||||
Args:
|
||||
api_key: 百炼 API Key(环境变量 BAILIAN_API_KEY)
|
||||
base_url: 百炼 API 端点(环境变量 BAILIAN_BASE_URL)
|
||||
model: 模型标识,如 qwen-plus(环境变量 BAILIAN_MODEL)
|
||||
"""
|
||||
self.model = model
|
||||
self._client = openai.AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式调用,逐 chunk yield 文本。用于应用 1 SSE。
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
temperature: 温度参数,默认 0.7
|
||||
max_tokens: 最大 token 数,默认 2000
|
||||
|
||||
Yields:
|
||||
文本 chunk
|
||||
"""
|
||||
messages = self._inject_current_time(messages)
|
||||
response = await self._call_with_retry(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in response:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
|
||||
async def chat_json(
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 4000,
|
||||
) -> tuple[dict, int]:
|
||||
"""非流式调用,返回解析后的 JSON dict 和 tokens_used。
|
||||
|
||||
用于应用 2-8 的结构化输出。使用 response_format={"type": "json_object"}
|
||||
确保返回合法 JSON。
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
temperature: 温度参数,默认 0.3(结构化输出用低温度)
|
||||
max_tokens: 最大 token 数,默认 4000
|
||||
|
||||
Returns:
|
||||
(parsed_json_dict, tokens_used) 元组
|
||||
|
||||
Raises:
|
||||
BailianJsonParseError: 响应内容无法解析为 JSON
|
||||
BailianApiError: API 调用失败(重试耗尽后)
|
||||
"""
|
||||
messages = self._inject_current_time(messages)
|
||||
response = await self._call_with_retry(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=False,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
tokens_used = response.usage.total_tokens if response.usage else 0
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw_content)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.error("百炼 API 返回非法 JSON: %s", raw_content[:500])
|
||||
raise BailianJsonParseError(
|
||||
f"JSON 解析失败: {e}",
|
||||
raw_content=raw_content,
|
||||
) from e
|
||||
|
||||
return parsed, tokens_used
|
||||
|
||||
def _inject_current_time(self, messages: list[dict]) -> list[dict]:
|
||||
"""纯函数:在首条消息的 content(JSON 字符串)中注入 current_time 字段。
|
||||
|
||||
- 深拷贝输入,不修改原始 messages
|
||||
- 首条消息 content 尝试解析为 JSON,注入 current_time
|
||||
- 如果首条消息 content 不是 JSON,则包装为 JSON
|
||||
- 其余消息不变
|
||||
- current_time 格式:ISO 8601 精确到秒,如 2026-03-08T14:30:00
|
||||
|
||||
Args:
|
||||
messages: 原始消息列表
|
||||
|
||||
Returns:
|
||||
注入 current_time 后的新消息列表
|
||||
"""
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
result = copy.deepcopy(messages)
|
||||
first = result[0]
|
||||
content = first.get("content", "")
|
||||
now_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
if isinstance(parsed, dict):
|
||||
parsed["current_time"] = now_str
|
||||
else:
|
||||
# content 是合法 JSON 但不是 dict(如数组、字符串),包装为 dict
|
||||
parsed = {"original_content": parsed, "current_time": now_str}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# content 不是 JSON,包装为 dict
|
||||
parsed = {"content": content, "current_time": now_str}
|
||||
|
||||
first["content"] = json.dumps(parsed, ensure_ascii=False)
|
||||
return result
|
||||
|
||||
async def _call_with_retry(self, **kwargs: Any) -> Any:
|
||||
"""带指数退避的重试封装。
|
||||
|
||||
重试策略:
|
||||
- 最多重试 MAX_RETRIES 次(默认 3 次)
|
||||
- 间隔:BASE_INTERVAL × 2^(n-1),即 1s → 2s → 4s
|
||||
- HTTP 4xx:不重试,直接抛出(401 → BailianAuthError)
|
||||
- HTTP 5xx / 超时:重试
|
||||
|
||||
Args:
|
||||
**kwargs: 传递给 openai client 的参数
|
||||
|
||||
Returns:
|
||||
API 响应对象
|
||||
|
||||
Raises:
|
||||
BailianAuthError: API Key 无效(HTTP 401)
|
||||
BailianApiError: API 调用失败(重试耗尽后)
|
||||
"""
|
||||
is_stream = kwargs.get("stream", False)
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
if is_stream:
|
||||
# 流式调用:返回 async iterator
|
||||
return await self._client.chat.completions.create(**kwargs)
|
||||
else:
|
||||
return await self._client.chat.completions.create(**kwargs)
|
||||
|
||||
except openai.AuthenticationError as e:
|
||||
# 401:API Key 无效,不重试
|
||||
logger.error("百炼 API 认证失败: %s", e)
|
||||
raise BailianAuthError(str(e)) from e
|
||||
|
||||
except openai.BadRequestError as e:
|
||||
# 400:请求参数错误,不重试
|
||||
logger.error("百炼 API 请求参数错误: %s", e)
|
||||
raise BailianApiError(str(e), status_code=400) from e
|
||||
|
||||
except openai.RateLimitError as e:
|
||||
# 429:限流,不重试(属于 4xx)
|
||||
logger.error("百炼 API 限流: %s", e)
|
||||
raise BailianApiError(str(e), status_code=429) from e
|
||||
|
||||
except openai.PermissionDeniedError as e:
|
||||
# 403:权限不足,不重试
|
||||
logger.error("百炼 API 权限不足: %s", e)
|
||||
raise BailianApiError(str(e), status_code=403) from e
|
||||
|
||||
except openai.NotFoundError as e:
|
||||
# 404:资源不存在,不重试
|
||||
logger.error("百炼 API 资源不存在: %s", e)
|
||||
raise BailianApiError(str(e), status_code=404) from e
|
||||
|
||||
except openai.UnprocessableEntityError as e:
|
||||
# 422:不可处理,不重试
|
||||
logger.error("百炼 API 不可处理的请求: %s", e)
|
||||
raise BailianApiError(str(e), status_code=422) from e
|
||||
|
||||
except (openai.InternalServerError, openai.APIConnectionError, openai.APITimeoutError) as e:
|
||||
# 5xx / 超时 / 连接错误:重试
|
||||
last_error = e
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
wait_time = self.BASE_INTERVAL * (2 ** attempt)
|
||||
logger.warning(
|
||||
"百炼 API 调用失败(第 %d/%d 次),%ds 后重试: %s",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
wait_time,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
else:
|
||||
logger.error(
|
||||
"百炼 API 调用失败,已达最大重试次数 %d: %s",
|
||||
self.MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
|
||||
# 重试耗尽
|
||||
status_code = getattr(last_error, "status_code", None)
|
||||
raise BailianApiError(
|
||||
f"百炼 API 调用失败(重试 {self.MAX_RETRIES} 次后): {last_error}",
|
||||
status_code=status_code,
|
||||
) from last_error
|
||||
188
apps/backend/app/ai/cache_service.py
Normal file
188
apps/backend/app/ai/cache_service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
AI 缓存读写服务。
|
||||
|
||||
负责 biz.ai_cache 表的 CRUD 和保留策略管理。
|
||||
所有查询和写入操作强制 site_id 隔离。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AICacheService:
|
||||
"""AI 缓存读写服务。"""
|
||||
|
||||
def get_latest(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
) -> dict | None:
|
||||
"""查询最新缓存记录。
|
||||
|
||||
按 (cache_type, site_id, target_id) 查询 created_at 最新的一条。
|
||||
无记录时返回 None。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, cache_type, site_id, target_id,
|
||||
result_json, score, triggered_by,
|
||||
created_at, expires_at
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = %s AND site_id = %s AND target_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(cache_type, site_id, target_id),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(columns, row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
limit: int = 2,
|
||||
) -> list[dict]:
|
||||
"""查询历史缓存记录(按 created_at DESC),用于 Prompt reference。
|
||||
|
||||
无记录时返回空列表。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, cache_type, site_id, target_id,
|
||||
result_json, score, triggered_by,
|
||||
created_at, expires_at
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = %s AND site_id = %s AND target_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(cache_type, site_id, target_id, limit),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
return [_row_to_dict(columns, row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def write_cache(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
result_json: dict,
|
||||
triggered_by: str | None = None,
|
||||
score: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> int:
|
||||
"""写入缓存记录,返回 id。写入后清理超限记录。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_cache
|
||||
(cache_type, site_id, target_id, result_json,
|
||||
triggered_by, score, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
cache_type,
|
||||
site_id,
|
||||
target_id,
|
||||
json.dumps(result_json, ensure_ascii=False),
|
||||
triggered_by,
|
||||
score,
|
||||
expires_at,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
cache_id: int = row[0]
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 写入成功后清理超限记录(失败仅记录警告,不影响写入结果)
|
||||
try:
|
||||
deleted = self._cleanup_excess(cache_type, site_id, target_id)
|
||||
if deleted > 0:
|
||||
logger.info(
|
||||
"清理超限缓存: cache_type=%s site_id=%s target_id=%s 删除=%d",
|
||||
cache_type, site_id, target_id, deleted,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"清理超限缓存失败: cache_type=%s site_id=%s target_id=%s",
|
||||
cache_type, site_id, target_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return cache_id
|
||||
|
||||
def _cleanup_excess(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
max_count: int = 500,
|
||||
) -> int:
|
||||
"""清理超限记录,保留最近 max_count 条,返回删除数量。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 删除超出保留上限的最旧记录
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM biz.ai_cache
|
||||
WHERE id IN (
|
||||
SELECT id FROM biz.ai_cache
|
||||
WHERE cache_type = %s AND site_id = %s AND target_id = %s
|
||||
ORDER BY created_at DESC
|
||||
OFFSET %s
|
||||
)
|
||||
""",
|
||||
(cache_type, site_id, target_id, max_count),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
return deleted
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row_to_dict(columns: list[str], row: tuple) -> dict:
|
||||
"""将数据库行转换为 dict,处理特殊类型序列化。"""
|
||||
result = {}
|
||||
for col, val in zip(columns, row):
|
||||
if isinstance(val, datetime):
|
||||
result[col] = val.isoformat()
|
||||
else:
|
||||
result[col] = val
|
||||
return result
|
||||
160
apps/backend/app/ai/conversation_service.py
Normal file
160
apps/backend/app/ai/conversation_service.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
对话记录持久化服务。
|
||||
|
||||
负责 biz.ai_conversations 和 biz.ai_messages 两张表的 CRUD。
|
||||
所有 8 个 AI 应用的每次调用都通过本服务记录对话和消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""AI 对话记录持久化服务。"""
|
||||
|
||||
def create_conversation(
|
||||
self,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
app_id: str,
|
||||
site_id: int,
|
||||
source_page: str | None = None,
|
||||
source_context: dict | None = None,
|
||||
) -> int:
|
||||
"""创建对话记录,返回 conversation_id。
|
||||
|
||||
系统自动调用时 user_id 为 'system'。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_conversations
|
||||
(user_id, nickname, app_id, site_id, source_page, source_context)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
str(user_id),
|
||||
nickname,
|
||||
app_id,
|
||||
site_id,
|
||||
source_page,
|
||||
json.dumps(source_context, ensure_ascii=False) if source_context else None,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return row[0]
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
tokens_used: int | None = None,
|
||||
) -> int:
|
||||
"""添加消息记录,返回 message_id。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_messages
|
||||
(conversation_id, role, content, tokens_used)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(conversation_id, role, content, tokens_used),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return row[0]
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_conversations(
|
||||
self,
|
||||
user_id: int | str,
|
||||
site_id: int,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> list[dict]:
|
||||
"""查询用户历史对话列表,按 created_at 降序,分页。"""
|
||||
offset = (page - 1) * page_size
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, nickname, app_id, site_id,
|
||||
source_page, source_context, created_at
|
||||
FROM biz.ai_conversations
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(str(user_id), site_id, page_size, offset),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
_row_to_dict(columns, row)
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
conversation_id: int,
|
||||
) -> list[dict]:
|
||||
"""查询对话的所有消息,按 created_at 升序。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, conversation_id, role, content,
|
||||
tokens_used, created_at
|
||||
FROM biz.ai_messages
|
||||
WHERE conversation_id = %s
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(conversation_id,),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
_row_to_dict(columns, row)
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row_to_dict(columns: list[str], row: tuple) -> dict:
|
||||
"""将数据库行转换为 dict,处理特殊类型序列化。"""
|
||||
result = {}
|
||||
for col, val in zip(columns, row):
|
||||
if isinstance(val, datetime):
|
||||
result[col] = val.isoformat()
|
||||
else:
|
||||
result[col] = val
|
||||
return result
|
||||
1
apps/backend/app/ai/prompts/__init__.py
Normal file
1
apps/backend/app/ai/prompts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AI Prompt 模板子模块
|
||||
157
apps/backend/app/ai/schemas.py
Normal file
157
apps/backend/app/ai/schemas.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""AI 集成层 Pydantic 模型定义。
|
||||
|
||||
覆盖请求/响应体、缓存类型枚举、线索模型、各应用结果模型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── 请求/响应 ──
|
||||
|
||||
|
||||
class ChatStreamRequest(BaseModel):
|
||||
"""SSE 流式对话请求体"""
|
||||
|
||||
message: str
|
||||
source_page: str | None = None
|
||||
page_context: dict | None = None
|
||||
screen_content: str | None = None
|
||||
|
||||
|
||||
class SSEEvent(BaseModel):
|
||||
"""SSE 事件"""
|
||||
|
||||
type: str # chunk / done / error
|
||||
content: str | None = None
|
||||
conversation_id: int | None = None
|
||||
tokens_used: int | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# ── 缓存类型枚举 ──
|
||||
|
||||
|
||||
class CacheTypeEnum(str, enum.Enum):
|
||||
APP2_FINANCE = "app2_finance"
|
||||
APP3_CLUE = "app3_clue"
|
||||
APP4_ANALYSIS = "app4_analysis"
|
||||
APP5_TACTICS = "app5_tactics"
|
||||
APP6_NOTE_ANALYSIS = "app6_note_analysis"
|
||||
APP7_CUSTOMER_ANALYSIS = "app7_customer_analysis"
|
||||
APP8_CLUE_CONSOLIDATED = "app8_clue_consolidated"
|
||||
|
||||
|
||||
# ── 线索相关 ──
|
||||
|
||||
|
||||
class App3CategoryEnum(str, enum.Enum):
|
||||
"""App3 线索分类(3 个枚举值)"""
|
||||
|
||||
CUSTOMER_BASIC = "客户基础"
|
||||
CONSUMPTION_HABIT = "消费习惯"
|
||||
PLAY_PREFERENCE = "玩法偏好"
|
||||
|
||||
|
||||
class App6CategoryEnum(str, enum.Enum):
|
||||
"""App6/8 线索分类(6 个枚举值)"""
|
||||
|
||||
CUSTOMER_BASIC = "客户基础"
|
||||
CONSUMPTION_HABIT = "消费习惯"
|
||||
PLAY_PREFERENCE = "玩法偏好"
|
||||
PROMO_PREFERENCE = "促销偏好"
|
||||
SOCIAL_RELATION = "社交关系"
|
||||
IMPORTANT_FEEDBACK = "重要反馈"
|
||||
|
||||
|
||||
class ClueItem(BaseModel):
|
||||
"""单条线索(App3/App6 共用)"""
|
||||
|
||||
category: str
|
||||
summary: str
|
||||
detail: str
|
||||
emoji: str
|
||||
|
||||
|
||||
class ConsolidatedClueItem(BaseModel):
|
||||
"""整合后线索(App8,含 providers)"""
|
||||
|
||||
category: str
|
||||
summary: str
|
||||
detail: str
|
||||
emoji: str
|
||||
providers: str
|
||||
|
||||
|
||||
# ── 各应用结果模型 ──
|
||||
|
||||
|
||||
class App2InsightItem(BaseModel):
|
||||
"""App2 财务洞察单条"""
|
||||
|
||||
seq: int
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
class App2Result(BaseModel):
|
||||
"""App2 财务洞察结果"""
|
||||
|
||||
insights: list[App2InsightItem]
|
||||
|
||||
|
||||
class App3Result(BaseModel):
|
||||
"""App3 客户数据维客线索结果"""
|
||||
|
||||
clues: list[ClueItem]
|
||||
|
||||
|
||||
class App4Result(BaseModel):
|
||||
"""App4 关系分析结果"""
|
||||
|
||||
task_description: str
|
||||
action_suggestions: list[str]
|
||||
one_line_summary: str
|
||||
|
||||
|
||||
class App5TacticsItem(BaseModel):
|
||||
"""App5 话术单条"""
|
||||
|
||||
scenario: str
|
||||
script: str
|
||||
|
||||
|
||||
class App5Result(BaseModel):
|
||||
"""App5 话术参考结果"""
|
||||
|
||||
tactics: list[App5TacticsItem]
|
||||
|
||||
|
||||
class App6Result(BaseModel):
|
||||
"""App6 备注分析结果"""
|
||||
|
||||
score: int = Field(ge=1, le=10)
|
||||
clues: list[ClueItem]
|
||||
|
||||
|
||||
class App7StrategyItem(BaseModel):
|
||||
"""App7 客户分析策略单条"""
|
||||
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class App7Result(BaseModel):
|
||||
"""App7 客户分析结果"""
|
||||
|
||||
strategies: list[App7StrategyItem]
|
||||
summary: str
|
||||
|
||||
|
||||
class App8Result(BaseModel):
|
||||
"""App8 维客线索整理结果"""
|
||||
|
||||
clues: list[ConsolidatedClueItem]
|
||||
@@ -10,12 +10,79 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# CHANGE 2026-03-07 | 项目根目录定位:防止 junction/symlink 穿透到 D 盘
|
||||
# 背景:C:\NeoZQYY 是 junction → D:\NeoZQYY\...\repo,
|
||||
# Path(__file__).resolve() 和 absolute() 都可能解析到 D 盘,
|
||||
# 导致加载 D 盘的 .env(路径全指向 D 盘),ETL 命令因此携带错误路径。
|
||||
# 策略:环境变量 > 已知固定路径 > __file__ 推算(最后手段)
|
||||
import logging as _logging
|
||||
_cfg_logger = _logging.getLogger("app.config")
|
||||
|
||||
|
||||
def _find_project_root() -> Path:
|
||||
"""定位项目根目录,返回包含 .env 的路径。
|
||||
|
||||
优先级:
|
||||
1. 环境变量 NEOZQYY_ROOT(最可靠,显式指定)
|
||||
2. __file__ 向上推算,但用 junction 安全的方式
|
||||
"""
|
||||
|
||||
# 1. 环境变量显式指定(部署时设置,最可靠)
|
||||
env_root = os.environ.get("NEOZQYY_ROOT")
|
||||
if env_root:
|
||||
p = Path(env_root)
|
||||
if (p / ".env").exists():
|
||||
_cfg_logger.info("[ROOT] 策略1命中: NEOZQYY_ROOT=%s", p)
|
||||
return p
|
||||
_cfg_logger.warning("[ROOT] NEOZQYY_ROOT=%s 但 .env 不存在,跳过", env_root)
|
||||
|
||||
# 2. 从 __file__ 推算(apps/backend/app/config.py → 上 3 级)
|
||||
raw_file = Path(__file__)
|
||||
abs_file = raw_file.absolute()
|
||||
candidate = abs_file.parents[3]
|
||||
|
||||
_cfg_logger.info(
|
||||
"[ROOT] 策略2: __file__=%s | absolute=%s | candidate=%s",
|
||||
raw_file, abs_file, candidate,
|
||||
)
|
||||
|
||||
# CHANGE 2026-03-07 | 防护:如果推算路径包含 test/repo 或 prod/repo 等
|
||||
# 多环境子目录,说明发生了 junction/symlink 穿透到 D 盘部署结构,
|
||||
# 此时向上搜索找到真正的项目根(包含 .env 的最浅目录)
|
||||
candidate_str = str(candidate)
|
||||
if any(seg in candidate_str for seg in ("\\test\\", "\\prod\\", "/test/", "/prod/")):
|
||||
_cfg_logger.warning(
|
||||
"[ROOT] 检测到多环境子目录穿透: %s,启动向上搜索", candidate_str
|
||||
)
|
||||
elif (candidate / ".env").exists():
|
||||
_cfg_logger.info("[ROOT] 策略2命中: %s", candidate)
|
||||
return candidate
|
||||
|
||||
# 3. 向上搜索:应对 junction 穿透导致层级偏移的情况
|
||||
cur = abs_file.parent
|
||||
for i in range(10):
|
||||
if (cur / ".env").exists():
|
||||
_cfg_logger.info("[ROOT] 策略3命中(第%d级): %s", i, cur)
|
||||
return cur
|
||||
parent = cur.parent
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
|
||||
_cfg_logger.warning("[ROOT] 所有策略均未命中,回退到: %s", candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
_project_root = _find_project_root()
|
||||
_cfg_logger.info("项目根目录: %s", _project_root)
|
||||
|
||||
# 根 .env(公共配置)
|
||||
_root_env = Path(__file__).resolve().parents[3] / ".env"
|
||||
_root_env = _project_root / ".env"
|
||||
_cfg_logger.info("加载根 .env: %s (存在: %s)", _root_env, _root_env.exists())
|
||||
load_dotenv(_root_env, override=False)
|
||||
|
||||
# 应用级 .env.local(私有覆盖,优先级更高)
|
||||
_local_env = Path(__file__).resolve().parents[1] / ".env.local"
|
||||
_local_env = _project_root / "apps" / "backend" / ".env.local"
|
||||
load_dotenv(_local_env, override=True)
|
||||
|
||||
|
||||
@@ -24,6 +91,14 @@ def get(key: str, default: str | None = None) -> str | None:
|
||||
return os.getenv(key, default)
|
||||
|
||||
|
||||
def _require_env(key: str) -> str:
|
||||
"""必需的环境变量,缺失时立即报错。"""
|
||||
raise RuntimeError(
|
||||
f"必需的环境变量 {key} 未设置。"
|
||||
f"请在 .env 中显式配置(当前 .env 路径: {_root_env})"
|
||||
)
|
||||
|
||||
|
||||
# ---- 数据库连接参数 ----
|
||||
DB_HOST: str = get("DB_HOST", "localhost")
|
||||
DB_PORT: str = get("DB_PORT", "5432")
|
||||
@@ -55,11 +130,55 @@ CORS_ORIGINS: list[str] = [
|
||||
]
|
||||
|
||||
# ---- ETL 项目路径 ----
|
||||
# ETL CLI 的工作目录(子进程 cwd),缺省时按 monorepo 相对路径推算
|
||||
ETL_PROJECT_PATH: str = get(
|
||||
"ETL_PROJECT_PATH",
|
||||
str(Path(__file__).resolve().parents[3] / "apps" / "etl" / "connectors" / "feiqiu"),
|
||||
)
|
||||
# CHANGE 2026-03-06 | 必须在 .env 显式设置,禁止依赖 __file__ 推算
|
||||
# 原因:__file__ 推算依赖 uvicorn 启动位置,不同部署环境会指向错误代码副本
|
||||
ETL_PROJECT_PATH: str = get("ETL_PROJECT_PATH") or _require_env("ETL_PROJECT_PATH")
|
||||
|
||||
# ETL 子进程 Python 可执行路径
|
||||
# CHANGE 2026-03-06 | 必须在 .env 显式设置,避免 PATH 歧义
|
||||
ETL_PYTHON_EXECUTABLE: str = get("ETL_PYTHON_EXECUTABLE") or _require_env("ETL_PYTHON_EXECUTABLE")
|
||||
|
||||
# ---- 运维面板 ----
|
||||
# CHANGE 2026-03-06 | 必须在 .env 显式设置,消除 __file__ 推算风险
|
||||
OPS_SERVER_BASE: str = get("OPS_SERVER_BASE") or _require_env("OPS_SERVER_BASE")
|
||||
|
||||
# CHANGE 2026-03-07 | 启动时验证关键路径:
|
||||
# 1. 路径必须实际存在于文件系统
|
||||
# 2. 路径不得包含多环境子目录(test/repo、prod/repo),这是 junction 穿透的标志
|
||||
_cfg_logger.info("ETL_PROJECT_PATH = %s", ETL_PROJECT_PATH)
|
||||
_cfg_logger.info("ETL_PYTHON_EXECUTABLE = %s", ETL_PYTHON_EXECUTABLE)
|
||||
_cfg_logger.info("OPS_SERVER_BASE = %s", OPS_SERVER_BASE)
|
||||
|
||||
for _var_name, _var_val in [
|
||||
("ETL_PROJECT_PATH", ETL_PROJECT_PATH),
|
||||
("ETL_PYTHON_EXECUTABLE", ETL_PYTHON_EXECUTABLE),
|
||||
("OPS_SERVER_BASE", OPS_SERVER_BASE),
|
||||
]:
|
||||
# 检测 junction 穿透特征:路径中包含 \test\repo 或 \prod\repo
|
||||
_normalized = _var_val.replace("/", "\\")
|
||||
if "\\test\\repo" in _normalized or "\\prod\\repo" in _normalized:
|
||||
_cfg_logger.error(
|
||||
"路径穿透检测: %s=%s 包含多环境子目录,"
|
||||
"说明 .env 来自 junction 穿透后的 D 盘副本。"
|
||||
"当前 .env 路径: %s | NEOZQYY_ROOT: %s",
|
||||
_var_name, _var_val, _root_env,
|
||||
os.environ.get("NEOZQYY_ROOT", "<未设置>"),
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"配置路径异常: {_var_name}={_var_val} 包含多环境子目录"
|
||||
f"(test/repo 或 prod/repo),疑似加载了错误的 .env。"
|
||||
f" 当前 .env: {_root_env}。"
|
||||
f" 请确保 NEOZQYY_ROOT 环境变量指向正确的项目根目录。"
|
||||
)
|
||||
|
||||
# ---- 微信小程序 ----
|
||||
WX_APPID: str = get("WX_APPID", "")
|
||||
WX_SECRET: str = get("WX_SECRET", "")
|
||||
# 开发模式:WX_DEV_MODE=true 时启用 mock 登录端点,跳过微信 code2Session
|
||||
WX_DEV_MODE: bool = get("WX_DEV_MODE", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
# ---- 营业日分割点 ----
|
||||
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
|
||||
# ---- 通用 ----
|
||||
TIMEZONE: str = get("TIMEZONE", "Asia/Shanghai")
|
||||
|
||||
@@ -12,10 +12,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import config
|
||||
# CHANGE 2026-02-19 | 新增 xcx_test 路由(MVP 验证)+ wx_callback 路由(微信消息推送)
|
||||
# CHANGE 2026-02-22 | 新增 member_birthday 路由(助教手动补录会员生日)
|
||||
# CHANGE 2026-02-23 | 新增 ops_panel 路由(运维控制面板)
|
||||
# CHANGE 2026-02-25 | 新增 xcx_auth 路由(小程序微信登录 + 申请 + 状态查询 + 店铺切换)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_birthday, ops_panel, xcx_auth
|
||||
# CHANGE 2026-02-26 | member_birthday 路由替换为 member_retention_clue(维客线索重构)
|
||||
# CHANGE 2026-02-26 | 新增 admin_applications 路由(管理端申请审核)
|
||||
# CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.ws.logs import ws_router
|
||||
@@ -24,9 +26,37 @@ from app.ws.logs import ws_router
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期:启动时拉起后台服务,关闭时优雅停止。"""
|
||||
# CHANGE 2026-03-07 | 启动横幅:打印关键路径,便于诊断连到了哪个实例
|
||||
import sys
|
||||
_banner = (
|
||||
"\n"
|
||||
"╔══════════════════════════════════════════════════════╗\n"
|
||||
"║ NeoZQYY Backend — 启动诊断 ║\n"
|
||||
"╠══════════════════════════════════════════════════════╣\n"
|
||||
f"║ Python: {sys.executable}\n"
|
||||
f"║ ROOT: {config._project_root}\n"
|
||||
f"║ ETL_PATH: {config.ETL_PROJECT_PATH}\n"
|
||||
f"║ ETL_PY: {config.ETL_PYTHON_EXECUTABLE}\n"
|
||||
f"║ OPS_BASE: {config.OPS_SERVER_BASE}\n"
|
||||
f"║ APP_DB: {config.APP_DB_NAME}\n"
|
||||
f"║ .env: {config._root_env}\n"
|
||||
"╚══════════════════════════════════════════════════════╝\n"
|
||||
)
|
||||
print(_banner, flush=True)
|
||||
|
||||
# 启动
|
||||
task_queue.start()
|
||||
scheduler.start()
|
||||
|
||||
# CHANGE 2026-02-27 | 注册触发器 job handler(核心业务模块)
|
||||
from app.services.trigger_scheduler import register_job
|
||||
from app.services import task_generator, task_expiry, recall_detector, note_reclassifier
|
||||
|
||||
register_job("task_generator", lambda **_kw: task_generator.run())
|
||||
register_job("task_expiry_check", lambda **_kw: task_expiry.run())
|
||||
register_job("recall_completion_check", recall_detector.run)
|
||||
register_job("note_reclassify_backfill", note_reclassifier.run)
|
||||
|
||||
yield
|
||||
# 关闭
|
||||
await scheduler.stop()
|
||||
@@ -63,12 +93,47 @@ app.include_router(etl_status.router)
|
||||
app.include_router(ws_router)
|
||||
app.include_router(xcx_test.router)
|
||||
app.include_router(wx_callback.router)
|
||||
app.include_router(member_birthday.router)
|
||||
app.include_router(member_retention_clue.router)
|
||||
app.include_router(ops_panel.router)
|
||||
app.include_router(xcx_auth.router)
|
||||
app.include_router(admin_applications.router)
|
||||
app.include_router(business_day.router)
|
||||
app.include_router(xcx_tasks.router)
|
||||
app.include_router(xcx_notes.router)
|
||||
|
||||
|
||||
@app.get("/health", tags=["系统"])
|
||||
async def health_check():
|
||||
"""健康检查端点,用于探活和监控。"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# CHANGE 2026-03-07 | 诊断端点:返回关键路径配置,用于确认连到的是哪个实例
|
||||
@app.get("/debug/config-paths", tags=["系统"])
|
||||
async def debug_config_paths():
|
||||
"""返回当前后端实例的关键路径配置(仅开发环境使用)。"""
|
||||
import sys
|
||||
import os
|
||||
import platform
|
||||
from app.services.cli_builder import cli_builder as _cb
|
||||
from app.schemas.tasks import TaskConfigSchema as _TCS
|
||||
|
||||
_test_cfg = _TCS(flow="api_ods_dwd", processing_mode="increment_only",
|
||||
tasks=["DWD_LOAD_FROM_ODS"], store_id=123)
|
||||
_test_cmd = _cb.build_command(
|
||||
_test_cfg, config.ETL_PROJECT_PATH,
|
||||
python_executable=config.ETL_PYTHON_EXECUTABLE,
|
||||
)
|
||||
_test_cmd_str = " ".join(_test_cmd)
|
||||
|
||||
return {
|
||||
"hostname": platform.node(),
|
||||
"python_executable": sys.executable,
|
||||
"project_root": str(config._project_root),
|
||||
"env_file": str(config._root_env),
|
||||
"etl_python_executable": config.ETL_PYTHON_EXECUTABLE,
|
||||
"etl_project_path": config.ETL_PROJECT_PATH,
|
||||
"simulated_command": _test_cmd_str,
|
||||
"NEOZQYY_ROOT_env": os.environ.get("NEOZQYY_ROOT", "<未设置>"),
|
||||
"cwd": os.getcwd(),
|
||||
}
|
||||
|
||||
124
apps/backend/app/middleware/permission.py
Normal file
124
apps/backend/app/middleware/permission.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
权限中间件 —— 基于 FastAPI 依赖注入的权限检查。
|
||||
|
||||
提供两个依赖工厂:
|
||||
- require_permission(*codes):检查用户 status=approved 且拥有指定权限
|
||||
- require_approved():仅检查用户 status=approved(不检查具体权限)
|
||||
|
||||
用法:
|
||||
@router.get("/finance")
|
||||
async def get_finance(
|
||||
user: CurrentUser = Depends(require_permission("view_board_finance"))
|
||||
):
|
||||
...
|
||||
|
||||
@router.get("/tasks")
|
||||
async def get_tasks(
|
||||
user: CurrentUser = Depends(require_approved())
|
||||
):
|
||||
...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
from app.services.role import get_user_permissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_user_status(user_id: int) -> str | None:
|
||||
"""从数据库查询用户当前 status。返回 None 表示用户不存在。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM auth.users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def require_permission(*permission_codes: str):
|
||||
"""
|
||||
权限依赖工厂:要求用户 status=approved 且拥有全部指定权限。
|
||||
|
||||
流程:
|
||||
1. 通过 get_current_user 从 JWT 提取 user_id + site_id
|
||||
2. 查询 auth.users.status —— 非 approved 则 403
|
||||
3. 查询 user_site_roles + role_permissions 获取权限列表
|
||||
4. 检查所需权限是否全部在列表中 —— 缺失则 403
|
||||
5. 返回 CurrentUser 对象
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
# 查询数据库中的实时 status
|
||||
db_status = _get_user_status(user.user_id)
|
||||
if db_status is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户不存在",
|
||||
)
|
||||
if db_status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户未通过审核,无法访问此资源",
|
||||
)
|
||||
|
||||
# 检查具体权限
|
||||
if permission_codes:
|
||||
user_perms = await get_user_permissions(user.user_id, user.site_id)
|
||||
missing = set(permission_codes) - set(user_perms)
|
||||
if missing:
|
||||
logger.warning(
|
||||
"用户 %s 在 site_id=%s 下缺少权限: %s",
|
||||
user.user_id,
|
||||
user.site_id,
|
||||
missing,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_approved():
|
||||
"""
|
||||
审核状态依赖工厂:仅检查用户 status=approved,不检查具体权限。
|
||||
|
||||
用于通用的已认证端点,只需确认用户已通过审核即可访问。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
db_status = _get_user_status(user.user_id)
|
||||
if db_status is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户不存在",
|
||||
)
|
||||
if db_status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户未通过审核,无法访问此资源",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
214
apps/backend/app/routers/admin_applications.py
Normal file
214
apps/backend/app/routers/admin_applications.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端审核路由 —— 申请列表、详情、批准、拒绝。
|
||||
|
||||
端点清单:
|
||||
- GET /api/admin/applications — 查询申请列表(可按 status 过滤)
|
||||
- GET /api/admin/applications/{id} — 查询申请详情 + 候选匹配
|
||||
- POST /api/admin/applications/{id}/approve — 批准申请
|
||||
- POST /api/admin/applications/{id}/reject — 拒绝申请
|
||||
|
||||
所有端点要求 JWT + site_admin 或 tenant_admin 角色。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.database import get_connection
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.xcx_auth import (
|
||||
ApplicationResponse,
|
||||
ApproveRequest,
|
||||
MatchCandidate,
|
||||
RejectRequest,
|
||||
)
|
||||
from app.services.application import approve_application, reject_application
|
||||
from app.services.matching import find_candidates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["管理端审核"])
|
||||
|
||||
|
||||
# ── 管理端需要 site_admin 或 tenant_admin 权限 ─────────────
|
||||
# require_permission() 不检查具体 permission code,
|
||||
# 但会验证 status=approved;管理端路由额外在依赖中检查角色。
|
||||
# 设计文档要求 site_admin / tenant_admin 角色,
|
||||
# 这里通过检查 CurrentUser.roles 实现。
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""
|
||||
管理端依赖:要求用户 status=approved 且角色包含 site_admin 或 tenant_admin。
|
||||
|
||||
复用 require_permission()(无具体权限码 → 仅检查 approved),
|
||||
再额外校验角色列表。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(require_permission()),
|
||||
) -> CurrentUser:
|
||||
admin_roles = {"site_admin", "tenant_admin"}
|
||||
if not admin_roles.intersection(user.roles):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限(site_admin 或 tenant_admin)",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── GET /api/admin/applications ───────────────────────────
|
||||
|
||||
|
||||
@router.get("/applications", response_model=list[ApplicationResponse])
|
||||
async def list_applications(
|
||||
status_filter: Optional[str] = Query(
|
||||
None, alias="status", description="按状态过滤:pending / approved / rejected"
|
||||
),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""查询申请列表,可按 status 过滤。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if status_filter:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
WHERE status = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_code, applied_role_text, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
ApplicationResponse(
|
||||
id=r[0],
|
||||
site_code=r[1],
|
||||
applied_role_text=r[2],
|
||||
status=r[3],
|
||||
review_note=r[4],
|
||||
created_at=r[5],
|
||||
reviewed_at=r[6],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── GET /api/admin/applications/{id} ─────────────────────
|
||||
|
||||
|
||||
@router.get("/applications/{application_id}")
|
||||
async def get_application_detail(
|
||||
application_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""查询申请详情 + 候选匹配。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, site_code, site_id, applied_role_text,
|
||||
phone, employee_number, status,
|
||||
review_note, created_at::text, reviewed_at::text
|
||||
FROM auth.user_applications
|
||||
WHERE id = %s
|
||||
""",
|
||||
(application_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="申请不存在",
|
||||
)
|
||||
|
||||
app_data = {
|
||||
"id": row[0],
|
||||
"user_id": row[1],
|
||||
"site_code": row[2],
|
||||
"site_id": row[3],
|
||||
"applied_role_text": row[4],
|
||||
"phone": row[5],
|
||||
"employee_number": row[6],
|
||||
"status": row[7],
|
||||
"review_note": row[8],
|
||||
"created_at": row[9],
|
||||
"reviewed_at": row[10],
|
||||
}
|
||||
|
||||
# 查找候选匹配
|
||||
candidates_raw = await find_candidates(
|
||||
site_id=app_data["site_id"],
|
||||
phone=app_data["phone"],
|
||||
employee_number=app_data["employee_number"],
|
||||
)
|
||||
candidates = [MatchCandidate(**c) for c in candidates_raw]
|
||||
|
||||
return {
|
||||
"application": app_data,
|
||||
"candidates": [c.model_dump() for c in candidates],
|
||||
}
|
||||
|
||||
|
||||
# ── POST /api/admin/applications/{id}/approve ────────────
|
||||
|
||||
|
||||
@router.post("/applications/{application_id}/approve", response_model=ApplicationResponse)
|
||||
async def approve(
|
||||
application_id: int,
|
||||
body: ApproveRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""批准申请:分配角色 + 可选绑定。"""
|
||||
result = await approve_application(
|
||||
application_id=application_id,
|
||||
reviewer_id=user.user_id,
|
||||
role_id=body.role_id,
|
||||
binding=body.binding,
|
||||
review_note=body.review_note,
|
||||
)
|
||||
return ApplicationResponse(**result)
|
||||
|
||||
|
||||
# ── POST /api/admin/applications/{id}/reject ─────────────
|
||||
|
||||
|
||||
@router.post("/applications/{application_id}/reject", response_model=ApplicationResponse)
|
||||
async def reject(
|
||||
application_id: int,
|
||||
body: RejectRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""拒绝申请:记录拒绝原因。"""
|
||||
result = await reject_application(
|
||||
application_id=application_id,
|
||||
reviewer_id=user.user_id,
|
||||
review_note=body.review_note,
|
||||
)
|
||||
return ApplicationResponse(**result)
|
||||
20
apps/backend/app/routers/business_day.py
Normal file
20
apps/backend/app/routers/business_day.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""营业日配置 API
|
||||
|
||||
提供公开端点返回当前营业日分割点配置,供前端动态获取。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import config
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["业务配置"])
|
||||
|
||||
|
||||
@router.get("/business-day")
|
||||
async def get_business_day_config():
|
||||
"""返回当前营业日分割点配置。
|
||||
|
||||
无需认证(公开配置),前端启动时调用一次缓存。
|
||||
"""
|
||||
return {"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}
|
||||
@@ -202,7 +202,8 @@ async def get_execution_history(
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, task_codes, status, started_at,
|
||||
finished_at, exit_code, duration_ms, command, summary
|
||||
finished_at, exit_code, duration_ms, command, summary,
|
||||
schedule_id
|
||||
FROM task_execution_log
|
||||
WHERE site_id = %s
|
||||
ORDER BY started_at DESC
|
||||
@@ -227,6 +228,7 @@ async def get_execution_history(
|
||||
duration_ms=row[7],
|
||||
command=row[8],
|
||||
summary=row[9],
|
||||
schedule_id=str(row[10]) if row[10] else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
102
apps/backend/app/routers/member_retention_clue.py
Normal file
102
apps/backend/app/routers/member_retention_clue.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
维客线索路由。
|
||||
|
||||
- POST /api/retention-clue — 提交维客线索(UPSERT)
|
||||
- GET /api/retention-clue/{member_id} — 查询某会员的全部维客线索
|
||||
- DELETE /api/retention-clue/{clue_id} — 删除单条线索
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.database import get_connection
|
||||
from app.schemas.member_retention_clue import RetentionClueSubmit, RetentionClueOut
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["维客线索"])
|
||||
|
||||
|
||||
@router.post("/retention-clue")
|
||||
async def submit_retention_clue(body: RetentionClueSubmit):
|
||||
"""
|
||||
提交维客线索(INSERT)。
|
||||
|
||||
同一会员可有多条不同大类的线索。
|
||||
"""
|
||||
sql = """
|
||||
INSERT INTO member_retention_clue
|
||||
(member_id, category, summary, detail,
|
||||
recorded_by_assistant_id, recorded_by_name, site_id, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (
|
||||
body.member_id,
|
||||
body.category.value,
|
||||
body.summary,
|
||||
body.detail,
|
||||
body.recorded_by_assistant_id,
|
||||
body.recorded_by_name,
|
||||
body.site_id,
|
||||
body.source.value,
|
||||
))
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return {"status": "ok", "id": row[0] if row else None}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.exception("维客线索写入失败: member_id=%s", body.member_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="线索提交失败,请稍后重试",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/retention-clue/{member_id}", response_model=list[RetentionClueOut])
|
||||
async def get_retention_clues(member_id: int, site_id: int):
|
||||
"""查询某会员的全部维客线索,按录入时间倒序。"""
|
||||
sql = """
|
||||
SELECT id, member_id, category, summary, detail,
|
||||
recorded_by_assistant_id, recorded_by_name, recorded_at, site_id, source
|
||||
FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
ORDER BY recorded_at DESC
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (member_id, site_id))
|
||||
rows = cur.fetchall()
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [dict(zip(cols, r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/retention-clue/{clue_id}")
|
||||
async def delete_retention_clue(clue_id: int):
|
||||
"""删除单条维客线索。"""
|
||||
sql = "DELETE FROM member_retention_clue WHERE id = %s"
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (clue_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="线索不存在")
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.exception("维客线索删除失败: id=%s", clue_id)
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -17,12 +17,13 @@ import psutil
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import OPS_SERVER_BASE
|
||||
|
||||
router = APIRouter(prefix="/api/ops", tags=["运维面板"])
|
||||
|
||||
# ---- 环境定义 ----
|
||||
# 服务器上的两套环境;开发机上回退到本机路径(方便调试)
|
||||
|
||||
_SERVER_BASE = Path("D:/NeoZQYY")
|
||||
# CHANGE 2026-03-04 | 从 config 读取,消除硬编码 D 盘路径
|
||||
_SERVER_BASE = Path(OPS_SERVER_BASE)
|
||||
|
||||
ENVIRONMENTS: dict[str, dict[str, Any]] = {
|
||||
"test": {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""调度任务 CRUD API
|
||||
|
||||
提供 5 个端点:
|
||||
提供 8 个端点:
|
||||
- GET /api/schedules — 列表(按 site_id 过滤)
|
||||
- POST /api/schedules — 创建
|
||||
- POST /api/schedules — 创建(支持 run_immediately)
|
||||
- PUT /api/schedules/{id} — 更新
|
||||
- DELETE /api/schedules/{id} — 删除
|
||||
- PATCH /api/schedules/{id}/toggle — 启用/禁用
|
||||
- GET /api/schedules/{id}/history — 调度任务执行历史
|
||||
- POST /api/schedules/{id}/run — 手动执行一次(不更新调度间隔)
|
||||
|
||||
所有端点需要 JWT 认证,site_id 从 JWT 提取。
|
||||
"""
|
||||
@@ -17,7 +19,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
@@ -26,7 +28,10 @@ from app.schemas.schedules import (
|
||||
ScheduleResponse,
|
||||
UpdateScheduleRequest,
|
||||
)
|
||||
from app.schemas.execution import ExecutionHistoryItem
|
||||
from app.schemas.tasks import TaskConfigSchema
|
||||
from app.services.scheduler import calculate_next_run
|
||||
from app.services.task_queue import task_queue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,7 +97,7 @@ async def create_schedule(
|
||||
body: CreateScheduleRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ScheduleResponse:
|
||||
"""创建调度任务,自动计算 next_run_at。"""
|
||||
"""创建调度任务,自动计算 next_run_at。支持 run_immediately 立即入队执行一次。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
next_run = calculate_next_run(body.schedule_config, now)
|
||||
|
||||
@@ -124,7 +129,18 @@ async def create_schedule(
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return _row_to_response(row)
|
||||
response = _row_to_response(row)
|
||||
|
||||
# 立即执行一次(入队,不影响调度间隔)
|
||||
if body.run_immediately:
|
||||
try:
|
||||
config = TaskConfigSchema(**body.task_config)
|
||||
config = config.model_copy(update={"store_id": user.site_id})
|
||||
task_queue.enqueue(config, user.site_id, schedule_id=response.id)
|
||||
except Exception:
|
||||
logger.warning("创建调度后立即执行入队失败 schedule_id=%s", response.id, exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ── PUT /api/schedules/{id} — 更新 ──────────────────────────
|
||||
@@ -291,3 +307,88 @@ async def toggle_schedule(
|
||||
conn.close()
|
||||
|
||||
return _row_to_response(updated_row)
|
||||
|
||||
|
||||
# ── POST /api/schedules/{id}/run — 手动执行一次 ──────────────
|
||||
|
||||
@router.post("/{schedule_id}/run")
|
||||
async def run_schedule_now(
|
||||
schedule_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""手动触发调度任务执行一次,不更新 last_run_at / next_run_at / run_count。
|
||||
|
||||
读取调度任务的 task_config,构造 TaskConfigSchema 后入队执行。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT task_config, site_id FROM scheduled_tasks WHERE id = %s AND site_id = %s",
|
||||
(schedule_id, user.site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="调度任务不存在",
|
||||
)
|
||||
|
||||
task_config_raw = row[0] if isinstance(row[0], dict) else json.loads(row[0])
|
||||
config = TaskConfigSchema(**task_config_raw)
|
||||
config = config.model_copy(update={"store_id": user.site_id})
|
||||
task_id = task_queue.enqueue(config, user.site_id, schedule_id=schedule_id)
|
||||
|
||||
return {"message": "已提交到执行队列", "task_id": task_id}
|
||||
|
||||
|
||||
# ── GET /api/schedules/{id}/history — 执行历史 ────────────────
|
||||
|
||||
@router.get("/{schedule_id}/history", response_model=list[ExecutionHistoryItem])
|
||||
async def get_schedule_history(
|
||||
schedule_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ExecutionHistoryItem]:
|
||||
"""获取调度任务的执行历史记录,按开始时间倒序,支持分页。"""
|
||||
offset = (page - 1) * page_size
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, task_codes, status, started_at, finished_at,
|
||||
exit_code, duration_ms, command, summary, schedule_id
|
||||
FROM task_execution_log
|
||||
WHERE schedule_id = %s AND site_id = %s
|
||||
ORDER BY started_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(schedule_id, user.site_id, page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
ExecutionHistoryItem(
|
||||
id=str(row[0]),
|
||||
site_id=row[1],
|
||||
task_codes=row[2] or [],
|
||||
status=row[3],
|
||||
started_at=row[4],
|
||||
finished_at=row[5],
|
||||
exit_code=row[6],
|
||||
duration_ms=row[7],
|
||||
command=row[8],
|
||||
summary=row[9],
|
||||
schedule_id=str(row[10]) if row[10] else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -236,6 +236,16 @@ async def sync_check(
|
||||
|
||||
backend_codes = {t.code for t in ALL_TASKS}
|
||||
|
||||
# ETL 侧存在但后端故意不注册的任务(一次性初始化 / 尚未上线)
|
||||
# 从 etl_only 差集中排除,避免同步检查误报
|
||||
ETL_ONLY_EXPECTED: set[str] = {
|
||||
"INIT_ODS_SCHEMA", # 一次性:ODS schema 初始化
|
||||
"INIT_DWD_SCHEMA", # 一次性:DWD schema 初始化
|
||||
"INIT_DWS_SCHEMA", # 一次性:DWS schema 初始化
|
||||
"SEED_DWS_CONFIG", # 一次性:DWS 配置种子数据
|
||||
"DWS_ASSISTANT_ORDER_CONTRIBUTION", # DWS 任务,后端暂未注册
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
@@ -257,7 +267,7 @@ async def sync_check(
|
||||
)
|
||||
|
||||
backend_only = sorted(backend_codes - etl_codes)
|
||||
etl_only = sorted(etl_codes - backend_codes)
|
||||
etl_only = sorted((etl_codes - backend_codes) - ETL_ONLY_EXPECTED)
|
||||
|
||||
return SyncCheckResponse(
|
||||
in_sync=len(backend_only) == 0 and len(etl_only) == 0,
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
- GET /api/xcx/me/sites — 查询关联店铺
|
||||
- POST /api/xcx/switch-site — 切换当前店铺
|
||||
- POST /api/xcx/refresh — 刷新令牌
|
||||
- POST /api/xcx/dev-login — 开发模式 mock 登录(仅 WX_DEV_MODE=true)
|
||||
- POST /api/xcx/dev-switch-role — 切换角色(仅 WX_DEV_MODE=true)
|
||||
- POST /api/xcx/dev-switch-status — 切换用户状态(仅 WX_DEV_MODE=true)
|
||||
- POST /api/xcx/dev-switch-binding — 切换人员绑定(仅 WX_DEV_MODE=true)
|
||||
- GET /api/xcx/dev-context — 查询调试上下文(仅 WX_DEV_MODE=true)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +34,7 @@ from app.auth.jwt import (
|
||||
create_token_pair,
|
||||
decode_refresh_token,
|
||||
)
|
||||
from app import config
|
||||
from app.database import get_connection
|
||||
from app.services.application import (
|
||||
create_application,
|
||||
@@ -37,6 +43,11 @@ from app.services.application import (
|
||||
from app.schemas.xcx_auth import (
|
||||
ApplicationRequest,
|
||||
ApplicationResponse,
|
||||
DevLoginRequest,
|
||||
DevSwitchBindingRequest,
|
||||
DevSwitchRoleRequest,
|
||||
DevSwitchStatusRequest,
|
||||
DevContextResponse,
|
||||
RefreshTokenRequest,
|
||||
SiteInfo,
|
||||
SwitchSiteRequest,
|
||||
@@ -45,6 +56,7 @@ from app.schemas.xcx_auth import (
|
||||
WxLoginResponse,
|
||||
)
|
||||
from app.services.wechat import WeChatAuthError, code2session
|
||||
from app.services.role import get_user_permissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,9 +106,9 @@ async def wx_login(body: WxLoginRequest):
|
||||
|
||||
流程:code → code2session(openid) → 查找/创建 auth.users → 签发 JWT。
|
||||
- disabled 用户返回 403
|
||||
- 新用户自动创建(status=pending)
|
||||
- 新用户自动创建(status=new),前端引导至申请页
|
||||
- approved 用户签发包含 site_id + roles 的完整令牌
|
||||
- pending/rejected 用户签发受限令牌
|
||||
- new/pending/rejected 用户签发受限令牌
|
||||
"""
|
||||
# 1. 调用微信 code2Session
|
||||
try:
|
||||
@@ -125,12 +137,12 @@ async def wx_login(body: WxLoginRequest):
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
# 新用户:创建 pending 记录
|
||||
# 新用户:创建 new 记录(尚未提交申请)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.users (wx_openid, wx_union_id, status)
|
||||
VALUES (%s, %s, 'pending')
|
||||
VALUES (%s, %s, 'new')
|
||||
RETURNING id, status
|
||||
""",
|
||||
(openid, unionid),
|
||||
@@ -166,7 +178,7 @@ async def wx_login(body: WxLoginRequest):
|
||||
# approved 但无 site 绑定(异常边界),签发受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
# pending / rejected → 受限令牌
|
||||
# new / pending / rejected → 受限令牌
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
|
||||
finally:
|
||||
@@ -415,3 +427,333 @@ async def refresh_token(body: RefreshTokenRequest):
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-login(仅开发模式) ─────────────────
|
||||
|
||||
if config.WX_DEV_MODE:
|
||||
|
||||
@router.post("/dev-login", response_model=WxLoginResponse)
|
||||
async def dev_login(body: DevLoginRequest):
|
||||
"""
|
||||
开发模式 mock 登录。
|
||||
|
||||
直接根据 openid 查找/创建用户,跳过微信 code2Session。
|
||||
- 已有用户:status 参数为空时保留当前状态,非空时覆盖
|
||||
- 新用户:status 参数为空时默认 new,非空时使用指定值
|
||||
仅在 WX_DEV_MODE=true 时注册。
|
||||
"""
|
||||
openid = body.openid
|
||||
target_status = body.status # 可能为 None
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查找已有用户
|
||||
cur.execute(
|
||||
"SELECT id, status FROM auth.users WHERE wx_openid = %s",
|
||||
(openid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
# 新用户:使用指定状态或默认 new
|
||||
init_status = target_status or "new"
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.users (wx_openid, status)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id, status
|
||||
""",
|
||||
(openid, init_status),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
else:
|
||||
# 已有用户:仅在显式传入 status 时覆盖
|
||||
if target_status is not None:
|
||||
user_id_existing = row[0]
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(target_status, user_id_existing),
|
||||
)
|
||||
conn.commit()
|
||||
row = (user_id_existing, target_status)
|
||||
|
||||
user_id, user_status = row
|
||||
|
||||
# 签发令牌(逻辑与正常登录一致)
|
||||
if user_status == "approved":
|
||||
default_site_id = _get_user_default_site(conn, user_id)
|
||||
if default_site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user_id)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=user_status,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# ── GET /api/xcx/dev-context(仅开发模式) ────────────────
|
||||
|
||||
@router.get("/dev-context", response_model=DevContextResponse)
|
||||
async def dev_context(
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
返回当前用户的完整调试上下文。
|
||||
|
||||
包含:用户信息、当前门店、角色、权限、人员绑定、所有关联门店。
|
||||
允许受限令牌访问(返回基础信息,门店/角色/权限为空)。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 用户基本信息
|
||||
cur.execute(
|
||||
"SELECT wx_openid, status, nickname FROM auth.users WHERE id = %s",
|
||||
(user.user_id,),
|
||||
)
|
||||
u_row = cur.fetchone()
|
||||
if u_row is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
openid, u_status, nickname = u_row
|
||||
|
||||
# 当前门店名称
|
||||
site_name = None
|
||||
if user.site_id:
|
||||
cur.execute(
|
||||
"SELECT site_name FROM auth.site_code_mapping WHERE site_id = %s",
|
||||
(user.site_id,),
|
||||
)
|
||||
sn_row = cur.fetchone()
|
||||
site_name = sn_row[0] if sn_row else None
|
||||
|
||||
# 当前门店下的角色
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, user.site_id) if user.site_id else []
|
||||
|
||||
# 当前门店下的权限
|
||||
permissions = await get_user_permissions(user.user_id, user.site_id) if user.site_id else []
|
||||
|
||||
# 人员绑定
|
||||
binding = None
|
||||
if user.site_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id, staff_id, binding_type
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(user.user_id, user.site_id),
|
||||
)
|
||||
b_row = cur.fetchone()
|
||||
if b_row:
|
||||
binding = {
|
||||
"assistant_id": b_row[0],
|
||||
"staff_id": b_row[1],
|
||||
"binding_type": b_row[2],
|
||||
}
|
||||
|
||||
# 所有关联门店
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT usr.site_id,
|
||||
COALESCE(scm.site_name, '') AS site_name,
|
||||
r.code, r.name
|
||||
FROM auth.user_site_roles usr
|
||||
JOIN auth.roles r ON usr.role_id = r.id
|
||||
LEFT JOIN auth.site_code_mapping scm ON usr.site_id = scm.site_id
|
||||
WHERE usr.user_id = %s
|
||||
ORDER BY usr.site_id, r.code
|
||||
""",
|
||||
(user.user_id,),
|
||||
)
|
||||
site_rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
sites_map: dict[int, dict] = {}
|
||||
for sid, sname, rcode, rname in site_rows:
|
||||
if sid not in sites_map:
|
||||
sites_map[sid] = {"site_id": sid, "site_name": sname, "roles": []}
|
||||
sites_map[sid]["roles"].append({"code": rcode, "name": rname})
|
||||
|
||||
return DevContextResponse(
|
||||
user_id=user.user_id,
|
||||
openid=openid,
|
||||
status=u_status,
|
||||
nickname=nickname,
|
||||
site_id=user.site_id,
|
||||
site_name=site_name,
|
||||
roles=roles,
|
||||
permissions=permissions,
|
||||
binding=binding,
|
||||
all_sites=list(sites_map.values()),
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-switch-role(仅开发模式) ───────────
|
||||
|
||||
@router.post("/dev-switch-role", response_model=WxLoginResponse)
|
||||
async def dev_switch_role(
|
||||
body: DevSwitchRoleRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
切换当前用户在当前门店下的角色。
|
||||
|
||||
删除旧角色绑定,插入新角色绑定,重签 token。
|
||||
"""
|
||||
valid_roles = ("coach", "staff", "site_admin", "tenant_admin")
|
||||
if body.role_code not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效角色,可选: {', '.join(valid_roles)}",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询目标角色 ID
|
||||
cur.execute(
|
||||
"SELECT id FROM auth.roles WHERE code = %s",
|
||||
(body.role_code,),
|
||||
)
|
||||
role_row = cur.fetchone()
|
||||
if role_row is None:
|
||||
raise HTTPException(status_code=400, detail=f"角色 {body.role_code} 不存在")
|
||||
role_id = role_row[0]
|
||||
|
||||
# 删除当前门店下的所有角色
|
||||
cur.execute(
|
||||
"DELETE FROM auth.user_site_roles WHERE user_id = %s AND site_id = %s",
|
||||
(user.user_id, user.site_id),
|
||||
)
|
||||
|
||||
# 插入新角色
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.user_site_roles (user_id, site_id, role_id)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (user_id, site_id, role_id) DO NOTHING
|
||||
""",
|
||||
(user.user_id, user.site_id, role_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 重签 token
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, user.site_id)
|
||||
cur.execute("SELECT status FROM auth.users WHERE id = %s", (user.user_id,))
|
||||
u_status = cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
tokens = create_token_pair(user.user_id, user.site_id, roles=roles)
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=u_status,
|
||||
user_id=user.user_id,
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-switch-status(仅开发模式) ─────────
|
||||
|
||||
@router.post("/dev-switch-status", response_model=WxLoginResponse)
|
||||
async def dev_switch_status(
|
||||
body: DevSwitchStatusRequest,
|
||||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||||
):
|
||||
"""
|
||||
切换当前用户状态,重签 token。
|
||||
|
||||
允许受限令牌访问(pending 用户也需要能切换状态)。
|
||||
"""
|
||||
valid_statuses = ("new", "pending", "approved", "rejected", "disabled")
|
||||
if body.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效状态,可选: {', '.join(valid_statuses)}",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(body.status, user.user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 根据新状态签发对应令牌
|
||||
if body.status == "approved":
|
||||
default_site_id = _get_user_default_site(conn, user.user_id)
|
||||
if default_site_id is not None:
|
||||
roles = _get_user_roles_at_site(conn, user.user_id, default_site_id)
|
||||
tokens = create_token_pair(user.user_id, default_site_id, roles=roles)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user.user_id)
|
||||
else:
|
||||
tokens = create_limited_token_pair(user.user_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return WxLoginResponse(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
token_type=tokens["token_type"],
|
||||
user_status=body.status,
|
||||
user_id=user.user_id,
|
||||
)
|
||||
|
||||
# ── POST /api/xcx/dev-switch-binding(仅开发模式) ────────
|
||||
|
||||
@router.post("/dev-switch-binding")
|
||||
async def dev_switch_binding(
|
||||
body: DevSwitchBindingRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
切换当前用户在当前门店下的人员绑定。
|
||||
|
||||
删除旧绑定,插入新绑定。
|
||||
"""
|
||||
valid_types = ("assistant", "staff", "manager")
|
||||
if body.binding_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效绑定类型,可选: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 删除当前门店下的旧绑定
|
||||
cur.execute(
|
||||
"DELETE FROM auth.user_assistant_binding WHERE user_id = %s AND site_id = %s",
|
||||
(user.user_id, user.site_id),
|
||||
)
|
||||
|
||||
# 插入新绑定
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.user_assistant_binding
|
||||
(user_id, site_id, assistant_id, staff_id, binding_type)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(user.user_id, user.site_id, body.assistant_id, body.staff_id, body.binding_type),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"ok": True, "binding_type": body.binding_type}
|
||||
|
||||
67
apps/backend/app/routers/xcx_notes.py
Normal file
67
apps/backend/app/routers/xcx_notes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序备注路由 —— 备注 CRUD(含星星评分)。
|
||||
|
||||
端点清单:
|
||||
- POST /api/xcx/notes — 创建备注
|
||||
- GET /api/xcx/notes — 查询备注列表(query: target_type, target_id)
|
||||
- DELETE /api/xcx/notes/{id} — 删除备注
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_notes import NoteCreateRequest, NoteOut
|
||||
from app.services import note_service
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/notes", tags=["小程序备注"])
|
||||
|
||||
|
||||
@router.post("", response_model=NoteOut)
|
||||
async def create_note(
|
||||
body: NoteCreateRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""创建备注(含星星评分,可选关联任务)。"""
|
||||
return await note_service.create_note(
|
||||
site_id=user.site_id,
|
||||
user_id=user.user_id,
|
||||
target_type=body.target_type,
|
||||
target_id=body.target_id,
|
||||
content=body.content,
|
||||
task_id=body.task_id,
|
||||
rating_service_willingness=body.rating_service_willingness,
|
||||
rating_revisit_likelihood=body.rating_revisit_likelihood,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_notes(
|
||||
target_type: str = Query("member", description="目标类型"),
|
||||
target_id: int = Query(..., description="目标 ID"),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""查询某目标的备注列表(按创建时间倒序)。"""
|
||||
return await note_service.get_notes(
|
||||
site_id=user.site_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""删除备注(验证归属后硬删除)。"""
|
||||
return await note_service.delete_note(
|
||||
note_id=note_id,
|
||||
user_id=user.user_id,
|
||||
site_id=user.site_id,
|
||||
)
|
||||
71
apps/backend/app/routers/xcx_tasks.py
Normal file
71
apps/backend/app/routers/xcx_tasks.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序任务路由 —— 任务列表、置顶、放弃、取消放弃。
|
||||
|
||||
端点清单:
|
||||
- GET /api/xcx/tasks — 获取活跃任务列表
|
||||
- POST /api/xcx/tasks/{id}/pin — 置顶任务
|
||||
- POST /api/xcx/tasks/{id}/unpin — 取消置顶
|
||||
- POST /api/xcx/tasks/{id}/abandon — 放弃任务
|
||||
- POST /api/xcx/tasks/{id}/cancel-abandon — 取消放弃
|
||||
|
||||
所有端点均需 JWT(approved 状态)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_approved
|
||||
from app.schemas.xcx_tasks import AbandonRequest, TaskListItem
|
||||
from app.services import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api/xcx/tasks", tags=["小程序任务"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskListItem])
|
||||
async def get_tasks(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""获取当前助教的活跃任务列表。"""
|
||||
return await task_manager.get_task_list(user.user_id, user.site_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/pin")
|
||||
async def pin_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""置顶任务。"""
|
||||
return await task_manager.pin_task(task_id, user.user_id, user.site_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/unpin")
|
||||
async def unpin_task(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""取消置顶。"""
|
||||
return await task_manager.unpin_task(task_id, user.user_id, user.site_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/abandon")
|
||||
async def abandon_task(
|
||||
task_id: int,
|
||||
body: AbandonRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""放弃任务(需填写原因)。"""
|
||||
return await task_manager.abandon_task(
|
||||
task_id, user.user_id, user.site_id, body.reason
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel-abandon")
|
||||
async def cancel_abandon(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""取消放弃,恢复为活跃状态。"""
|
||||
return await task_manager.cancel_abandon(task_id, user.user_id, user.site_id)
|
||||
@@ -50,6 +50,7 @@ class ExecutionHistoryItem(BaseModel):
|
||||
duration_ms: int | None = None
|
||||
command: str | None = None
|
||||
summary: dict[str, Any] | None = None
|
||||
schedule_id: str | None = None
|
||||
|
||||
|
||||
class ExecutionLogsResponse(BaseModel):
|
||||
|
||||
54
apps/backend/app/schemas/member_retention_clue.py
Normal file
54
apps/backend/app/schemas/member_retention_clue.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
维客线索相关 Pydantic 模型。
|
||||
|
||||
大类枚举:客户基础信息、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ClueCategory(str, Enum):
|
||||
"""维客线索大类枚举"""
|
||||
BASIC_INFO = "客户基础信息"
|
||||
CONSUMPTION = "消费习惯"
|
||||
PLAY_PREF = "玩法偏好"
|
||||
PROMO_PREF = "促销偏好"
|
||||
SOCIAL = "社交关系"
|
||||
FEEDBACK = "重要反馈"
|
||||
|
||||
|
||||
class ClueSource(str, Enum):
|
||||
"""维客线索来源枚举"""
|
||||
MANUAL = "manual" # 助教手动录入
|
||||
AI_CONSUMPTION = "ai_consumption" # 应用 3:消费分析自动生成
|
||||
AI_NOTE = "ai_note" # 应用 6:备注分析自动提取
|
||||
|
||||
|
||||
class RetentionClueSubmit(BaseModel):
|
||||
"""提交维客线索请求。"""
|
||||
member_id: int = Field(..., gt=0, description="会员 ID")
|
||||
category: ClueCategory = Field(..., description="线索大类")
|
||||
summary: str = Field(..., min_length=1, max_length=200, description="摘要:重点信息")
|
||||
detail: Optional[str] = Field(None, max_length=2000, description="详情:分析及扩展说明,可为空")
|
||||
recorded_by_assistant_id: int = Field(..., gt=0, description="记录助教 ID")
|
||||
recorded_by_name: str = Field(..., min_length=1, max_length=50, description="记录助教姓名")
|
||||
site_id: int = Field(..., gt=0, description="门店 ID")
|
||||
source: ClueSource = Field(default=ClueSource.MANUAL, description="线索来源")
|
||||
|
||||
|
||||
class RetentionClueOut(BaseModel):
|
||||
"""维客线索返回模型。"""
|
||||
id: int
|
||||
member_id: int
|
||||
category: ClueCategory
|
||||
summary: str
|
||||
detail: Optional[str]
|
||||
recorded_by_assistant_id: Optional[int]
|
||||
recorded_by_name: Optional[str]
|
||||
recorded_at: datetime
|
||||
site_id: int
|
||||
source: ClueSource = ClueSource.MANUAL
|
||||
@@ -32,6 +32,7 @@ class CreateScheduleRequest(BaseModel):
|
||||
task_codes: list[str]
|
||||
task_config: dict[str, Any]
|
||||
schedule_config: ScheduleConfigSchema
|
||||
run_immediately: bool = False
|
||||
|
||||
|
||||
class UpdateScheduleRequest(BaseModel):
|
||||
|
||||
@@ -47,6 +47,11 @@ class TaskConfigSchema(BaseModel):
|
||||
store_id: int | None = None
|
||||
dwd_only_tables: list[str] | None = None
|
||||
force_full: bool = False
|
||||
# Pipeline 调优参数(可选,不传则使用 ETL 默认值)
|
||||
pipeline_workers: int | None = None
|
||||
pipeline_batch_size: int | None = None
|
||||
pipeline_rate_min: float | None = None
|
||||
pipeline_rate_max: float | None = None
|
||||
extra_args: dict[str, Any] = {}
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -25,6 +25,46 @@ class WxLoginResponse(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
class DevLoginRequest(BaseModel):
|
||||
"""开发模式 mock 登录请求(仅 WX_DEV_MODE=true 时可用)。"""
|
||||
openid: str = Field(..., min_length=1, description="模拟的微信 openid")
|
||||
status: str | None = Field(None, description="模拟的用户状态;为空时保留已有用户的当前状态,新用户默认 new")
|
||||
|
||||
|
||||
# ── 开发调试端点(仅 WX_DEV_MODE=true) ─────────────────
|
||||
|
||||
class DevSwitchRoleRequest(BaseModel):
|
||||
"""切换角色请求。替换当前用户在当前门店下的所有角色为指定角色。"""
|
||||
role_code: str = Field(..., description="目标角色 code(coach/staff/site_admin/tenant_admin)")
|
||||
|
||||
|
||||
class DevSwitchStatusRequest(BaseModel):
|
||||
"""切换用户状态请求。"""
|
||||
status: str = Field(..., description="目标状态(new/pending/approved/rejected/disabled)")
|
||||
|
||||
|
||||
class DevSwitchBindingRequest(BaseModel):
|
||||
"""切换人员绑定请求。"""
|
||||
binding_type: str = Field(..., description="绑定类型(assistant/staff/manager)")
|
||||
assistant_id: int | None = Field(None, description="助教 ID(binding_type=assistant 时必填)")
|
||||
staff_id: int | None = Field(None, description="员工 ID(binding_type=staff/manager 时必填)")
|
||||
|
||||
|
||||
class DevContextResponse(BaseModel):
|
||||
"""开发调试上下文信息。"""
|
||||
user_id: int
|
||||
openid: str | None = None
|
||||
status: str
|
||||
nickname: str | None = None
|
||||
site_id: int | None = None
|
||||
site_name: str | None = None
|
||||
roles: list[str] = []
|
||||
permissions: list[str] = []
|
||||
binding: dict | None = None
|
||||
all_sites: list[dict] = []
|
||||
|
||||
|
||||
|
||||
# ── 用户申请 ──────────────────────────────────────────────
|
||||
|
||||
class ApplicationRequest(BaseModel):
|
||||
@@ -102,3 +142,69 @@ class RejectRequest(BaseModel):
|
||||
"""拒绝申请请求。"""
|
||||
review_note: str = Field(..., min_length=1, description="拒绝原因")
|
||||
|
||||
|
||||
# ── 开发调试端点(仅 WX_DEV_MODE=true) ─────────────────
|
||||
|
||||
class DevSwitchRoleRequest(BaseModel):
|
||||
"""切换角色请求。替换当前用户在当前门店下的所有角色为指定角色。"""
|
||||
role_code: str = Field(..., description="目标角色 code(coach/staff/site_admin/tenant_admin)")
|
||||
|
||||
|
||||
class DevSwitchStatusRequest(BaseModel):
|
||||
"""切换用户状态请求。"""
|
||||
status: str = Field(..., description="目标状态(new/pending/approved/rejected/disabled)")
|
||||
|
||||
|
||||
class DevSwitchBindingRequest(BaseModel):
|
||||
"""切换人员绑定请求。"""
|
||||
binding_type: str = Field(..., description="绑定类型(assistant/staff/manager)")
|
||||
assistant_id: int | None = Field(None, description="助教 ID(binding_type=assistant 时必填)")
|
||||
staff_id: int | None = Field(None, description="员工 ID(binding_type=staff/manager 时必填)")
|
||||
|
||||
|
||||
class DevContextResponse(BaseModel):
|
||||
"""开发调试上下文信息。"""
|
||||
user_id: int
|
||||
openid: str | None = None
|
||||
status: str
|
||||
nickname: str | None = None
|
||||
site_id: int | None = None
|
||||
site_name: str | None = None
|
||||
roles: list[str] = []
|
||||
permissions: list[str] = []
|
||||
binding: dict | None = None
|
||||
all_sites: list[dict] = []
|
||||
|
||||
|
||||
# ── 开发调试端点(仅 WX_DEV_MODE=true) ─────────────────
|
||||
|
||||
class DevSwitchRoleRequest(BaseModel):
|
||||
"""切换角色请求。替换当前用户在当前门店下的所有角色为指定角色。"""
|
||||
role_code: str = Field(..., description="目标角色 code(coach/staff/site_admin/tenant_admin)")
|
||||
|
||||
|
||||
class DevSwitchStatusRequest(BaseModel):
|
||||
"""切换用户状态请求。"""
|
||||
status: str = Field(..., description="目标状态(new/pending/approved/rejected/disabled)")
|
||||
|
||||
|
||||
class DevSwitchBindingRequest(BaseModel):
|
||||
"""切换人员绑定请求。"""
|
||||
binding_type: str = Field(..., description="绑定类型(assistant/staff/manager)")
|
||||
assistant_id: int | None = Field(None, description="助教 ID(binding_type=assistant 时必填)")
|
||||
staff_id: int | None = Field(None, description="员工 ID(binding_type=staff/manager 时必填)")
|
||||
|
||||
|
||||
class DevContextResponse(BaseModel):
|
||||
"""开发调试上下文信息。"""
|
||||
user_id: int
|
||||
openid: str | None = None
|
||||
status: str
|
||||
nickname: str | None = None
|
||||
site_id: int | None = None
|
||||
site_name: str | None = None
|
||||
roles: list[str] = []
|
||||
permissions: list[str] = []
|
||||
binding: dict | None = None
|
||||
all_sites: list[dict] = []
|
||||
|
||||
|
||||
35
apps/backend/app/schemas/xcx_notes.py
Normal file
35
apps/backend/app/schemas/xcx_notes.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
小程序备注相关 Pydantic 模型。
|
||||
|
||||
覆盖:备注创建请求、备注输出等场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NoteCreateRequest(BaseModel):
|
||||
"""创建备注请求(含星星评分,评分 1-5 范围约束)。"""
|
||||
|
||||
target_type: str = Field(default="member")
|
||||
target_id: int
|
||||
content: str = Field(..., min_length=1)
|
||||
task_id: int | None = None
|
||||
rating_service_willingness: int | None = Field(None, ge=1, le=5)
|
||||
rating_revisit_likelihood: int | None = Field(None, ge=1, le=5)
|
||||
|
||||
|
||||
class NoteOut(BaseModel):
|
||||
"""备注输出模型(含评分 + AI 评分)。"""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
content: str
|
||||
rating_service_willingness: int | None
|
||||
rating_revisit_likelihood: int | None
|
||||
ai_score: int | None
|
||||
ai_analysis: str | None
|
||||
task_id: int | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
34
apps/backend/app/schemas/xcx_tasks.py
Normal file
34
apps/backend/app/schemas/xcx_tasks.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
小程序任务相关 Pydantic 模型。
|
||||
|
||||
覆盖:任务列表项、放弃请求等场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
"""任务列表项(含客户信息 + RS 指数 + 爱心 icon)。"""
|
||||
|
||||
id: int
|
||||
task_type: str
|
||||
status: str
|
||||
priority_score: float | None
|
||||
is_pinned: bool
|
||||
expires_at: str | None
|
||||
created_at: str
|
||||
# 客户信息(FDW 读取)
|
||||
member_id: int
|
||||
member_name: str | None
|
||||
member_phone: str | None
|
||||
# RS 指数 + 爱心 icon
|
||||
rs_score: float | None
|
||||
heart_icon: str # 💖 / 🧡 / 💛 / 💙
|
||||
|
||||
|
||||
class AbandonRequest(BaseModel):
|
||||
"""放弃任务请求(reason 必填)。"""
|
||||
|
||||
reason: str = Field(..., min_length=1, description="放弃原因(必填)")
|
||||
@@ -98,6 +98,15 @@ async def create_application(
|
||||
(nickname, user_id),
|
||||
)
|
||||
|
||||
# 5. 更新用户状态为 pending(new → pending)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.users SET status = 'pending', updated_at = NOW()
|
||||
WHERE id = %s AND status IN ('new', 'rejected')
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -124,6 +124,16 @@ class CLIBuilder:
|
||||
if config.store_id is not None:
|
||||
cmd.extend(["--store-id", str(config.store_id)])
|
||||
|
||||
# -- Pipeline 调优参数 --
|
||||
if config.pipeline_workers is not None:
|
||||
cmd.extend(["--pipeline-workers", str(config.pipeline_workers)])
|
||||
if config.pipeline_batch_size is not None:
|
||||
cmd.extend(["--pipeline-batch-size", str(config.pipeline_batch_size)])
|
||||
if config.pipeline_rate_min is not None:
|
||||
cmd.extend(["--pipeline-rate-min", str(config.pipeline_rate_min)])
|
||||
if config.pipeline_rate_max is not None:
|
||||
cmd.extend(["--pipeline-rate-max", str(config.pipeline_rate_max)])
|
||||
|
||||
# -- 额外参数(只传递 CLI 支持的参数) --
|
||||
for key, value in config.extra_args.items():
|
||||
if value is not None and key in CLI_SUPPORTED_ARGS:
|
||||
|
||||
217
apps/backend/app/services/note_reclassifier.py
Normal file
217
apps/backend/app/services/note_reclassifier.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
备注回溯重分类器(Note Reclassifier)
|
||||
|
||||
召回完成后,回溯检查是否有普通备注需重分类为回访备注。
|
||||
查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up →
|
||||
触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务。
|
||||
|
||||
由 trigger_jobs 中的 note_reclassify_backfill 配置驱动(event: recall_completed)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _insert_history(
|
||||
cur,
|
||||
task_id: int,
|
||||
action: str,
|
||||
old_status: str | None = None,
|
||||
new_status: str | None = None,
|
||||
old_task_type: str | None = None,
|
||||
new_task_type: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""在 coach_task_history 中记录变更。"""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_history
|
||||
(task_id, action, old_status, new_status,
|
||||
old_task_type, new_task_type, detail)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
action,
|
||||
old_status,
|
||||
new_status,
|
||||
old_task_type,
|
||||
new_task_type,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def ai_analyze_note(note_id: int) -> int | None:
|
||||
"""
|
||||
AI 应用 6 备注分析接口(占位)。
|
||||
|
||||
P5 AI 集成层实现后替换此占位函数。
|
||||
当前返回 None 表示 AI 未就绪,跳过评分逻辑。
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
"""
|
||||
备注回溯主流程。
|
||||
|
||||
payload 包含: {site_id, assistant_id, member_id, service_time}
|
||||
|
||||
1. 查找 biz.notes 中该 (site_id, target_type='member', target_id=member_id)
|
||||
在 service_time 之后提交的第一条 type='normal' 的备注
|
||||
2. 将该备注 type 从 'normal' 更新为 'follow_up'
|
||||
3. 触发 AI 应用 6 接口(P5 实现,本 SPEC 仅定义触发接口):
|
||||
- 调用 ai_analyze_note(note_id) → 返回 ai_score
|
||||
4. 若 ai_score >= 6:
|
||||
- 生成 follow_up_visit 任务,status='completed'(回溯完成)
|
||||
5. 若 ai_score < 6:
|
||||
- 生成 follow_up_visit 任务,status='active'(需助教重新备注)
|
||||
|
||||
返回: {"reclassified_count": int, "tasks_created": int}
|
||||
"""
|
||||
if not payload:
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
site_id = payload.get("site_id")
|
||||
assistant_id = payload.get("assistant_id")
|
||||
member_id = payload.get("member_id")
|
||||
service_time = payload.get("service_time")
|
||||
|
||||
if not all([site_id, assistant_id, member_id, service_time]):
|
||||
logger.warning("备注回溯缺少必要参数: %s", payload)
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
reclassified_count = 0
|
||||
tasks_created = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 查找 service_time 之后的第一条 normal 备注 ──
|
||||
note_id = None
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM biz.notes
|
||||
WHERE site_id = %s
|
||||
AND target_type = 'member'
|
||||
AND target_id = %s
|
||||
AND type = 'normal'
|
||||
AND created_at > %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(site_id, member_id, service_time),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
note_id = row[0]
|
||||
conn.commit()
|
||||
|
||||
if note_id is None:
|
||||
logger.info(
|
||||
"未找到符合条件的 normal 备注: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
# ── 2. 将备注 type 从 'normal' 更新为 'follow_up' ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET type = 'follow_up', updated_at = NOW()
|
||||
WHERE id = %s AND type = 'normal'
|
||||
""",
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
reclassified_count = 1
|
||||
|
||||
# ── 3. 触发 AI 应用 6 接口(占位,当前返回 None) ──
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
|
||||
# ── 4/5. 根据 ai_score 生成 follow_up_visit 任务 ──
|
||||
if ai_score is not None:
|
||||
if ai_score >= 6:
|
||||
# 回溯完成:生成 completed 任务
|
||||
task_status = "completed"
|
||||
else:
|
||||
# 需助教重新备注:生成 active 任务
|
||||
task_status = "active"
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type,
|
||||
status, completed_at, completed_task_type)
|
||||
VALUES (
|
||||
%s, %s, %s, 'follow_up_visit',
|
||||
%s,
|
||||
CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END,
|
||||
CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id, assistant_id, member_id,
|
||||
task_status, task_status, task_status,
|
||||
),
|
||||
)
|
||||
new_task_row = cur.fetchone()
|
||||
new_task_id = new_task_row[0]
|
||||
|
||||
# 记录任务创建历史
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created_by_reclassify",
|
||||
old_status=None,
|
||||
new_status=task_status,
|
||||
old_task_type=None,
|
||||
new_task_type="follow_up_visit",
|
||||
detail={
|
||||
"note_id": note_id,
|
||||
"ai_score": ai_score,
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
tasks_created = 1
|
||||
else:
|
||||
# AI 未就绪,跳过任务创建
|
||||
logger.info(
|
||||
"AI 接口未就绪,跳过任务创建: note_id=%s", note_id
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"备注回溯失败: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"备注回溯完成: reclassified_count=%d, tasks_created=%d",
|
||||
reclassified_count, tasks_created,
|
||||
)
|
||||
return {
|
||||
"reclassified_count": reclassified_count,
|
||||
"tasks_created": tasks_created,
|
||||
}
|
||||
326
apps/backend/app/services/note_service.py
Normal file
326
apps/backend/app/services/note_service.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
备注服务
|
||||
|
||||
负责备注 CRUD、星星评分存储与读取。
|
||||
备注类型根据关联任务自动确定:follow_up_visit 任务 → follow_up,否则 normal。
|
||||
星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _record_history(
|
||||
cur,
|
||||
task_id: int,
|
||||
action: str,
|
||||
old_status: str | None = None,
|
||||
new_status: str | None = None,
|
||||
old_task_type: str | None = None,
|
||||
new_task_type: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""在 coach_task_history 中记录变更(复用 task_manager 的模式)。"""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_history
|
||||
(task_id, action, old_status, new_status,
|
||||
old_task_type, new_task_type, detail)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
action,
|
||||
old_status,
|
||||
new_status,
|
||||
old_task_type,
|
||||
new_task_type,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def ai_analyze_note(note_id: int) -> int | None:
|
||||
"""
|
||||
AI 应用 6 备注分析接口(占位)。
|
||||
|
||||
P5 AI 集成层实现后替换此占位函数。
|
||||
当前返回 None 表示 AI 未就绪,跳过评分逻辑。
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
async def create_note(
|
||||
site_id: int,
|
||||
user_id: int,
|
||||
target_type: str,
|
||||
target_id: int,
|
||||
content: str,
|
||||
task_id: int | None = None,
|
||||
rating_service_willingness: int | None = None,
|
||||
rating_revisit_likelihood: int | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
创建备注。
|
||||
|
||||
1. 验证评分范围(1-5 或 NULL),不合法则 422
|
||||
2. 确定 note type:
|
||||
- 若 task_id 关联的任务 task_type='follow_up_visit' → type='follow_up'
|
||||
- 否则 → type='normal'
|
||||
3. INSERT INTO biz.notes
|
||||
4. 若 type='follow_up':
|
||||
- 触发 AI 应用 6 分析(P5 实现)
|
||||
- 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||||
5. 返回创建的备注记录
|
||||
|
||||
注意:星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
|
||||
"""
|
||||
# 验证评分范围
|
||||
for label, val in [
|
||||
("再次服务意愿评分", rating_service_willingness),
|
||||
("再来店可能性评分", rating_revisit_likelihood),
|
||||
]:
|
||||
if val is not None and (val < 1 or val > 5):
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"{label}必须在 1-5 范围内"
|
||||
)
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# 确定 note type
|
||||
note_type = "normal"
|
||||
task_info = None
|
||||
|
||||
if task_id is not None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="关联任务不存在")
|
||||
|
||||
task_info = {
|
||||
"id": row[0],
|
||||
"task_type": row[1],
|
||||
"status": row[2],
|
||||
"site_id": row[3],
|
||||
}
|
||||
|
||||
if task_info["site_id"] != site_id:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
if task_info["task_type"] == "follow_up_visit":
|
||||
note_type = "follow_up"
|
||||
|
||||
# INSERT 备注
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.notes
|
||||
(site_id, user_id, target_type, target_id, type,
|
||||
content, rating_service_willingness,
|
||||
rating_revisit_likelihood, task_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, site_id, user_id, target_type, target_id,
|
||||
type, content, rating_service_willingness,
|
||||
rating_revisit_likelihood, task_id,
|
||||
ai_score, ai_analysis, created_at, updated_at
|
||||
""",
|
||||
(
|
||||
site_id, user_id, target_type, target_id, note_type,
|
||||
content, rating_service_willingness,
|
||||
rating_revisit_likelihood, task_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
note = {
|
||||
"id": row[0],
|
||||
"site_id": row[1],
|
||||
"user_id": row[2],
|
||||
"target_type": row[3],
|
||||
"target_id": row[4],
|
||||
"type": row[5],
|
||||
"content": row[6],
|
||||
"rating_service_willingness": row[7],
|
||||
"rating_revisit_likelihood": row[8],
|
||||
"task_id": row[9],
|
||||
"ai_score": row[10],
|
||||
"ai_analysis": row[11],
|
||||
"created_at": row[12].isoformat() if row[12] else None,
|
||||
"updated_at": row[13].isoformat() if row[13] else None,
|
||||
}
|
||||
|
||||
# 若 type='follow_up',触发 AI 分析并可能标记任务完成
|
||||
if note_type == "follow_up" and task_id is not None:
|
||||
ai_score = ai_analyze_note(note["id"])
|
||||
|
||||
if ai_score is not None:
|
||||
# 更新备注的 ai_score
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET ai_score = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(ai_score, note["id"]),
|
||||
)
|
||||
note["ai_score"] = ai_score
|
||||
|
||||
# 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||||
if ai_score >= 6 and task_info and task_info["status"] == "active":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = NOW(),
|
||||
completed_task_type = task_type,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed_by_note",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_info["task_type"],
|
||||
new_task_type=task_info["task_type"],
|
||||
detail={
|
||||
"note_id": note["id"],
|
||||
"ai_score": ai_score,
|
||||
},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return note
|
||||
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def get_notes(
|
||||
site_id: int, target_type: str, target_id: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
查询某目标的备注列表。
|
||||
|
||||
按 created_at DESC 排序,包含星星评分和 AI 评分。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, user_id, target_type, target_id,
|
||||
type, content, rating_service_willingness,
|
||||
rating_revisit_likelihood, task_id,
|
||||
ai_score, ai_analysis, created_at, updated_at
|
||||
FROM biz.notes
|
||||
WHERE site_id = %s
|
||||
AND target_type = %s
|
||||
AND target_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(site_id, target_type, target_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"site_id": r[1],
|
||||
"user_id": r[2],
|
||||
"target_type": r[3],
|
||||
"target_id": r[4],
|
||||
"type": r[5],
|
||||
"content": r[6],
|
||||
"rating_service_willingness": r[7],
|
||||
"rating_revisit_likelihood": r[8],
|
||||
"task_id": r[9],
|
||||
"ai_score": r[10],
|
||||
"ai_analysis": r[11],
|
||||
"created_at": r[12].isoformat() if r[12] else None,
|
||||
"updated_at": r[13].isoformat() if r[13] else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def delete_note(note_id: int, user_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
删除备注。
|
||||
|
||||
验证备注归属(user_id + site_id)后执行硬删除。
|
||||
- 不存在 → 404
|
||||
- 不属于当前用户 → 403
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, site_id
|
||||
FROM biz.notes
|
||||
WHERE id = %s
|
||||
""",
|
||||
(note_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
|
||||
if row[2] != site_id or row[1] != user_id:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"DELETE FROM biz.notes WHERE id = %s",
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": note_id, "deleted": True}
|
||||
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
266
apps/backend/app/services/recall_detector.py
Normal file
266
apps/backend/app/services/recall_detector.py
Normal file
@@ -0,0 +1,266 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
召回完成检测器(Recall Completion Detector)
|
||||
|
||||
ETL 数据更新后,通过 FDW 读取助教服务记录,
|
||||
匹配活跃任务标记为 completed,记录 completed_at 和 completed_task_type 快照,
|
||||
触发 recall_completed 事件通知备注回溯重分类器。
|
||||
|
||||
由 trigger_jobs 中的 recall_completion_check 配置驱动(event: etl_data_updated)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _insert_history(
|
||||
cur,
|
||||
task_id: int,
|
||||
action: str,
|
||||
old_status: str | None = None,
|
||||
new_status: str | None = None,
|
||||
old_task_type: str | None = None,
|
||||
new_task_type: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""在 coach_task_history 中记录变更。"""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_history
|
||||
(task_id, action, old_status, new_status,
|
||||
old_task_type, new_task_type, detail)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
action,
|
||||
old_status,
|
||||
new_status,
|
||||
old_task_type,
|
||||
new_task_type,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
"""
|
||||
召回完成检测主流程。
|
||||
|
||||
1. 从 trigger_jobs 读取 last_run_at 作为增量过滤基准
|
||||
2. 获取所有 distinct site_id(从 active 任务中)
|
||||
3. 对每个 site_id,SET LOCAL app.current_site_id 后
|
||||
通过 FDW 读取 v_dwd_assistant_service_log 中 service_time > last_run_at 的新增服务记录
|
||||
4. 对每条服务记录,查找 biz.coach_tasks 中匹配的
|
||||
(site_id, assistant_id, member_id) 且 status='active' 的任务
|
||||
5. 将匹配任务标记为 completed:
|
||||
- status = 'completed'
|
||||
- completed_at = 服务时间
|
||||
- completed_task_type = 当前 task_type(快照)
|
||||
6. 记录 coach_task_history
|
||||
7. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
|
||||
|
||||
返回: {"completed_count": int}
|
||||
"""
|
||||
completed_count = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 读取 last_run_at ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT last_run_at
|
||||
FROM biz.trigger_jobs
|
||||
WHERE job_name = 'recall_completion_check'
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
last_run_at = row[0] if row else None
|
||||
conn.commit()
|
||||
|
||||
# ── 2. 获取所有有 active 任务的 distinct site_id ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE status = 'active'
|
||||
"""
|
||||
)
|
||||
site_ids = [r[0] for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# ── 3. 逐 site_id 读取新增服务记录 ──
|
||||
for site_id in site_ids:
|
||||
try:
|
||||
count = _process_site(conn, site_id, last_run_at)
|
||||
completed_count += count
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理门店召回检测失败: site_id=%s", site_id
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("召回完成检测完成: completed_count=%d", completed_count)
|
||||
return {"completed_count": completed_count}
|
||||
|
||||
|
||||
def _process_site(conn, site_id: int, last_run_at) -> int:
|
||||
"""
|
||||
处理单个门店的召回完成检测。
|
||||
|
||||
通过 FDW 读取新增服务记录,匹配 active 任务并标记 completed。
|
||||
返回本门店完成的任务数。
|
||||
"""
|
||||
completed = 0
|
||||
|
||||
# 通过 FDW 读取新增服务记录(需要 SET LOCAL 启用 RLS)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
if last_run_at is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT assistant_id, member_id, service_time
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
WHERE service_time > %s
|
||||
ORDER BY service_time ASC
|
||||
""",
|
||||
(last_run_at,),
|
||||
)
|
||||
else:
|
||||
# 首次运行,读取所有服务记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT assistant_id, member_id, service_time
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
ORDER BY service_time ASC
|
||||
"""
|
||||
)
|
||||
service_records = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# ── 4-7. 逐条服务记录匹配并处理 ──
|
||||
for assistant_id, member_id, service_time in service_records:
|
||||
try:
|
||||
count = _process_service_record(
|
||||
conn, site_id, assistant_id, member_id, service_time
|
||||
)
|
||||
completed += count
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理服务记录失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
def _process_service_record(
|
||||
conn,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
service_time,
|
||||
) -> int:
|
||||
"""
|
||||
处理单条服务记录:匹配 active 任务并标记 completed。
|
||||
|
||||
每条服务记录独立事务,失败不影响其他。
|
||||
返回本次完成的任务数。
|
||||
"""
|
||||
completed = 0
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# 查找匹配的 active 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
active_tasks = cur.fetchall()
|
||||
|
||||
if not active_tasks:
|
||||
conn.commit()
|
||||
return 0
|
||||
|
||||
# 将所有匹配的 active 任务标记为 completed
|
||||
for task_id, task_type in active_tasks:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = %s,
|
||||
completed_task_type = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(service_time, task_type, task_id),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_type,
|
||||
new_task_type=task_type,
|
||||
detail={
|
||||
"service_time": str(service_time),
|
||||
"completed_task_type": task_type,
|
||||
},
|
||||
)
|
||||
completed += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ── 7. 触发 recall_completed 事件 ──
|
||||
# 延迟导入 fire_event 避免循环依赖
|
||||
try:
|
||||
from app.services.trigger_scheduler import fire_event
|
||||
|
||||
fire_event(
|
||||
"recall_completed",
|
||||
{
|
||||
"site_id": site_id,
|
||||
"assistant_id": assistant_id,
|
||||
"member_id": member_id,
|
||||
"service_time": str(service_time),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"触发 recall_completed 事件失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
|
||||
return completed
|
||||
@@ -213,7 +213,7 @@ class Scheduler:
|
||||
|
||||
# 入队
|
||||
try:
|
||||
queue_id = task_queue.enqueue(config, site_id)
|
||||
queue_id = task_queue.enqueue(config, site_id, schedule_id=task_id)
|
||||
logger.info(
|
||||
"调度任务 [%s] 入队成功 → queue_id=%s site_id=%s",
|
||||
task_id, queue_id, site_id,
|
||||
|
||||
@@ -17,19 +17,24 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import ETL_PROJECT_PATH
|
||||
# CHANGE 2026-03-07 | 只保留模块引用,execute() 中实时读取属性值
|
||||
# 禁止 from ..config import ETL_PROJECT_PATH(值拷贝,reload 后过期)
|
||||
from .. import config as _config_module
|
||||
from ..database import get_connection
|
||||
from ..schemas.tasks import TaskConfigSchema
|
||||
from ..services.cli_builder import cli_builder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 实例标识:用于区分多后端实例写入同一 DB 的记录
|
||||
import platform as _platform
|
||||
_INSTANCE_HOST = _platform.node() # hostname
|
||||
|
||||
|
||||
class TaskExecutor:
|
||||
"""管理 ETL CLI 子进程的生命周期"""
|
||||
@@ -112,21 +117,58 @@ class TaskExecutor:
|
||||
execution_id: str,
|
||||
queue_id: str | None = None,
|
||||
site_id: int | None = None,
|
||||
schedule_id: str | None = None,
|
||||
) -> None:
|
||||
"""以子进程方式调用 ETL CLI。
|
||||
|
||||
使用 subprocess.Popen + 线程读取,兼容 Windows(避免
|
||||
asyncio.create_subprocess_exec 在 Windows 上的 NotImplementedError)。
|
||||
"""
|
||||
# CHANGE 2026-03-07 | 实时从 config 模块读取,避免 import 时复制的值过期
|
||||
etl_path = _config_module.ETL_PROJECT_PATH
|
||||
etl_python = _config_module.ETL_PYTHON_EXECUTABLE
|
||||
|
||||
cmd = cli_builder.build_command(
|
||||
config, ETL_PROJECT_PATH, python_executable=sys.executable
|
||||
config, etl_path, python_executable=etl_python
|
||||
)
|
||||
command_str = " ".join(cmd)
|
||||
|
||||
# CHANGE 2026-03-07 | 运行时防护:拒绝执行包含非预期路径的命令
|
||||
# 检测两种异常:
|
||||
# 1. D 盘路径(junction 穿透)
|
||||
# 2. 多环境子目录(test/repo、prod/repo)
|
||||
_cmd_normalized = command_str.replace("/", "\\")
|
||||
_bad_patterns = []
|
||||
if "D:\\" in command_str or "D:/" in command_str:
|
||||
_bad_patterns.append("D盘路径")
|
||||
if "\\test\\repo" in _cmd_normalized or "\\prod\\repo" in _cmd_normalized:
|
||||
_bad_patterns.append("多环境子目录(test/repo或prod/repo)")
|
||||
|
||||
if _bad_patterns:
|
||||
_issues = " + ".join(_bad_patterns)
|
||||
logger.error(
|
||||
"路径防护触发:命令包含 %s,拒绝执行。"
|
||||
" command=%s | ETL_PY=%s | ETL_PATH=%s"
|
||||
" | NEOZQYY_ROOT=%s | config.__file__=%s",
|
||||
_issues, command_str, etl_python, etl_path,
|
||||
__import__('os').environ.get("NEOZQYY_ROOT", "<未设置>"),
|
||||
_config_module.__file__,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"ETL 命令包含异常路径({_issues}),拒绝执行。"
|
||||
f" 请检查 .env 中 ETL_PYTHON_EXECUTABLE 和 ETL_PROJECT_PATH 配置。"
|
||||
f" 当前值: ETL_PY={etl_python}, ETL_PATH={etl_path}"
|
||||
)
|
||||
|
||||
effective_site_id = site_id or config.store_id
|
||||
|
||||
# CHANGE 2026-03-07 | 在 command 前缀中注入实例标识,
|
||||
# 便于在多后端实例共享同一 DB 时区分记录来源
|
||||
command_str_with_host = f"[{_INSTANCE_HOST}] {command_str}"
|
||||
|
||||
logger.info(
|
||||
"启动 ETL 子进程 [%s]: %s (cwd=%s)",
|
||||
execution_id, command_str, ETL_PROJECT_PATH,
|
||||
execution_id, command_str, etl_path,
|
||||
)
|
||||
|
||||
self._log_buffers[execution_id] = []
|
||||
@@ -140,7 +182,8 @@ class TaskExecutor:
|
||||
task_codes=config.tasks,
|
||||
status="running",
|
||||
started_at=started_at,
|
||||
command=command_str,
|
||||
command=command_str_with_host,
|
||||
schedule_id=schedule_id,
|
||||
)
|
||||
|
||||
exit_code: int | None = None
|
||||
@@ -226,7 +269,7 @@ class TaskExecutor:
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=ETL_PROJECT_PATH,
|
||||
cwd=_config_module.ETL_PROJECT_PATH,
|
||||
env=env,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -302,18 +345,30 @@ class TaskExecutor:
|
||||
status: str,
|
||||
started_at: datetime,
|
||||
command: str,
|
||||
schedule_id: str | None = None,
|
||||
) -> None:
|
||||
"""插入一条执行日志记录(running 状态)。"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 如果调用方未传 schedule_id,尝试从 task_queue 回查
|
||||
effective_schedule_id = schedule_id
|
||||
if effective_schedule_id is None and queue_id is not None:
|
||||
cur.execute(
|
||||
"SELECT schedule_id FROM task_queue WHERE id = %s",
|
||||
(queue_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
effective_schedule_id = str(row[0])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO task_execution_log
|
||||
(id, queue_id, site_id, task_codes, status,
|
||||
started_at, command)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
started_at, command, schedule_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
execution_id,
|
||||
@@ -323,6 +378,7 @@ class TaskExecutor:
|
||||
status,
|
||||
started_at,
|
||||
command,
|
||||
effective_schedule_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
117
apps/backend/app/services/task_expiry.py
Normal file
117
apps/backend/app/services/task_expiry.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
有效期轮询器(Task Expiry Checker)
|
||||
|
||||
每小时运行一次,检查 expires_at 不为 NULL 且已过期的 active 任务,
|
||||
将其标记为 inactive 并记录 history。
|
||||
|
||||
由 trigger_jobs 中的 task_expiry_check 配置驱动。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _insert_history(
|
||||
cur,
|
||||
task_id: int,
|
||||
action: str,
|
||||
old_status: str | None = None,
|
||||
new_status: str | None = None,
|
||||
old_task_type: str | None = None,
|
||||
new_task_type: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""在 coach_task_history 中记录变更。"""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_history
|
||||
(task_id, action, old_status, new_status,
|
||||
old_task_type, new_task_type, detail)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
action,
|
||||
old_status,
|
||||
new_status,
|
||||
old_task_type,
|
||||
new_task_type,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
"""
|
||||
有效期轮询主流程。
|
||||
|
||||
1. SELECT id, task_type FROM biz.coach_tasks
|
||||
WHERE expires_at IS NOT NULL AND expires_at < NOW() AND status = 'active'
|
||||
2. 逐条 UPDATE status = 'inactive'
|
||||
3. INSERT coach_task_history (action='expired')
|
||||
|
||||
每条过期任务独立事务,失败不影响其他。
|
||||
|
||||
返回: {"expired_count": int}
|
||||
"""
|
||||
expired_count = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# 查询所有已过期的 active 任务
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
FROM biz.coach_tasks
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < NOW()
|
||||
AND status = 'active'
|
||||
"""
|
||||
)
|
||||
expired_tasks = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# 逐条处理,每条独立事务
|
||||
for task_id, task_type in expired_tasks:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expired",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=task_type,
|
||||
new_task_type=task_type,
|
||||
)
|
||||
conn.commit()
|
||||
expired_count += 1
|
||||
except Exception:
|
||||
logger.exception("处理过期任务失败: task_id=%s", task_id)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("有效期轮询完成: expired_count=%d", expired_count)
|
||||
return {"expired_count": expired_count}
|
||||
483
apps/backend/app/services/task_generator.py
Normal file
483
apps/backend/app/services/task_generator.py
Normal file
@@ -0,0 +1,483 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务生成器(Task Generator)
|
||||
|
||||
每日 4:00 运行,基于 WBI/NCI/RS 指数为每个助教生成/更新任务。
|
||||
|
||||
本模块包含:
|
||||
- TaskPriority 枚举:任务类型优先级定义
|
||||
- TASK_TYPE_PRIORITY 映射:task_type 字符串 → 优先级
|
||||
- IndexData 数据类:客户-助教对的指数数据
|
||||
- determine_task_type():根据指数确定任务类型(纯函数)
|
||||
- should_replace_task():判断是否应替换现有任务(纯函数)
|
||||
- compute_heart_icon():根据 RS 指数计算爱心 icon 档位(纯函数)
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class TaskPriority(IntEnum):
|
||||
"""任务类型优先级,数值越小优先级越高。"""
|
||||
|
||||
HIGH_PRIORITY_RECALL = 0
|
||||
PRIORITY_RECALL = 0
|
||||
FOLLOW_UP_VISIT = 1
|
||||
RELATIONSHIP_BUILDING = 2
|
||||
|
||||
|
||||
TASK_TYPE_PRIORITY: dict[str, TaskPriority] = {
|
||||
"high_priority_recall": TaskPriority.HIGH_PRIORITY_RECALL,
|
||||
"priority_recall": TaskPriority.PRIORITY_RECALL,
|
||||
"follow_up_visit": TaskPriority.FOLLOW_UP_VISIT,
|
||||
"relationship_building": TaskPriority.RELATIONSHIP_BUILDING,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexData:
|
||||
"""某客户-助教对的指数数据。"""
|
||||
|
||||
site_id: int
|
||||
assistant_id: int
|
||||
member_id: int
|
||||
wbi: Decimal # 流失回赢指数
|
||||
nci: Decimal # 新客转化指数
|
||||
rs: Decimal # 关系强度指数
|
||||
has_active_recall: bool # 是否有活跃召回任务
|
||||
has_follow_up_note: bool # 召回完成后是否有回访备注
|
||||
|
||||
|
||||
def determine_task_type(index_data: IndexData) -> str | None:
|
||||
"""
|
||||
根据指数数据确定应生成的任务类型。
|
||||
|
||||
优先级规则(高 → 低):
|
||||
1. max(WBI, NCI) > 7 → high_priority_recall
|
||||
2. max(WBI, NCI) > 5 → priority_recall
|
||||
3. RS < 6 → relationship_building
|
||||
4. 不满足任何条件 → None(不生成任务)
|
||||
|
||||
返回: task_type 字符串或 None
|
||||
"""
|
||||
priority_score = max(index_data.wbi, index_data.nci)
|
||||
|
||||
if priority_score > 7:
|
||||
return "high_priority_recall"
|
||||
if priority_score > 5:
|
||||
return "priority_recall"
|
||||
if index_data.rs < 6:
|
||||
return "relationship_building"
|
||||
return None
|
||||
|
||||
|
||||
def should_replace_task(existing_type: str, new_type: str) -> bool:
|
||||
"""
|
||||
判断新任务类型是否应替换现有任务类型。
|
||||
|
||||
规则:类型不同即替换,相同类型不替换。
|
||||
"""
|
||||
if existing_type == new_type:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_heart_icon(rs_score: Decimal) -> str:
|
||||
"""
|
||||
根据 RS 指数计算爱心 icon 档位。
|
||||
|
||||
档位规则:
|
||||
- RS > 8.5 → 💖
|
||||
- 7 < RS ≤ 8.5 → 🧡
|
||||
- 5 < RS ≤ 7 → 💛
|
||||
- RS ≤ 5 → 💙
|
||||
"""
|
||||
if rs_score > Decimal("8.5"):
|
||||
return "💖"
|
||||
if rs_score > Decimal("7"):
|
||||
return "🧡"
|
||||
if rs_score > Decimal("5"):
|
||||
return "💛"
|
||||
return "💙"
|
||||
|
||||
|
||||
# ── run() 主流程 ──────────────────────────────────────────────
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
"""
|
||||
任务生成器主流程。
|
||||
|
||||
1. 通过 auth.user_assistant_binding 获取所有已绑定助教
|
||||
2. 对每个助教-客户对,通过 FDW 读取 WBI/NCI/RS 指数
|
||||
3. 调用 determine_task_type() 确定任务类型
|
||||
4. 检查已存在的 active 任务:相同 task_type → 跳过;
|
||||
不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
|
||||
5. 处理 follow_up_visit 的 48 小时滞留机制(expires_at 填充)
|
||||
6. 更新 trigger_jobs 时间戳
|
||||
|
||||
返回: {"created": int, "replaced": int, "skipped": int}
|
||||
"""
|
||||
stats = {"created": 0, "replaced": 0, "skipped": 0}
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 获取所有已绑定助教 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id, assistant_id
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE assistant_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
bindings = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# ── 2. 逐助教处理 ──
|
||||
for site_id, assistant_id in bindings:
|
||||
try:
|
||||
_process_assistant(conn, site_id, assistant_id, stats)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理助教失败: site_id=%s, assistant_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
# ── 6. 更新 trigger_jobs 时间戳 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET last_run_at = NOW()
|
||||
WHERE job_name = 'task_generator'
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"任务生成器完成: created=%d, replaced=%d, skipped=%d",
|
||||
stats["created"],
|
||||
stats["replaced"],
|
||||
stats["skipped"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def _process_assistant(
|
||||
conn, site_id: int, assistant_id: int, stats: dict
|
||||
) -> None:
|
||||
"""处理单个助教下所有客户-助教对的任务生成。"""
|
||||
|
||||
# 通过 FDW 读取该助教关联的客户指数数据
|
||||
# 需要 SET LOCAL app.current_site_id 以启用 RLS
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
# 读取 WBI(流失回赢指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_winback_index
|
||||
"""
|
||||
)
|
||||
wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
# 读取 NCI(新客转化指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_newconv_index
|
||||
"""
|
||||
)
|
||||
nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
# 读取 RS(关系强度指数)— 按 assistant_id 过滤
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 合并所有涉及的 member_id
|
||||
all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys())
|
||||
|
||||
# 逐客户处理,每对独立事务
|
||||
for member_id in all_member_ids:
|
||||
try:
|
||||
_process_member_task(
|
||||
conn,
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
wbi_map.get(member_id, Decimal("0")),
|
||||
nci_map.get(member_id, Decimal("0")),
|
||||
rs_map.get(member_id, Decimal("0")),
|
||||
stats,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理客户任务失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def _process_member_task(
|
||||
conn,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
wbi: Decimal,
|
||||
nci: Decimal,
|
||||
rs: Decimal,
|
||||
stats: dict,
|
||||
) -> None:
|
||||
"""
|
||||
处理单个客户-助教对的任务生成/更新。
|
||||
|
||||
每对独立事务,失败不影响其他。
|
||||
"""
|
||||
index_data = IndexData(
|
||||
site_id=site_id,
|
||||
assistant_id=assistant_id,
|
||||
member_id=member_id,
|
||||
wbi=wbi,
|
||||
nci=nci,
|
||||
rs=rs,
|
||||
# follow_up_visit 条件由外部传入;当前简化:不自动生成 follow_up_visit
|
||||
# (follow_up_visit 由召回完成检测器触发,不在 task_generator 主动生成)
|
||||
has_active_recall=True,
|
||||
has_follow_up_note=True,
|
||||
)
|
||||
|
||||
new_task_type = determine_task_type(index_data)
|
||||
if new_task_type is None:
|
||||
# 不满足任何条件 → 检查是否有需要填充 expires_at 的 follow_up_visit
|
||||
_handle_no_task_condition(conn, site_id, assistant_id, member_id)
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
priority_score = max(wbi, nci)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# ── 4. 检查已存在的 active 任务 ──
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, expires_at, created_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
existing_tasks = cur.fetchall()
|
||||
|
||||
# 检查是否已有相同 task_type 的 active 任务
|
||||
same_type_exists = any(row[1] == new_task_type for row in existing_tasks)
|
||||
if same_type_exists:
|
||||
# 相同 task_type → 跳过
|
||||
conn.commit()
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
# 不同 task_type 的 active 任务 → 关闭旧任务 + 创建新任务
|
||||
for task_id, old_type, old_expires_at, old_created_at in existing_tasks:
|
||||
if should_replace_task(old_type, new_task_type):
|
||||
# 特殊处理:旧任务是 follow_up_visit → 填充 expires_at 而非直接 inactive
|
||||
if old_type == "follow_up_visit" and old_expires_at is None:
|
||||
# 需求 5.2: follow_up_visit 被高优先级任务顶替时,
|
||||
# 填充 expires_at = created_at + 48h,保持 active
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expires_at_filled",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
old_task_type=old_type,
|
||||
new_task_type=old_type,
|
||||
detail={"reason": "higher_priority_task_created"},
|
||||
)
|
||||
else:
|
||||
# 关闭旧任务
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="type_change_close",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=old_type,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["replaced"] += 1
|
||||
|
||||
# ── 创建新任务 ──
|
||||
# follow_up_visit 生成时 expires_at = NULL(需求 4.1)
|
||||
expires_at_val = None
|
||||
|
||||
# 需求 4.4: 若新任务是 follow_up_visit 且已存在有 expires_at 的旧 follow_up_visit
|
||||
# → 旧任务已在上面被标记为 inactive,新任务 expires_at = NULL
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type, status,
|
||||
priority_score, expires_at, parent_task_id)
|
||||
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
new_task_type,
|
||||
float(priority_score),
|
||||
expires_at_val,
|
||||
# parent_task_id: 关联最近被关闭的旧任务(如有)
|
||||
existing_tasks[0][0] if existing_tasks else None,
|
||||
),
|
||||
)
|
||||
new_task_id = cur.fetchone()[0]
|
||||
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created",
|
||||
old_status=None,
|
||||
new_status="active",
|
||||
old_task_type=existing_tasks[0][1] if existing_tasks else None,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _handle_no_task_condition(
|
||||
conn, site_id: int, assistant_id: int, member_id: int
|
||||
) -> None:
|
||||
"""
|
||||
当不满足任何任务生成条件时,检查是否有 follow_up_visit 需要填充 expires_at。
|
||||
|
||||
需求 4.2: 当 follow_up_visit 的触发条件不再满足时,
|
||||
填充 expires_at = created_at + 48h。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, expires_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status = 'active'
|
||||
AND expires_at IS NULL
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for task_id, _ in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expires_at_filled",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
detail={"reason": "condition_no_longer_met"},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_history(
|
||||
cur,
|
||||
task_id: int,
|
||||
action: str,
|
||||
old_status: str | None = None,
|
||||
new_status: str | None = None,
|
||||
old_task_type: str | None = None,
|
||||
new_task_type: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""在 coach_task_history 中记录变更。"""
|
||||
import json
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_history
|
||||
(task_id, action, old_status, new_status,
|
||||
old_task_type, new_task_type, detail)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
action,
|
||||
old_status,
|
||||
new_status,
|
||||
old_task_type,
|
||||
new_task_type,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
395
apps/backend/app/services/task_manager.py
Normal file
395
apps/backend/app/services/task_manager.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
任务管理服务
|
||||
|
||||
负责任务 CRUD、置顶、放弃、取消放弃等操作。
|
||||
通过 FDW 读取客户信息和 RS 指数,计算爱心 icon 档位。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.task_generator import compute_heart_icon
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _record_history(
|
||||
cur,
|
||||
task_id: int,
|
||||
action: str,
|
||||
old_status: str | None = None,
|
||||
new_status: str | None = None,
|
||||
old_task_type: str | None = None,
|
||||
new_task_type: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""在 coach_task_history 中记录变更。"""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_history
|
||||
(task_id, action, old_status, new_status,
|
||||
old_task_type, new_task_type, detail)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
action,
|
||||
old_status,
|
||||
new_status,
|
||||
old_task_type,
|
||||
new_task_type,
|
||||
json.dumps(detail) if detail else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_assistant_id(conn, user_id: int, site_id: int) -> int:
|
||||
"""
|
||||
通过 user_assistant_binding 获取 assistant_id。
|
||||
|
||||
找不到绑定关系时抛出 403。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE user_id = %s AND site_id = %s AND assistant_id IS NOT NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
return row[0]
|
||||
|
||||
|
||||
def _verify_task_ownership(
|
||||
conn, task_id: int, assistant_id: int, site_id: int, required_status: str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
验证任务归属并返回任务信息。
|
||||
|
||||
- 任务不存在 → 404
|
||||
- 不属于当前助教 → 403
|
||||
- required_status 不匹配 → 409
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, is_pinned, abandon_reason,
|
||||
assistant_id, site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="资源不存在")
|
||||
|
||||
task = {
|
||||
"id": row[0],
|
||||
"task_type": row[1],
|
||||
"status": row[2],
|
||||
"is_pinned": row[3],
|
||||
"abandon_reason": row[4],
|
||||
"assistant_id": row[5],
|
||||
"site_id": row[6],
|
||||
}
|
||||
|
||||
if task["site_id"] != site_id or task["assistant_id"] != assistant_id:
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
if required_status and task["status"] != required_status:
|
||||
raise HTTPException(status_code=409, detail="任务状态不允许此操作")
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
"""
|
||||
获取助教的活跃任务列表。
|
||||
|
||||
1. 通过 auth.user_assistant_binding 获取 assistant_id
|
||||
2. 查询 biz.coach_tasks WHERE status='active'
|
||||
3. 通过 FDW 读取客户基本信息(dim_member)和 RS 指数
|
||||
4. 计算爱心 icon 档位
|
||||
5. 排序:is_pinned DESC, priority_score DESC, created_at ASC
|
||||
|
||||
FDW 查询需要 SET LOCAL app.current_site_id。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# 查询活跃任务
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC
|
||||
""",
|
||||
(site_id, assistant_id),
|
||||
)
|
||||
tasks = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
if not tasks:
|
||||
return []
|
||||
|
||||
member_ids = list({t[7] for t in tasks})
|
||||
|
||||
# 通过 FDW 读取客户信息和 RS 指数(需要 SET LOCAL app.current_site_id)
|
||||
member_info_map: dict[int, dict] = {}
|
||||
rs_map: dict[int, Decimal] = {}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
# 读取客户基本信息
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, member_name, member_phone
|
||||
FROM fdw_etl.v_dim_member
|
||||
WHERE member_id = ANY(%s)
|
||||
""",
|
||||
(member_ids,),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
member_info_map[row[0]] = {
|
||||
"member_name": row[1],
|
||||
"member_phone": row[2],
|
||||
}
|
||||
|
||||
# 读取 RS 指数
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s
|
||||
AND member_id = ANY(%s)
|
||||
""",
|
||||
(assistant_id, member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rs_map[row[0]] = Decimal(str(row[1]))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 组装结果
|
||||
result = []
|
||||
for task_row in tasks:
|
||||
(task_id, task_type, status, priority_score,
|
||||
is_pinned, expires_at, created_at, member_id) = task_row
|
||||
|
||||
info = member_info_map.get(member_id, {})
|
||||
rs_score = rs_map.get(member_id, Decimal("0"))
|
||||
heart_icon = compute_heart_icon(rs_score)
|
||||
|
||||
result.append({
|
||||
"id": task_id,
|
||||
"task_type": task_type,
|
||||
"status": status,
|
||||
"priority_score": float(priority_score) if priority_score else None,
|
||||
"is_pinned": is_pinned,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
"member_id": member_id,
|
||||
"member_name": info.get("member_name"),
|
||||
"member_phone": info.get("member_phone"),
|
||||
"rs_score": float(rs_score),
|
||||
"heart_icon": heart_icon,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def pin_task(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
置顶任务。
|
||||
|
||||
验证任务归属后设置 is_pinned=TRUE,记录 history。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
task = _verify_task_ownership(
|
||||
conn, task_id, assistant_id, site_id, required_status="active"
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET is_pinned = TRUE, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="pin",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
old_task_type=task["task_type"],
|
||||
new_task_type=task["task_type"],
|
||||
detail={"is_pinned": True},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": task_id, "is_pinned": True}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
取消置顶。
|
||||
|
||||
验证任务归属后设置 is_pinned=FALSE。
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
task = _verify_task_ownership(
|
||||
conn, task_id, assistant_id, site_id, required_status="active"
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET is_pinned = FALSE, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": task_id, "is_pinned": False}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def abandon_task(
|
||||
task_id: int, user_id: int, site_id: int, reason: str
|
||||
) -> dict:
|
||||
"""
|
||||
放弃任务。
|
||||
|
||||
1. 验证 reason 非空(空或纯空白 → 422)
|
||||
2. 验证任务归属和 status='active'
|
||||
3. 设置 status='abandoned', abandon_reason=reason
|
||||
4. 记录 coach_task_history
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
raise HTTPException(status_code=422, detail="放弃原因不能为空")
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
task = _verify_task_ownership(
|
||||
conn, task_id, assistant_id, site_id, required_status="active"
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'abandoned',
|
||||
abandon_reason = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(reason, task_id),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="abandon",
|
||||
old_status="active",
|
||||
new_status="abandoned",
|
||||
old_task_type=task["task_type"],
|
||||
new_task_type=task["task_type"],
|
||||
detail={"abandon_reason": reason},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": task_id, "status": "abandoned"}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
取消放弃。
|
||||
|
||||
1. 验证任务归属和 status='abandoned'
|
||||
2. 恢复 status='active', 清空 abandon_reason
|
||||
3. 记录 coach_task_history
|
||||
"""
|
||||
conn = _get_connection()
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
task = _verify_task_ownership(
|
||||
conn, task_id, assistant_id, site_id, required_status="abandoned"
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'active',
|
||||
abandon_reason = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="cancel_abandon",
|
||||
old_status="abandoned",
|
||||
new_status="active",
|
||||
old_task_type=task["task_type"],
|
||||
new_task_type=task["task_type"],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": task_id, "status": "active"}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -25,6 +26,11 @@ from ..schemas.tasks import TaskConfigSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CHANGE 2026-03-07 | 实例标识:用于多后端实例共享同一 DB 时的任务隔离
|
||||
# 背景:发现有另一台机器(宿主机 D 盘)的后端也在消费同一个 task_queue,
|
||||
# 导致任务被错误实例执行。通过 enqueued_by 列实现"谁入队谁消费"。
|
||||
_INSTANCE_ID = platform.node()
|
||||
|
||||
# 后台循环轮询间隔(秒)
|
||||
POLL_INTERVAL_SECONDS = 2
|
||||
|
||||
@@ -43,6 +49,7 @@ class QueuedTask:
|
||||
finished_at: Any = None
|
||||
exit_code: int | None = None
|
||||
error_message: str | None = None
|
||||
schedule_id: str | None = None
|
||||
|
||||
|
||||
class TaskQueue:
|
||||
@@ -56,12 +63,13 @@ class TaskQueue:
|
||||
# 入队
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enqueue(self, config: TaskConfigSchema, site_id: int) -> str:
|
||||
def enqueue(self, config: TaskConfigSchema, site_id: int, schedule_id: str | None = None) -> str:
|
||||
"""将任务配置入队,自动分配 position。
|
||||
|
||||
Args:
|
||||
config: 任务配置
|
||||
site_id: 门店 ID(门店隔离)
|
||||
schedule_id: 关联的调度任务 ID(可选)
|
||||
|
||||
Returns:
|
||||
新创建的队列任务 ID(UUID 字符串)
|
||||
@@ -84,18 +92,19 @@ class TaskQueue:
|
||||
max_pos = cur.fetchone()[0]
|
||||
new_pos = max_pos + 1
|
||||
|
||||
# CHANGE 2026-03-07 | 写入 enqueued_by 实现多实例任务隔离
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO task_queue (id, site_id, config, status, position)
|
||||
VALUES (%s, %s, %s, 'pending', %s)
|
||||
INSERT INTO task_queue (id, site_id, config, status, position, schedule_id, enqueued_by)
|
||||
VALUES (%s, %s, %s, 'pending', %s, %s, %s)
|
||||
""",
|
||||
(task_id, site_id, json.dumps(config_json), new_pos),
|
||||
(task_id, site_id, json.dumps(config_json), new_pos, schedule_id, _INSTANCE_ID),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("任务入队 [%s] site_id=%s position=%s", task_id, site_id, new_pos)
|
||||
logger.info("任务入队 [%s] site_id=%s position=%s schedule_id=%s", task_id, site_id, new_pos, schedule_id)
|
||||
return task_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -114,19 +123,21 @@ class TaskQueue:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 选取 position 最小的 pending 任务并锁定
|
||||
# CHANGE 2026-03-07 | 只消费本实例入队的任务(enqueued_by 匹配)
|
||||
# 背景:多后端实例共享同一 DB 时,防止 A 实例消费 B 实例入队的任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, config, status, position,
|
||||
created_at, started_at, finished_at,
|
||||
exit_code, error_message
|
||||
exit_code, error_message, schedule_id
|
||||
FROM task_queue
|
||||
WHERE site_id = %s AND status = 'pending'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
ORDER BY position ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
(site_id,),
|
||||
(site_id, _INSTANCE_ID),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
@@ -144,6 +155,7 @@ class TaskQueue:
|
||||
finished_at=row[7],
|
||||
exit_code=row[8],
|
||||
error_message=row[9],
|
||||
schedule_id=str(row[10]) if row[10] else None,
|
||||
)
|
||||
|
||||
# 更新状态为 running
|
||||
@@ -261,10 +273,11 @@ class TaskQueue:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_pending(self, site_id: int) -> list[QueuedTask]:
|
||||
"""列出指定门店的所有 pending 任务,按 position 升序。"""
|
||||
"""列出指定门店的所有 pending 任务(仅限本实例入队的),按 position 升序。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-07 | 只列出本实例入队的 pending 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, config, status, position,
|
||||
@@ -272,9 +285,10 @@ class TaskQueue:
|
||||
exit_code, error_message
|
||||
FROM task_queue
|
||||
WHERE site_id = %s AND status = 'pending'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
ORDER BY position ASC
|
||||
""",
|
||||
(site_id,),
|
||||
(site_id, _INSTANCE_ID),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
@@ -298,18 +312,20 @@ class TaskQueue:
|
||||
]
|
||||
|
||||
def has_running(self, site_id: int) -> bool:
|
||||
"""检查指定门店是否有 running 状态的任务。"""
|
||||
"""检查指定门店是否有本实例的 running 状态任务。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-07 | 只检查本实例的 running 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM task_queue
|
||||
WHERE site_id = %s AND status = 'running'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
)
|
||||
""",
|
||||
(site_id,),
|
||||
(site_id, _INSTANCE_ID),
|
||||
)
|
||||
result = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
@@ -333,7 +349,10 @@ class TaskQueue:
|
||||
from .task_executor import task_executor
|
||||
|
||||
self._running = True
|
||||
logger.info("TaskQueue process_loop 启动")
|
||||
logger.info(
|
||||
"TaskQueue process_loop 启动 (instance_id=%s,仅消费本实例入队的任务)",
|
||||
_INSTANCE_ID,
|
||||
)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
@@ -369,6 +388,7 @@ class TaskQueue:
|
||||
asyncio.create_task(
|
||||
self._execute_and_update(
|
||||
executor, config, execution_id, task.id, site_id,
|
||||
schedule_id=task.schedule_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -379,6 +399,7 @@ class TaskQueue:
|
||||
execution_id: str,
|
||||
queue_id: str,
|
||||
site_id: int,
|
||||
schedule_id: str | None = None,
|
||||
) -> None:
|
||||
"""执行任务并更新队列状态。"""
|
||||
try:
|
||||
@@ -387,6 +408,7 @@ class TaskQueue:
|
||||
execution_id=execution_id,
|
||||
queue_id=queue_id,
|
||||
site_id=site_id,
|
||||
schedule_id=schedule_id,
|
||||
)
|
||||
# 执行完成后根据 executor 的结果更新 task_queue 状态
|
||||
self._update_queue_status_from_log(queue_id)
|
||||
@@ -395,15 +417,18 @@ class TaskQueue:
|
||||
self._mark_failed(queue_id, "执行过程中发生未捕获异常")
|
||||
|
||||
def _get_pending_site_ids(self) -> list[int]:
|
||||
"""获取所有有 pending 任务的 site_id 列表。"""
|
||||
"""获取所有有 pending 任务的 site_id 列表(仅限本实例入队的)。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-07 | 只查本实例入队的 pending 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id FROM task_queue
|
||||
WHERE status = 'pending'
|
||||
"""
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
""",
|
||||
(_INSTANCE_ID,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
@@ -191,6 +191,8 @@ DWD_TABLES: list[DwdTableDefinition] = [
|
||||
DwdTableDefinition("dwd.dim_goods_category", "商品分类维度", "商品", "ods.stock_goods_category_tree", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_groupbuy_package", "团购套餐维度", "团购", "ods.group_buy_packages", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_groupbuy_package_ex", "团购套餐维度(扩展)", "团购", "ods.group_buy_packages", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_staff", "员工维度", "人事", "ods.staff_info_master", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_staff_ex", "员工维度(扩展)", "人事", "ods.staff_info_master", is_dimension=True),
|
||||
# 事实表
|
||||
DwdTableDefinition("dwd.dwd_settlement_head", "结算主表", "结算", "ods.settlement_records"),
|
||||
DwdTableDefinition("dwd.dwd_settlement_head_ex", "结算主表(扩展)", "结算", "ods.settlement_records"),
|
||||
|
||||
161
apps/backend/app/services/trigger_scheduler.py
Normal file
161
apps/backend/app/services/trigger_scheduler.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
触发器调度框架(Trigger Scheduler)
|
||||
|
||||
统一管理 cron/interval/event 三种触发方式,驱动后台任务执行。
|
||||
|
||||
- cron/interval 类型通过 check_scheduled_jobs() 轮询 next_run_at 触发
|
||||
- event 类型通过 fire_event() 方法直接触发
|
||||
- 每个 job 独立事务,失败不影响其他触发器
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
return get_connection()
|
||||
|
||||
# job_type → 执行函数的注册表
|
||||
_JOB_REGISTRY: dict[str, Callable] = {}
|
||||
|
||||
|
||||
def register_job(job_type: str, handler: Callable) -> None:
|
||||
"""注册 job_type 对应的执行函数。"""
|
||||
_JOB_REGISTRY[job_type] = handler
|
||||
|
||||
|
||||
def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
"""
|
||||
触发事件驱动型任务。
|
||||
|
||||
查找 trigger_condition='event' 且 trigger_config.event_name 匹配的 enabled job,
|
||||
立即执行对应的 handler。
|
||||
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
executed = 0
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_type, job_name
|
||||
FROM biz.trigger_jobs
|
||||
WHERE status = 'enabled'
|
||||
AND trigger_condition = 'event'
|
||||
AND trigger_config->>'event_name' = %s
|
||||
""",
|
||||
(event_name,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for job_id, job_type, job_name in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
if not handler:
|
||||
logger.warning(
|
||||
"未注册的 job_type: %s (job_name=%s)", job_type, job_name
|
||||
)
|
||||
continue
|
||||
try:
|
||||
handler(payload=payload)
|
||||
executed += 1
|
||||
# 更新 last_run_at
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
return executed
|
||||
|
||||
|
||||
def check_scheduled_jobs() -> int:
|
||||
"""
|
||||
检查 cron/interval 类型的到期 job 并执行。
|
||||
|
||||
由 Scheduler 后台循环调用。
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
executed = 0
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_type, job_name, trigger_condition, trigger_config
|
||||
FROM biz.trigger_jobs
|
||||
WHERE status = 'enabled'
|
||||
AND trigger_condition IN ('cron', 'interval')
|
||||
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||||
ORDER BY next_run_at ASC NULLS FIRST
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for job_id, job_type, job_name, trigger_condition, trigger_config in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
if not handler:
|
||||
logger.warning("未注册的 job_type: %s", job_type)
|
||||
continue
|
||||
try:
|
||||
handler()
|
||||
executed += 1
|
||||
# 计算 next_run_at 并更新
|
||||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET last_run_at = NOW(), next_run_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(next_run, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
return executed
|
||||
|
||||
|
||||
def _calculate_next_run(
|
||||
trigger_condition: str, trigger_config: dict
|
||||
) -> datetime | None:
|
||||
"""
|
||||
根据触发条件和配置计算下次运行时间。
|
||||
|
||||
- interval: now + interval_seconds
|
||||
- cron: 复用 scheduler._parse_simple_cron
|
||||
- event: 返回 None(无 next_run_at)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if trigger_condition == "interval":
|
||||
seconds = trigger_config.get("interval_seconds", 3600)
|
||||
return now + timedelta(seconds=seconds)
|
||||
elif trigger_condition == "cron":
|
||||
# 延迟导入:支持从 monorepo 根目录和 apps/backend/ 两种路径导入
|
||||
try:
|
||||
from app.services.scheduler import _parse_simple_cron
|
||||
except ModuleNotFoundError:
|
||||
from apps.backend.app.services.scheduler import _parse_simple_cron
|
||||
|
||||
return _parse_simple_cron(
|
||||
trigger_config.get("cron_expression", "0 4 * * *"), now
|
||||
)
|
||||
return None # event 类型无 next_run_at
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get
|
||||
from app.config import WX_APPID, WX_SECRET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,8 +59,8 @@ async def code2session(code: str) -> dict:
|
||||
WeChatAuthError: 微信接口返回非零 errcode 时抛出
|
||||
RuntimeError: WX_APPID / WX_SECRET 环境变量缺失时抛出
|
||||
"""
|
||||
appid = get("WX_APPID", "")
|
||||
secret = get("WX_SECRET", "")
|
||||
appid = WX_APPID
|
||||
secret = WX_SECRET
|
||||
|
||||
if not appid or not secret:
|
||||
raise RuntimeError("微信配置缺失:WX_APPID 或 WX_SECRET 未设置")
|
||||
|
||||
Reference in New Issue
Block a user