forked from erp-dev/erp
feat: wecom robot markdown message support
This commit is contained in:
41
api_v1/management/commands/wecom_webhook_send_text.py
Normal file
41
api_v1/management/commands/wecom_webhook_send_text.py
Normal 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}"))
|
||||
|
||||
116
api_v1/utils/wecom_webhook.py
Normal file
116
api_v1/utils/wecom_webhook.py
Normal 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)
|
||||
|
||||
36
docs/2026-01-23_summary.md
Normal file
36
docs/2026-01-23_summary.md
Normal file
@@ -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`(默认测试环境关闭,非测试环境开启)
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user