Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
短信发送工具
|
||||
|
||||
支持两种短信服务商:
|
||||
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},
|
||||
)
|
||||
Reference in New Issue
Block a user