├── config ├── __init__.py ├── logger.py └── config.py ├── server.py ├── requirements.txt ├── app ├── __init__.py ├── api │ ├── health.py │ ├── __init__.py │ └── sms.py └── sms.py ├── Dockerfile ├── run.py ├── .gitignore ├── README.md └── application.py /config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from application import create_app 2 | 3 | 4 | app = create_app() 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-restful 3 | click 4 | envparse 5 | gunicorn 6 | aliyun-python-sdk-core-v3 7 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from app.api import api_bp 2 | 3 | 4 | # register blueprint 5 | def register(app): 6 | app.register_blueprint(api_bp) 7 | -------------------------------------------------------------------------------- /app/api/health.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource 2 | 3 | 4 | class Health(Resource): 5 | 6 | def get(self): 7 | return {'message': 'ok'}, 200 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | COPY . /opt 4 | WORKDIR /opt 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | CMD ["gunicorn", "-b", "0.0.0.0:80", "server:app"] 7 | 8 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restful import Api 3 | from app.api.health import Health 4 | from app.api.sms import Sms 5 | 6 | 7 | api_bp = Blueprint('api', __name__, url_prefix='/api') 8 | api = Api(api_bp) 9 | 10 | api.add_resource(Health, '/health') 11 | api.add_resource(Sms, '/sms') 12 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import click 2 | from envparse import env 3 | from application import create_app 4 | 5 | 6 | @click.command() 7 | @click.option('-h', '--host', help='Bind host', default='localhost', show_default=True) 8 | @click.option('-p', '--port', help='Bind port', default=8000, type=int, show_default=True) 9 | @click.option('-e', '--env', help='Running env, override environment FLASK_ENV.', default='development', show_default=True) 10 | @click.option('-f', '--env-file', help='Environment from file', type=click.Path(exists=True)) 11 | def main(**kwargs): 12 | if kwargs['env_file']: 13 | env.read_envfile(kwargs['env_file']) 14 | app = create_app(kwargs['env']) 15 | app.run(host=kwargs['host'], port=kwargs['port']) 16 | 17 | 18 | if __name__ == '__main__': 19 | main() 20 | -------------------------------------------------------------------------------- /app/api/sms.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask_restful import Resource, reqparse 3 | from app.sms import get_sms 4 | 5 | 6 | parser = reqparse.RequestParser(bundle_errors=True) 7 | parser.add_argument('receivers', help='Comma separated receivers.', required=True) 8 | parser.add_argument('template', help='Notification template name.', required=True) 9 | parser.add_argument('params', help='Notification template params.', type=dict) 10 | 11 | 12 | class Sms(Resource): 13 | 14 | def post(self): 15 | args = parser.parse_args() 16 | sms = get_sms() 17 | try: 18 | res = sms.send(**args) 19 | except Exception as e: 20 | logging.error(e) 21 | return {'message': 'failed'}, 500 22 | if res.status_code < 300: 23 | return {'message': 'send'}, 200 24 | else: 25 | logging.error('Send sms failed with {}'.format(res.text)) 26 | return {'message': 'failed'}, 500 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # IDEs 57 | .idea/ 58 | 59 | # data 60 | data/ 61 | out/ 62 | instance/ 63 | .DS_store 64 | 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask 短信微服务 2 | ============== 3 | 4 | # Description 5 | 6 | 基于公有云 API 的短信微服务,提供统一的接口。 7 | 8 | 支持的公有云: 9 | - 阿里云 10 | - 华为云 11 | 12 | # Environments 13 | 14 | - SMS_PROVIDER: which cloud provider to use,使用哪个公有云服务,["aliyun", "huawei"] 15 | 16 | Aliyun 阿里云配置 17 | 18 | - ALIYUN_SMS_VERSION:API 版本,默认 2017-05-25 19 | - ALIYUN_SMS_APP_KEY:App key,阿里云控制台获取 20 | - ALIYUN_SMS_APP_SECRET:App secret,阿里云控制台获取 21 | - ALIYUN_SMS_REGION_ID:Region ID,阿里云区域 22 | - ALIYUN_SMS_SIGN_NAME:Sign name,短信签名,阿里云后台创建审核后提供 23 | 24 | HuaweiCloud 华为云配置 25 | 26 | - HUAWEI_URL:华为云 API URL 27 | - HUAWEI_SMS_APP_KEY:App key,华为云控制台获取 28 | - HUAWEI_SMS_APP_SECRET:App secret,华为云控制台获取 29 | - HUAWEI_SMS_SENDER_ID:Sender ID,发送签名 ID 30 | 31 | # Usage 32 | 33 | Run in dev mode 34 | 35 | ``` 36 | python run.sh 37 | ``` 38 | 39 | Run in production mode 40 | 41 | ``` 42 | gunicorn -b 0.0.0.0:80 server:app 43 | ``` 44 | 45 | Run in Docker 46 | 47 | Use `Dockerfile` to build docker image and run. 48 | 49 | ``` 50 | docker build -t : . 51 | docker run -d -p 80:80 : 52 | ``` 53 | 54 | -------------------------------------------------------------------------------- /config/logger.py: -------------------------------------------------------------------------------- 1 | from logging.config import dictConfig 2 | 3 | 4 | def config_logger(enable_console_handler=True, enable_file_handler=True, log_file='app.log', log_level='ERROR', 5 | log_file_max_bytes=5000000, log_file_max_count=5): 6 | console_handler = { 7 | 'class': 'logging.StreamHandler', 8 | 'formatter': 'default', 9 | 'level': log_level, 10 | 'stream': 'ext://flask.logging.wsgi_errors_stream' 11 | } 12 | file_handler = { 13 | 'class': 'logging.handlers.RotatingFileHandler', 14 | 'formatter': 'detail', 15 | 'filename': log_file, 16 | 'level': log_level, 17 | 'maxBytes': log_file_max_bytes, 18 | 'backupCount': log_file_max_count 19 | } 20 | default_formatter = { 21 | 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' 22 | } 23 | detail_formatter = { 24 | 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' 25 | } 26 | handlers = [] 27 | if enable_console_handler: 28 | handlers.append('console') 29 | if enable_file_handler: 30 | handlers.append('file') 31 | d = { 32 | 'version': 1, 33 | 'formatters': { 34 | 'default': default_formatter, 35 | 'detail': detail_formatter 36 | }, 37 | 'handlers': { 38 | 'console': console_handler, 39 | 'file': file_handler 40 | }, 41 | 'root': { 42 | 'level': log_level, 43 | 'handlers': handlers 44 | } 45 | } 46 | dictConfig(d) 47 | -------------------------------------------------------------------------------- /application.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | from flask import Flask 4 | from config.logger import config_logger 5 | from config import config 6 | 7 | 8 | def register_logger(): 9 | log_level = os.environ.get('LOG_LEVEL') or 'INFO' 10 | log_file = os.environ.get('LOG_FILE') or 'app.log' 11 | config_logger( 12 | enable_console_handler=True, 13 | enable_file_handler=True, 14 | log_level=log_level, 15 | log_file=log_file 16 | ) 17 | 18 | 19 | def register_app(app): 20 | for a in config.registered_app: 21 | module = importlib.import_module(a) 22 | if hasattr(module, 'register'): 23 | getattr(module, 'register')(app) 24 | 25 | 26 | def get_config_object(env=None): 27 | if not env: 28 | env = os.environ.get('FLASK_ENV') 29 | else: 30 | os.environ['FLASK_ENV'] = env 31 | if env in config.config_map: 32 | return config.config_map[env] 33 | else: 34 | # set default env if not set 35 | env = 'production' 36 | return config.config_map[env] 37 | 38 | 39 | def create_app_by_config(conf=None): 40 | # initialize logger 41 | register_logger() 42 | # check instance path 43 | instance_path = os.environ.get('INSTANCE_PATH') or None 44 | # create and configure the app 45 | app = Flask(__name__, instance_path=instance_path) 46 | if not conf: 47 | conf = get_config_object() 48 | app.config.from_object(conf) 49 | # ensure the instance folder exists 50 | if app.instance_path: 51 | try: 52 | os.makedirs(app.instance_path) 53 | except OSError: 54 | pass 55 | # register app 56 | register_app(app) 57 | return app 58 | 59 | 60 | def create_app(env=None): 61 | conf = get_config_object(env) 62 | return create_app_by_config(conf) 63 | -------------------------------------------------------------------------------- /config/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class BaseConfig: 5 | SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(16) 6 | DEBUG = False 7 | TESTING = False 8 | 9 | # SMS config 10 | SMS_PROVIDER = os.environ.get('SMS_PROVIDER') 11 | SMS_CONF = { 12 | 'aliyun': { 13 | 'provider_cls': 'app.sms.AliyunSmsProvider', 14 | 'config': { 15 | 'domain': 'dysmsapi.aliyuncs.com', 16 | 'version': os.environ.get('ALIYUN_SMS_VERSION') or '2017-05-25', 17 | 'app_key': os.environ.get('ALIYUN_SMS_APP_KEY'), 18 | 'app_secret': os.environ.get('ALIYUN_SMS_APP_SECRET'), 19 | 'region_id': os.environ.get('ALIYUN_SMS_REGION_ID'), 20 | 'sign_name': os.environ.get('ALIYUN_SMS_SIGN_NAME'), 21 | 'template_id_map': { 22 | 'captcha': 'xxx' 23 | } 24 | } 25 | }, 26 | 'huawei': { 27 | 'provider_cls': 'app.sms.HuaweiSmsProvider', 28 | 'config': { 29 | 'url': os.environ.get('HUAWEI_URL'), 30 | 'app_key': os.environ.get('HUAWEI_SMS_APP_KEY'), 31 | 'app_secret': os.environ.get('HUAWEI_SMS_APP_SECRET'), 32 | 'sender': os.environ.get('HUAWEI_SMS_SENDER_ID'), 33 | 'template_id_map': { 34 | 'captcha': 'xxx' 35 | } 36 | } 37 | } 38 | } 39 | 40 | 41 | class ProductionConfig(BaseConfig): 42 | pass 43 | 44 | 45 | class DevelopmentConfig(BaseConfig): 46 | DEBUG = True 47 | 48 | 49 | class TestingConfig(BaseConfig): 50 | DEBUG = True 51 | TESTING = True 52 | 53 | 54 | registered_app = [ 55 | 'app' 56 | ] 57 | 58 | config_map = { 59 | 'development': DevelopmentConfig, 60 | 'production': ProductionConfig, 61 | 'testing': TestingConfig 62 | } 63 | -------------------------------------------------------------------------------- /app/sms.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import hashlib 4 | import json 5 | import base64 6 | import requests 7 | from flask import g, current_app 8 | from werkzeug.utils import import_string 9 | 10 | 11 | def create_sms(): 12 | provider = current_app.config['SMS_PROVIDER'] 13 | sms_config = current_app.config['SMS_CONF'] 14 | if provider in sms_config: 15 | cls = sms_config[provider]['provider_cls'] 16 | conf = sms_config[provider]['config'] 17 | sms = import_string(cls)(**conf) 18 | return sms 19 | return None 20 | 21 | 22 | def get_sms(): 23 | if 'sms' not in g: 24 | g.sms = create_sms() 25 | return g.sms 26 | 27 | 28 | class SmsProvider: 29 | 30 | def __init__(self, **kwargs): 31 | self.conf = kwargs 32 | 33 | def send(self, template, receivers, **kwargs): 34 | pass 35 | 36 | 37 | class AliyunSmsProvider(SmsProvider): 38 | 39 | def send(self, template, receivers, **kwargs): 40 | from aliyunsdkcore.request import CommonRequest 41 | client = self.get_client(self.conf['app_key'], self.conf['app_secret'], self.conf['region_id']) 42 | request = CommonRequest() 43 | request.set_accept_format('json') 44 | request.set_domain(self.conf['domain']) 45 | request.set_method('POST') 46 | request.set_protocol_type('https') 47 | request.set_version(self.conf['version']) 48 | request.set_action_name('SendSms') 49 | request.add_query_param('RegionId', self.conf['region_id']) 50 | request.add_query_param('PhoneNumbers', receivers) 51 | request.add_query_param('SignName', self.conf['sign_name']) 52 | request.add_query_param('TemplateCode', self.get_template_id(template)) 53 | request.add_query_param('TemplateParam', self.build_template_params(**kwargs)) 54 | return client.do_action_with_exception(request) 55 | 56 | def get_template_id(self, name): 57 | if name in self.conf['template_id_map']: 58 | return self.conf['template_id_map'][name] 59 | else: 60 | raise ValueError('no template {} found!'.format(name)) 61 | 62 | @staticmethod 63 | def get_client(app_key, app_secret, region_id): 64 | from aliyunsdkcore.client import AcsClient 65 | return AcsClient(app_key, app_secret, region_id) 66 | 67 | @staticmethod 68 | def build_template_params(**kwargs): 69 | if 'params' in kwargs and kwargs['params']: 70 | return json.dumps(kwargs['params']) 71 | else: 72 | return '' 73 | 74 | 75 | class HuaweiSmsProvider(SmsProvider): 76 | 77 | def send(self, template, receivers, **kwargs): 78 | header = {'Authorization': 'WSSE realm="SDP",profile="UsernameToken",type="Appkey"', 79 | 'X-WSSE': self.build_wsse_header(self.conf['app_key'], self.conf['app_secret'])} 80 | form_data = { 81 | 'from': self.conf['sender'], 82 | 'to': receivers, 83 | 'templateId': self.get_template_id(template), 84 | 'templateParas': self.build_template_params(**kwargs), 85 | } 86 | r = requests.post(self.conf['url'], data=form_data, headers=header, verify=False) 87 | return r 88 | 89 | def get_template_id(self, name): 90 | if name in self.conf['template_id_map']: 91 | return self.conf['template_id_map'][name] 92 | else: 93 | raise ValueError('no template {} found!'.format(name)) 94 | 95 | @staticmethod 96 | def build_wsse_header(app_key, app_secret): 97 | now = time.strftime('%Y-%m-%dT%H:%M:%SZ') 98 | nonce = str(uuid.uuid4()).replace('-', '') 99 | digest = hashlib.sha256((nonce + now + app_secret).encode()).hexdigest() 100 | digest_base64 = base64.b64encode(digest.encode()).decode() 101 | return 'UsernameToken Username="{}",PasswordDigest="{}",Nonce="{}",Created="{}"'.format(app_key, digest_base64, 102 | nonce, now) 103 | 104 | @staticmethod 105 | def build_template_params(**kwargs): 106 | if 'params' in kwargs and kwargs['params']: 107 | return json.dumps(list(kwargs['params'].values())) 108 | else: 109 | return '' 110 | --------------------------------------------------------------------------------