1
0
forked from erp-dev/erp

feat: wecom robot markdown message support

This commit is contained in:
2026-01-23 15:47:37 +08:00
parent 7fd44c64aa
commit 88688f9481
6 changed files with 297 additions and 1 deletions

View File

@@ -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}"))

View File

@@ -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)

View File

@@ -0,0 +1,36 @@
### 2026-01-23 工作记录
#### 1) 新增:企业微信机器人 Webhooktext 消息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`(默认测试环境关闭,非测试环境开启)

View File

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

View File

@@ -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}'
)

View File

@@ -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)