"""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