349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""
|
|
短信发送工具
|
|
|
|
支持两种短信服务商:
|
|
1. 阿里云短信 (Alibaba Cloud SMS) - 使用 HTTP API + HMAC-SHA1 签名
|
|
2. 腾讯云短信 (Tencent Cloud SMS) - 使用 HTTP API + HMAC-SHA256 签名
|
|
|
|
配置通过 config_manager 三级获取(Redis → 数据库 → env)
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, List, Optional
|
|
from urllib.parse import quote_plus
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AliyunSMS:
|
|
"""阿里云短信服务"""
|
|
|
|
API_URL = "https://dysmsapi.aliyuncs.com"
|
|
|
|
@staticmethod
|
|
async def _get_config() -> dict:
|
|
"""通过 config_manager 三级获取阿里云短信配置"""
|
|
from app.config_manager import config_manager
|
|
group_config = await config_manager.get_group("notify_sms")
|
|
return {
|
|
"provider": group_config.get("provider") or "aliyun",
|
|
"access_key_id": group_config.get("aliyun_access_key_id") or None,
|
|
"access_key_secret": group_config.get("aliyun_access_key_secret") or None,
|
|
"sign_name": group_config.get("aliyun_sign_name") or None,
|
|
"template_code": group_config.get("aliyun_template_code") or None,
|
|
}
|
|
|
|
@staticmethod
|
|
async def is_configured_async() -> bool:
|
|
"""检查阿里云短信是否已配置"""
|
|
config = await AliyunSMS._get_config()
|
|
return bool(
|
|
config["access_key_id"]
|
|
and config["access_key_secret"]
|
|
and config["sign_name"]
|
|
and config["template_code"]
|
|
)
|
|
|
|
@staticmethod
|
|
def _percent_encode(s: str) -> str:
|
|
"""阿里云特殊 URL 编码"""
|
|
return quote_plus(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
|
|
|
@staticmethod
|
|
def _sign(params: dict, access_key_secret: str) -> str:
|
|
"""计算阿里云 API 签名"""
|
|
sorted_params = sorted(params.items())
|
|
canonicalized = "&".join(
|
|
f"{AliyunSMS._percent_encode(k)}={AliyunSMS._percent_encode(str(v))}"
|
|
for k, v in sorted_params
|
|
)
|
|
string_to_sign = f"GET&%2F&{AliyunSMS._percent_encode(canonicalized)}"
|
|
sign_key = f"{access_key_secret}&"
|
|
hmac_hash = hmac.new(
|
|
sign_key.encode("utf-8"),
|
|
string_to_sign.encode("utf-8"),
|
|
hashlib.sha1,
|
|
).digest()
|
|
return base64.b64encode(hmac_hash).decode("utf-8")
|
|
|
|
@staticmethod
|
|
async def send(
|
|
phone_numbers: List[str],
|
|
template_param: Optional[Dict[str, str]] = None,
|
|
) -> Dict[str, bool]:
|
|
"""
|
|
发送短信
|
|
|
|
Args:
|
|
phone_numbers: 手机号列表
|
|
template_param: 模板参数,如 {"title": "审批通知", "content": "您有一条待审批任务"}
|
|
|
|
Returns:
|
|
{phone: success} 字典
|
|
"""
|
|
config = await AliyunSMS._get_config()
|
|
if not config["access_key_id"]:
|
|
logger.warning("阿里云短信未配置,跳过发送")
|
|
return {phone: False for phone in phone_numbers}
|
|
|
|
results = {}
|
|
for phone in phone_numbers:
|
|
try:
|
|
params = {
|
|
"AccessKeyId": config["access_key_id"],
|
|
"Action": "SendSms",
|
|
"Format": "JSON",
|
|
"PhoneNumbers": phone,
|
|
"RegionId": "cn-hangzhou",
|
|
"SignName": config["sign_name"],
|
|
"SignatureMethod": "HMAC-SHA1",
|
|
"SignatureNonce": str(uuid.uuid4()),
|
|
"SignatureVersion": "1.0",
|
|
"TemplateCode": config["template_code"],
|
|
"Timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"Version": "2017-05-25",
|
|
}
|
|
if template_param:
|
|
params["TemplateParam"] = json.dumps(template_param, ensure_ascii=False)
|
|
|
|
signature = AliyunSMS._sign(params, config["access_key_secret"])
|
|
params["Signature"] = signature
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(AliyunSMS.API_URL, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if data.get("Code") == "OK":
|
|
results[phone] = True
|
|
logger.info(f"阿里云短信发送成功: {phone}")
|
|
else:
|
|
results[phone] = False
|
|
logger.warning(f"阿里云短信发送失败: {phone}, Code={data.get('Code')}, Message={data.get('Message')}")
|
|
except Exception as e:
|
|
results[phone] = False
|
|
logger.error(f"阿里云短信发送异常: {phone}, {e}")
|
|
|
|
return results
|
|
|
|
|
|
class TencentSMS:
|
|
"""腾讯云短信服务"""
|
|
|
|
API_URL = "https://sms.tencentcloudapi.com"
|
|
SERVICE = "sms"
|
|
VERSION = "2021-01-11"
|
|
ACTION = "SendSms"
|
|
|
|
@staticmethod
|
|
async def _get_config() -> dict:
|
|
"""通过 config_manager 三级获取腾讯云短信配置"""
|
|
from app.config_manager import config_manager
|
|
group_config = await config_manager.get_group("notify_sms")
|
|
return {
|
|
"provider": group_config.get("provider") or "aliyun",
|
|
"secret_id": group_config.get("tencent_secret_id") or None,
|
|
"secret_key": group_config.get("tencent_secret_key") or None,
|
|
"sdk_app_id": group_config.get("tencent_sdk_app_id") or None,
|
|
"sign_name": group_config.get("tencent_sign_name") or None,
|
|
"template_id": group_config.get("tencent_template_id") or None,
|
|
}
|
|
|
|
@staticmethod
|
|
async def is_configured_async() -> bool:
|
|
"""检查腾讯云短信是否已配置"""
|
|
config = await TencentSMS._get_config()
|
|
return bool(
|
|
config["secret_id"]
|
|
and config["secret_key"]
|
|
and config["sdk_app_id"]
|
|
and config["sign_name"]
|
|
and config["template_id"]
|
|
)
|
|
|
|
@staticmethod
|
|
def _sign_v3(secret_key: str, date: str, service: str, string_to_sign: str) -> str:
|
|
"""腾讯云 TC3-HMAC-SHA256 签名"""
|
|
|
|
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
|
|
secret_date = _hmac_sha256(f"TC3{secret_key}".encode("utf-8"), date)
|
|
secret_service = _hmac_sha256(secret_date, service)
|
|
secret_signing = _hmac_sha256(secret_service, "tc3_request")
|
|
return hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
|
|
@staticmethod
|
|
async def send(
|
|
phone_numbers: List[str],
|
|
template_param_set: Optional[List[str]] = None,
|
|
) -> Dict[str, bool]:
|
|
"""
|
|
发送短信
|
|
|
|
Args:
|
|
phone_numbers: 手机号列表(需带国际区号前缀,如 +86)
|
|
template_param_set: 模板参数列表,如 ["审批通知", "您有一条待审批任务"]
|
|
|
|
Returns:
|
|
{phone: success} 字典
|
|
"""
|
|
config = await TencentSMS._get_config()
|
|
if not config["secret_id"]:
|
|
logger.warning("腾讯云短信未配置,跳过发送")
|
|
return {phone: False for phone in phone_numbers}
|
|
|
|
# 腾讯云手机号需要 +86 前缀
|
|
formatted_phones = []
|
|
for phone in phone_numbers:
|
|
if not phone.startswith("+"):
|
|
phone = f"+86{phone}"
|
|
formatted_phones.append(phone)
|
|
|
|
payload = {
|
|
"SmsSdkAppId": config["sdk_app_id"],
|
|
"SignName": config["sign_name"],
|
|
"TemplateId": config["template_id"],
|
|
"PhoneNumberSet": formatted_phones,
|
|
}
|
|
if template_param_set:
|
|
payload["TemplateParamSet"] = template_param_set
|
|
|
|
payload_json = json.dumps(payload)
|
|
|
|
# 构建签名
|
|
now = int(time.time())
|
|
date = datetime.fromtimestamp(now, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
|
|
# CanonicalRequest
|
|
hashed_payload = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
|
canonical_request = (
|
|
f"POST\n/\n\n"
|
|
f"content-type:application/json; charset=utf-8\n"
|
|
f"host:sms.tencentcloudapi.com\n\n"
|
|
f"content-type;host\n"
|
|
f"{hashed_payload}"
|
|
)
|
|
|
|
# StringToSign
|
|
credential_scope = f"{date}/{TencentSMS.SERVICE}/tc3_request"
|
|
hashed_canonical = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
|
string_to_sign = f"TC3-HMAC-SHA256\n{now}\n{credential_scope}\n{hashed_canonical}"
|
|
|
|
# Signature
|
|
signature = TencentSMS._sign_v3(config["secret_key"], date, TencentSMS.SERVICE, string_to_sign)
|
|
|
|
# Authorization
|
|
authorization = (
|
|
f"TC3-HMAC-SHA256 "
|
|
f"Credential={config['secret_id']}/{credential_scope}, "
|
|
f"SignedHeaders=content-type;host, "
|
|
f"Signature={signature}"
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": authorization,
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Host": "sms.tencentcloudapi.com",
|
|
"X-TC-Action": TencentSMS.ACTION,
|
|
"X-TC-Version": TencentSMS.VERSION,
|
|
"X-TC-Timestamp": str(now),
|
|
}
|
|
|
|
results = {phone: False for phone in phone_numbers}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(TencentSMS.API_URL, headers=headers, content=payload_json)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
response = data.get("Response", {})
|
|
if "Error" in response:
|
|
logger.error(
|
|
f"腾讯云短信发送失败: Code={response['Error'].get('Code')}, "
|
|
f"Message={response['Error'].get('Message')}"
|
|
)
|
|
return results
|
|
|
|
send_status_set = response.get("SendStatusSet", [])
|
|
for i, status in enumerate(send_status_set):
|
|
original_phone = phone_numbers[i] if i < len(phone_numbers) else "unknown"
|
|
if status.get("Code") == "Ok":
|
|
results[original_phone] = True
|
|
logger.info(f"腾讯云短信发送成功: {original_phone}")
|
|
else:
|
|
logger.warning(
|
|
f"腾讯云短信发送失败: {original_phone}, "
|
|
f"Code={status.get('Code')}, Message={status.get('Message')}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"腾讯云短信发送异常: {e}")
|
|
|
|
return results
|
|
|
|
|
|
class SMSSender:
|
|
"""
|
|
统一短信发送器
|
|
|
|
根据配置的 provider 自动选择阿里云或腾讯云
|
|
"""
|
|
|
|
@staticmethod
|
|
async def _get_provider() -> str:
|
|
"""获取当前配置的短信服务商"""
|
|
from app.config_manager import config_manager
|
|
group_config = await config_manager.get_group("notify_sms")
|
|
return group_config.get("provider") or "aliyun"
|
|
|
|
@staticmethod
|
|
async def is_configured_async() -> bool:
|
|
"""检查短信服务是否已配置"""
|
|
provider = await SMSSender._get_provider()
|
|
if provider == "tencent":
|
|
return await TencentSMS.is_configured_async()
|
|
return await AliyunSMS.is_configured_async()
|
|
|
|
@staticmethod
|
|
async def send(
|
|
phone_numbers: List[str],
|
|
title: str,
|
|
content: str,
|
|
) -> Dict[str, bool]:
|
|
"""
|
|
发送短信通知
|
|
|
|
Args:
|
|
phone_numbers: 手机号列表
|
|
title: 通知标题
|
|
content: 通知内容
|
|
|
|
Returns:
|
|
{phone: success} 字典
|
|
"""
|
|
if not phone_numbers:
|
|
return {}
|
|
|
|
provider = await SMSSender._get_provider()
|
|
|
|
if provider == "tencent":
|
|
return await TencentSMS.send(
|
|
phone_numbers=phone_numbers,
|
|
template_param_set=[title, content],
|
|
)
|
|
else:
|
|
return await AliyunSMS.send(
|
|
phone_numbers=phone_numbers,
|
|
template_param={"title": title, "content": content},
|
|
)
|