Files
xieyi-weixin-/proactive_contact_service.py
2026-11-02 14:05:03 +08:00

415 lines
22 KiB
Python
Raw Permalink 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.
"""Patient-bound proactive contact business rules, independent of Qt."""
from datetime import datetime
import hashlib
import time
import uuid
import threading
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
from proactive_contact_schedule import CONTACT_ZONE, next_slot, normalize_schedule
from proactive_contact_store import ContactStore, encode
MAX_BATCH = 100
def binding_version(binding):
return encode({k: binding.get(k) for k in ("id", "patient_id", "updated_at", "relation_type", "status")})
def bound_people(context, account, conversation, peer):
"""Do not accept a different member's patient or a same-named conversation."""
conv = context.get("conversation") or {}
permissions = context.get("permissions") or {}
if not context.get("available") or permissions.get("can_search") is not True:
raise ValueError(context.get("reason") or permissions.get("reason") or "患者绑定暂无法核验")
if conv.get("external_id") != conversation or str(conv.get("source_account")) != account:
raise ValueError("患者绑定所属账号或会话不一致")
people = [p for p in context.get("people", []) if any(
str(i.get("external_id")) == peer for i in p.get("identities", []))]
if len(people) != 1:
raise ValueError("当前客户的联系人身份无法唯一核对")
return people[0]
class PatientGateway:
"""Use authenticated existing backend APIs; never treat a submitted ID as proof."""
def __init__(self):
self._candidate_cache = OrderedDict()
self._candidate_lock = threading.Lock()
self._candidate_epoch = 0
self._candidate_build_cache = {}
self._candidate_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="contact-binding")
def invalidate_candidates(self):
with self._candidate_lock:
self._candidate_cache.clear()
self._candidate_epoch += 1
def close_candidates(self):
self._candidate_pool.shutdown(wait=False, cancel_futures=True)
def warm_candidates(self):
from review_assistant_contacts import active_context
from wecom_native_sender import readonly_build_validation_scope
with readonly_build_validation_scope(self._candidate_build_cache):
active_context()
@staticmethod
def _verify_patient_visibility(binding):
import backend_client
# The deployed keyword API matches names/phones, not patient IDs.
# Use the server's bound name for discovery, but only accept the exact
# patient ID. Same-name results alone are never proof of this binding.
keyword = str(binding.get("patient_name") or "").strip()[:64]
if not keyword:
raise ValueError("绑定患者缺少姓名,请在管理绑定中重新查询并核对")
patient_id = int(binding["patient_id"])
page_size = 50
for page_no in range(1, 21):
result = backend_client.search_desktop_patients(keyword, page_no=page_no, page_size=page_size)
items = result.get("items")
if not isinstance(items, list):
raise ValueError("患者查询结果格式不正确,请稍后重试")
for item in items:
try:
found_id = int(item.get("patient_id") or 0) if isinstance(item, dict) else 0
except (TypeError, ValueError):
continue
if found_id == patient_id:
return
# total counts raw diagnoses; items are deduplicated by patient.
# A short page can still have more patients on subsequent pages.
total = max(0, int(result.get("total") or 0))
if page_no * page_size >= total:
raise ValueError("当前查询未找到绑定的患者,请在管理绑定中重新查询并核对")
if not items:
raise ValueError("患者查询结果未完整返回,暂无法核验,请稍后重试")
raise ValueError("同名患者查询结果超过核验范围,请在管理绑定中核对患者信息")
def owner(self):
import backend_client
settings = backend_client.load_settings()
account = settings.get("desktop_account") or {}
if not account.get("id") or not settings.get("desktop_token_protected"):
raise ValueError("请先登录客服助手")
return hashlib.sha256(encode([settings.get("server_url"), account["id"]]).encode()).hexdigest()
def candidates(self, query="", offset=0, *, progress=None, cancelled=lambda: False, refresh_bindings=False):
from wecom_native_sender import readonly_build_validation_scope
with readonly_build_validation_scope(self._candidate_build_cache):
return self._candidates(query, offset, progress=progress, cancelled=cancelled, refresh_bindings=refresh_bindings)
def _candidates(self, query="", offset=0, *, progress=None, cancelled=lambda: False, refresh_bindings=False):
import backend_client
from review_assistant_contacts import active_context, list_recipients, recipient_peer
query = str(query or "").strip()[:100]
identity = active_context()
if not identity.get("ready"):
raise ValueError(identity.get("reason") or "请先读取当前企微账号")
account = identity["account_id"]
page = list_recipients(account, query, 20, offset, status="sendable")
if not page.get("ready"):
raise ValueError(page.get("reason") or "客户列表不可用")
# Cache display data only, scoped to the login session and exact account.
# add/check/preview/send always use the uncached verification below.
owner = self.owner()
session = hashlib.sha256(str(backend_client.load_settings().get("desktop_token_protected") or "").encode()).hexdigest()
with self._candidate_lock:
epoch = self._candidate_epoch
scope = (owner, session, account, epoch)
items = [{"account": account, "conversation": c["id"], "name": c["name"],
"matched_name": c.get("matchedName", ""),
"bindings": [], "binding_loading": True, "reason": "正在读取患者绑定…"} for c in page["items"]]
result = {"account": account, "items": items, "offset": offset, "has_more": page["has_more"],
"total": page["total"], "query": query}
def publish():
if progress and not cancelled():
progress({**result, "items": [dict(row) for row in items]})
def lookup(conversation):
try:
ctx = backend_client.desktop_patient_context(account, conversation, timeout=4.0)
person = bound_people(ctx, account, conversation, recipient_peer(account, conversation))
bindings = []
seen = set()
for b in person.get("patient_bindings", []):
if not isinstance(b, dict) or b.get("status") != "active":
continue
try:
patient_id = int(b.get("patient_id") or 0)
except (TypeError, ValueError):
continue
binding_id, patient_name = str(b.get("id") or "").strip(), str(b.get("patient_name") or "").strip()
if patient_id <= 0 or not binding_id or not patient_name or binding_id in seen:
continue
seen.add(binding_id)
bindings.append({"id": binding_id, "patient_id": patient_id, "patient_name": patient_name})
value = {"bindings": bindings, "binding_loading": False,
"reason": "" if bindings else "未绑定患者,请先绑定"}
with self._candidate_lock:
if epoch == self._candidate_epoch and not cancelled():
self._candidate_cache[(*scope, conversation)] = (time.monotonic(), value)
while len(self._candidate_cache) > 400:
self._candidate_cache.popitem(last=False)
return value
except Exception as exc:
return {"bindings": [], "binding_loading": False, "binding_error": True,
"reason": f"绑定暂未读取成功,可重新搜索重试:{str(exc)[:180]}"}
pending = {}
for row in items:
with self._candidate_lock:
cached = self._candidate_cache.get((*scope, row["conversation"]))
if cached and not refresh_bindings and time.monotonic() - cached[0] < 60:
row.update(cached[1])
elif refresh_bindings:
# Explicit searches must not retain a revoked binding, even if
# fresh lookup fails. Autocomplete can reuse only verified cache.
with self._candidate_lock:
self._candidate_cache.pop((*scope, row["conversation"]), None)
publish() # The local list is visible before the first remote response.
try:
for row in items:
if cancelled():
break
if row["binding_loading"]:
pending[self._candidate_pool.submit(lookup, row["conversation"])] = row
while pending and not cancelled():
done, _ = wait(pending, timeout=.05, return_when=FIRST_COMPLETED)
for future in done:
pending.pop(future).update(future.result())
if done:
publish()
finally:
for future in pending:
future.cancel()
if self.owner() != owner:
raise ValueError("登录账号已切换,请重新搜索")
return result
def check(self, account, conversation, binding_id, *, full=True):
import backend_client
from review_assistant_contacts import validate_recipient, recipient_peer
from conversation_blacklist import blocked_reason
valid = validate_recipient(account, conversation)
if valid.get("ok") is not True:
raise ValueError(valid.get("reason") or "客户不可发送")
reason = blocked_reason(account, conversation)
if reason:
raise ValueError(reason)
peer = recipient_peer(account, conversation)
ctx = backend_client.desktop_patient_context(account, conversation)
person = bound_people(ctx, account, conversation, peer)
binding = next((b for b in person.get("patient_bindings", []) if b.get("id") == binding_id
and b.get("status") == "active" and int(b.get("patient_id") or 0) > 0), None)
if not binding or not binding.get("updated_at"):
raise ValueError("患者未绑定或原绑定已失效,请重新选择")
if full:
self._verify_patient_visibility(binding)
# Recheck binding after the patient lookup, which may take seconds.
latest = backend_client.desktop_patient_context(account, conversation)
current = bound_people(latest, account, conversation, peer)
if not any(binding_version(b) == binding_version(binding) for b in current.get("patient_bindings", [])):
raise ValueError("核验期间患者绑定已变化,请刷新后重试")
return {"account": account, "conversation": conversation, "peer": peer,
"name": valid["recipient"]["name"], "staff_name": valid.get("account_name", ""),
"person_id": person["id"], "patient_id": binding["patient_id"],
"patient_name": binding.get("patient_name", ""), "binding_id": binding["id"],
"binding_version": binding_version(binding), "valid": True, "reason": "", "checked_at": time.time()}
class ContactService:
def __init__(self, root, gateway, transport, *, clock=time.time):
self.store = ContactStore(root)
self.gateway, self.transport, self.clock = gateway, transport, clock
self.previews = {}
def verify(self, customer, *, full=True):
current = self.gateway.check(customer["account"], customer["conversation"], customer["binding_id"], full=full)
if current["binding_version"] != customer["binding_version"] or current["person_id"] != customer["person_id"]:
raise ValueError("患者绑定已变化,请重新添加客户并创建任务")
return current
def mark_invalid(self, owner, customer, reason):
stored = next((c for c in self.store.customers(owner) if c["id"] == customer["id"]), None)
if stored and stored["binding_version"] == customer["binding_version"]:
self.store.save_customer(owner, {**stored, "valid": False, "reason": reason})
for task in self.store.tasks(owner, pending=True, limit=10000):
if task["customer"] == customer["id"] and task["binding_version"] == customer["binding_version"]:
self.store.update(owner, task["id"], "paused", expected={"scheduled", "queued"}, error=reason)
def add(self, owner, items):
if not isinstance(items, list) or not 1 <= len(items) <= MAX_BATCH:
raise ValueError("每批请选择 1–100 位客户")
added, rejected = [], []
for item in items:
try:
data = self.gateway.check(str(item["account"]), str(item["conversation"]), str(item["binding_id"]))
if self.gateway.owner() != owner:
raise ValueError("登录账号已切换")
old = next((c for c in self.store.customers(owner) if c["account"] == data["account"] and c["peer"] == data["peer"]), None)
if old and old["binding_version"] != data["binding_version"]:
self.mark_invalid(owner, old, "患者绑定已变化,旧任务需取消并重新创建")
if old:
data["template"] = old.get("template", "")
added.append(self.store.save_customer(owner, data))
except Exception as exc:
rejected.append({"name": item.get("name") or item.get("conversation"), "reason": str(exc)[:240]})
return {"message": f"已加入 {len(added)} 位客户,未加入 {len(rejected)} 位", "added": added, "rejected": rejected}
def selected(self, owner, ids):
if not isinstance(ids, list) or not 1 <= len(set(ids)) <= MAX_BATCH:
raise ValueError("每批请选择 1–100 位客户")
wanted = set(ids)
rows = [c for c in self.store.customers(owner) if c["id"] in wanted]
if len(rows) != len(wanted):
raise ValueError("客户不存在或不属于当前登录账号")
return rows
def set_text(self, owner, ids, text):
self.validate_text(text)
self.previews.clear()
for customer in self.selected(owner, ids):
self.store.save_customer(owner, {**customer, "template": text})
return {"message": f"已为 {len(set(ids))} 位客户保存文字。本次未创建发送任务。"}
@staticmethod
def validate_text(text):
if not isinstance(text, str) or not text.strip():
raise ValueError("请先填写发送文字")
if "\0" in text:
raise ValueError("发送文字包含不支持的字符,请重新输入")
if len(text.encode("utf-8")) > 4000:
raise ValueError("发送文字过长,请缩短后重试(最多约 1300 个汉字)")
def preview(self, owner, ids, schedule, *, text=None):
self.previews.clear()
if text is not None:
self.validate_text(text)
now = self.clock()
schedule = normalize_schedule(schedule, now)
tasks = []
cursor = now
for customer in self.selected(owner, ids):
try:
self.verify(customer)
except Exception as exc:
self.mark_invalid(owner, customer, str(exc))
raise
body = str(customer.get("template", "")) if text is None else text
if not body.strip():
raise ValueError(f"{customer['name']} 尚未保存文字,请填写本次发送文字或先为该客户保存文字")
due = next_slot(schedule, cursor)
if due is None:
raise ValueError("所选时间范围不足,请扩大时间段")
for key, value in {"客户名称": customer["name"], "客服名称": customer.get("staff_name", ""),
"日期": datetime.fromtimestamp(due, CONTACT_ZONE).strftime("%Y-%m-%d")}.items():
if "{{" + key + "}}" in body and not value:
raise ValueError(f"{customer['name']} 缺少变量:{key}")
body = body.replace("{{" + key + "}}", value)
if "{{" in body or "}}" in body:
raise ValueError("存在未知或未填写的文字变量")
self.validate_text(body)
baseline = self.transport.baseline(customer)
tasks.append({**{k: v for k, v in customer.items() if k != "id"}, "customer": customer["id"], "text": body, "schedule": schedule,
"due": due, "baseline": baseline})
cursor = due + schedule["interval"]
preview = {"id": uuid.uuid4().hex, "owner": owner, "expires": self.clock() + 600,
"tasks": tasks, "schedule": schedule}
self.previews = {preview["id"]: preview}
return {"preview": preview, "message": "预览已生成,本次尚未创建任务。请核对收件人、患者、正文和发送时间,再确认创建。"}
def confirm(self, owner, key):
preview = self.previews.get(key)
if not preview or preview["owner"] != owner or preview["expires"] < self.clock():
raise ValueError("预览已过期,请重新预览")
for task in preview["tasks"]:
self.verify(task)
if self.gateway.owner() != owner:
raise ValueError("登录账号已切换")
count = self.store.create_campaign(owner, preview, self.clock())
return {"message": f"已创建 {count} 条发送任务" if count else "此批任务已创建,请查看联系计划",
"confirmed": key, "creation": {"campaign": key, "count": len(preview["tasks"]),
"new_count": count, "kind": preview["schedule"]["kind"]}}
def control(self, owner, ids, action):
if not isinstance(ids, list) or not 1 <= len(ids) <= MAX_BATCH:
raise ValueError("请选择需要操作的任务")
results = []
errors = []
for key in dict.fromkeys(ids):
task = self.store.get_task(owner, key)
if not task:
continue
try:
if action == "cancel":
ok = self.store.update(owner, key, "cancelled", expected={"scheduled", "queued", "paused", "failed"}, error="用户取消")
elif action == "pause":
ok = self.store.update(owner, key, "paused", expected={"scheduled", "queued"}, error="用户暂停")
elif action == "resume":
if task["status"] != "paused":
raise ValueError("只有暂停且未提交的任务可恢复;失败或结果不确定请先核对记录")
self.verify(task)
if self.transport.baseline(task) != task["baseline"]:
raise ValueError("客户已有新消息,请取消旧任务并重新预览创建")
due = next_slot(task["schedule"], self.clock())
if due is None:
raise ValueError("计划时间范围已结束,请重新创建")
ok = self.store.update(owner, key, "scheduled", expected={"paused"}, due=due, error="")
else:
raise ValueError("不支持的任务操作")
message = {"pause": "已暂停,不会自动发送", "cancel": "已取消,不会发送",
"resume": "已恢复调度,尚未发送;将按计划通过协议通道自动执行"}[action] if ok else "当前状态不可操作"
results.append(f"{task['name']}:{message}")
if not ok:
errors.append(results[-1])
except Exception as exc:
results.append(f"{task['name']}:{exc}")
errors.append(results[-1])
return {"message": ";".join(results), "errors": errors}
def delete_tasks(self, owner, ids):
if (not isinstance(ids, list) or not 1 <= len(ids) <= MAX_BATCH
or any(not isinstance(key, str) or not key for key in ids)):
raise ValueError("请选择 1–100 条需要删除的任务")
if self.gateway.owner() != owner:
raise ValueError("登录账号已切换,请刷新")
deleted, errors = self.store.delete_tasks(owner, ids)
message = f"已删除 {len(deleted)} 条任务。未发送的任务已停止;已发送消息不会撤回,客户后续回复仍按自动回复设置处理。"
if errors:
message += "未删除:" + ";".join(errors)
return {"message": message, "deleted": deleted, "errors": errors}
def recheck(self, owner):
confirmed = self.reconcile_receipts(owner)
for customer in self.store.customers(owner):
try:
current = self.verify(customer)
self.store.save_customer(owner, {**customer, **current})
except Exception as exc:
self.mark_invalid(owner, customer, str(exc))
return confirmed
def reconcile_receipts(self, owner):
from proactive_contact_scheduler import verified_receipt
confirmed = 0
for task in self.store.tasks(owner, include_deleted=True):
if task["status"] != "delivery_unknown":
continue
try:
receipt = self.transport.receipt(task)
if verified_receipt(receipt, task) and self.gateway.owner() == owner:
confirmed += int(self.store.confirm_receipt(owner, task["id"], receipt))
except Exception:
# A failed read must neither resend nor turn uncertainty into a
# claimed failure. The next refresh/periodic check can retry reads.
continue
return confirmed
def snapshot(self, owner):
tasks = self.store.tasks(owner)
return {"customers": self.store.customers(owner), "tasks": tasks, "enabled": self.store.enabled(owner),
"timezone": "UTC+08:00", "transport": "已验证协议通道", "maxBatch": MAX_BATCH}