""" 复制工单号 H5 的短命 token。 本文件职责:签发/校验 token → 工单号;URL 只带 token,禁止带 userid/工单号。 优先 Redis(HTTP 与 Worker 不同进程必须共享);单测无 Redis 时用内存。 禁止:当六态;禁止把工单号拼进公网路径。 """ from __future__ import annotations import logging import secrets import threading import time from typing import Optional from agent.config import get_settings from agent.redis_coord.keys import TOKEN_COPY_TICKET logger = logging.getLogger(__name__) # 销售可能隔几天才点卡;7 天内同一 token 可重复打开复制。 COPY_TTL_SECONDS = 7 * 24 * 60 * 60 _FORM_FALLBACK = "https://ai.ytd-scm.com/inquiry-form" _MEM: dict[str, tuple[str, float]] = {} _MEM_LOCK = threading.Lock() def issue_copy_token(work_order_no: str, *, ttl_seconds: int = COPY_TTL_SECONDS) -> str: """ 签发复制页 token。 副作用:写 Redis 或内存。工单号为空则返回空串,调用方不要出跳转按钮。 """ wo = (work_order_no or "").strip() if not wo: return "" token = secrets.token_urlsafe(24) if _redis_set(token, wo, ttl_seconds): return token expires = time.time() + ttl_seconds with _MEM_LOCK: _MEM[token] = (wo, expires) logger.info("copy token 走内存 wo=%s(HTTP/Worker 分进程时页面会找不到)", wo) return token def inquiry_form_base() -> str: """H5 公网根路径(含 /inquiry-form),不含 userid。""" return _form_base() def build_upload_url(token: str) -> str: """ 拼附件上传页:{inquiry-form}/upload/{token}。 token 为空则返回空串,调用方不要出跳转按钮。 """ raw = (token or "").strip() if not raw: return "" return f"{_form_base()}/upload/{raw}" def lookup_copy_token(token: str) -> Optional[str]: """校验 token 未过期;返回工单号或 None。不消费,可重复打开。""" raw = (token or "").strip() if not raw: return None wo = _redis_get(raw) if wo: return wo now = time.time() with _MEM_LOCK: hit = _MEM.get(raw) if not hit: return None wo, exp = hit if exp < now: _MEM.pop(raw, None) return None return wo def build_copy_ticket_url(work_order_no: str) -> str: """ 拼复制页绝对地址:{inquiry-form}/copy/{token}。 域名来自环境变量,禁止写死测试/正式切换。缺省回合同域名,保证企微能打开。 """ token = issue_copy_token(work_order_no) if not token: return "" return f"{_form_base()}/copy/{token}" def _form_base() -> str: settings = get_settings() form = (settings.inquiry_form_public_base_url or "").strip().rstrip("/") if form: return form pub = (settings.public_base_url or "").strip().rstrip("/") if pub: return f"{pub}/inquiry-form" return _FORM_FALLBACK def _redis_set(token: str, work_order_no: str, ttl_seconds: int) -> bool: client = _try_redis() if client is None: return False try: client.raw.set(client.key(TOKEN_COPY_TICKET, token), work_order_no, ex=ttl_seconds) return True except Exception as exc: # noqa: BLE001 logger.warning("copy token Redis 写入失败:%s", exc) return False def _redis_get(token: str) -> Optional[str]: client = _try_redis() if client is None: return None try: val = client.raw.get(client.key(TOKEN_COPY_TICKET, token)) except Exception as exc: # noqa: BLE001 logger.warning("copy token Redis 读取失败:%s", exc) return None if val is None: return None text = val if isinstance(val, str) else val.decode("utf-8") return text.strip() or None def _try_redis(): """有 Redis 才用;单测 memory 后端也可用,保证同进程能读到。""" try: from agent.redis_coord import get_redis_runtime return get_redis_runtime(get_settings()).client except Exception: # noqa: BLE001 return None