Files

162 lines
6.1 KiB
Python
Raw Permalink 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.
"""模块:阿里云短信
直接 HTTP 调用阿里云短信 API(dysmsapi),避免 SDK 依赖。
签名计算用 HMAC-SHA1 + Base64(阿里云 RPC API 规范)。
配置(secrets.ini [aliyun_sms] 段):
ACCESS_KEY / ACCESS_SECRET / SIGN_NAME
配置(app.ini [aliyun_sms] 段):
VERIFICATION_CODE_TEMPLATE 验证码模板 code
常用 API:
from gvsds.wechat import SMS
SMS.SendCode(phone, code) # 发送验证码
SMS.Send(phone, template_code, params) # 通用单发
SMS.SendBatch(phones, template_code, params) # 批量发送
返回:{'success': bool, 'biz_id': str, 'msg': str}
"""
import base64
import hashlib
import hmac
import json
import logging
import time
import urllib.parse
import uuid
from gvsds.app_config import get as _app_get
from gvsds.secrets import get as _secret_get
logger = logging.getLogger(__name__)
_SMS_ENDPOINT = 'https://dysmsapi.aliyuncs.com'
_API_VERSION = '2017-05-25'
class SMS:
"""阿里云短信发送器(HTTP 直接调用)。"""
@staticmethod
def _GetConfig():
"""读取阿里云短信配置。"""
access_key = _secret_get('aliyun_sms', 'ACCESS_KEY')
access_secret = _secret_get('aliyun_sms', 'ACCESS_SECRET')
sign_name = _secret_get('aliyun_sms', 'SIGN_NAME')
if not access_key or not access_secret or not sign_name:
raise RuntimeError('[SMS] 未配置 aliyun_sms ACCESS_KEY/ACCESS_SECRET/SIGN_NAME')
return access_key, access_secret, sign_name
@staticmethod
def _PercentEncode(value):
"""阿里云规范的特殊百分号编码。"""
if value is None:
return ''
if not isinstance(value, str):
value = str(value)
encoded = urllib.parse.quote(value, safe='')
encoded = encoded.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
return encoded
@classmethod
def _Sign(cls, params, access_secret):
"""计算阿里云 RPC API 签名(HMAC-SHA1 + Base64)。"""
sorted_items = sorted(params.items())
canonical = '&'.join(
f'{cls._PercentEncode(k)}={cls._PercentEncode(v)}'
for k, v in sorted_items
)
sign_string = 'GET&' + cls._PercentEncode('/') + '&' + cls._PercentEncode(canonical)
hmac_obj = hmac.new(
(access_secret + '&').encode('utf-8'),
sign_string.encode('utf-8'),
hashlib.sha1,
)
return base64.b64encode(hmac_obj.digest()).decode('utf-8')
@classmethod
def _Invoke(cls, params):
"""调用阿里云短信 API 并解析响应。"""
access_key, access_secret, _ = cls._GetConfig()
common = {
'Format': 'JSON',
'Version': _API_VERSION,
'AccessKeyId': access_key,
'SignatureMethod': 'HMAC-SHA1',
'Timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'SignatureVersion': '1.0',
'SignatureNonce': uuid.uuid4().hex,
}
all_params = {**common, **params}
all_params['Signature'] = cls._Sign(all_params, access_secret)
try:
import requests
resp = requests.get(_SMS_ENDPOINT, params=all_params, timeout=10)
data = resp.json()
except Exception as e:
logger.error('[SMS] 请求阿里云失败: %s', e, exc_info=True)
return {'success': False, 'biz_id': '', 'msg': f'请求异常: {e}'}
biz_id = data.get('BizId', '')
code = data.get('Code', '')
if code == 'OK':
return {'success': True, 'biz_id': biz_id, 'msg': 'OK'}
msg = data.get('Message', 'unknown')
logger.warning('[SMS] 发送失败 Code=%s Message=%s', code, msg)
return {'success': False, 'biz_id': biz_id, 'msg': f'{code}: {msg}'}
@staticmethod
def _StringifyParams(params):
"""把 dict 转成 JSON 字符串。"""
if params is None:
return '{}'
if isinstance(params, str):
return params
return json.dumps(params, ensure_ascii=False, separators=(',', ':'))
@classmethod
def SendCode(cls, phone, code):
"""发送验证码(使用配置中的 VERIFICATION_CODE_TEMPLATE)。"""
template_code = _app_get('aliyun_sms', 'VERIFICATION_CODE_TEMPLATE')
if not template_code:
raise RuntimeError('[SMS] 未配置 aliyun_sms VERIFICATION_CODE_TEMPLATE')
return cls.Send(phone, template_code, {'code': str(code)})
@classmethod
def Send(cls, phone, template_code, params=None):
"""通用单发。params 是 dict,如 {'code': '123456'}。"""
if not phone:
return {'success': False, 'biz_id': '', 'msg': 'phone 为空'}
_, _, sign_name = cls._GetConfig()
req_params = {
'Action': 'SendSms',
'PhoneNumbers': str(phone),
'SignName': sign_name,
'TemplateCode': template_code,
'TemplateParam': cls._StringifyParams(params),
}
return cls._Invoke(req_params)
@classmethod
def SendBatch(cls, phones, template_code, params=None):
"""批量发送(同模板同参数广播给所有号码)。
阿里云 SendBatchSms 要求 PhoneNumberJson/SignNameJson/TemplateParamJson
均为 JSON 数组,且长度一致。此处将同一签名、同一参数套用到所有号码。
"""
phones = list(phones or [])
if not phones:
return {'success': False, 'biz_id': '', 'msg': 'phones 为空'}
_, _, sign_name = cls._GetConfig()
phone_list = [str(p) for p in phones]
sign_list = [sign_name] * len(phone_list)
param_dict = params if isinstance(params, dict) else {}
param_list = [param_dict] * len(phone_list)
req_params = {
'Action': 'SendBatchSms',
'PhoneNumberJson': json.dumps(phone_list, ensure_ascii=False),
'SignNameJson': json.dumps(sign_list, ensure_ascii=False),
'TemplateCode': template_code,
'TemplateParamJson': json.dumps(param_list, ensure_ascii=False),
}
return cls._Invoke(req_params)