Files
banban/talkingq-url/banban/service/sms_notification.py
2026-07-24 15:06:11 +08:00

421 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import json
import re
import time
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from sqlalchemy import text
from config import settings
from services.database_service_base import DatabaseServiceBase
from utils.logger import session_logger
_SMS_ADDRESS_FALLBACK = "请打开小程序查看"
_SMS_ADDRESS_MAX_LENGTH = 30
_SMS_ADDRESS_ROAD_PATTERN = re.compile(r".*(?:大道|街道|路|街|巷|弄)")
_SMS_COORDINATE_PATTERN = re.compile(r"^\s*-?\d+(?:\.\d+)?\s*[,]\s*-?\d+(?:\.\d+)?\s*$")
_SMS_ADDRESS_ALLOWED_PATTERN = re.compile(r"[^0-9A-Za-z\u4e00-\u9fff]")
class SmsNotificationService(DatabaseServiceBase):
def __init__(self):
super().__init__(service_name="sms_notification_service")
self._last_sent_at: dict[tuple[str, str, str], float] = {}
async def notify_alarm(
self,
*,
device_id: str,
alarm_id: int | None = None,
child_name: str | None = None,
address: str | None = None,
) -> None:
if not self._is_enabled():
return
if alarm_id and (not child_name or not address):
context = await self._get_alarm_context(alarm_id=alarm_id, device_id=device_id)
child_name = child_name or context.get("child_name")
address = address or context.get("address")
child_name = self._normalize_conference(child_name)
if not child_name:
session_logger.warning(device_id, "sms", "告警短信跳过:缺少符合模板要求的孩子姓名")
return
recipients = await self._list_device_family_phone_numbers(device_id=device_id)
if not recipients:
session_logger.info(device_id, "sms", "告警短信跳过:没有可用家长手机号")
return
template_code = self._alarm_template_code()
if not template_code:
session_logger.warning(device_id, "sms", "告警短信跳过:未配置短信模板")
return
params = self._build_template_params(
child_name=child_name,
address=address,
event_time=self._format_now(),
event_label="设备告警",
)
await self._send_to_recipients(
device_id=device_id,
notification_type="alarm",
recipients=recipients,
template_code=template_code,
template_params=params,
dedup_seconds=max(0, int(settings.sms_alarm_dedup_seconds or 0)),
ref_id=str(alarm_id or ""),
)
async def notify_leave_message(
self,
*,
device_id: str,
child_name: str | None = None,
) -> None:
if not self._is_enabled():
return
child_name = self._normalize_conference(child_name)
if not child_name:
session_logger.warning(device_id, "sms", "留言短信跳过:缺少符合模板要求的孩子姓名")
return
recipients = await self._list_device_family_phone_numbers(device_id=device_id)
if not recipients:
session_logger.info(device_id, "sms", "留言短信跳过:没有可用家长手机号")
return
template_code = self._leave_message_template_code()
if not template_code:
session_logger.warning(device_id, "sms", "留言短信跳过:未配置短信模板")
return
params = self._build_template_params(
child_name=child_name,
address="小程序",
event_time=self._format_now(),
event_label="设备留言",
)
await self._send_to_recipients(
device_id=device_id,
notification_type="leave_message",
recipients=recipients,
template_code=template_code,
template_params=params,
dedup_seconds=max(0, int(settings.sms_leave_message_dedup_seconds or 0)),
ref_id="",
)
async def notify_alarm_best_effort(self, **kwargs: Any) -> None:
try:
await self.notify_alarm(**kwargs)
except Exception as exc:
session_logger.warning(kwargs.get("device_id") or "", "sms", f"告警短信发送失败,不影响主流程: {exc}")
async def notify_leave_message_best_effort(self, **kwargs: Any) -> None:
try:
await self.notify_leave_message(**kwargs)
except Exception as exc:
session_logger.warning(kwargs.get("device_id") or "", "sms", f"留言短信发送失败,不影响主流程: {exc}")
def schedule_alarm_notification(self, **kwargs: Any) -> None:
self._schedule_best_effort(self.notify_alarm_best_effort(**kwargs), device_id=kwargs.get("device_id"))
def schedule_leave_message_notification(self, **kwargs: Any) -> None:
self._schedule_best_effort(
self.notify_leave_message_best_effort(**kwargs),
device_id=kwargs.get("device_id"),
)
def _schedule_best_effort(self, coro: Any, *, device_id: str | None) -> None:
try:
asyncio.create_task(coro)
except RuntimeError as exc:
session_logger.warning(device_id or "", "sms", f"短信后台任务创建失败,不影响主流程: {exc}")
try:
coro.close()
except Exception:
pass
async def _send_to_recipients(
self,
*,
device_id: str,
notification_type: str,
recipients: list[str],
template_code: str,
template_params: dict[str, str],
dedup_seconds: int,
ref_id: str,
) -> None:
for phone_number in recipients:
if self._is_deduped(
device_id=device_id,
notification_type=notification_type,
phone_number=phone_number,
dedup_seconds=dedup_seconds,
):
session_logger.info(device_id, "sms", f"短信防抖跳过: type={notification_type}, ref={ref_id}")
continue
result = await self._send_sms(
phone_number=phone_number,
template_code=template_code,
template_params=template_params,
)
if result.get("ok"):
self._mark_sent(
device_id=device_id,
notification_type=notification_type,
phone_number=phone_number,
)
session_logger.info(
device_id,
"sms",
f"短信发送成功: type={notification_type}, code={result.get('code')}, request_id={result.get('request_id')}",
)
else:
session_logger.warning(
device_id,
"sms",
f"短信发送失败: type={notification_type}, code={result.get('code')}, message={result.get('message')}",
)
async def _send_sms(
self,
*,
phone_number: str,
template_code: str,
template_params: dict[str, str],
) -> dict[str, Any]:
if settings.sms_dry_run:
return {
"ok": True,
"code": "DRY_RUN",
"message": "dry run",
"request_id": None,
"biz_id": None,
}
try:
return await asyncio.to_thread(
self._send_aliyun_sms_sync,
phone_number=phone_number,
template_code=template_code,
template_params=template_params,
)
except Exception as exc:
return {
"ok": False,
"code": getattr(exc, "code", None),
"message": getattr(exc, "message", str(exc)),
"request_id": None,
"biz_id": None,
}
def _send_aliyun_sms_sync(
self,
*,
phone_number: str,
template_code: str,
template_params: dict[str, str],
) -> dict[str, Any]:
from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient
from alibabacloud_dysmsapi20170525 import models as sms_models
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
client = DysmsapiClient(
open_api_models.Config(
access_key_id=settings.aliyun_sms_access_key_id,
access_key_secret=settings.aliyun_sms_access_key_secret,
endpoint=settings.aliyun_sms_endpoint or "dysmsapi.aliyuncs.com",
connect_timeout=int(settings.sms_http_timeout_seconds * 1000),
read_timeout=int(settings.sms_http_timeout_seconds * 1000),
)
)
request = sms_models.SendSmsRequest(
sign_name=settings.aliyun_sms_sign_name,
template_code=template_code,
phone_numbers=phone_number,
template_param=json.dumps(template_params, ensure_ascii=False),
)
response = client.send_sms_with_options(request, util_models.RuntimeOptions())
body = getattr(response, "body", None)
code = getattr(body, "code", None)
return {
"ok": code == "OK",
"code": code,
"message": getattr(body, "message", None),
"request_id": getattr(body, "request_id", None),
"biz_id": getattr(body, "biz_id", None),
}
async def _list_device_family_phone_numbers(self, *, device_id: str) -> list[str]:
db_session = await self.get_session()
try:
result = await db_session.execute(
text(
"""
SELECT p.phone
FROM (
SELECT
db.owner_user_id AS user_id,
0 AS sort_role,
db.bound_at AS sort_time,
db.id AS sort_id
FROM device_bindings AS db
WHERE db.device_id = :device_id
AND db.status = 1
AND db.owner_user_id IS NOT NULL
UNION ALL
SELECT
dfm.user_id,
dfm.role AS sort_role,
dfm.joined_at AS sort_time,
dfm.id AS sort_id
FROM device_family_members AS dfm
WHERE dfm.device_id = :device_id
AND dfm.status = 1
) AS recipients
JOIN parents AS p
ON p.user_id = recipients.user_id
AND p.status = 1
WHERE p.phone IS NOT NULL
AND p.phone <> ''
ORDER BY recipients.sort_role ASC, recipients.sort_time ASC, recipients.sort_id ASC
"""
),
{"device_id": device_id},
)
phone_numbers: list[str] = []
seen: set[str] = set()
for row in result.mappings().all():
phone_number = str(row["phone"]).strip()
if not phone_number or phone_number in seen:
continue
seen.add(phone_number)
phone_numbers.append(phone_number)
return phone_numbers
finally:
await db_session.close()
async def _get_alarm_context(self, *, alarm_id: int, device_id: str) -> Mapping[str, Any]:
db_session = await self.get_session()
try:
result = await db_session.execute(
text(
"""
SELECT
c.child_name,
COALESCE(dae.address, clc.address) AS address,
dae.lat,
dae.lng
FROM device_alarm_events AS dae
LEFT JOIN children AS c
ON c.child_id = dae.child_id
AND c.status = 1
LEFT JOIN child_location_current AS clc
ON clc.child_id = dae.child_id
WHERE dae.alarm_id = :alarm_id
AND dae.device_id = :device_id
LIMIT 1
"""
),
{"alarm_id": alarm_id, "device_id": device_id},
)
row = result.mappings().first()
if not row:
return {}
data = dict(row)
if not data.get("address") and data.get("lat") is not None and data.get("lng") is not None:
data["address"] = f"{data['lat']},{data['lng']}"
return data
finally:
await db_session.close()
def _is_enabled(self) -> bool:
if not settings.sms_enabled:
return False
if (settings.sms_provider or "").strip().lower() != "aliyun":
session_logger.warning("system", "sms", f"短信服务未启用:不支持的 provider={settings.sms_provider}")
return False
required = (
settings.aliyun_sms_access_key_id,
settings.aliyun_sms_access_key_secret,
settings.aliyun_sms_sign_name,
self._default_template_code() or self._alarm_template_code() or self._leave_message_template_code(),
)
if any(not str(value or "").strip() for value in required):
session_logger.warning("system", "sms", "短信服务未启用:阿里云短信配置不完整")
return False
return True
def _default_template_code(self) -> str:
return (settings.aliyun_sms_template_code or "").strip()
def _alarm_template_code(self) -> str:
return (settings.aliyun_sms_alarm_template_code or "").strip() or self._default_template_code()
def _leave_message_template_code(self) -> str:
return (settings.aliyun_sms_leave_message_template_code or "").strip() or self._default_template_code()
def _build_template_params(
self,
*,
child_name: str | None,
address: str | None,
event_time: str,
event_label: str,
) -> dict[str, str]:
return {
"code": event_label,
"conference": self._normalize_conference(child_name),
"address": self._sanitize_sms_address(address),
"time": event_time,
}
def _normalize_conference(self, child_name: str | None) -> str:
return str(child_name or "").strip()[:20]
def _sanitize_sms_address(self, address: str | None) -> str:
raw_address = str(address or "").strip()
if not raw_address or _SMS_COORDINATE_PATTERN.match(raw_address):
return _SMS_ADDRESS_FALLBACK
road_match = _SMS_ADDRESS_ROAD_PATTERN.match(raw_address)
if road_match and len(road_match.group(0)) < len(raw_address):
raw_address = f"{road_match.group(0)}附近"
sanitized = _SMS_ADDRESS_ALLOWED_PATTERN.sub("", raw_address)
if not sanitized:
return _SMS_ADDRESS_FALLBACK
return sanitized[:_SMS_ADDRESS_MAX_LENGTH]
def _format_now(self) -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def _is_deduped(
self,
*,
device_id: str,
notification_type: str,
phone_number: str,
dedup_seconds: int,
) -> bool:
if dedup_seconds <= 0:
return False
last_sent_at = self._last_sent_at.get((device_id, notification_type, phone_number))
return last_sent_at is not None and time.time() - last_sent_at < dedup_seconds
def _mark_sent(self, *, device_id: str, notification_type: str, phone_number: str) -> None:
self._last_sent_at[(device_id, notification_type, phone_number)] = time.time()
sms_notification_service = SmsNotificationService()