欢迎来到十九分快乐的博客

生死看淡,不服就干。

4. 用户模块 - 用户注册

用户模块

我们当前开发的项目属于社交类型项目,所以关于用户的信息和功能直接贯穿了整个项目。所以此处实现用户模块功能,我们先把用户基本信息构建起来,并通过基本信息实现用户注册登录相关功能,后面遇到业务再继续扩展。

服务端实现API接口

先删除原来编写在apps/home蓝图下的测试视图和测试模型代码,当然数据库中的测试数据和表结构也要删除。

创建用户蓝图应用以及路由信息配置。

cd application/apps
python ../../manage.py blue -n users

注册蓝图应用 application/settings/dev.py

# 注册蓝图
INSTALLED_APPS = [
    "application.apps.users",
]

注册总路由 application/urls.py

from application import include

# 蓝图子路由列表
urlpatterns = [
    include('/users','users.urls'),
]

用户相关模型

创建公共数据模型, application/utils/models.py,代码:

from application import db
from datetime import datetime

# 公共模型
class BaseModel(db.Model):
    __abstract__ = True  # 抽象模型,创建表时不会给此模型建表
    id = db.Column(db.Integer, primary_key=True, comment="主键ID")
    name = db.Column(db.String(255), default="", comment="名称/标题")
    is_deleted = db.Column(db.Boolean, default=False, comment="逻辑删除")
    orders = db.Column(db.Integer, default=0, comment="排序")
    status = db.Column(db.Boolean, default=True, comment="状态(是否显示,是否激活)")
    created_time = db.Column(db.DateTime, default=datetime.now, comment="创建时间")
    updated_time = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")

    def __repr__(self):
        return "<%s: %s>" % (self.__class__.__name__, self.name)

    @classmethod
    def add(cls, instance):
        '''
        保存用户信息
        :param instance: 用户模型对象
        :return:
        '''
        db.session.add(instance)
        db.session.commit()

    @classmethod
    def add_all(cls, instance_list):
        '''
        保存用户信息
        :param instance_list: 用户模型对象列表
        :return:
        '''
        db.session.add_all(instance_list)
        db.session.commit()

用户蓝图应用下, 基于公共模型,创建用户模型

application/apps/users/models.py,代码:

from application.utils.models import BaseModel, db
from werkzeug.security import generate_password_hash, check_password_hash


class User(BaseModel):
    """用户基本信息表"""
    __tablename__ = "mf_user"
    name = db.Column(db.String(255), index=True, comment="用户账户")
    nickname = db.Column(db.String(255), comment="用户昵称")
    _password = db.Column(db.String(255), comment="登录密码")
    age = db.Column(db.SmallInteger, comment="年龄")
    money = db.Column(db.Numeric(7, 2), comment="账户余额")
    ip_address = db.Column(db.String(255), default="", index=True, comment="登录IP")
    intro = db.Column(db.String(500), default="", comment="个性签名")
    avatar = db.Column(db.String(255), default="", comment="头像url地址")
    sex = db.Column(db.SmallInteger, default=0, comment="性别")  # 0表示未设置,保密, 1表示男,2表示女
    email = db.Column(db.String(32), index=True, default="", nullable=False, comment="邮箱地址")
    mobile = db.Column(db.String(32), index=True, nullable=False, comment="手机号码")
    unique_id = db.Column(db.String(255), index=True, default="", comment="客户端唯一标记符")
    province = db.Column(db.String(255), default="", comment="省份")
    city = db.Column(db.String(255), default="", comment="城市")
    area = db.Column(db.String(255), default="", comment="地区")
    info = db.relationship('UserProfile', primaryjoin='User.id == UserProfile.user_id', foreign_keys='UserProfile.user_id', backref='user', uselist=False)

    """
     密码存取器是成对出现的,函数也是一样的
    """
    @property
    def password(self):
        return self._password

    @password.setter
    def password(self, rawpwd):
        """密码加密"""
        self._password = generate_password_hash(rawpwd)

    def check_password(self, rawpwd):
        """验证密码"""
        return check_password_hash(self.password, rawpwd)


class UserProfile(BaseModel):
    """用户详情信息表"""
    __tablename__ = "mf_user_profile"
    user_id = db.Column(db.Integer, comment="用户ID")
    education = db.Column(db.Integer, comment="学历教育")
    middle_school = db.Column(db.String(255), default="", comment="初中/中专")
    high_school = db.Column(db.String(255), default="", comment="高中/高职")
    college_school = db.Column(db.String(255), default="", comment="大学/大专")
    profession_cate = db.Column(db.String(255), default="", comment="职业类型")
    profession_info = db.Column(db.String(255), default="", comment="职业名称")
    position = db.Column(db.SmallInteger, default=0, comment="职位/职称")
    emotion_status = db.Column(db.SmallInteger, default=0, comment="情感状态")
    birthday = db.Column(db.DateTime, default="", comment="生日")
    hometown_province = db.Column(db.String(255), default="", comment="家乡省份")
    hometown_city = db.Column(db.String(255), default="", comment="家乡城市")
    hometown_area = db.Column(db.String(255), default="", comment="家乡地区")
    hometown_address = db.Column(db.String(255), default="", comment="家乡地址")
    living_province = db.Column(db.String(255), default="", comment="现居住省份")
    living_city = db.Column(db.String(255), default="", comment="现居住城市")
    living_area = db.Column(db.String(255), default="", comment="现居住地区")
    living_address = db.Column(db.String(255), default="", comment="现居住地址")

注册功能实现

我们把提示文本信息和自定义响应状态码单独写到一个文件中,方便后续调用, 在application/utils公共目录下创建文件

提示文本信息 application/utils/message.py

"""提示文本信息"""

ok = '成功!!'
mobile_format_error = '手机号码格式有误'
mobile_is_use = '手机号已经被注册'

自定义响应状态码 application/utils/code.py

"""自定义响应状态码"""

CODE_OK = 1000                  # 成功
CODE_VALIDATE_ERROR = 1001      # 数据验证错误

为了方便其他文件调用, 在项目引入文件application/__init__.py中导入两个文件

from application.utils import message, code

手机号码唯一验证接口

在开发中,针对客户端提交的数据进行验证或提供模型对象数据转换格式成字典给客户端。可以使用Marshmallow模块来进行数据的序列化 , 反序列化(校验数据)。

  1. 创建视图, 对客户端手机号码进行校验 application/apps/users/api.py
# 引入构造器(序列化器)
from .marshmallow import MobileSchema, ValidationError
# 引入返回信息,和自定义提示码
from application import message, code

# 校验手机号
def check_mobile(mobile):
    # 实例化构造器对象
    ms = MobileSchema()
    try:
        # load反序列化校验数据
        ms.load({'mobile':mobile}) 
        res = {'errno': code.CODE_OK, 'errmsg':message.ok}
    except ValidationError as e:
        print(e.messages) # {'mobile': ['手机号码格式有误']}
        res = {'errno': code.CODE_VALIDATE_ERROR, 'errmsg': e.messages['mobile'][0]}

    return res

  1. 创建构造器类, 校验数据(手机号码) application/apps/users/marshmallow.py
from marshmallow import Schema, fields, validate, validates, ValidationError
from .models import User
from application import message

# 手机号码验证构造器
class MobileSchema(Schema):
    mobile = fields.String(required=True, validate=validate.Regexp("^1[3-9]\d{9}$", error=message.mobile_format_error))

    # 判断手机号是否被注册过
    @validates('mobile')
    def validate_mobile(self,data):
        # 传入data数据就是手机号, 查看数据库中此号码是否存在
        user = User.query.filter(User.mobile == data).first()
        if user:
            raise ValidationError(message=message.mobile_is_use)
        # 必须有返回值
        return data
  1. 编写视图路由 application/apps/users/urls.py
from application import path, api_rpc
# 引入当前蓝图应用视图 , 引入rpc视图
from . import views, api

# 蓝图路径与函数映射列表
urlpatterns = []

# rpc方法与函数映射列表[rpc接口列表]
apipatterns = [
    api_rpc('check_mobile', api.check_mobile)
]

我们已经针对已经完成的接口编写测试用例并为这个接口编写一个接口文档。方便与其他的同事协同开发,和将来项目的维护。

测试用例

安装requests,代码:

pip install requests -i https://pypi.douban.com/simple
  1. 创建一个测试基类application/utils/unittest.py,代码:
import unittest, json, requests, uuid

# 测试基类
class BasicTestCase(unittest.TestCase):
    # 访问路径
    BASE_URL = 'http://127.0.0.1:5000/api'

    # post方法请求
    def post(self, data):
        # 携带的请求数据
        content = {
            "jsonrpc": "2.0",
            "id": uuid.uuid4().__str__(),
            "method":"",
            "params":{}
        }
        # 传入的数据覆盖
        content.update(data)
        # 发送post请求
        res = requests.post(self.BASE_URL, json.dumps(content))
        self.result = res

        return res

    # 响应数据
    @property
    def response(self)
        return json.loads(self.result.content)
  1. 为了方便其他文件调用, 在项目引入文件application/__init__.py中导入测试基类
from application.utils.unittest import BasicTestCase
  1. 在蓝图应用编写测试用例 application/apps/users/test.py
# 引入测试基类
from application import BasicTestCase

# 测试手机号的测试用例
class CheckMobile(BasicTestCase):
    # 定义的函数必须是test开头
    def test_check_mobile(self):
        # 携带请求数据
        data = {
            # 请求方法路径
            "method": "Users.check_mobile",
            # 传入参数
            "params": {"mobile":"13312345677"}
        }

        self.post(data) # 父类post请求
        print(self.response)

        # 验证响应数据判断请求是否正确
        self.assertIn("result", self.response) # 包含
        self.assertIn("errmsg", self.response["result"])
        # 响应状态码是否相等, 1000代表成功
        self.assertEqual(1000, self.response["result"]["errno"])
  1. 自定义终端命令,启动单元测试。application/utils/commands.py,代码:
import unittest

# 单元测试用例命令
class UnitTestServer(Command):
    # 生成命令名称
    name = 'test'
    def __call__(self, app, *args, **kwargs):
        # 循环注册蓝图列表
        for test_path in app.config.get('INSTALL_BLUEPRINT'):
            # 自动找到目录下的test.py测试文件并导入
            tests = unittest.TestLoader().discover(test_path)
            # 运行测试用例
            unittest.TextTestRunner(verbosity=2).run(tests)

OK,接下来我们就可以在终端下,测试当前用例是否能成功调试api接口。

python manage.py test
api接口文档

使用showdoc工具进行编写

查看效果: https://www.showdoc.com.cn/mofangapi?page_id=0

访问密码:123456

客户端进行手机号验证

html/register.html,代码:

<!DOCTYPE html>
<html>
<head>
	<title>注册</title>
	<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
	<meta charset="utf-8">
	<link rel="stylesheet" href="../static/css/main.css">
	<script src="../static/js/vue.js"></script>
	<script src="../static/js/axios.js"></script>
	<script src="../static/js/uuid.js"></script>
	<script src="../static/js/main.js"></script>
</head>
<body>
	<div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../static/images/player.png">
    <div class="bg">
			<img src="../static/images/bg0.jpg">
		</div>
		<div class="form">
			<div class="form-title">
				<img src="../static/images/register.png">
				<img class="back" @click="backpage" src="../static/images/back.png">
			</div>
			<div class="form-data">
				<div class="form-data-bg">
					<img src="../static/images/bg1.png">
				</div>
				<div class="form-item">
					<label class="text">手机</label>
					<input type="text" v-model="mobile" placeholder="请输入手机号">
				</div>
				<div class="form-item">
					<label class="text">验证码</label>
					<input type="text" class="code" v-model="code" placeholder="请输入验证码">
					<img class="refresh" src="../static/images/refresh.png">
				</div>
				<div class="form-item">
					<label class="text">密码</label>
					<input type="password" v-model="password" placeholder="请输入密码">
				</div>
				<div class="form-item">
					<label class="text">确认密码</label>
					<input type="password" v-model="password2" placeholder="请再次输入密码">
				</div>
				<div class="form-item">
					<input type="checkbox" class="agree" v-model="agree" checked>
					<label><span class="agree_text">同意磨方《用户协议》和《隐私协议》</span></label>
				</div>
				<div class="form-item">
					<img class="commit" @click="game.play_music('../static/mp3/btn1.mp3')" src="../static/images/commit.png"/>
				</div>
			</div>
		</div>
	</div>
	<script>
	apiready = function(){
    var game = new Game("../static/mp3/bg3.mp3");
    Vue.prototype.game = game;
		new Vue({
			el:"#app",
			data(){
				return {
          music_play:true,
					mobile:'',
					code:'',
					password:'',
					password2:'',
					agree:true,
				}
			},
			watch:{
        music_play(){
          if(this.music_play){
            this.game.play_music("../static/mp3/bg3.mp3");
          }else{
            this.game.stop_music();
          }
        },

				// 监听手机号码输入是否正确
				mobile(){
					// 先本地校验,通过再服务端校验
					if(this.check_mobile_format()){
						alert('dkjks')
						this.http_check_mobile()
					}
				},
      },
			methods:{
				// 返回上一页,本质是关闭当前页面
				backpage(){
					this.game.closeFrame()
				},

				// 本地校验手机号
				check_mobile_format(){
					return /^1[3-9]\d{9}$/.test(this.mobile)
				},

				// 远程服务端校验手机号,发送post请求
				http_check_mobile(){
					let self = this
					// 验证手机号是否被注册
					this.game.post(this,{
						method:"Users.check_mobile",
						params:{"mobile":this.mobile},
						success(response){
							let data = response.data;
							if(data.result && data.result.errno === 1001){
								self.game.tips('该手机号已经被注册过!')
							}
						}
					});
				},

			}
		})
	}
	</script>
</body>
</html>

axios初始化

在客户单请求过程中, 我们需要设置id作为请求唯一标识, 同时, 将来在客户端项目中多个页面都会继续使用到上面的初始化代码,所以我们一并抽取这部分代码到另一个static/js/main.js文件中,调用Game类进行实例化时调用init方法进行axios初始化.

class Game{
	constructor(bg_music){
		// 构造函数,相当于python中类的__init__方法
		this.init();
		if(bg_music){
    	this.play_music(bg_music);
		}
	}
	init(){
		// 初始化
		console.log("系统初始化");
    this.rem(); // 自适配方案,根据当前受屏幕,自动调整rem单位
		this.init_config(); //初始化配置
		this.init_http(); // 初始化http网络请求
	}

  rem(){
    if(window.innerWidth<1200){
			this.UIWidth = document.documentElement.clientWidth;
      this.UIHeight = document.documentElement.clientHeight;
      document.documentElement.style.fontSize = (0.01*this.UIWidth*3)+"px";
			document.querySelector("#app").style.height=this.UIHeight+"px"
    }
    window.onresize = ()=>{
      if(window.innerWidth<1200){
        this.UIWidth = document.documentElement.clientWidth;
        this.UIHeight = document.documentElement.clientHeight;
        document.documentElement.style.fontSize = (0.01*this.UIWidth*3)+"px";
      }
    }
  }

	// 初始化配置
	init_config(){
		// 客户端项目的全局配置
		this.config = {
			// 服务端API地址
			API_SERVER:"http://192.168.189.138:5000/api"
		}
	}

	// 初始化http网络请求
	init_http(){
		// ajax初始化
		if(window.axios){
			axios.defaults.baseURL = this.config.API_SERVER // 接口网关地址
			axios.defaults.timeout = 2500 // 请求超时时间
			axios.defaults.withCredentials = false // 跨域请求时禁止携带cookie
			// 请求拦截器和响应拦截器相当于中间件作用
			// 1. 添加请求拦截器
			axios.interceptors.request.use((config) => {
				// 请求正确
				// 在发送请求之前的初始化[添加请求头],config就是本次请求数据对象
				// 显示进度提示框
				api.showProgress({
				    style: 'default', 	// 进度提示框风格
				    animationType: 'zoom', // 动画类型 缩放
				    title: '努力加载中...', // 标题
				    text: '请稍等...',		// 内容
				    modal: true  //是否模态,模态时整个页面将不可交互
				});
				return config // 返回对象

			}, (error) => {
				// 请求错误, 隐藏进度提示框
				api.hideProgress();
				// 弹出错误提示框
				this.tips("网络错误!!");

				// 返回
				return Promise.reject(error);
			});

			// 2. 添加响应拦截器 - 找出响应数据错误
			axios.interceptors.response.use((response) => {
				// 关闭进度提示框
				api.hideProgress();
				// 判断接口返回状态码
				if(response.data && response.data.error){
					// 服务器报错
					let error = response.data.error;
					switch (error.code) {
						case -32601: // 请求接口不存在
							this.tips("请求地址不存在!");

							break;
						case 500:
							this.tips("服务端程序执行错误!\n" + error.message);

							break;
					}

					if(response.data && response.data.result){
						// 判断请求唯一标识是否一致
						if(axios.uuid != response.data.id){
							this.tips("请求拦截错误!");

							return false;
						}
					}

					let result = response.data.resut;
					if(result.errno != 1000){
						api.toast(this.tips(result.errmsg));
					}
				//	return response  // 没有错误的话,返回响应数据
				}
				return response  // 没有错误的话,返回响应数据

			}, (error) => {
				// 关闭进度提示框
				api.hideProgress();
				// 网络错误提示
				switch (error.message) {
					case "Network Error":
						this.tips('网络错误!!');

						break;
				}
				return Promise.reject(error);

			});

			if(Vue){
				// js语法: prototype 向对象添加属性和方法
				Vue.prototype.axios = axios;
			}

			if(window.UUID){
				// prototype 向对象添加属性和方法
				Vue.prototype.uuid = UUID.generate;
			}

		}

	}

	// 窗口提示
	tips(msg, duration = 5000, location = "top"){
		// 参数: 提示信息 - 时间  - 显示位置(上中下)
		let params = {
			msg: msg,
			duration: duration,
			location: location
		}
		api.toast(params)
	}

	// 网络发送post请求
	post(vm, data){
		// 基于axios发送post请求
		vm.axios.uuid = vm.uuid();
		vm.axios.post("", {
			"jsonrpc": "2.0",
			"id": vm.axios.uuid,
			"method": data.method,
			"params": data.params
		}, data.header).then(
			data.success
		).catch(
			data.fail
		);
	}

	print(data){
		// 打印数据
		console.log(JSON.stringify(data));
	}
	stop_music(){
		this.print("停止背景音乐");
		document.body.removeChild(this.audio);
	}
  play_music(src){
		this.print("播放背景音乐");
    this.audio = document.createElement("audio");
    this.source = document.createElement("source");
    this.source.type = "audio/mp3";
    this.audio.autoplay = "autoplay";
    this.source.src=src;
    this.audio.appendChild(this.source);

		document.body.appendChild(this.audio);

		// 自动暂停关闭背景音乐
    var t = setInterval(()=>{
      if(this.audio.readyState > 0){
        if(this.audio.ended){
          clearInterval(t);
          document.body.removeChild(this.audio);
        }
      }
    },100);
  }

	//创建窗口
	openWin(name,url,pageParam){
		if(!pageParam){
			pageParam = {}
		}
		api.openWin({
			name: name,           // 自定义窗口名称,如果是新建窗口,则名字必须是第一次出现的名字。
			bounces: false,       // 窗口是否上下拉动
			reload: true,         // 如果窗口已经在之前被打开了,是否要重新加载当前窗口中的页面
			url: url,             // 窗口创建时展示的html页面的本地路径[相对于当前代码所在文件的路径]
			animation:{           // 打开新建窗口时的过渡动画效果
				type:"push",                //动画类型(详见动画类型常量)
				subType:"from_right",       //动画子类型(详见动画子类型常量)
				duration:300               //动画过渡时间,默认300毫秒
			},
			pageParam: pageParam,
		});
	}

	// 关闭指定窗口
	closeWin(name=''){
		let params
		if(name !== ''){
			params = {
				name:name,
			}
		}
		api.closeWin(params);
	}

	// 创建帧页面
	openFrame(name,url,pageParam){
		if(!pageParam){
			pageParam = {}
		}
		api.openFrame({
			name: name,		// 帧页面的名称
			url: url,	// 帧页面打开的url地址
			bounces:false,        // 页面是否可以下拉拖动
			reload: true,         // 帧页面如果已经存在,是否重新刷新加载
			useWKWebView:true,    // 是否使用WKWebView来加载页面
			historyGestureEnabled:true,  // 是否可以通过手势来进行历史记录前进后退,只在useWKWebView参数为true时有效
			vScrollBarEnabled: false,	// 是否显示垂直滚动条
			hScrollBarEnabled: false,	// 是否显示水平滚动条

			animation:{
					type:"push",             //动画类型(详见动画类型常量)
				subType:"from_right",    //动画子类型(详见动画子类型常量)
				duration:300             //动画过渡时间,默认300毫秒
			},
			rect: {               // 当前帧的宽高范围
					// 方式1,设置矩形大小宽高
					x: 0,             // 左上角x轴坐标
					y: 0,             // 左上角y轴坐标
					w: 'auto',        // 当前帧页面的宽度, auto表示满屏
					h: 'auto'         // 当前帧页面的高度, auto表示满屏
			// 方式2,设置矩形大小宽高
					// marginLeft:,    //相对父页面左外边距的距离,数字类型
					// marginTop:,     //相对父页面上外边距的距离,数字类型
					// marginBottom:,  //相对父页面下外边距的距离,数字类型
					// marginRight:    //相对父页面右外边距的距离,数字类型
			},
			pageParam: {          // 要传递新建帧页面的参数,在新页面可通过 api.pageParam.name 获取
					name: pageParam      // name只是举例, 可以传递任意自定义参数
			}
	});
	}

	// 关闭帧页面
	closeFrame(name=''){
		let params
		if(name !== ''){
			params = {
				name: name
			}
		}
		api.closeFrame(params);
	}
}


保存用户注册信息接口

  1. 创建Marshmallow构造器[暂时不涉及到手机验证码功能],users/marshmallow.py,代码:
from marshmallow import Schema, fields, validate, validates, ValidationError, post_load, validates_schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field

from .models import User
from application import message,db,code

# 手机号码验证构造器
class MobileSchema(Schema):
    mobile = fields.String(required=True, validate=validate.Regexp("^1[3-9]\d{9}$", error=message.mobile_format_error))

    # 判断手机号是否被注册过
    @validates('mobile')
    def validate_mobile(self,data):
        # 传入data数据就是手机号, 查看数据库中此号码是否存在
        user = User.query.filter(User.mobile == data).first()
        if user:
            raise ValidationError(message=message.mobile_is_use)
        # 必须有返回值
        return data

# 用户数据校验模型构造器
class UserSchema(SQLAlchemyAutoSchema):
    mobile = auto_field(required=True)
    password = fields.String(required=True, load_only=True)
    password2 = fields.String(required=True, load_only=True)
    sms_code = fields.String(required=True, load_only=True)

    class Meta:
        model = User
        include_fk = False  # 启用外键关系
        include_relationships = True    # 模型关系外部属性
        # 如果要返回客户端用户模型的全部字段,就不要声明fields或exclude字段即可
        fields = ['id', 'name', 'mobile', 'password', 'password2', 'sms_code']

    @post_load
    def save_object(self, data, **kwargs):
        """保存用户基本信息"""
        # 删除不必要的字段
        data.pop('password2')
        data.pop('sms_code')
        data['name'] = data['mobile']

        instance = User(**data)
        db.session.add(instance)
        db.session.commit()

        return instance


    @validates_schema
    def validate_password(self, data, **kwargs):
        # 校验两次密码是否输入正确
        if data['password'] != data['password2']:
            raise ValidationError(message=message.password_not_match, field_name='password')

        # todo 校验短信验证码
        return data

  1. 视图 : users/api.py,代码:
from flask import current_app

# 引入构造器(序列化器)
from .marshmallow import MobileSchema, ValidationError, UserSchema
# 引入返回信息,和自定义状态码
from application import message, code

# 校验手机号
def check_mobile(mobile):
    # 实例化构造器对象
    ms = MobileSchema()
    try:
        # load反序列化校验数据
        ms.load({'mobile':mobile})
        res = {'errno': code.CODE_OK, 'errmsg':message.ok}
    except ValidationError as e:
        print(e.messages) # {'mobile': ['手机号码格式有误']}
        res = {'errno': code.CODE_VALIDATE_ERROR, 'errmsg': e.messages['mobile'][0]}
    return res

# 用户注册
def register(mobile:str, password:str, password2:str, sms_code):
    """
    用户注册基本信息
    :param mobile: 手机号码
    :param password: 登录密码
    :param password2: 确认密码
    :param sms_code: 短信验证码
    :return:
    """
    # 1.验证手机是否已经注册
    res = check_mobile(mobile)
    if res['errno'] != code.CODE_OK:
        return res

    # 2.验证并保存用户信息
    try:
        urs = UserSchema()
        # 反序列化校验数据
        instance = urs.load({
            'mobile':mobile,
            'password':password,
            'password2':password2,
            'sms_code':sms_code
        })

        res = {'errno':code.CODE_OK, 'errmsg':message.ok, 'data':urs.dump(instance)}

    # 数据验证异常
    except ValidationError as e:
        # 验证码错误
        if e.messages.get('sms_code'):
            errmsg = e.messages['sms_code'][0]
        # 两次密码不一致
        elif e.messages.get('password'):
            errmsg = e.messages['password'][0]
        else:
            errmsg = message.check_data_fail

        res = {'errno':code.CODE_VALIDATE_ERROR, 'errmsg':errmsg}

    # 其他异常
    except Exception as e:
        # 打印错误日志
        current_app.log.error('服务端程序发生未知异常!')
        current_app.log.error(f'错误信息: {e}')

        res = {'errno':code.CODE_SERVER_ERROR, 'errmsg':message.server_is_error}

    return res

  1. 路由,users/urls.py,代码:
from application import path, api_rpc
# 引入当前蓝图应用视图 , 引入rpc视图
from . import views, api

# 蓝图路径与函数映射列表
urlpatterns = []

# rpc方法与函数映射列表[rpc接口列表]
apipatterns = [
    api_rpc('check_mobile', api.check_mobile),
    api_rpc('register', api.register),
]

  1. 文本提示信息和提示码

application/utils/message.py,代码:

"""提示文本信息"""

ok = '成功!!'
mobile_format_error = '手机号码格式有误'
mobile_is_use = '手机号已经被注册'
password_not_match = '两次密码输入不一致'
check_data_fail = '数据验证失败'
server_is_error = '服务端程序出错!'

application/utils/code.py,代码:

"""自定义响应状态码"""

CODE_OK = 1000                  # 成功
CODE_VALIDATE_ERROR = 1001      # 数据验证错误
CODE_SERVER_ERROR = 1002       # 服务端程序错误

基于Faker生成随机的用户名

安装Faker模块

pip install faker

  1. application/__init__.py中进行随机数据生成初始化
# 先内置,后官方,然后第三方,接着是自己的。
import os,sys

from flask import Flask
from flask_script import Manager
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
from flask_session import Session
from flask_jsonrpc import JSONRPC
from flask_marshmallow import Marshmallow
from faker import Faker

from application.utils.config import init_config
from application.utils.logger import Log
from application.utils.commands import load_commands
from application.utils.blueprint import register_blueprint,path,include,api
from application.utils import message,code
# 终端脚本工具初始化
manage = Manager()

# redis初始化
redis_cache = FlaskRedis(config_prefix="REDIS")
redis_check = FlaskRedis(config_prefix="CHECK")
redis_session = FlaskRedis(config_prefix="SESSION")

# SQLAlchemy初始化
db = SQLAlchemy()

# session存储配置初始化
session_store = Session()

# 自定义日志初始化
logger = Log()

# JSONRPC实例化
jsonrpc = JSONRPC()

# 初始化Mashmallow
marshmallow = Marshmallow()

faker = Faker(locale="zh_CN")

def init_app(config_path):
    """用于创建app实例对象并完成初始化过程的工厂函数"""
    app = Flask(__name__)
    # 当前项目根目录
    app.BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    # 加载配置
    init_config(app, config_path)

    # SQLAlchemy加载配置
    db.init_app(app)

    # marshmallow加载配置
    marshmallow.init_app(app)

    # redis加载配置
    redis_cache.init_app(app)
    redis_check.init_app(app)
    redis_session.init_app(app)

    # session保存数据到redis时启用的链接对象
    app.config["SESSION_REDIS"] = redis_session
    # session存储配置类加载配置
    session_store.init_app(app)

    # 日志加载配置
    log = logger.init_app(app)
    app.log = log

    # jsonrpc加载配置
    jsonrpc.init_app(app)
    jsonrpc.service_url = app.config.get("JSON_SERVER_URL", "/api")
    jsonrpc.enable_web_browsable_api = app.config.get("ENABLE_WEB_BROWSABLE_API",False)
    app.jsonrpc = jsonrpc

    # 自动注册蓝图
    register_blueprint(app)

    # db创建数据表
    with app.app_context():
        db.create_all()

    # 终端脚本工具加载配置
    manage.app = app
    # 自动注册自定义命令
    load_commands(manage) # 导入默认的那个命令文件
    return manage

  1. 调整构造器中生成用户名,users/marshmallow.py,代码:
from marshmallow import Schema, fields, validate, validates, ValidationError, post_load, validates_schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field

from .models import User
from application import message,db,code, faker

# 手机号码验证构造器
class MobileSchema(Schema):
    mobile = fields.String(required=True, validate=validate.Regexp("^1[3-9]\d{9}$", error=message.mobile_format_error))

    # 判断手机号是否被注册过
    @validates('mobile')
    def validate_mobile(self,data):
        # 传入data数据就是手机号, 查看数据库中此号码是否存在
        user = User.query.filter(User.mobile == data).first()
        if user:
            raise ValidationError(message=message.mobile_is_use)
        # 必须有返回值
        return data

# 用户注册数据校验模型构造器
class UserSchema(SQLAlchemyAutoSchema):
    mobile = auto_field(required=True, load_only=True)
    password = fields.String(required=True, load_only=True)
    password2 = fields.String(required=True, load_only=True)
    sms_code = fields.String(required=True, load_only=True)

    class Meta:
        model = User
        include_fk = False  # 启用外键关系
        include_relationships = True    # 模型关系外部属性
        # 如果要返回客户端用户模型的全部字段,就不要声明fields或exclude字段即可
        fields = ['id', 'name', 'mobile', 'password', 'password2', 'sms_code']

    @post_load
    def save_object(self, data, **kwargs):
        """保存用户基本信息"""
        # 删除不必要的字段
        data.pop('password2')
        data.pop('sms_code')
        data['name'] = faker.name() # 使用随机生成的名字作为用户初始姓名

        instance = User(**data)
        db.session.add(instance)
        db.session.commit()

        return instance


    @validates_schema
    def validate_password(self, data, **kwargs):
        # 校验两次密码是否输入正确
        if data['password'] != data['password2']:
            raise ValidationError(message=message.password_not_match, field_name='password')

        # todo 校验短信验证码
        return data

分离数据服务层

开发中,往往我们存储数据的地方不仅仅是一个mysql,随着项目的开发进展,将来会遇到越来越多的数据,不同的数据的结构和用途以及使用的频率上来看,并非所有的数据都适合使用mysql来保存,所以我们将来项目中肯定也会把数据保存到其他地方,例如:mongoDB,mysql,es,等等。那现在我们针对数据的保存代码如果全部分散卸载验证器,视图等地方的话,不利于将来项目的维护。所以,大部分公司在视图操作模型的中间再次构建一个数据服务层(数据提供者),往往就是一个单独文件,这个文件一般名为:data.py,services.py,bridge.py等,表示视图需要调用此文件中声明的工具方法来调用数据。数据服务层对接的就可能是mysql,或者是其他数据库,而这个过程,视图是不需要关心的。

users.services,代码:

from .models import db, User


def get_user_by_mobile(mobile: str)->User:
    """
    根据手机号码获取用户信息
    :param mobile: 手机号码
    :return: 用户模型对象
    """
    user = User.query.filter(User.mobile == mobile).first()
    return user


def add_user(data: dict)->User:
    """
    新增用户信息
    :param data: 字典
    :return: 用户模型对象
    """
    instance = User(**data)
    db.session.add(instance)
    db.session.commit()
    return instance


users.marshmallow,代码:

from marshmallow import Schema, fields, validate, validates, ValidationError, validates_schema, post_load
from marshmallow_sqlalchemy import auto_field

from application import message, db, marshmallow as MA, faker
from .models import User
from .services import get_user_by_mobile, add_user


class MobileSchema(Schema):
    """手机号转换器"""
    mobile = fields.String(required=True, validate=validate.Regexp("^1[3-9]\d{9}$", error=message.mobile_format_error))

    @validates("mobile")
    def validate_mobile(self, data):
        """验证手机号是否已经被注册"""
        user = get_user_by_mobile(mobile=data)
        if user:
            raise ValidationError(message=message.mobile_is_use)
        return data


class UserSchema(MA.SQLAlchemyAutoSchema):
    mobile = auto_field(required=True, load_only=True)
    # 此处,因为模型中定义的密码的真正字段是_password,而对外暴露的字段实际上是password这个属性方法,所以我们需要单独声明
    password = fields.String(required=True, load_only=True)
    password2 = fields.String(required=True, load_only=True)
    sms_code = fields.String(required=True, load_only=True)

    class Meta:
        model = User
        include_fk = False  # 启用外键关系
        include_relationships = True  # 模型关系外部属性
        # 如果要返回客户端用户模型的全部字段,就不要声明fields或exclude字段即可
        fields = ["id", "name", "mobile", "password", "password2", "sms_code"]

    @post_load
    def save_object(self, data, **kwargs):
        """保存用户基本信息"""
        data.pop("password2")
        data.pop("sms_code")
        # data["name"] = data["mobile"]
        data["name"] = faker.name()
        return add_user(data)

    @validates_schema
    def validate(self, data, **kwargs):
        # 校验密码和确认密码
        if data["password"] != data["password2"]:
            raise ValidationError(message=message.password_not_match, field_name="password")

        # todo 校验短信验证码
        return data

测试用例,users.test,代码:

from application.utils.unittest import BasicTestCase

class CheckMobile(BasicTestCase):
    """测试手机号的用例"""
    def test_check_mobile(self):
        data = {
            "method": "Users.mobile",
            "params": {"mobile":"13312345677"}
        }
        self.post(data)
        print(self.response)
        self.assertIn("result", self.response)
        self.assertIn("errmsg", self.response["result"])
        self.assertEqual(1000, self.response["result"]["errno"])

class UserRegister(BasicTestCase):
    """测试用户注册的用例"""
    def test_user_register(self):
        data = {
            "method": "Users.register",
            # 多行代码并作一行 Ctrl+Shift+J
            "params": {"sms_code": "555555", "mobile": "13412311674", "password": "123456", "password2": "123456" }
        }
        self.post(data)
        print(self.response)
        self.assertIn("result", self.response)
        self.assertIn("errmsg", self.response["result"])
        self.assertEqual(1000, self.response["result"]["errno"])

客户端发送用户数据进行注册

  1. 向服务端发送数据: html/register.html,代码:
<!DOCTYPE html>
<html>
<head>
	<title>注册</title>
	<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
	<meta charset="utf-8">
	<link rel="stylesheet" href="../static/css/main.css">
	<script src="../static/js/vue.js"></script>
	<script src="../static/js/axios.js"></script>
	<script src="../static/js/uuid.js"></script>
	<script src="../static/js/main.js"></script>
</head>
<body>
	<div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../static/images/player.png">
    <div class="bg">
			<img src="../static/images/bg0.jpg">
		</div>
		<div class="form">
			<div class="form-title">
				<img src="../static/images/register.png">
				<img class="back" @click="backpage" src="../static/images/back.png">
			</div>
			<div class="form-data">
				<div class="form-data-bg">
					<img src="../static/images/bg1.png">
				</div>
				<div class="form-item">
					<label class="text">手机</label>
					<input type="text" v-model="mobile" placeholder="请输入手机号">
				</div>
				<div class="form-item">
					<label class="text">验证码</label>
					<input type="text" class="code" v-model="code" placeholder="请输入验证码">
					<img class="refresh" src="../static/images/refresh.png">
				</div>
				<div class="form-item">
					<label class="text">密码</label>
					<input type="password" v-model="password" placeholder="请输入密码">
				</div>
				<div class="form-item">
					<label class="text">确认密码</label>
					<input type="password" v-model="password2" placeholder="请再次输入密码">
				</div>
				<div class="form-item">
					<input type="checkbox" class="agree" v-model="agree" checked>
					<label><span class="agree_text">同意磨方《用户协议》和《隐私协议》</span></label>
				</div>
				<div class="form-item">
					<img class="commit" @click="registerhandle" src="../static/images/commit.png"/>
				</div>
			</div>
		</div>
	</div>
	<script>
	apiready = function(){
    var game = new Game("../static/mp3/bg3.mp3");
    Vue.prototype.game = game;
		new Vue({
			el:"#app",
			data(){
				return {
          music_play:true,
					mobile:'',
					code:'',
					password:'',
					password2:'',
					agree:true,
				}
			},
			watch:{
        music_play(){
          if(this.music_play){
            this.game.play_music("../static/mp3/bg3.mp3");
          }else{
            this.game.stop_music();
          }
        },

				// 监听手机号码输入是否正确
				mobile(){
					// 先本地校验,通过再服务端校验
					if(this.check_mobile_format()){
						// alert('dkjks')
						this.http_check_mobile()
					}else{

					}
				},
      },
			methods:{
				// 返回上一页,本质是关闭当前页面
				backpage(){
					this.game.closeFrame()
				},

				// 本地校验手机号
				check_mobile_format(){
					return /^1[3-9]\d{9}$/.test(this.mobile)
				},

				// 远程服务端校验手机号,发送post请求
				http_check_mobile(){
					let self = this
					// 验证手机号是否被注册
					this.game.post(this,{
						method:"Users.check_mobile",
						params:{"mobile":this.mobile},
						success(response){
							let data = response.data;
							if(data.result && data.result.errno === 1001){
								self.game.tips('该手机号已经被注册过!')
							}
						}
					});
				},

				registerhandle(){
					// 点击按钮时的声音
					this.game.play_music('../static/mp3/btn1.mp3')
					// 客户端进行数据验证
					if(!this.check_mobile_format(this.mobile)){
						// 弹窗提示
						this.game.tips('手机号码格式不正确!');
						return false;
					}
					if(this.password.length < 8 || this.password.length > 16){
						this.game.tips('密码必须是8~16位!');
						return false;
					}
					if(this.password != this.password2){
						this.game.tips('两次密码输入不正确!');
						return false;
					}
					if(this.code.length != 6){
						this.game.tips('验证码格式不正确!');
						return false;
					}
					if(!this.agree){
						this.game.tips("对不起,必须同意魔方APP用户协议和隐私协议才可以进行用户注册");
						return false;
					}

					// 向服务端发送请求
					let self = this
					this.game.post(this,{
						method:"Users.register",
						params:{
							"mobile":self.mobile,
							"password":self.password,
							"password2":self.password2,
							"sms_code":self.code
						},
						success(response){
							if(response.data && response.data.result && response.data.result.errno === 1000){
								self.game.tips('注册成功!')
							}
						}
					})
				},

			}
		})
	}
	</script>
</body>
</html>


  1. 在客户端项目中新增html/user.html用户中心,在注册成功以后进行页面跳转,代码:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  用户中心
</body>
</html>


  1. 用户注册成功以后,进行页面跳转,html/regsiter.html,代码:
<!DOCTYPE html>
<html>
<head>
	<title>注册</title>
	<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
	<meta charset="utf-8">
	<link rel="stylesheet" href="../static/css/main.css">
	<script src="../static/js/vue.js"></script>
	<script src="../static/js/axios.js"></script>
	<script src="../static/js/uuid.js"></script>
	<script src="../static/js/main.js"></script>
</head>
<body>
	<div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../static/images/player.png">
    <div class="bg">
			<img src="../static/images/bg0.jpg">
		</div>
		<div class="form">
			<div class="form-title">
				<img src="../static/images/register.png">
				<img class="back" @click="backpage" src="../static/images/back.png">
			</div>
			<div class="form-data">
				<div class="form-data-bg">
					<img src="../static/images/bg1.png">
				</div>
				<div class="form-item">
					<label class="text">手机</label>
					<input type="text" v-model="mobile" placeholder="请输入手机号">
				</div>
				<div class="form-item">
					<label class="text">验证码</label>
					<input type="text" class="code" v-model="code" placeholder="请输入验证码">
					<img class="refresh" src="../static/images/refresh.png">
				</div>
				<div class="form-item">
					<label class="text">密码</label>
					<input type="password" v-model="password" placeholder="请输入密码">
				</div>
				<div class="form-item">
					<label class="text">确认密码</label>
					<input type="password" v-model="password2" placeholder="请再次输入密码">
				</div>
				<div class="form-item">
					<input type="checkbox" class="agree" v-model="agree" checked>
					<label><span class="agree_text">同意磨方《用户协议》和《隐私协议》</span></label>
				</div>
				<div class="form-item">
					<img class="commit" @click="registerhandle" src="../static/images/commit.png"/>
				</div>
			</div>
		</div>
	</div>
	<script>
	apiready = function(){
		Vue.prototype.game = new Game("../static/mp3/bg1.mp3");
		new Vue({
			el:"#app",
			data(){
				return {
          music_play:true,
					mobile:"13312345600",
					code:"4567",
					password:"123456",
					password2:"123456",
					agree:true,
				}
			},
			watch:{
        music_play(){
          if(this.music_play){
            this.game.play_music("../static/mp3/bg1.mp3");
          }else{
            this.game.stop_music();
          }
        },
				mobile(){
					// 验证手机号
					if( this.check_mobile_format() ){
						// 发送ajax请求判断手机号
						this.http_check_mobile();
					}
				}
      },
			methods:{
				backpage(){
					// 返回上一页,本质就是关闭当前页面,不需要传递参数
					this.game.closeFrame();
				},
				check_mobile_format(){
					return /^1[3-9]\d{9}$/.test(this.mobile);
				},
				// 远程服务端校验手机号,发送post请求
				http_check_mobile(){
					let self = this
					// 验证手机号是否被注册
					this.game.post(this,{
						method:"Users.check_mobile",
						params:{"mobile":this.mobile},
						success(response){
							let data = response.data;
							if(data.result && data.result.errno === 1001){
								self.game.tips('该手机号已经被注册过!')
							}
						}
					});
				},
                
				registerhandle(){
					// 点击按钮的声音
					this.game.play_music('../static/mp3/btn1.mp3');
					// 本地验证数据 vue-validator插件,可以很方便的进行验证数据
					// 双向验证模式:
					// 1. 客户端为了提高用户体验,较少不必要的服务端请求,会验证一遍。
					// 2. 服务端在接受数据保存到数据库前为了避免出现脏数据,为了安全考虑,会验证第二遍
					if(!this.check_mobile_format(this.mobile)){
						this.game.tips("手机号码格式不正确!");
						return false;
					}
					if(this.password.length<6 || this.password.length>16){
						this.game.tips("密码长度必须在6~16位字符之间!");
						return false;
					}
					if(this.password!=this.password2){
						this.game.tips("密码或确认密码有误!");
						return false;
					}
					if(this.code.length<4){
						this.game.tips("短信验证码有误!");
						return false;
					}
					if(!this.agree){
						this.game.tips("对不起,必须同意魔方APP用户协议和隐私协议才可以进行用户注册");
						return false;
					}

					// 发送请求
					let self = this;
					this.game.post(this,{
						method:"Users.register",
						params:{
							"mobile": self.mobile,
							"password": self.password,
							"password2": self.password2,
							"sms_code": self.code,
						},
						success(response){
							if(response.data && response.data.result && response.data.result.errno === 1000){
								self.game.tips("注册成功!");
								// 打开用户中心窗口
								self.game.openWin("user","user.html");
								// 关闭登录注册窗口
								// self.game.closeWin();
							}
						}
					});
				}
			}
		})
	}
	</script>
</body>
</html>

  1. 因为我们在打开用户中心的时候如果立刻关闭login登录窗口会导致user用户中心窗口无法顺利打开。所以我们可以进行延时关闭。html/register.html,代码:
<!DOCTYPE html>
<html>
<head>
	<title>注册</title>
	<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
	<meta charset="utf-8">
	<link rel="stylesheet" href="../static/css/main.css">
	<script src="../static/js/vue.js"></script>
	<script src="../static/js/axios.js"></script>
	<script src="../static/js/uuid.js"></script>
	<script src="../static/js/main.js"></script>
</head>
<body>
	<div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../static/images/player.png">
    <div class="bg">
			<img src="../static/images/bg0.jpg">
		</div>
		<div class="form">
			<div class="form-title">
				<img src="../static/images/register.png">
				<img class="back" @click="backpage" src="../static/images/back.png">
			</div>
			<div class="form-data">
				<div class="form-data-bg">
					<img src="../static/images/bg1.png">
				</div>
				<div class="form-item">
					<label class="text">手机</label>
					<input type="text" v-model="mobile" placeholder="请输入手机号">
				</div>
				<div class="form-item">
					<label class="text">验证码</label>
					<input type="text" class="code" v-model="code" placeholder="请输入验证码">
					<img class="refresh" src="../static/images/refresh.png">
				</div>
				<div class="form-item">
					<label class="text">密码</label>
					<input type="password" v-model="password" placeholder="请输入密码">
				</div>
				<div class="form-item">
					<label class="text">确认密码</label>
					<input type="password" v-model="password2" placeholder="请再次输入密码">
				</div>
				<div class="form-item">
					<input type="checkbox" class="agree" v-model="agree" checked>
					<label><span class="agree_text">同意磨方《用户协议》和《隐私协议》</span></label>
				</div>
				<div class="form-item">
					<img class="commit" @click="registerhandle" src="../static/images/commit.png"/>
				</div>
			</div>
		</div>
	</div>
	<script>
	apiready = function(){
    var game = new Game("../static/mp3/bg3.mp3");
    Vue.prototype.game = game;
		new Vue({
			el:"#app",
			data(){
				return {
          music_play:true,
					mobile:'',
					code:'',
					password:'',
					password2:'',
					agree:true,
				}
			},
			watch:{
        music_play(){
          if(this.music_play){
            this.game.play_music("../static/mp3/bg3.mp3");
          }else{
            this.game.stop_music();
          }
        },

				// 监听手机号码输入是否正确
				mobile(){
					// 先本地校验,通过再服务端校验
					if(this.check_mobile_format()){
						// alert('dkjks')
						this.http_check_mobile()
					}else{

					}
				},
      },
			methods:{
				// 返回上一页,本质是关闭当前页面
				backpage(){
					this.game.closeFrame()
				},

				// 本地校验手机号
				check_mobile_format(){
					return /^1[3-9]\d{9}$/.test(this.mobile)
				},

				// 远程服务端校验手机号,发送post请求
				http_check_mobile(){
					let self = this
					// 验证手机号是否被注册
					this.game.post(this,{
						method:"Users.check_mobile",
						params:{"mobile":this.mobile},
						success(response){
							let data = response.data;
							if(data.result && data.result.errno === 1001){
								self.game.tips('该手机号已经被注册过!')
							}
						}
					});
				},

				registerhandle(){
					// 点击按钮时的声音
					this.game.play_music('../static/mp3/btn1.mp3')
					// 客户端进行数据验证
					if(!this.check_mobile_format(this.mobile)){
						// 弹窗提示
						this.game.tips('手机号码格式不正确!');
						return false;
					}
					if(this.password.length < 8 || this.password.length > 16){
						this.game.tips('密码必须是8~16位!');
						return false;
					}
					if(this.password != this.password2){
						this.game.tips('两次密码输入不正确!');
						return false;
					}
					if(this.code.length != 6){
						this.game.tips('验证码格式不正确!');
						return false;
					}
					if(!this.agree){
						this.game.tips("对不起,必须同意魔方APP用户协议和隐私协议才可以进行用户注册");
						return false;
					}

					// 向服务端发送请求
					let self = this
					this.game.post(this,{
						method:"Users.register",
						params:{
							"mobile":self.mobile,
							"password":self.password,
							"password2":self.password2,
							"sms_code":self.code
						},
						success(response){
							if(response.data && response.data.result && response.data.result.errno === 1000){
								self.game.tips('注册成功!')
								// 打开(跳转)用户中心窗口
								self.game.openWin('user', 'user.html')
								// 延时关闭当前登陆窗口
								setTimeout(() => {
									self.game.closeWin()
								},300)
							}
						}
					})
				},

			}
		})
	}
	</script>
</body>
</html>

全局事件通知 - 注册成功

因为项目中用户登录或者注册或者的行为时,总会有导致其他页面需要切换数据或者进行初始化的时候,那么我们可以使用APICloud提供的全局事件通知,基于自定义事件操作,向整个项目发送通知,当将来有其他页面需要获取/监听这个事件时,我们可以直接进行监听操作。

发送全局事件通知/全局事件广播,代码:

api.sendEvent({
    name: 'myEvent',     // 我们自定义事件名称,采用变量命名规范
    extra: {
        key1: 'value1',  // 附带参数
        key2: 'value2'
    }
});


其他页面将来需要获取/监听这个广播/通知时,仅仅需要在代码中执行以下监听操作即可。

api.addEventListener({
    name: 'myEvent'
}, function(ret, err) {
    alert(JSON.stringify(ret.value));
});


OK,接下来,我们就可以在main.js中封装一个发送全局事件通知的方法,static/js/main.js,代码:

class Game {
	// 发送APP全局事件通知/全局事件广播
	sendEvent(name, data){
		api.sendEvent({
		    name: name,
		    extra: data
		});
	}
}

html/register.html 发送注册成功广播,代码:

<!DOCTYPE html>
<html>
<head>
	<title>注册</title>
	<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
	<meta charset="utf-8">
	<link rel="stylesheet" href="../static/css/main.css">
	<script src="../static/js/vue.js"></script>
	<script src="../static/js/axios.js"></script>
	<script src="../static/js/uuid.js"></script>
	<script src="../static/js/main.js"></script>
</head>
<body>
	<div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../static/images/player.png">
    <div class="bg">
			<img src="../static/images/bg0.jpg">
		</div>
		<div class="form">
			<div class="form-title">
				<img src="../static/images/register.png">
				<img class="back" @click="backpage" src="../static/images/back.png">
			</div>
			<div class="form-data">
				<div class="form-data-bg">
					<img src="../static/images/bg1.png">
				</div>
				<div class="form-item">
					<label class="text">手机</label>
					<input type="text" v-model="mobile" placeholder="请输入手机号">
				</div>
				<div class="form-item">
					<label class="text">验证码</label>
					<input type="text" class="code" v-model="code" placeholder="请输入验证码">
					<img class="refresh" src="../static/images/refresh.png">
				</div>
				<div class="form-item">
					<label class="text">密码</label>
					<input type="password" v-model="password" placeholder="请输入密码">
				</div>
				<div class="form-item">
					<label class="text">确认密码</label>
					<input type="password" v-model="password2" placeholder="请再次输入密码">
				</div>
				<div class="form-item">
					<input type="checkbox" class="agree" v-model="agree" checked>
					<label><span class="agree_text">同意磨方《用户协议》和《隐私协议》</span></label>
				</div>
				<div class="form-item">
					<img class="commit" @click="registerhandle" src="../static/images/commit.png"/>
				</div>
			</div>
		</div>
	</div>
	<script>
	apiready = function(){
		Vue.prototype.game = new Game("../static/mp3/bg1.mp3");
		new Vue({
			el:"#app",
			data(){
				return {
          music_play:true,
					mobile:"13312345600",
					code:"4567",
					password:"123456",
					password2:"123456",
					agree:true,
				}
			},
			watch:{
        music_play(){
          if(this.music_play){
            this.game.play_music("../static/mp3/bg1.mp3");
          }else{
            this.game.stop_music();
          }
        },
				mobile(){
					// 验证手机号
					if( this.check_mobile_format() ){
						// 发送ajax请求判断手机号
						this.http_check_mobile();
					}
				}
      },
			methods:{
				backpage(){
					// 返回上一页,本质就是关闭当前页面,不需要传递参数
					this.game.closeFrame();
				},
				check_mobile_format(){
					return /^1[3-9]\d{9}$/.test(this.mobile);
				},
				// 远程服务端校验手机号,发送post请求
				http_check_mobile(){
					let self = this
					// 验证手机号是否被注册
					this.game.post(this,{
						method:"Users.check_mobile",
						params:{"mobile":this.mobile},
						success(response){
							let data = response.data;
							if(data.result && data.result.errno === 1001){
								self.game.tips('该手机号已经被注册过!')
							}
						}
					});
				},
                
                // 点击提交,向服务端发送注册请求
				registerhandle(){
					// 点击按钮的声音
					this.game.play_music('../static/mp3/btn1.mp3');
					// 本地验证数据 vue-validator插件,可以很方便的进行验证数据
					// 双向验证模式:
					// 1. 客户端为了提高用户体验,较少不必要的服务端请求,会验证一遍。
					// 2. 服务端在接受数据保存到数据库前为了避免出现脏数据,为了安全考虑,会验证第二遍
					if(!this.check_mobile_format(this.mobile)){
						this.game.tips("手机号码格式不正确!");
						return false;
					}
					if(this.password.length<6 || this.password.length>16){
						this.game.tips("密码长度必须在6~16位字符之间!");
						return false;
					}
					if(this.password!=this.password2){
						this.game.tips("密码或确认密码有误!");
						return false;
					}
					if(this.code.length<4){
						this.game.tips("短信验证码有误!");
						return false;
					}
					if(!this.agree){
						this.game.tips("对不起,必须同意魔方APP用户协议和隐私协议才可以进行用户注册");
						return false;
					}

					// 发送请求
					let self = this;
					this.game.post(this,{
						method:"Users.register",
						params:{
							"mobile": self.mobile,
							"password": self.password,
							"password2": self.password2,
							"sms_code": self.code,
						},
						success(response){
							if(response.data && response.data.result && response.data.result.errno === 1000){
								self.game.tips("注册成功!");
								setTimeout(()=>{
									// 打开用户中心窗口
									self.game.openWin("user","user.html");
									setTimeout(()=>{
										// 发送事件广播,第二个参数就是服务端返回的data数据
										self.game.sendEvent("user_register_success",response.data.result.data);
									},500);

								},3000);
							}
						}
					});
				}
			}
		})
	}
	</script>
</body>
</html>

html/user.html 接收注册成功事件广播,代码(注意:当前写的代码仅用于测试而已,后面会替换掉):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <h1>用户中心</h1>
  <script>
    apiready = () => {
      // 接收用户注册广播
      api.addEventListener({
        name:'register_success'
      }, (ret,err) => {
        if(ret){
          alert(JSON.stringify(ret))
        }
        api.closeWin({
            name: 'login'
        });

      });
    }

  </script>
</body>
</html>

短信验证

使用云通讯发送短信

官方文档:https://www.yuntongxun.com/member/main

在登录后的平台上面获取以下信息:

ACCOUNT SID:8aaf0708780055cd0178252959bb0e5d
AUTH TOKEN : 0e3f59a9f6fa4cf2944ca18e9c2e363d
AppID(默认):8aaf0708780055cd017825295ae60e63
Rest URL(短信服务器): https://app.cloopen.com:8883

在开发过程中,为了节约发送短信的成本, 可以把自己的或者开发组的同事的手机加入到测试号码中.

项目中安装sdk

pip install ronglian_sms_sdk


服务端实现发送短信验证码的api接口

application/settings/dev.py,配置文件中填写短信接口相关配置,代码:

"""容联云通讯短信验证配置"""
SMS_ACCOUNT_ID = "8aaf0708780055cd0178252959bb0e5d" # 接口主账号
SMS_ACCOUNT_TOKEN = "0e3f59a9f6fa4cf2944ca18e9c2e363d" # 认证token令牌
SMS_APP_ID = "8aaf0708780055cd017825295ae60e63" # 应用ID
SMS_TEMPLATE_ID = 1 # 短信模板ID
SMS_EXPIRE_TIME = 60 * 5 # 短信有效时间,单位:秒/s
SMS_INTERVAL_TIME = 60 # 短信发送冷却时间,单位:秒/s

短信属于公共业务,所以在此我们把功能接口写在Home蓝图下或者单独创建一个Auth认证相关的蓝图。

接下来,我们创建一个Home公共蓝图(之前创建的就不需要继续创建了):

cd application/apps
python ../../manage.py blue -nhome


注册蓝图,application/settings/dev.py,代码:

"""蓝图列表"""
INSTALL_BLUEPRINT = [
    "application.apps.users",  # 用户
    "application.apps.home",   # 公共蓝图
]


注册路由,application/urls.py,代码:

from application import include

urlpatterns = [
    include("", "home.urls"),  # 公共蓝图
    include("/users", "users.urls"),  # 用户
]

安装orjson模块,这是一个rust语言编写的高性能json解析模块,与官方的json模块比起来,性能更加强悍!

pip install orjson


视图: application.apps.home.api,代码:

import re, random, orjson

from ronglian_sms_sdk import SmsSDK
from flask import current_app

from application import message,code,redis_check

# 短信发送接口
class SMS():

    # 初始化sdk,短信发送对象
    # sdk = SmsSDK(accId, accToken, appId)
    @property
    def sdk(self):
        sdk = SmsSDK(
            current_app.config.get('SMS_ACCOUNT_ID'),
            current_app.config.get('SMS_ACCOUNT_TOKEN'),
            current_app.config.get('SMS_APP_ID')
        )
        return sdk

    # 发送短信验证码
    def send_sms_code(self, mobile):
        # 1.验证手机号格式
        if not re.match('^1[3-9]\d{9}$', mobile):
            return {
                'errno':code.CODE_VALIDATE_ERROR,
                'errmsg':message.mobile_format_error
            }

        # 2.查询当前手机号 - 冷却时间内不能再发送短信
        ret = redis_check.get('int_%s'%mobile)
        if ret is not None:
            return {
                'errno': code.CODE_INTERVAL_TIME,
                'errmsg': message.sms_interval_time
            }

        # 3.生成验证码
        sms_code = '%06d' % random.randint(0,999999)

        # 4.发送短信验证码
        ret = self.sdk.sendMessage(
            current_app.config.get('SMS_TEMPLATE_ID'),# 短信模板ID
            mobile,
            # 模板变量信息,元组类型 (验证码,有效时间)
            (sms_code, current_app.config.get('SMS_EXPIRE_TIME')//60)
        )

        # 发送短信后得到的响应结果
        result = orjson.loads(ret)

        # 5.判断验证码是否发送成功 - 000000代表成功
        if result['statusCode'] == '000000':
            # 事物管理,保存数据
            pipe = redis_check.pipeline() # 操作redis的事务必须依赖于管道对象pipeline
            pipe.multi() # 开启事务

            # 保存短信记录到redis中 - 有效时间
            pipe.setex('sms_%s' % mobile, current_app.config.get('SMS_EXPIRE_TIME'), sms_code)
            # 冷却倒计时
            pipe.setex('int_%s' % mobile, current_app.config.get('SMS_INTERVAL_TIME'), '_')
            pipe.execute() # 提交事务

            return {
                'errno': code.CODE_OK,
                'errmsg': message.ok
            }
        else:
            return {
                'errno': code.CODE_SMS_FAIL,
                'errmsg': message.sms_send_fail
            }

子路由 home/urls.py

from application import path, api_rpc
# 引入当前蓝图应用视图, 引入rpc视图
from . import views, api

# 蓝图路径与函数映射列表
urlpatterns = [
    # path('/index', views.index, methods=['post']),
]
# rpc方法与函数映射列表[rpc接口列表]
sms = api.SMS() # 实例化类视图
apipatterns = [
    api_rpc('sms', sms.send_sms_code), # 类映射
]

自定义响应状态码,application/utils/code.py,代码:

"""自定义响应状态码"""

CODE_OK = 1000                  # 成功
CODE_VALIDATE_ERROR = 1001      # 数据验证错误
CODE_SERVER_ERROR = 1002       # 服务端程序错误
CODE_SMS_FAIL = 1003            # 短信发送失败
CODE_INTERVAL_TIME = 1004      # 短信验证码再冷却时间内

提示文本信息,application/utils/message.py,代码:

"""提示文本信息"""

ok = '成功!!'
mobile_format_error = '手机号码格式有误'
mobile_is_use = '手机号已经被注册'
password_not_match = '两次密码输入不一致'
check_data_fail = '数据验证失败'
server_is_error = '服务端程序出错!'
sms_interval_time = '冷却时间内,验证码不能重新发送!'
sms_send_fail = '短信发送失败!'


短信验证测试用例

home/test.py,代码:

from application import BasicTestCase

# 测试短信验证码用例
class CheckSMS(BasicTestCase):
    def test_send_sms_code(self):
        data = {
            "method":'Home.sms',
            "params":{"mobile":"17600351804"}
        }
        # 基于父类方法发送请求
        self.post(data)

        # 判断是否发送成功
        self.assertIn("result", self.response)
        self.assertIn("errmsg", self.response['result'])
        self.assertEqual(1000, self.response['result']['errno'])

客户端实现点击发送短信

register.html,代码:

<!DOCTYPE html>
<html>
<head>
	<title>注册</title>
	<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
	<meta charset="utf-8">
	<link rel="stylesheet" href="../static/css/main.css">
	<script src="../static/js/vue.js"></script>
	<script src="../static/js/axios.js"></script>
	<script src="../static/js/uuid.js"></script>
	<script src="../static/js/main.js"></script>
</head>
<body>
	<div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../static/images/player.png">
    <div class="bg">
			<img src="../static/images/bg0.jpg">
		</div>
		<div class="form">
			<div class="form-title">
				<img src="../static/images/register.png">
				<img class="back" @click="backpage" src="../static/images/back.png">
			</div>
			<div class="form-data">
				<div class="form-data-bg">
					<img src="../static/images/bg1.png">
				</div>
				<div class="form-item">
					<label class="text">手机</label>
					<input type="text" v-model="mobile" placeholder="请输入手机号">
				</div>
				<div class="form-item">
					<label class="text">验证码</label>
					<input type="text" class="code" v-model="code" placeholder="请输入验证码">
					<img class="refresh" @click='send_sms' src="../static/images/refresh.png">
				</div>
				<div class="form-item">
					<label class="text">密码</label>
					<input type="password" v-model="password" placeholder="请输入密码">
				</div>
				<div class="form-item">
					<label class="text">确认密码</label>
					<input type="password" v-model="password2" placeholder="请再次输入密码">
				</div>
				<div class="form-item">
					<input type="checkbox" class="agree" v-model="agree" checked>
					<label><span class="agree_text">同意磨方《用户协议》和《隐私协议》</span></label>
				</div>
				<div class="form-item">
					<img class="commit" @click="registerhandle" src="../static/images/commit.png"/>
				</div>
			</div>
		</div>
	</div>
	<script>
	apiready = function(){
    var game = new Game("../static/mp3/bg3.mp3");
    Vue.prototype.game = game;
		new Vue({
			el:"#app",
			data(){
				return {
          			music_play:true,
					mobile:'',
					code:'', // 验证码
					password:'',
					password2:'',
					agree:true,
					sms_interval:false, // 是否处于短信发送间隔冷却时间内
				}
			},
			watch:{
                music_play(){
                  if(this.music_play){
                    this.game.play_music("../static/mp3/bg3.mp3");
                  }else{
                    this.game.stop_music();
                  }
                },

				// 监听手机号码输入是否正确
				mobile(){
					// 先本地校验,通过再服务端校验
					if(this.check_mobile_format()){
						// alert('dkjks')
						this.http_check_mobile()
					}else{

					}
				},
      		},
			methods:{
				// 返回上一页,本质是关闭当前页面
				backpage(){
					this.game.closeFrame()
				},

				// 本地校验手机号
				check_mobile_format(){
					return /^1[3-9]\d{9}$/.test(this.mobile)
				},

				// 远程服务端校验手机号,发送post请求
				http_check_mobile(){
					let self = this
					// 验证手机号是否被注册
					this.game.post(this,{
						method:"Users.check_mobile",
						params:{"mobile":this.mobile},
						success(response){
							let data = response.data;
							if(data.result && data.result.errno === 1001){
								self.game.tips('该手机号已经被注册过!')
							}
						}
					});
				},

				// 点击提交,向服务端发送注册请求
				registerhandle(){
					// 点击按钮时的声音
					this.game.play_music('../static/mp3/btn1.mp3')
					// 客户端进行数据验证
					if(!this.check_mobile_format(this.mobile)){
						this.game.tips('手机号码格式不正确')
					}
					// 限定密码格式
					if(this.password.length < 8 || this.password.length > 16){
						this.game.tips('密码必须是8~16位!');
						return false;
					}
					if(this.password != this.password2){
						this.game.tips('两次密码输入不正确!');
						return false;
					}
					if(this.code.length != 6){
						this.game.tips('验证码格式不正确!');
						return false;
					}
					if(!this.agree){
						this.game.tips("对不起,必须同意魔方APP用户协议和隐私协议才可以进行用户注册");
						return false;
					}
					// 向服务端发送请求
					let self = this
					this.game.post(this,{
						method:"Users.register",
						params:{
							"mobile":self.mobile,
							"password":self.password,
							"password2":self.password2,
							"sms_code":self.code
						},
						success(response){
							let data = response.data
							if(data.result && data.result.errno === 1001){
                                self.game.tips('验证码输入有误!');
                                return false;
                              }
							if(data.result && data.result.errno === 1000){
								self.game.tips('注册成功!')
								setTimeout(() =>{
									// 打开(跳转)用户中心窗口
									self.game.openWin('user', 'user.html')
									setTimeout(() => {
										// 发送注册成功事件广播,第二个参数就是服务端返回的data数据
										self.game.sendEvent('register_success',response.data.result.data)
									},500);
								}, 2000);
							}
						}
					})
				},

				// 发送短信验证码
				send_sms(){
					// 点击按钮时的声音
					this.game.play_music('../static/mp3/btn1.mp3')
					// 客户端进行数据验证
					if(!this.check_mobile_format(this.mobile)){
						// 弹窗提示
						this.game.tips('手机号码格式不正确!');
						return false;
					}
					if(this.sms_interval){
						this.game.tips('短信发送过于频繁!');
						return false;
					}
					// 发送短信,获取验证码
					let self = this
					this.game.post(this,{
						method:'Home.sms',
						params:{'mobile':self.mobile},
						success(response){
							if(response.data && response.data.result && response.data.result.errno === 1000){
								self.game.tips('短信发送成功!')
								self.sms_interval = true  // 发送短信进入冷却状态
								let time = self.game.config.SMS_TIME_OUT // 获取冷却时间
								// 设置定时器 - 冷却倒计时
								let timer = setInterval(() => {
									if(--time < 1){
										self.sms_interval = false; // 退出冷却状态,允许再次发送短信
										clearInterval(timer);      // 关闭定时器
									}
								}, 1000)
							}
						}
					})
				},

			}
		})
	}
	</script>
</body>
</html>


static/js/main.js中, 增加短信发送的冷却时间的配置信息,Game类下面的init_config方法,代码:

    init_config() {
        // 客户端项目的全局配置
        this.config = {
            API_SERVER: "http://192.168.233.129:5000/api", // 服务端API地址
            SMS_TIME_OUT: 60,  // 短信发送冷却时间/秒
        }
    }

完成短信验证码的校验

application.apps.ursers.marshmallow,代码:

from marshmallow import Schema, fields, validate, validates, ValidationError, validates_schema, post_load
from marshmallow_sqlalchemy import auto_field

from application import message, db, marshmallow as MA, faker,redis_check
from .models import User
from .services import get_user_by_mobile, add_user


class MobileSchema(Schema):
    """手机号转换器"""
    mobile = fields.String(required=True, validate=validate.Regexp("^1[3-9]\d{9}$", error=message.mobile_format_error))

    @validates("mobile")
    def validate_mobile(self, data):
        """验证手机号是否已经被注册"""
        user = get_user_by_mobile(mobile=data)
        if user:
            raise ValidationError(message=message.mobile_is_use)
        return data


class UserSchema(MA.SQLAlchemyAutoSchema):
    mobile = auto_field(required=True, load_only=True)
    # 此处,因为模型中定义的密码的真正字段是_password,而对外暴露的字段实际上是password这个属性方法,所以我们需要单独声明
    password = fields.String(required=True, load_only=True)
    password2 = fields.String(required=True, load_only=True)
    sms_code = fields.String(required=True, load_only=True)

    class Meta:
        model = User
        include_fk = False  # 启用外键关系
        include_relationships = True  # 模型关系外部属性
        # 如果要返回客户端用户模型的全部字段,就不要声明fields或exclude字段即可
        fields = ["id", "name", "mobile", "password", "password2", "sms_code"]

    @post_load
    def save_object(self, data, **kwargs):
        """保存用户基本信息"""
        data.pop("password2")
        data.pop("sms_code")
        # data["name"] = data["mobile"]
        data["name"] = faker.name()
        return add_user(data)

    @validates_schema
    def validate(self, data, **kwargs):
        # 校验密码和确认密码
        if data["password"] != data["password2"]:
            raise ValidationError(message=message.password_not_match, field_name="password")

        # 校验短信验证码
        # 1. 从redis中提取验证码
        redis_sms_code = redis_check.get("sms_%s" % data["mobile"])
        if redis_sms_code is None:
            raise ValidationError(message=message.sms_code_expired, field_name="sms_code")

        # python从redis中提取的数据最终都是bytes类型,所以需要进行编码处理
        redis_sms_code = redis_sms_code.decode()
        # 2. 从客户端提交的数据data中提取验证码
        sms_code = data["sms_code"]
        # 3. 字符串比较,如果失败,则抛出异常,否则,直接删除验证码
        if sms_code != redis_sms_code:
            raise ValidationError(message=message.sms_code_not_match, field_name="sms_code")

        redis_check.delete("sms_%s" % data["mobile"])

        return data


提示文本信息 : application/utils/message.py, 代码:

"""提示文本信息"""

ok = '成功!!'
mobile_format_error = '手机号码格式有误'
mobile_is_use = '手机号已经被注册'
password_not_match = '两次密码输入不一致'
check_data_fail = '数据验证失败'
server_is_error = '服务端程序出错!'
sms_interval_time = '冷却时间内,验证码不能重新发送!'
sms_send_fail = '短信发送失败!'
sms_code_expired = '短信验证码已过期!'
sms_code_not_match = '验证码输入不正确!'

基于Celery实现短信异步发送

安装 celery

pip install celery==5.1.0 -i https://pypi.douban.com/simple


  1. 在项目入口程序applicaiton/__init__.py中,创建celery应用实例对象,并完成配置加载和初始化过程。代码:
import os

from flask import Flask
from flask_script import Manager
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
from flask_session import Session
from flask_jsonrpc import JSONRPC
from faker import Faker
from celery import Celery

from application.utils.config import init_config
from application.utils.logger import Log
from application.utils.commands import load_commands
from application.utils.bluerprint import register_blueprint, path, include, api_rpc
from application.utils import message, code
from application.utils.unittest import BasicTestCase

# 终端脚本工具初始化
manager = Manager()

# SQLAlchemy初始化
db = SQLAlchemy()

# redis数据库初始化
# - 1.默认缓存数据库对象,配置前缀为REDIS
redis_cache = FlaskRedis(config_prefix='REDIS')
# - 2.验证相关数据库对象,配置前缀为CHECK
redis_check = FlaskRedis(config_prefix='CHECK')
# - 3.验证相关数据库对象,配置前缀为SESSION
redis_session = FlaskRedis(config_prefix='SESSION')

# session储存配置初始化
session_store = Session()

# 自定义日志初始化
logger = Log()

# 初始化jsonrpc模块
jsonrpc = JSONRPC()

# 初始化随机生成数据模块faker
faker = Faker(locale='zh-CN') # 指定中文

# 初始化异步celery
celery = Celery()

# 全局初始化
def init_app(config_path):
    """全局初始化 - 需要传入加载开发或生产环境配置路径"""
    # 创建app应用对象
    app = Flask(__name__)

    # 当前项目根目录
    app.BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

    # 开发或生产环境加载配置
    init_config(app, config_path)

    # SQLAlchemy加载配置
    db.init_app(app)

    # redis加载配置
    redis_cache.init_app(app)
    redis_check.init_app(app)
    redis_session.init_app(app)

    """一定先加载默认配置,再传入APP加载session对象"""
    # session保存数据到redis时启用的链接对象
    app.config["SESSION_REDIS"] = redis_session
    # session存储对象加载配置
    session_store.init_app(app)

    # 为日志对象加载配置
    log = logger.init_app(app)
    app.log = log

    # json-rpc加载配置
    jsonrpc.init_app(app)
    # rpc访问路径入口(只有唯一一个访问路径入口),默认/api
    jsonrpc.service_url = app.config.get('JSON_SERVER_URL', '/api')
    jsonrpc.enable_web_browsable_api = app.config.get("ENABLE_WEB_BROWSABLE_API",False)
    app.jsonrpc = jsonrpc

    # 自动注册蓝图
    register_blueprint(app)

    # 加载celery配置
    celery.main = app.name
    celery.app = app
    # 更新配置
    celery.conf.update(app.config)
    # 自动注册任务
    celery.autodiscover_tasks(app.config.get('INSTALL_BLUEPRINT'))


    # 注册模型,创建表
    with app.app_context():
        db.create_all()

    # 终端脚本工具加载配置
    manager.app = app

    # 自动注册自定义命令
    load_commands(manager)

    return manager

  1. celery配置,application/settings/dev.py,代码:
"""开发环境配置"""

# 调试模式
DEBUG = True

"""SQLAlchemy数据库配置"""
# 数据库链接
SQLALCHEMY_DATABASE_URI = 'mysql://mofanguser:mofang@127.0.0.1:3306/mofang?charset=utf8mb4'
# 查询时会显示原始SQL语句
SQLALCHEMY_ECHO = False

"""redis数据库配置"""
# 默认缓存数据库 - 0号库
REDIS_URL = 'redis://:@127.0.0.1:6379/0'
# 验证相关数据 - 1号库
CHECK_URL = 'redis://:@127.0.0.1:6379/1'
# session储存数据库 - 2号库
SESSION_URL = 'redis://:@127.0.0.1:6379/2'

"""session数据储存到Redis的配置"""
# 配置session链接方式
SESSION_TYPE = 'redis'
# 如果设置session的生命周期是否是会话期, 为True,则关闭浏览器session就失效
SESSION_PERMANENT = False
# 设置session_id在浏览器中的cookie有效期
PERMANENT_SESSION_LIFETIME = 24 * 60 * 60  # session 的有效期,单位是秒
# 是否对发送到浏览器上session的cookie值进行加密
SESSION_USE_SIGNER = True
# 保存到redis的session数的名称前缀
SESSION_KEY_PREFIX = "session:"

"""日志基本信息配置"""
# LOG_LEVEL = 'INFO'              # 日志输出到文件中的等级
LOG_LEVEL = 'DEBUG'              # 日志输出到文件中的等级
LOG_DIR = '/logs/mofang.log'    # 日志存储路径
LOG_BACKPU_COUNT = 20           # 日志文件的最大备份数量
LOG_NAME = 'mofang'             # 日志器的名字,flask的名为:flask.app

"""待注册的蓝图应用路径列表"""
INSTALL_BLUEPRINT = [
    'application.apps.users', # 用户
    'application.apps.home', # 公共蓝图
]

"""JSONRPC配置"""
# api访问路径的入口
JSON_SERVER_URL = "/api"
# 是否允许通过浏览器访问api接口列表
ENABLE_WEB_BROWSABLE_API = True
# 是否自动补充当前蓝图名称作为api接口访问前缀,如访问名称 index,则变成 "Home.index",Home就是当前视图所在蓝图(首字母自动大写)
JSON_PREFIX_NAME = True

"""容联云通讯短信验证配置"""
SMS_ACCOUNT_ID = "8aaf0708780055cd0178252959bb0e5d" # 接口主账号
SMS_ACCOUNT_TOKEN = "0e3f59a9f6fa4cf2944ca18e9c2e363d" # 认证token令牌
SMS_APP_ID = "8aaf0708780055cd017825295ae60e63" # 应用ID
SMS_TEMPLATE_ID = 1 # 短信模板ID
SMS_EXPIRE_TIME = 60 * 5 # 短信有效时间,单位:秒/s
SMS_INTERVAL_TIME = 60 # 短信发送冷却时间,单位:秒/s

"""Celery异步配置"""
# 某些情况下可以防止死锁
CELERY_FORCE_EXECV = True
# 设置并发的worker数量
CELERYD_CONCURRENCY = 20
# 设置失败允许重试
CELERY_ACKS_LATE = True
# 每个worker最多执行500个任务被销毁,可以防止内存泄漏
CELERYD_MAX_TASKS_PER_CHILD = 500
# 单个任务的最大运行时间,超时会被杀死
CELERYD_TIME_LIMIT = 10 * 60
# 任务发出后,经过一段时间还未收到acknowledge , 就将任务重新交给其他worker执行
CELERY_DISABLE_RATE_LIMITS = True
# celery的任务结果内容格式
CELERY_ACCEPT_CONTENT = ['json', 'pickle']
# celery的任务队列地址 transport:
BROKER_URL = "redis://127.0.0.1:6379/15"
# celery的结果队列地址 results:
CELERY_RESULT_BACKEND = "redis://127.0.0.1:6379/14"
# celery的定时任务调度器配置
BEAT_SCHEDULE = {
    # "test": {
    #     "task": "get_sendback",
    #     "schedule": 10,
    # }
}

  1. 启动文件中引入celery实例对象,manage.py,代码:
from application import init_app, celery

# 开发环境配置路径
config_path = 'application.settings.dev'

# 引入终端命令实例化对象
manager = init_app(config_path)
app = manager.app

@app.route('/')
def index():
    app.log.error('dhfskl')
    app.logger.error('dhfskl')
    return 'hello!!'

if __name__ == '__main__':
    manager.run()

  1. 创建异步任务,application/apps/home/tasks.py,代码:
from application import celery

@celery.task(name="send_sms")
def send_sms(mobile:str):
    print("发送短信~")


  1. 终端下单独启动celery, 测试效果。
# 在manage.py启动文件所在的目录下,执行这段命令
celery -A manage.celery worker -l info

编写发送短信的异步任务

在任务执行过程中, 基于监听器和任务bind属性对失败任务进行记录和重新尝试执行.

application/apps/home/tasks.py,代码:

import orjson

from ronglian_sms_sdk import SmsSDK
from celery.app.task import Task

from application import celery, redis_check

# 发送短信
@celery.task(name='send_sms', bind=True)
def send_sms(self,mobile:str, sms_code:str):
    # 实例化sdk对象
    sdk = SmsSDK(
        celery.app.config.get('SMS_ACCOUNT_ID'),
        celery.app.config.get('SMS_ACCOUNT_TOKEN'),
        celery.app.config.get('SMS_APP_ID')
    )
    try:
        # 发送短信
        ret = sdk.sendMessage(
            celery.app.config.get('SMS_TEMPLATE_ID'),  # 短信模板ID
            mobile,
            # 模板变量信息,元组类型 (验证码,有效时间)
            (sms_code, celery.app.config.get('SMS_EXPIRE_TIME') // 60)
        )

        # 发送短信后得到的响应结果json格式
        result = orjson.loads(ret)

        # 判断验证码是否发送成功 - 000000代表成功
        if result['statusCode'] == '000000':
            # 事物管理,保存数据
            pipe = redis_check.pipeline()  # 操作redis的事务必须依赖于管道对象pipeline
            pipe.multi()  # 开启事务

            # 保存短信记录到redis中 - 有效时间
            pipe.setex('sms_%s' % mobile, celery.app.config.get('SMS_EXPIRE_TIME'), sms_code)
            # 冷却倒计时
            pipe.setex('int_%s' % mobile, celery.app.config.get('SMS_INTERVAL_TIME'), '_')
            pipe.execute()  # 提交事务

            return result
        else:
            celery.app.log.error("短信发送失败!\r\n%s" % ret)
            raise Exception
    except Exception as exc:
        # 发生异常,每隔3秒尝试重新执行,一共5次
        self.retry(exc=exc, countdown=3, max_retries=5)

# 把任务任务方法封装类中
class SMSTask(Task):
    def on_success(self, retval, task_id, args, kwargs):
        print( '任务执行成功!')
        return super().on_success(retval, task_id, args, kwargs)

    def on_failure(self, exc, task_id, args, kwargs, einfo):
        print('任务执行失败!%s' % self.request.retries)
        # 重新尝试执行失败任务,时间间隔:3秒,最大尝试次数:5次
        self.retry(exc=exc, countdown=3, max_retries=5)
        return super().on_failure(exc, task_id, args, kwargs, einfo)

    def after_return(self, status, retval, task_id, args, kwargs, einfo):
        print('任务执行的回调操作,不管执行的结果是成功还是失败,都会执行这里')
        return super().after_return(status, retval, task_id, args, kwargs, einfo)

    def on_retry(self, exc, task_id, args, kwargs, einfo):
        print('当任务尝试重新执行时,会执行到这里,但是目前执行有问题')
        return super().on_retry(exc, task_id, args, kwargs, einfo)

# 发送短信
@celery.task(name='send_sms2', base=SMSTask)
def send_sms2(mobile,sms_code):
    # 实例化sdk对象
    sdk = SmsSDK(
        celery.app.config.get('SMS_ACCOUNT_ID'),
        celery.app.config.get('SMS_ACCOUNT_TOKEN'),
        celery.app.config.get('SMS_APP_ID')
    )
    try:
        # 发送短信
        ret = sdk.sendMessage(
            celery.app.config.get('SMS_TEMPLATE_ID'),  # 短信模板ID
            mobile,
            # 模板变量信息,元组类型 (验证码,有效时间)
            (sms_code, celery.app.config.get('SMS_EXPIRE_TIME') // 60)
        )

        # 发送短信后得到的响应结果json格式
        result = orjson.loads(ret)

        # 判断验证码是否发送成功 - 000000代表成功
        if result['statusCode'] == '000000':
            # 事物管理,保存数据
            pipe = redis_check.pipeline()  # 操作redis的事务必须依赖于管道对象pipeline
            pipe.multi()  # 开启事务

            # 保存短信记录到redis中 - 有效时间
            pipe.setex('sms_%s' % mobile, celery.app.config.get('SMS_EXPIRE_TIME'), sms_code)
            # 冷却倒计时
            pipe.setex('int_%s' % mobile, celery.app.config.get('SMS_INTERVAL_TIME'), '_')
            pipe.execute()  # 提交事务

            return result
        else:
            celery.app.log.error("短信发送失败!\r\n%s" % ret)
            raise Exception
    except Exception as exc:
        celery.app.log.error("短信发送失败!\r\n%s" % exc)

视图更改: application.apps.home.api,代码:

import re, random, orjson

from ronglian_sms_sdk import SmsSDK
from flask import current_app

from application import message,code,redis_check
from .tasks import send_sms, send_sms2


# 短信发送接口
class SMS():

    # 初始化sdk,短信发送对象
    # sdk = SmsSDK(accId, accToken, appId)
    @property
    def sdk(self):
        sdk = SmsSDK(
            current_app.config.get('SMS_ACCOUNT_ID'),
            current_app.config.get('SMS_ACCOUNT_TOKEN'),
            current_app.config.get('SMS_APP_ID')
        )
        return sdk

    # 发送短信验证码
    def send_sms_code(self, mobile):
        # 1.验证手机号格式
        if not re.match('^1[3-9]\d{9}$', mobile):
            return {
                'errno':code.CODE_VALIDATE_ERROR,
                'errmsg':message.mobile_format_error
            }

        # 2.查询当前手机号 - 冷却时间内不能再发送短信
        ret = redis_check.get('int_%s'%mobile)
        if ret is not None:
            return {
                'errno': code.CODE_INTERVAL_TIME,
                'errmsg': message.sms_interval_time
            }

        # 3.生成验证码
        sms_code = '%06d' % random.randint(0,999999)

        # 4.异步发送短信
        # 立刻把任务添加任务队列,等待celery异步执行
        result = send_sms2.delay(mobile,sms_code)

        # 5.判断验证码是否发送成功
        if result:
            return {
                'errno': code.CODE_OK,
                'errmsg': message.ok
            }
        else:
            return {
                'errno': code.CODE_SMS_FAIL,
                'errmsg': message.sms_send_fail
            }

celery在终端下进行重启。并且在mofang项目调用异步任务发送短信

celery -A manage.celery worker -l info

posted @ 2021-06-07 21:57  十九分快乐  阅读(256)  评论(0编辑  收藏  举报