2026-05-26 11:36:57 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
from urllib import error, request
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from backend.app.core.config import get_settings
|
|
|
|
|
|
from backend.app.core.error_codes import ErrorCode
|
|
|
|
|
|
from backend.app.core.exceptions import AppException
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WeChatNotificationService:
|
|
|
|
|
|
"""微信小程序订阅消息通知服务。
|
|
|
|
|
|
|
|
|
|
|
|
通过微信 subscribeMessage.send 接口向用户推送服务提醒。
|
|
|
|
|
|
需要在小程序后台配置消息模板,并将模板 ID 填入系统配置。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
TOKEN_CACHE: dict[str, str] = {"token": "", "expires_at": 0}
|
|
|
|
|
|
|
|
|
|
|
|
REMINDER_TYPE_TEMPLATE_MAP: dict[str, str] = {
|
|
|
|
|
|
"order_status_change": "wechat_template_order_status",
|
|
|
|
|
|
"order_approval_needed": "wechat_template_approval",
|
|
|
|
|
|
"task_assigned": "wechat_template_task_assigned",
|
|
|
|
|
|
"logistics_timeout": "wechat_template_logistics_timeout",
|
|
|
|
|
|
"arrears": "wechat_template_arrears",
|
|
|
|
|
|
"inactive_customer": "wechat_template_inactive_customer",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 13:25:10 +08:00
|
|
|
|
# 每个模板的微信字段名映射
|
|
|
|
|
|
# 格式: reminder_type -> { "微信模板字段名": "extra_data 中的 key" }
|
|
|
|
|
|
TEMPLATE_FIELD_MAP: dict[str, dict[str, str]] = {
|
|
|
|
|
|
"order_status_change": {
|
2026-06-15 13:18:43 +08:00
|
|
|
|
"phrase3": "status_text",
|
|
|
|
|
|
"time5": "change_time",
|
|
|
|
|
|
"character_string6": "order_no",
|
|
|
|
|
|
"thing4": "remark",
|
|
|
|
|
|
"thing18": "goods_info",
|
2026-06-06 13:25:10 +08:00
|
|
|
|
},
|
|
|
|
|
|
"order_approval_needed": {
|
2026-06-15 13:18:43 +08:00
|
|
|
|
"thing18": "submitter",
|
|
|
|
|
|
"thing16": "task_name",
|
|
|
|
|
|
"time20": "submit_time",
|
|
|
|
|
|
"thing10": "remark",
|
2026-06-06 13:25:10 +08:00
|
|
|
|
},
|
|
|
|
|
|
"task_assigned": {
|
2026-06-15 13:18:43 +08:00
|
|
|
|
"time2": "assign_time",
|
|
|
|
|
|
"thing7": "task_name",
|
|
|
|
|
|
"time9": "publish_time",
|
|
|
|
|
|
"time11": "start_time",
|
2026-06-06 13:25:10 +08:00
|
|
|
|
},
|
|
|
|
|
|
"logistics_timeout": {
|
2026-06-15 13:18:43 +08:00
|
|
|
|
"phrase1": "order_status",
|
|
|
|
|
|
"date2": "deadline",
|
|
|
|
|
|
"thing3": "remark",
|
|
|
|
|
|
"character_string4": "order_no",
|
2026-06-06 13:25:10 +08:00
|
|
|
|
},
|
|
|
|
|
|
"arrears": {
|
2026-06-15 13:18:43 +08:00
|
|
|
|
"thing5": "order_detail",
|
|
|
|
|
|
"date6": "order_time",
|
|
|
|
|
|
"time10": "appointment_time",
|
|
|
|
|
|
"amount12": "pending_amount",
|
|
|
|
|
|
"character_string4": "order_no",
|
2026-06-06 13:25:10 +08:00
|
|
|
|
},
|
|
|
|
|
|
"inactive_customer": {
|
2026-06-15 13:18:43 +08:00
|
|
|
|
"thing1": "activity_name",
|
|
|
|
|
|
"time2": "activity_time",
|
|
|
|
|
|
"thing3": "location",
|
|
|
|
|
|
"thing4": "remark",
|
2026-06-06 13:25:10 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def send_subscribe_message(self, open_id: str, reminder_type: str, title: str = "", content: str = "", extra_data: dict | None = None, biz_url: str = "") -> bool:
|
2026-05-26 11:36:57 +08:00
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
if not settings.wechat_app_id or not settings.wechat_app_secret:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
template_id = self._get_template_id(settings, reminder_type)
|
|
|
|
|
|
if not template_id:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
access_token = self._get_access_token(settings)
|
|
|
|
|
|
if not access_token:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-06 13:25:10 +08:00
|
|
|
|
field_map = self.TEMPLATE_FIELD_MAP.get(reminder_type)
|
|
|
|
|
|
if not field_map:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
data = extra_data or {}
|
|
|
|
|
|
now_str = time.strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
|
payload_data = {}
|
|
|
|
|
|
for wx_field, data_key in field_map.items():
|
|
|
|
|
|
value = data.get(data_key, "")
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
value = title if data_key == "order_no" else now_str
|
|
|
|
|
|
payload_data[wx_field] = {"value": str(value)[:100]}
|
|
|
|
|
|
|
2026-05-26 11:36:57 +08:00
|
|
|
|
payload = {
|
|
|
|
|
|
"touser": open_id,
|
|
|
|
|
|
"template_id": template_id,
|
|
|
|
|
|
"page": biz_url,
|
2026-06-06 13:25:10 +08:00
|
|
|
|
"data": payload_data,
|
2026-05-26 11:36:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
url = f"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={access_token}"
|
|
|
|
|
|
req = request.Request(
|
|
|
|
|
|
url=url,
|
|
|
|
|
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
|
|
|
|
headers={"Content-Type": "application/json; charset=UTF-8"},
|
|
|
|
|
|
method="POST",
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with request.urlopen(req, timeout=10) as response:
|
|
|
|
|
|
result = json.loads(response.read().decode("utf-8") or "{}")
|
|
|
|
|
|
return result.get("errcode", -1) == 0
|
|
|
|
|
|
except (error.HTTPError, error.URLError, json.JSONDecodeError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-15 14:24:25 +08:00
|
|
|
|
async def send_reminder(self, open_id: str, reminder_type: str, data: dict | None = None) -> bool:
|
|
|
|
|
|
"""发送提醒消息(兼容 event_bus 调用)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
open_id: 用户的微信 open_id
|
|
|
|
|
|
reminder_type: 提醒类型
|
|
|
|
|
|
data: 额外数据
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
extra_data = {}
|
|
|
|
|
|
if data:
|
|
|
|
|
|
# 从 event_bus 传递的数据中提取模板字段所需的数据
|
|
|
|
|
|
extra_data = {
|
|
|
|
|
|
"order_no": data.get("order_no", ""),
|
|
|
|
|
|
"status_text": data.get("status_desc", data.get("status", "")),
|
|
|
|
|
|
"change_time": data.get("change_time", time.strftime("%Y-%m-%d %H:%M")),
|
|
|
|
|
|
"remark": data.get("remark", data.get("content", "")),
|
|
|
|
|
|
"goods_info": data.get("goods_info", ""),
|
|
|
|
|
|
"submitter": data.get("salesman_name", data.get("submitter", "")),
|
|
|
|
|
|
"task_name": data.get("task_no", data.get("task_name", "")),
|
|
|
|
|
|
"submit_time": data.get("submit_time", time.strftime("%Y-%m-%d %H:%M")),
|
|
|
|
|
|
"assign_time": data.get("assign_time", time.strftime("%Y-%m-%d %H:%M")),
|
|
|
|
|
|
"publish_time": data.get("publish_time", time.strftime("%Y-%m-%d %H:%M")),
|
|
|
|
|
|
"start_time": data.get("start_time", time.strftime("%Y-%m-%d %H:%M")),
|
|
|
|
|
|
"order_status": data.get("status_desc", data.get("status", "")),
|
|
|
|
|
|
"deadline": data.get("deadline", ""),
|
|
|
|
|
|
"order_detail": data.get("order_detail", data.get("content", "")),
|
|
|
|
|
|
"order_time": data.get("order_time", ""),
|
|
|
|
|
|
"appointment_time": data.get("appointment_time", ""),
|
|
|
|
|
|
"pending_amount": data.get("amount", ""),
|
|
|
|
|
|
"activity_name": data.get("customer_name", ""),
|
|
|
|
|
|
"activity_time": data.get("activity_time", time.strftime("%Y-%m-%d %H:%M")),
|
|
|
|
|
|
"location": data.get("location", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
title = data.get("title", "") if data else ""
|
|
|
|
|
|
content = data.get("content", "") if data else ""
|
|
|
|
|
|
biz_url = data.get("biz_url", "") if data else ""
|
|
|
|
|
|
|
|
|
|
|
|
return self.send_subscribe_message(
|
|
|
|
|
|
open_id=open_id,
|
|
|
|
|
|
reminder_type=reminder_type,
|
|
|
|
|
|
title=title,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
extra_data=extra_data,
|
|
|
|
|
|
biz_url=biz_url
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-26 11:36:57 +08:00
|
|
|
|
def bind_open_id(self, session: Session, user_id: int, open_id: str) -> None:
|
|
|
|
|
|
from backend.app.models.system import User
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
user = session.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
|
|
|
|
|
if user is not None:
|
|
|
|
|
|
user.open_id = open_id
|
|
|
|
|
|
session.add(user)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
def _get_template_id(self, settings, reminder_type: str) -> str:
|
|
|
|
|
|
config_key = self.REMINDER_TYPE_TEMPLATE_MAP.get(reminder_type, "")
|
|
|
|
|
|
if not config_key:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return getattr(settings, config_key, "")
|
|
|
|
|
|
|
|
|
|
|
|
def _get_access_token(self, settings) -> str:
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
if self.TOKEN_CACHE["token"] and self.TOKEN_CACHE["expires_at"] > now + 60:
|
|
|
|
|
|
return self.TOKEN_CACHE["token"]
|
|
|
|
|
|
|
|
|
|
|
|
url = (
|
|
|
|
|
|
f"https://api.weixin.qq.com/cgi-bin/token"
|
|
|
|
|
|
f"?grant_type=client_credential"
|
|
|
|
|
|
f"&appid={settings.wechat_app_id}"
|
|
|
|
|
|
f"&secret={settings.wechat_app_secret}"
|
|
|
|
|
|
)
|
|
|
|
|
|
req = request.Request(url=url, method="GET")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with request.urlopen(req, timeout=10) as response:
|
|
|
|
|
|
result = json.loads(response.read().decode("utf-8") or "{}")
|
|
|
|
|
|
token = result.get("access_token", "")
|
|
|
|
|
|
expires_in = int(result.get("expires_in", 0))
|
|
|
|
|
|
if token and expires_in > 0:
|
|
|
|
|
|
self.TOKEN_CACHE["token"] = token
|
|
|
|
|
|
self.TOKEN_CACHE["expires_at"] = now + expires_in
|
|
|
|
|
|
return token
|
|
|
|
|
|
except (error.HTTPError, error.URLError, json.JSONDecodeError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
wechat_notification_service = WeChatNotificationService()
|