2026-09-01 13:52:56 +08:00
|
|
|
|
# app/services/track_webhook_service.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
Track 系统 Webhook 通知服务(发送侧)
|
|
|
|
|
|
|
|
|
|
|
|
真实扫码入库成功后,通过后台线程 + httpx 异步 POST 通知 Track 系统。
|
|
|
|
|
|
容错策略:
|
|
|
|
|
|
- 未配置 TRACK_WEBHOOK_URL 时静默跳过,不通知。
|
|
|
|
|
|
- 全包裹 try-except,任何异常(连接拒绝/超时/网络错误)仅记录 logger.error,
|
|
|
|
|
|
绝不抛出异常阻断入库主业务流程。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
from flask import current_app
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# 请求超时(秒):后台线程,宁可超时失败也不能拖住业务
|
|
|
|
|
|
_WEBHOOK_TIMEOUT = 5.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _post_to_track(payload, url, api_key):
|
|
|
|
|
|
"""后台线程中执行的真正 POST 请求(含详细日志)"""
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
# ★ 鉴权字段名必须为 X-API-Key,与 Track 接收端严格对齐
|
|
|
|
|
|
'X-API-Key': api_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = httpx.post(url, json=payload, headers=headers, timeout=_WEBHOOK_TIMEOUT)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[TrackWebhook] event=%s source_table=%s status=%s",
|
|
|
|
|
|
payload.get('event'), payload.get('source_table'), resp.status_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 13:13:53 +08:00
|
|
|
|
# 各通道未命中路由表时回落到哪个扁平配置变量(保持改造前的单实例行为)
|
|
|
|
|
|
_TRACK_ROUTE_FALLBACK = {
|
|
|
|
|
|
'api': 'TRACK_API_URL',
|
|
|
|
|
|
'inbound': 'TRACK_WEBHOOK_URL',
|
|
|
|
|
|
'outbound': 'TRACK_OUTBOUND_WEBHOOK_URL',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_track_route(company_name, channel):
|
|
|
|
|
|
"""按公司名解析 Track 实例地址。
|
|
|
|
|
|
|
|
|
|
|
|
channel: 'api' | 'inbound' | 'outbound'
|
|
|
|
|
|
|
|
|
|
|
|
⚠️ 未命中必须**回落到扁平变量**,不能返回空 —— 漏配一个 key 就静默断链,
|
|
|
|
|
|
比配置写错更难排查。
|
|
|
|
|
|
"""
|
|
|
|
|
|
routes = current_app.config.get('TRACK_ROUTES') or {}
|
|
|
|
|
|
|
|
|
|
|
|
if company_name and company_name in routes:
|
|
|
|
|
|
url = ((routes.get(company_name) or {}).get(channel) or '').strip()
|
|
|
|
|
|
if url:
|
|
|
|
|
|
return url
|
|
|
|
|
|
# 公司在表里但该通道没配 —— 这是配置漏项,务必暴露出来
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[TrackRoute] 公司 %s 的 %s 通道未配置,回落到全局默认地址",
|
|
|
|
|
|
company_name, channel,
|
|
|
|
|
|
)
|
|
|
|
|
|
elif company_name:
|
|
|
|
|
|
# 非空但不在路由表内:可能是拼写异常的公司名,也可能是
|
|
|
|
|
|
# get_current_company_filter() 的 '__NO_COMPANY__' 哨兵(用户 JWT 无公司)。
|
|
|
|
|
|
# 不猜、不阻断,只暴露给业务确认。
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[TrackRoute] 公司 '%s' 不在 TRACK_ROUTES 中,回落到全局默认地址(channel=%s)。"
|
|
|
|
|
|
"若该值不是合法部门名,请业务确认主数据",
|
|
|
|
|
|
company_name, channel,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
fallback_key = _TRACK_ROUTE_FALLBACK.get(channel)
|
|
|
|
|
|
if not fallback_key:
|
|
|
|
|
|
return ''
|
|
|
|
|
|
return (current_app.config.get(fallback_key) or '').strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _channel_from_event(event):
|
|
|
|
|
|
"""从事件名推断路由通道;缺省按入库处理。"""
|
|
|
|
|
|
return 'outbound' if 'outbound' in (event or '').lower() else 'inbound'
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:52:56 +08:00
|
|
|
|
def get_current_operator():
|
|
|
|
|
|
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from flask_jwt_extended import get_jwt
|
|
|
|
|
|
claims = get_jwt()
|
|
|
|
|
|
if not claims:
|
|
|
|
|
|
return ''
|
|
|
|
|
|
# 优先显示名(如"张三"),回退到账号ID
|
|
|
|
|
|
return claims.get('display_name') or claims.get('username') or ''
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 13:13:53 +08:00
|
|
|
|
def notify_track(payload, url=None, channel=None):
|
2026-09-01 13:52:56 +08:00
|
|
|
|
"""
|
|
|
|
|
|
异步通知 Track 系统业务事件(入库/出库等)。
|
|
|
|
|
|
|
|
|
|
|
|
调用方在 db.session.commit() 成功后调用,本函数绝不抛出异常:
|
|
|
|
|
|
- 未配置 URL / Key 为空 -> 静默跳过
|
|
|
|
|
|
- 网络异常 / 超时 -> 仅记录日志
|
|
|
|
|
|
|
2026-09-22 13:13:53 +08:00
|
|
|
|
:param url: 显式指定 Track webhook 地址,优先级最高(指定后不再做公司路由)
|
|
|
|
|
|
:param channel: 路由通道 'inbound' | 'outbound';不传则按 payload['event'] 推断。
|
|
|
|
|
|
仅在不传 url 时生效——实例由 payload['company_name'] 决定。
|
2026-09-01 13:52:56 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-09-22 13:13:53 +08:00
|
|
|
|
if not url:
|
|
|
|
|
|
channel = channel or _channel_from_event(payload.get('event'))
|
|
|
|
|
|
url = resolve_track_route(payload.get('company_name'), channel)
|
|
|
|
|
|
url = (url or '').strip()
|
2026-09-01 13:52:56 +08:00
|
|
|
|
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
|
|
|
|
|
|
if not url:
|
|
|
|
|
|
logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
thread = threading.Thread(
|
|
|
|
|
|
target=_post_to_track,
|
|
|
|
|
|
args=(payload, url, api_key),
|
|
|
|
|
|
daemon=True, # 守护线程:不阻塞进程退出
|
|
|
|
|
|
name='track_webhook_notify',
|
|
|
|
|
|
)
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("[TrackWebhook] 通知调用失败(不影响业务): %s", e)
|