144 lines
4.1 KiB
Python
144 lines
4.1 KiB
Python
"""
|
||
复制工单号 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
|