214 lines
9.1 KiB
Python
214 lines
9.1 KiB
Python
"""Transactional WeCom outbox. Network calls never hold an order transaction.
|
|
|
|
Protocol: https://developer.work.weixin.qq.com/document/path/91770
|
|
The worker uses its own sessions; importing the app never sends a message.
|
|
"""
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
import secrets
|
|
import threading
|
|
from datetime import datetime, timedelta, timezone
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlsplit
|
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
|
|
from sqlalchemy import and_, or_, text
|
|
|
|
from models import Notification, NotificationSettings, Order
|
|
|
|
CHANNELS = {'approval': '审批通知群', 'result': '结果通知群'}
|
|
STATES = {'pending': '等待发送', 'sending': '发送中', 'retry': '等待重试',
|
|
'sent': '已发送', 'failed': '发送失败', 'cancelled': '已跳过'}
|
|
MAX_ATTEMPTS = 5
|
|
|
|
|
|
def validate_webhook(value):
|
|
value = value.strip()
|
|
if value and not re.fullmatch(
|
|
r'https://qyapi\.weixin\.qq\.com/cgi-bin/webhook/send\?key=[A-Za-z0-9_-]{1,200}', value):
|
|
raise ValueError('Webhook 须为企业微信群机器人的完整 HTTPS 地址')
|
|
return value
|
|
|
|
|
|
def validate_base_url(value):
|
|
value = value.strip().rstrip('/')
|
|
if not value:
|
|
return ''
|
|
try:
|
|
parsed = urlsplit(value)
|
|
host = parsed.hostname or ''
|
|
port = parsed.port
|
|
if (parsed.scheme not in ('http', 'https') or not host or parsed.username or parsed.password
|
|
or parsed.path or parsed.query or parsed.fragment or len(value) > 300
|
|
or re.search(r'[\s\\<>"()\[\]]', value.replace('[' + host + ']', host))
|
|
or port == 0):
|
|
raise ValueError()
|
|
try:
|
|
addr = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
if (host.lower() == 'localhost' or host.lower().endswith('.localhost')
|
|
or not re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?', host)
|
|
or re.fullmatch(r'[0-9.]+', host)):
|
|
raise ValueError()
|
|
else:
|
|
networks = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7')
|
|
if not any(addr in ipaddress.ip_network(net) for net in networks):
|
|
raise ValueError()
|
|
except ValueError:
|
|
raise ValueError('请填写手机可访问的内网根地址,例如 http://192.168.1.10:8080;不要使用本机回环地址或订单路径') from None
|
|
return value
|
|
|
|
|
|
def enqueue_transition(db, order, log):
|
|
config = db.get(NotificationSettings, 1)
|
|
if not config or not config.enabled:
|
|
return
|
|
channel = 'approval' if order.status == 'pending_approval' else 'result'
|
|
if not getattr(config, channel + '_webhook'):
|
|
return
|
|
db.add(Notification(log_id=log.id, order_id=order.id, revision=order.revision,
|
|
channel=channel, event=log.action, expected_status=order.status))
|
|
|
|
|
|
def display_time(value):
|
|
return value.replace(tzinfo=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M')
|
|
|
|
|
|
def payload_for(notice, order, base_url):
|
|
if order:
|
|
# Legacy order numbers are untrusted Markdown input. No prices, bank data,
|
|
# comments, supplier contacts or account tokens are sent to the group.
|
|
number = re.sub(r'[^\w\-]', ' ', order.order_no)[:50]
|
|
event = re.sub(r'[^\w\-]', ' ', notice.event)[:50]
|
|
content = ('**采购订单通知**\n订单:%s\n第 %s 次提交 · %s\n'
|
|
'记录时间:%s\n[打开订单](%s/orders/%s/view)\n'
|
|
'请连接公司 Wi-Fi 后登录;以订单页最新状态为准。') % (
|
|
number, notice.revision, event, display_time(notice.created_at),
|
|
base_url, order.id)
|
|
else:
|
|
content = '**采购订单测试通知**\n%s\n[打开工作台](%s/)\n请连接公司 Wi-Fi 后登录。' % (
|
|
CHANNELS[notice.channel], base_url)
|
|
return {'msgtype': 'markdown', 'markdown': {'content': content}}
|
|
|
|
|
|
class DeliveryError(Exception):
|
|
"""Only controlled, credential-free messages may cross this boundary."""
|
|
|
|
|
|
class NoRedirect(HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None
|
|
|
|
|
|
def send_wecom(webhook, payload):
|
|
validate_webhook(webhook)
|
|
if not webhook:
|
|
raise DeliveryError('未配置该通知群')
|
|
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
req = Request(webhook, data=data, headers={'Content-Type': 'application/json; charset=utf-8'}, method='POST')
|
|
try:
|
|
with build_opener(NoRedirect()).open(req, timeout=5) as response:
|
|
raw = response.read(16385)
|
|
if response.status != 200 or len(raw) > 16384:
|
|
raise DeliveryError('企业微信响应异常')
|
|
result = json.loads(raw)
|
|
code = result.get('errcode') if isinstance(result, dict) else None
|
|
if type(code) is not int:
|
|
raise DeliveryError('企业微信响应格式错误')
|
|
if code != 0:
|
|
raise DeliveryError('企业微信拒绝消息,错误码 %s' % code)
|
|
except HTTPError as error:
|
|
raise DeliveryError('企业微信 HTTP 错误 %s' % error.code) from None
|
|
except (URLError, OSError, TimeoutError):
|
|
raise DeliveryError('无法连接企业微信,请检查网络后重试') from None
|
|
except (ValueError, UnicodeError):
|
|
raise DeliveryError('企业微信响应格式错误') from None
|
|
|
|
|
|
def dispatch_once(factory, sender=None, now=None):
|
|
"""Claim -> commit -> send -> acknowledge, with a recoverable lease.
|
|
|
|
At-least-once delivery: a timeout/crash after WeCom accepts a message can
|
|
cause a duplicate notice. Approvals still require authenticated POSTs.
|
|
"""
|
|
now = now or datetime.utcnow()
|
|
with factory() as db:
|
|
db.execute(text('BEGIN IMMEDIATE'))
|
|
config = db.get(NotificationSettings, 1)
|
|
if not config or not config.enabled:
|
|
return False
|
|
if config.next_dispatch_at and config.next_dispatch_at > now:
|
|
return False
|
|
notice = db.query(Notification).filter(or_(
|
|
and_(Notification.status.in_(('pending', 'retry')), Notification.next_attempt_at <= now),
|
|
and_(Notification.status == 'sending', Notification.lease_until <= now)
|
|
)).order_by(Notification.id).first()
|
|
if not notice:
|
|
return False
|
|
order = db.get(Order, notice.order_id) if notice.order_id else None
|
|
obsolete = notice.order_id and (not order or (notice.channel in ('review', 'approval') and (
|
|
order.revision != notice.revision or order.status != notice.expected_status)))
|
|
webhook = getattr(config, notice.channel + '_webhook')
|
|
if obsolete or not webhook:
|
|
notice.status = 'cancelled'
|
|
notice.last_error = '订单已进入其他状态或版本' if obsolete else '通知群配置已移除'
|
|
db.commit()
|
|
return True
|
|
if notice.attempts >= MAX_ATTEMPTS:
|
|
notice.status, notice.last_error = 'failed', '已达到最大尝试次数,请检查后手动重试'
|
|
db.commit()
|
|
return True
|
|
notice.attempts += 1
|
|
notice.status = 'sending'
|
|
notice.lease_token = secrets.token_hex(16)
|
|
notice.lease_until = now + timedelta(seconds=60)
|
|
# Shared across workers and channels, including when two channels use
|
|
# the same bot. Up to 15 attempts/minute from this installation.
|
|
config.next_dispatch_at = now + timedelta(seconds=4)
|
|
payload = payload_for(notice, order, config.base_url)
|
|
notice_id, lease = notice.id, notice.lease_token
|
|
db.commit()
|
|
|
|
error = ''
|
|
try:
|
|
(sender or send_wecom)(webhook, payload)
|
|
except DeliveryError as failure:
|
|
error = str(failure)
|
|
except Exception:
|
|
# Exceptions may embed the secret URL. Never persist/log their text.
|
|
error = '通知发送异常,请检查网络及配置'
|
|
with factory() as db:
|
|
db.execute(text('BEGIN IMMEDIATE'))
|
|
notice = db.get(Notification, notice_id)
|
|
if notice.status != 'sending' or notice.lease_token != lease:
|
|
return True
|
|
notice.lease_until = notice.lease_token = None
|
|
notice.last_error = error
|
|
if error:
|
|
notice.status = 'failed' if notice.attempts >= MAX_ATTEMPTS else 'retry'
|
|
notice.next_attempt_at = now + timedelta(seconds=min(900, 30 * 2 ** (notice.attempts - 1)))
|
|
else:
|
|
notice.status, notice.sent_at = 'sent', now
|
|
db.commit()
|
|
return True
|
|
|
|
|
|
def start_notification_worker(app, factory):
|
|
"""Call once from the server entry point; returns a stop event for shutdown."""
|
|
if 'po_notification_stop' in app.extensions:
|
|
return app.extensions['po_notification_stop']
|
|
stop = threading.Event()
|
|
app.extensions['po_notification_stop'] = stop
|
|
|
|
def work():
|
|
while not stop.is_set():
|
|
try:
|
|
dispatch_once(factory)
|
|
except Exception:
|
|
app.logger.error('Notification worker iteration failed; will retry (details suppressed)')
|
|
stop.wait(1)
|
|
|
|
threading.Thread(target=work, name='wecom-outbox', daemon=True).start()
|
|
return stop
|