落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 18:12:45 +08:00
co-authored by Cursor
parent 235ecd8c83
commit 12219cacb1
39 changed files with 4436 additions and 256 deletions
+177
View File
@@ -0,0 +1,177 @@
"""
测服自己的微盛监听名单与 last_seq。
本文件职责:把协同群 chat_id、按席/按房间的查询游标写到 REDIS_KEY_PREFIX 下。
禁止:写现网 ytd:prod: 前缀;禁止把游标当六态。
线程:可被建群线程与 Worker 轮询同时调用,只做单 key 读写。
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Optional
from agent.redis_coord import get_redis_runtime
from agent.redis_coord.client import RedisClient
from agent.redis_coord.keys import WSHOTO_LAST_SEQ, WSHOTO_ROOM_TICKET, WSHOTO_ROOMS
logger = logging.getLogger(__name__)
# 建群后或刚听到群话:立刻唤醒轮询,并在热窗内更勤地扫。
_poll_wake = threading.Event()
_hot_until: dict[str, float] = {}
_hot_lock = threading.Lock()
HOT_WINDOW_SEC = 90.0
def wake_wshoto_poll() -> None:
"""打断轮询等待。建群线程可调;真正拉消息仍在 Worker 轮询线程。"""
_poll_wake.set()
def wait_wshoto_poll(timeout: float) -> bool:
"""等下一轮或被唤醒。返回是否被踢醒。"""
fired = _poll_wake.wait(timeout=max(0.1, float(timeout)))
if fired:
_poll_wake.clear()
return fired
def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None:
"""这群接下来几十秒更勤扫:刚建群或刚有人说话。"""
raw = (chat_id or "").strip()
if not raw:
return
until = time.time() + max(5.0, float(ttl_sec))
with _hot_lock:
_hot_until[raw] = until
wake_wshoto_poll()
def has_hot_room() -> bool:
"""是否还有热窗内的协同群。"""
now = time.time()
with _hot_lock:
stale = [k for k, until in _hot_until.items() if until <= now]
for k in stale:
_hot_until.pop(k, None)
return any(until > now for until in _hot_until.values())
def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]:
if redis is not None:
return redis
try:
return get_redis_runtime().client
except Exception:
logger.exception("微盛监听名单:Redis 不可用")
return None
def watch_collab_room(
chat_id: str,
*,
work_order_no: str = "",
redis: Optional[RedisClient] = None,
) -> None:
"""
建群成功后登记 chat_id,供按房间补拉。
可同时记下房间→工单号,主账按群查找暂时不可用时也能对上单。
副作用:改 Redis。失败只打日志,不挡销售侧建群。
"""
raw = (chat_id or "").strip()
if not raw:
return
client = _client(redis)
if client is None:
return
key = client.key(WSHOTO_ROOMS)
current = client.raw.get(key) or ""
if isinstance(current, bytes):
current = current.decode("utf-8")
rooms = {x.strip() for x in str(current).split(",") if x.strip()}
rooms.add(raw)
client.raw.set(key, ",".join(sorted(rooms)))
no = (work_order_no or "").strip()
if no:
client.raw.set(client.key(WSHOTO_ROOM_TICKET, raw), no)
logger.info("存档监听已登记群数=%s", len(rooms))
mark_room_hot(raw)
def ticket_no_for_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> str:
"""用协同群 chat_id 找回工单号(测服 Redis 短索引,不是六态)。"""
raw = (chat_id or "").strip()
if not raw:
return ""
client = _client(redis)
if client is None:
return ""
val = client.raw.get(client.key(WSHOTO_ROOM_TICKET, raw))
if val is None:
return ""
if isinstance(val, bytes):
val = val.decode("utf-8")
return str(val).strip()
def unwatch_collab_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> None:
"""协同结束后去掉监听,避免解散群还被当成活群补拉。"""
raw = (chat_id or "").strip()
if not raw:
return
client = _client(redis)
if client is None:
return
key = client.key(WSHOTO_ROOMS)
current = client.raw.get(key) or ""
if isinstance(current, bytes):
current = current.decode("utf-8")
rooms = {x.strip() for x in str(current).split(",") if x.strip() and x.strip() != raw}
client.raw.set(key, ",".join(sorted(rooms)))
client.raw.delete(client.key(WSHOTO_ROOM_TICKET, raw))
def list_watched_rooms(*, redis: Optional[RedisClient] = None) -> list[str]:
"""当前要按房间补拉的协同群。"""
client = _client(redis)
if client is None:
return []
current = client.raw.get(client.key(WSHOTO_ROOMS)) or ""
if isinstance(current, bytes):
current = current.decode("utf-8")
return [x.strip() for x in str(current).split(",") if x.strip()]
def load_last_seq(scope: str, *, redis: Optional[RedisClient] = None) -> int:
"""
读取本前缀下的 last_seq。
scope:seat:AI001 或 room:wrxxx。没有记录返回 0(调用方改走时间窗,避免从 0 翻全量历史)。
"""
client = _client(redis)
if client is None:
return 0
val = client.raw.get(client.key(WSHOTO_LAST_SEQ, scope))
if val is None:
return 0
try:
return int(val)
except (TypeError, ValueError):
return 0
def save_last_seq(scope: str, seq: int, *, redis: Optional[RedisClient] = None) -> None:
"""推进测服自己的查询游标。seq 必须单调不减。"""
if seq <= 0:
return
client = _client(redis)
if client is None:
return
prev = load_last_seq(scope, redis=client)
if seq < prev:
return
client.raw.set(client.key(WSHOTO_LAST_SEQ, scope), str(int(seq)))