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", } # 每个模板的微信字段名映射 # 格式: reminder_type -> { "微信模板字段名": "extra_data 中的 key" } TEMPLATE_FIELD_MAP: dict[str, dict[str, str]] = { "order_status_change": { "phrase3": "status_text", "time5": "change_time", "character_string6": "order_no", "thing4": "remark", "thing18": "goods_info", }, "order_approval_needed": { "thing18": "submitter", "thing16": "task_name", "time20": "submit_time", "thing10": "remark", }, "task_assigned": { "time2": "assign_time", "thing7": "task_name", "time9": "publish_time", "time11": "start_time", }, "logistics_timeout": { "phrase1": "order_status", "date2": "deadline", "thing3": "remark", "character_string4": "order_no", }, "arrears": { "thing5": "order_detail", "date6": "order_time", "time10": "appointment_time", "amount12": "pending_amount", "character_string4": "order_no", }, "inactive_customer": { "thing1": "activity_name", "time2": "activity_time", "thing3": "location", "thing4": "remark", }, } def send_subscribe_message(self, open_id: str, reminder_type: str, title: str = "", content: str = "", extra_data: dict | None = None, biz_url: str = "") -> bool: 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 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]} payload = { "touser": open_id, "template_id": template_id, "page": biz_url, "data": payload_data, } 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 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 ) 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()