PMFlow/backend/app/services/feishu_integration_service/mixin_reply_outbox.py

298 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from .shared import *
class FeishuReplyOutboxMixin:
async def _safe_reply_text(
self,
*,
chat_id: str,
text: str,
mention_open_id: Optional[str] = None,
mention_name: Optional[str] = None,
trace_id: Optional[str] = None,
source_message_id: Optional[str] = None,
perf_t0: Optional[float] = None,
) -> None:
try:
plain_text = self._normalize_outgoing_reply_text(text)
mention_prefix = self._build_mention_prefix(
open_id=mention_open_id,
display_name=mention_name,
)
final_text = f"{mention_prefix}{plain_text}".strip()
outbox_id = await self._enqueue_reply_outbox(
chat_id=chat_id,
message_text=final_text,
trace_id=trace_id,
source_message_id=source_message_id,
)
perf_t1 = time.perf_counter()
if outbox_id > 0 and perf_t0 is not None:
_OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0)
if perf_t0 is not None:
print(
f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} "
f"delta={perf_t1 - perf_t0:.2f}s "
f"outbox_id={outbox_id}"
)
else:
print(
f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} "
f"delta=N/A "
f"outbox_id={outbox_id}"
)
except Exception as exc:
print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}")
# 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。
return
async def _enqueue_reply_outbox(
self,
*,
chat_id: str,
message_text: str,
trace_id: Optional[str],
source_message_id: Optional[str],
) -> int:
async with AsyncSessionLocal() as db:
normalized_source_message_id = (source_message_id or "")[:128] or None
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
reply_hash = self._build_reply_hash(
source_message_id=normalized_source_message_id,
message_text=normalized_message_text,
)
if reply_hash:
sent_exists = (
await db.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1),
FeishuReplyOutbox.reply_hash == reply_hash,
FeishuReplyOutbox.status == "sent",
)
.limit(1)
)
).scalar_one_or_none()
if sent_exists is not None:
return int(sent_exists)
row = FeishuReplyOutbox(
tenant_id=int(self.tenant_id or 1),
trace_id=(trace_id or "")[:64] or None,
source_message_id=normalized_source_message_id,
chat_id=(chat_id or "")[:128],
message_text=normalized_message_text,
reply_hash=reply_hash,
status="pending",
attempts=0,
next_retry_at=None,
)
db.add(row)
try:
await db.flush()
await self._notify_outbox_ready(db, outbox_id=int(row.id) if row.id is not None else None)
await db.commit()
except IntegrityError:
await db.rollback()
if reply_hash:
sent_exists = (
await db.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1),
FeishuReplyOutbox.reply_hash == reply_hash,
FeishuReplyOutbox.status == "sent",
)
.limit(1)
)
).scalar_one_or_none()
if sent_exists is not None:
return int(sent_exists)
raise
await db.refresh(row)
return int(row.id)
@classmethod
async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int:
fetch_limit = max(1, min(200, int(limit)))
dispatched = 0
for _ in range(fetch_limit):
claimed = await cls._claim_next_outbox_entry(now=now_cn_naive())
if claimed is None:
break
await cls._dispatch_claimed_outbox_entry(claimed=claimed)
dispatched += 1
return dispatched
@classmethod
async def _claim_next_outbox_entry(cls, *, now: datetime) -> Optional[dict[str, Any]]:
stale_before = now - timedelta(seconds=OUTBOX_STALE_SENDING_SECONDS)
async with AsyncSessionLocal() as db:
await db.execute(
update(FeishuReplyOutbox)
.where(
FeishuReplyOutbox.status == "sending",
FeishuReplyOutbox.updated_at <= stale_before,
)
.values(status="retrying", next_retry_at=now)
)
candidate_id = (
await db.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.status.in_(("pending", "retrying")),
or_(
FeishuReplyOutbox.next_retry_at.is_(None),
FeishuReplyOutbox.next_retry_at <= now,
),
)
.order_by(FeishuReplyOutbox.id.asc())
.with_for_update(skip_locked=True)
.limit(1)
)
).scalar_one_or_none()
if candidate_id is None:
await db.commit()
return None
claimed = (
await db.execute(
update(FeishuReplyOutbox)
.where(
FeishuReplyOutbox.id == int(candidate_id),
FeishuReplyOutbox.status.in_(("pending", "retrying")),
or_(
FeishuReplyOutbox.next_retry_at.is_(None),
FeishuReplyOutbox.next_retry_at <= now,
),
)
.values(
status="sending",
attempts=FeishuReplyOutbox.attempts + 1,
last_error=None,
next_retry_at=None,
)
.returning(
FeishuReplyOutbox.id,
FeishuReplyOutbox.tenant_id,
FeishuReplyOutbox.chat_id,
FeishuReplyOutbox.message_text,
FeishuReplyOutbox.reply_hash,
FeishuReplyOutbox.attempts,
)
)
).mappings().first()
await db.commit()
if claimed is None:
return None
return dict(claimed)
@classmethod
async def _dispatch_claimed_outbox_entry(cls, *, claimed: dict[str, Any]) -> None:
outbox_id = int(claimed.get("id") or 0)
if outbox_id <= 0:
return
tenant_id = int(claimed.get("tenant_id") or 1)
reply_hash = str(claimed.get("reply_hash") or "").strip()
if reply_hash:
async with AsyncSessionLocal() as db_check:
sent_duplicate = (
await db_check.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.id != outbox_id,
FeishuReplyOutbox.tenant_id == tenant_id,
FeishuReplyOutbox.reply_hash == reply_hash,
FeishuReplyOutbox.status == "sent",
)
.limit(1)
)
).scalar_one_or_none()
if sent_duplicate is not None:
async with AsyncSessionLocal() as db_skip:
row = (
await db_skip.execute(
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id)
)
).scalar_one_or_none()
if row is not None and str(row.status or "").lower() == "sending":
row.status = "sent"
row.sent_at = now_cn_naive()
row.next_retry_at = None
row.last_error = f"dedupe_skip_sent:{int(sent_duplicate)}"
await db_skip.commit()
_OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None)
return
send_error: Optional[str] = None
try:
async with AsyncSessionLocal() as db_send:
service = FeishuIntegrationService(db_send)
await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1))
await service.send_message_to_group(
chat_id=str(claimed.get("chat_id") or ""),
message=str(claimed.get("message_text") or ""),
)
except Exception as exc:
send_error = str(exc)
async with AsyncSessionLocal() as db_update:
row = (
await db_update.execute(
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id)
)
).scalar_one_or_none()
if row is None:
return
if str(row.status or "").lower() != "sending":
return
attempts = int(row.attempts or claimed.get("attempts") or 0)
if send_error is None:
row.status = "sent"
row.sent_at = now_cn_naive()
row.next_retry_at = None
row.last_error = None
await db_update.commit()
perf_t2 = time.perf_counter()
perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None)
if perf_t0 is not None:
print(
f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} "
f"delta={perf_t2 - perf_t0:.2f}s "
f"outbox_id={outbox_id}"
)
else:
print(
f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} "
f"delta=N/A "
f"outbox_id={outbox_id}"
)
return
if attempts >= OUTBOX_MAX_ATTEMPTS:
row.status = "failed"
row.next_retry_at = None
_OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None)
else:
row.status = "retrying"
delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1)))
row.next_retry_at = now_cn_naive() + timedelta(seconds=delay_seconds)
row.last_error = (send_error or "unknown_send_error")[:1000]
await db_update.commit()
@classmethod
def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str:
text = cls._clean_reply_text(value)
if (not text) or cls._looks_like_provider_error(text):
return cls._build_error_reply_text()
return text[:500]
@staticmethod
def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]:
source = str(source_message_id or "").strip()
body = str(message_text or "").strip()
if not source or not body:
return None
raw = f"{source}{body}".encode("utf-8")
return hashlib.md5(raw, usedforsecurity=False).hexdigest()