From 88688f94811cce669d2be99de45b2b3589b5ae60 Mon Sep 17 00:00:00 2001 From: colaftc Date: Fri, 23 Jan 2026 15:47:37 +0800 Subject: [PATCH] feat: wecom robot markdown message support --- .../commands/wecom_webhook_send_text.py | 41 +++++++ api_v1/utils/wecom_webhook.py | 116 ++++++++++++++++++ docs/2026-01-23_summary.md | 36 ++++++ flower/settings.py | 18 +++ printing/apps.py | 12 +- printing/handlers.py | 75 +++++++++++ 6 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 api_v1/management/commands/wecom_webhook_send_text.py create mode 100644 api_v1/utils/wecom_webhook.py create mode 100644 docs/2026-01-23_summary.md diff --git a/api_v1/management/commands/wecom_webhook_send_text.py b/api_v1/management/commands/wecom_webhook_send_text.py new file mode 100644 index 0000000..731981e --- /dev/null +++ b/api_v1/management/commands/wecom_webhook_send_text.py @@ -0,0 +1,41 @@ +from django.core.management.base import BaseCommand, CommandError + +from api_v1.utils.wecom_webhook import send_wecom_webhook_message + + +class Command(BaseCommand): + help = "发送企业微信机器人 webhook 文本消息(用于测试)" + + def add_arguments(self, parser): + parser.add_argument("text", type=str, help="要发送的文本内容") + parser.add_argument( + "--msgtype", + type=str, + choices=["text", "markdown"], + default="text", + help="消息类型(默认 text)", + ) + parser.add_argument("--key", type=str, default=None, help="可选:临时覆盖 settings.WECOM_WEBHOOK_KEY") + parser.add_argument( + "--timeout", + type=float, + default=10.0, + help="HTTP 超时(秒)", + ) + + def handle(self, *args, **options): + text = options["text"] + msgtype = options["msgtype"] + key = options["key"] + timeout = options["timeout"] + try: + resp = send_wecom_webhook_message(content=text, msgtype=msgtype, key=key, timeout_seconds=timeout) + except Exception as exc: + raise CommandError(str(exc)) from exc + + # 企业微信成功时 errcode=0 + if not resp.ok: + raise CommandError(f"WeCom webhook 返回失败:errcode={resp.errcode}, errmsg={resp.errmsg}, raw={resp.raw}") + + self.stdout.write(self.style.SUCCESS(f"OK: {resp.raw}")) + diff --git a/api_v1/utils/wecom_webhook.py b/api_v1/utils/wecom_webhook.py new file mode 100644 index 0000000..0b15743 --- /dev/null +++ b/api_v1/utils/wecom_webhook.py @@ -0,0 +1,116 @@ +""" +WeCom (企业微信) robot webhook helpers. + +Only text/markdown messages are supported for now. + +API: + POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx + Content-Type: application/json + { + "msgtype": "text", + "text": {"content": "hello world"} + } +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from django.conf import settings + + +@dataclass(frozen=True) +class WeComWebhookResponse: + errcode: int + errmsg: str + raw: dict[str, Any] + + @property + def ok(self) -> bool: + return int(self.errcode) == 0 + + +def send_wecom_webhook_message( + *, + content: str, + msgtype: str = "text", + key: str | None = None, + timeout_seconds: float = 10.0, +) -> WeComWebhookResponse: + """ + Send a message to WeCom robot webhook. + + Supported msgtype: + - text: {"msgtype":"text","text":{"content": "..."}} + - markdown: {"msgtype":"markdown","markdown":{"content": "..."}} + """ + t = (msgtype or "text").strip().lower() + if t not in {"text", "markdown"}: + raise ValueError("msgtype 仅支持 text 或 markdown") + + content = (content or "").strip() + if not content: + raise ValueError("content 不能为空") + + base_url = getattr(settings, "WECOM_WEBHOOK_BASE_URL", "").strip() + if not base_url: + base_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send" + + k = (key or getattr(settings, "WECOM_WEBHOOK_KEY", "") or "").strip() + if not k: + raise ValueError("WeCom webhook key 未配置(settings.WECOM_WEBHOOK_KEY)") + + url = f"{base_url}?{urlencode({'key': k})}" + if t == "markdown": + payload = {"msgtype": "markdown", "markdown": {"content": content}} + else: + payload = {"msgtype": "text", "text": {"content": content}} + + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + req = Request( + url=url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urlopen(req, timeout=float(timeout_seconds)) as resp: + body = resp.read().decode("utf-8", errors="replace") + except HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8", errors="replace") + except Exception: + pass + raise RuntimeError(f"WeCom webhook HTTPError: status={exc.code}, body={body}") from exc + except URLError as exc: + raise RuntimeError(f"WeCom webhook URLError: {exc}") from exc + + try: + raw = json.loads(body) if body else {} + except Exception as exc: + raise RuntimeError(f"WeCom webhook 响应不是合法 JSON: {body}") from exc + + errcode = int(raw.get("errcode") or 0) + errmsg = str(raw.get("errmsg") or "") + return WeComWebhookResponse(errcode=errcode, errmsg=errmsg, raw=raw) + + +def send_wecom_webhook_text( + *, + content: str, + key: str | None = None, + timeout_seconds: float = 10.0, +) -> WeComWebhookResponse: + """ + Backward-compatible wrapper for sending text. + """ + return send_wecom_webhook_message(content=content, msgtype="text", key=key, timeout_seconds=timeout_seconds) + diff --git a/docs/2026-01-23_summary.md b/docs/2026-01-23_summary.md new file mode 100644 index 0000000..7c63fe6 --- /dev/null +++ b/docs/2026-01-23_summary.md @@ -0,0 +1,36 @@ +### 2026-01-23 工作记录 + +#### 1) 新增:企业微信机器人 Webhook(text 消息)util 与测试命令 + +- settings: + - `flower/settings.py` 新增 `WECOM_WEBHOOK_BASE_URL` / `WECOM_WEBHOOK_KEY`(按当前需求直接写入 settings,未接入 .env) +- util: + - `api_v1/utils/wecom_webhook.py` + - 提供 `send_wecom_webhook_message(content=..., msgtype=..., key=..., timeout_seconds=...)` + - `msgtype` 支持:`text`(默认)、`markdown` +- command: + - `api_v1/management/commands/wecom_webhook_send_text.py` + - 用法示例: + - `uv run python manage.py wecom_webhook_send_text "hello world"` + - 发送 markdown:`uv run python manage.py wecom_webhook_send_text "**bold**" --msgtype markdown` + - 可选覆盖 key:`uv run python manage.py wecom_webhook_send_text "hello" --key xxx` + - 参数: + - `text`:必填,消息内容 + - `--msgtype`:可选,`text`(默认)或 `markdown` + - `--key`:可选,临时覆盖 `settings.WECOM_WEBHOOK_KEY` + - `--timeout`:可选,HTTP 超时秒数(默认 10) + +#### 2) 新增:PrintingJob 每次状态推进(state_advanced)发送企业微信 markdown 通知 + +- 信号来源:`stateflow.signals.state_advanced`(每次推进都会触发,发送点在 `stateflow.services.advance_to_next_state()` 内) +- 监听注册:`printing/apps.py` 里 `state_advanced.connect(..., sender=PrintingJob)` +- handler:`printing/handlers.py::on_printing_job_state_advanced` + - 使用 `transaction.on_commit(...)`,确保事务提交后再发送企业微信(避免回滚导致通知不一致) + - 发送内容(markdown)包含: + - `PrintingOrder.id` + - `PrintingJob.id` + - `state.name` + - 推进时间(取 `state_log.completed_at`) +- 模板位置(便于后续改文案):`flower/settings.py` + - `PRINTING_JOB_STATE_ADVANCED_WECOM_MARKDOWN_TEMPLATE` + - 开关:`PRINTING_JOB_STATE_ADVANCED_WECOM_NOTIFY_ENABLED`(默认测试环境关闭,非测试环境开启) diff --git a/flower/settings.py b/flower/settings.py index 9fc6d26..8566977 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -76,6 +76,11 @@ TENCENTCLOUD_TIIA_ENDPOINT = env('TENCENTCLOUD_TIIA_ENDPOINT', default='tiia.ten TENCENTCLOUD_TIIA_PIC_NAME_PREFIX = env('TENCENTCLOUD_TIIA_PIC_NAME_PREFIX', default='plate_order') TENCENTCLOUD_TIIA_QPS = env.int('TENCENTCLOUD_TIIA_QPS', default=10) # Tencent API 限速:每秒最多 10 次 +# 企业微信机器人 Webhook(目前按需求直接写入 settings,不走 .env) +# 使用:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx +WECOM_WEBHOOK_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send' +WECOM_WEBHOOK_KEY = 'cc88bdef-a13f-4d7e-bdb6-ee51b68b8205' + # CORS 配置 CORS_ALLOW_ALL_ORIGINS = DEBUG # 开发环境允许所有源,生产环境需要配置白名单 @@ -262,6 +267,19 @@ else: } +# PrintingJob:状态推进时的企业微信通知(markdown) +# - 默认:测试环境关闭(避免单测出网/刷屏),非测试环境开启 +PRINTING_JOB_STATE_ADVANCED_WECOM_NOTIFY_ENABLED = (not TESTING) +PRINTING_JOB_STATE_ADVANCED_WECOM_MARKDOWN_TEMPLATE = ( + "### PrintingJob 状态推进\n" + "\n" + "- **PrintingOrder.id**: `{printing_order_id}`\n" + "- **PrintingJob.id**: `{printing_job_id}`\n" + "- **State**: **{state_name}**\n" + "- **AdvancedAt**: `{advanced_at}`\n" +) + + # Logging configuration # 目标: # - 生产环境出现 500 时,能在 stdout 日志里看到完整 traceback diff --git a/printing/apps.py b/printing/apps.py index a841eff..6927355 100644 --- a/printing/apps.py +++ b/printing/apps.py @@ -11,7 +11,7 @@ class PrintingConfig(AppConfig): def ready(self): """注册信号处理函数""" - from stateflow.signals import process_completed + from stateflow.signals import process_completed, state_advanced from .models import PrintingJob from . import handlers @@ -20,8 +20,18 @@ class PrintingConfig(AppConfig): handlers.on_printing_job_process_completed, sender=PrintingJob ) + + # 监听 PrintingJob 状态推进信号(每次推进都会触发) + state_advanced.connect( + handlers.on_printing_job_state_advanced, + sender=PrintingJob + ) logger.info( f'[printing.apps] 已注册 process_completed 信号处理器, ' f'sender={PrintingJob}, handler={handlers.on_printing_job_process_completed}' + ) + logger.info( + f'[printing.apps] 已注册 state_advanced 信号处理器, ' + f'sender={PrintingJob}, handler={handlers.on_printing_job_state_advanced}' ) \ No newline at end of file diff --git a/printing/handlers.py b/printing/handlers.py index e190264..8a1930f 100644 --- a/printing/handlers.py +++ b/printing/handlers.py @@ -8,6 +8,7 @@ import logging from decimal import Decimal, InvalidOperation from django.conf import settings from django.db import transaction +from django.utils import timezone logger = logging.getLogger(__name__) @@ -149,3 +150,77 @@ def on_printing_job_process_completed(sender, **kwargs): f'PrintingJob #{printing_job.id} 流程完成后创建销售品失败: {e}', exc_info=True ) + + +def on_printing_job_state_advanced(sender, **kwargs): + """ + PrintingJob 每次状态推进时的处理(监听 stateflow.signals.state_advanced)。 + + 需求: + - 仅监听 PrintingJob + - 在事务提交后发送企业微信机器人通知(markdown) + - 消息包含: + - PrintingOrder.id + - PrintingJob.id + - 被推进状态名 state.name + - 推进时间(state_log.completed_at) + """ + # 测试环境默认关闭,避免单测出网/刷屏 + if not getattr(settings, 'PRINTING_JOB_STATE_ADVANCED_WECOM_NOTIFY_ENABLED', True): + return + if getattr(settings, 'TESTING', False): + return + + content_object = kwargs.get('content_object') + state = kwargs.get('state') + state_log = kwargs.get('state_log') + if content_object is None or state is None or state_log is None: + logger.warning('[printing.handlers] state_advanced 信号缺少关键参数,跳过通知') + return + + printing_job = content_object + printing_job_id = getattr(printing_job, 'id', None) + printing_order = getattr(printing_job, 'printing_order', None) + printing_order_id = getattr(printing_order, 'id', None) + + state_name = getattr(state, 'name', None) or str(state) + advanced_at_dt = getattr(state_log, 'completed_at', None) or getattr(state_log, 'created_at', None) + advanced_at = ( + timezone.localtime(advanced_at_dt).strftime('%Y-%m-%d %H:%M:%S') + if advanced_at_dt is not None + else timezone.localtime(timezone.now()).strftime('%Y-%m-%d %H:%M:%S') + ) + + template = getattr( + settings, + 'PRINTING_JOB_STATE_ADVANCED_WECOM_MARKDOWN_TEMPLATE', + ( + "### PrintingJob 状态推进\n" + "\n" + "- **PrintingOrder.id**: `{printing_order_id}`\n" + "- **PrintingJob.id**: `{printing_job_id}`\n" + "- **State**: **{state_name}**\n" + "- **AdvancedAt**: `{advanced_at}`\n" + ), + ) + message = template.format( + printing_order_id=str(printing_order_id) if printing_order_id is not None else '-', + printing_job_id=str(printing_job_id) if printing_job_id is not None else '-', + state_name=str(state_name), + advanced_at=str(advanced_at), + ) + + def _send_wecom(): + try: + from api_v1.utils.wecom_webhook import send_wecom_webhook_message + resp = send_wecom_webhook_message(content=message, msgtype='markdown') + if not resp.ok: + logger.warning( + '[printing.handlers] WeCom webhook 返回失败:errcode=%s, errmsg=%s, raw=%s', + resp.errcode, resp.errmsg, resp.raw + ) + except Exception: + logger.exception('[printing.handlers] 发送 WeCom webhook 失败(已忽略,不影响主流程)') + + # 在事务提交后再发送,避免事务回滚但通知已发出 + transaction.on_commit(_send_wecom)