421 lines
16 KiB
Python
421 lines
16 KiB
Python
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()
|