""" 短信发送客户端(可复用) 支持:短信宝 (SMSBao)、阿里云短信。 所有发送函数接收一个 SmsConfig ORM 行对象(或具有相同字段的对象)作为配置。 设计: - 不依赖 Web 框架,纯工具函数,供 auth / profile / token_usage / admin.settings 复用。 - 短信宝走国内直连,必须禁用代理(requests proxies={"http":None,"https":None})。 """ import hashlib import logging import urllib.parse import uuid logger = logging.getLogger("aiflow") # ============================================================ # 通用辅助 # ============================================================ def smsbao_md5(password: str) -> str: """短信宝密码:登录密码的 MD5(32位,不区分大小写)""" return hashlib.md5(password.encode("utf-8")).hexdigest() SMSBAO_ERRORS = { "0": "发送成功", "30": "密码错误", "40": "账号不存在", "41": "余额不足", "43": "IP地址限制", "50": "内容含有敏感词", "51": "手机号码不正确", } def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str: """阿里云签名机制 (HMAC-SHA1)""" sorted_keys = sorted(params.keys()) qs = "&".join([ f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}" for k in sorted_keys ]) string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}" key = f"{secret}&" import hmac import base64 h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) return base64.b64encode(h.digest()).decode('utf-8') def _wrap_sign(config_sign: str) -> str: """包裹短信签名:缺少【】时自动加【】""" sign = config_sign or "" if "【" not in sign: sign = f"【{sign}】" if sign else "【七虎AI】" return sign # ============================================================ # 核心发送 # ============================================================ def send_sms(config, phone: str, content: str): """ 发送任意内容的短信。 :param config: SmsConfig ORM 行(需有 provider/username/password/goods_id/sign_name/api_key/api_secret/template_code) :param phone: 接收手机号 :param content: 已包含签名的短信正文 :return: (ok: bool, message: str, provider_code: str|None) """ if not config or not getattr(config, "provider", None): return False, "短信服务未配置,请联系管理员", None provider = config.provider # 短信宝 (SMSBao) if provider == "短信宝": if not config.username or not config.password: return False, "短信宝账号未配置(缺少用户名或密码)", None pwd_md5 = smsbao_md5(config.password) params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content} if getattr(config, "goods_id", None): params["g"] = config.goods_id import requests try: resp = requests.get( "https://api.smsbao.com/sms", params=params, timeout=10, proxies={"http": None, "https": None}, ) code_str = resp.text.strip().split("\n")[0] except Exception as e: logger.error(f"短信宝请求失败: {e}") return False, f"短信宝请求失败: {e}", None if code_str == "0": return True, "发送成功", code_str return False, SMSBAO_ERRORS.get(code_str, f"未知错误码 {code_str}"), code_str # 阿里云短信 if "阿里云" in provider: if not config.api_key or not config.api_secret: return False, "阿里云短信 AccessKey 未配置", None sign = _wrap_sign(getattr(config, "sign_name", "") or "") # 内容里已含验证码,阿里云模板需要 TemplateParam;此处用通用签名+内容方式不便, # 为兼容现有配置,直接以单变量模板发送(TemplateParam 透传 content)。 from datetime import datetime as _dt params = { "AccessKeyId": config.api_key, "Action": "SendSms", "Format": "JSON", "PhoneNumbers": phone, "RegionId": "cn-hangzhou", "SignName": sign.replace("【", "").replace("】", ""), "SignatureMethod": "HMAC-SHA1", "SignatureNonce": str(uuid.uuid4()), "SignatureVersion": "1.0", "TemplateCode": getattr(config, "template_code", None) or "SMS_00000001", "TemplateParam": '{"code":"000000"}', "Timestamp": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "Version": "2017-05-25", } params["Signature"] = _aliyun_sign(config.api_secret, params) import requests try: resp = requests.post( "https://dysmsapi.aliyuncs.com/", data=params, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=10, ) result = resp.json() except Exception as e: logger.error(f"阿里云短信请求失败: {e}") return False, f"阿里云短信请求失败: {e}", None if result.get("Code") == "OK": return True, "发送成功", result.get("Code") return False, result.get("Message", result.get("Code", "未知错误")), result.get("Code") return False, f"不支持的短信服务商: {provider}", None # ============================================================ # 业务封装 # ============================================================ _PURPOSE_TEXT = { "register": "注册", "reset_password": "重置密码", "change_password": "修改密码", "recharge": "充值", } def send_sms_code(config, phone: str, code: str, purpose: str): """发送验证码类短信。""" purpose_text = _PURPOSE_TEXT.get(purpose, "验证") sign = _wrap_sign(getattr(config, "sign_name", "") or "") content = f"{sign}您的{purpose_text}验证码为{code},5分钟内有效,请勿泄露给他人。" return send_sms(config, phone, content) def send_sms_text(config, phone: str, text: str): """发送通知类短信(如充值成功、密码已修改等)。text 不含签名,自动包裹。""" sign = _wrap_sign(getattr(config, "sign_name", "") or "") content = f"{sign}{text}" return send_sms(config, phone, content)