flask-03

用户模块

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

 

用户注册

创建并注册用户蓝图

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

创建并注册用户蓝图以及路由信息。

./command.sh blue users

application/settings/dev.py,代码:

from typing import List
​
"""蓝图列表"""
INSTALL_BLUEPRINT: List = [
    "application.apps.home",
    "application.apps.users",
]
View Code

 

application.urls,代码:

from typing import List
from application import path
​
urlpatterns: List = [
    path("/home", "home.urls"),
    path("/users", "users.urls"),
]
View Code

 

提交版本

git add .
git commit -m "delete home any codes and create users's module"
git push
git checkout -b feature/users
 

 

用户相关模型

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="名称/标题")
    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="更新时间")
    deleted_time = db.Column(db.DateTime, default=None, comment="删除时间")
​
    def __repr__(self):
        return "<%s: %s>" % (self.__class__.__name__, self.name)
View Code
 

application.apps.users.models,代码:

from application.utils.models import BaseModel, db
from werkzeug.security import generate_password_hash, check_password_hash
​
​
class User(BaseModel):
    """用户基本信息表"""
    __tablename__ = "ym_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), default=0.0, comment="账户余额")
    credit = db.Column(db.Integer, default=0, 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", uselist=False, backref="user", primaryjoin="User.id==UserProfile.user_id",
                           foreign_keys="UserProfile.user_id")
​
    @property
    def password(self):  # user.password
        return self._password
​
    @password.setter
    def password(self, rawpwd):  # user.password = '123456'
        """密码加密"""
        self._password = generate_password_hash(rawpwd)
​
    def check_password(self, rawpwd):
        """验证密码"""
        return check_password_hash(self.password, rawpwd)
​
​
class UserProfile(BaseModel):
    """用户详情信息表"""
    __tablename__ = "ym_user_profile"
    user_id = db.Column(db.Integer, index=True, 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="现居住地址")
​
"""
 外界开发中,不过是SQLAlachemy或者django的ORM,大部分的公司都会放弃使用外键约束来关联查询数据库表。
 因为外键约束,在数据库操作过程中,需要消耗额外的维护成本来管理这个外键关系。因此在大数据的查询中,一般都会设置成逻辑外键[虚拟外键]。数据库本身维护的外键一般我们称之为 "物理外键".
"""
View Code

 

删除原来数据表,让flask重新运行项目即可创建上面模型对应的数据表了,初始化主程序中已经自动建表,application.__init__

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

 

提交版本

git add .
git commit -m "fix: add user's model"
git push --set-upstream origin feature/users
 

 

注册功能实现

手机号码唯一验证接口

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

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

from marshmallow import Schema, fields, validate, validates, ValidationError
from .models import User
from application import message
​
​
class MobileSchema(Schema):
    """手机号唯一校验序列化器"""
    mobile: fields.String = fields.String(required=True, validate=validate.Regexp(
        regex=r"^1[3-9]\d{9}$",
        error=message.mobile_format_error
    ), error_messages={"required": message.mobile_is_required})
​
    @validates("mobile")
    def validate_mobile(self, mobile: str) -> str:
        """到数据库查询验证是否曾经注册过当前手机号"""
        user: User = User.query.filter(User.mobile == mobile).first()
        if user:
            raise ValidationError(message=message.mobile_is_used, field_name="mobile")
​
        return mobile
View Code

application/utils/message.py,服务端错误提示,代码:

​
success: str = "成功!""""用户模块"""
# 注册相关
mobile_is_required: str = "手机号不能为空!"
mobile_format_error: str = "手机号格式有误!"
mobile_is_used: str = "当前手机号已经被使用!"
View Code

 

服务端接口操作返回状态码,application/utils/code.py,代码:

CODE_OK: int = 0  # 接口操作成功
CODE_VALIDATE_ERROR: int = 1001  # 验证有误!
View Code

 

application/__init__.py,引入message,方便其他地方导报,代码:

from application.utils import message, code

 

application/apps/users/api.py,视图调用序列化构造器,实现手机号验证接口,代码:

from typing import Dict, Union, Any
from . import serializers
from application import message, code
​
​
def check_mobile(mobile: str) -> Dict[str, Any]:
    """
    验证手机号格式与是否唯一
    :param mobile: 手机号码
    :return:
    """
    # 调用构造器反序列化验证
    ms: serializers.MobileSchema = serializers.MobileSchema()
    try:
        ms.load({"mobile": mobile})
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": message.success}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {'errno': code.CODE_VALIDATE_ERROR, "errmsg": e.messages["mobile"][0]}
    return result
​
View Code

 

users/urls.py,代码:

from typing import List
from application import path
from . import api
​
​
apipatterns: List = [
    path("mobile", api.check_mobile)
]
View Code

 

提交版本

git add .
git commit -m "api: check mobile"
git push

 

接口测试,可以通过自己编写单元测试来完成,也可以使用JSON-RPC提供的browse界面来完成。

访问:http://192.168.21.253:5000/api/browse/#/Users.mobile

 

客户端请求验证手机号

main.js中添加配置相关信息,保存成一个config对象,并创建生成uuid的方法。

class Game{
    constructor(bg_music){
        // 构造函数,相当于 python的 __init__方法
        this.init();
        this.play_music(bg_music);
    }
    init(){
        // 初始化函数
        console.log("系统初始化");
        this.rem();
        this.init_config();
    }
    init_config(){
        // 初始化配置
        this.config = {
            "server_api": "http://192.168.21.253:5000/api", // api服务端的网关地址
        }
    }
    print(data){
        // 打印数据
        console.log(JSON.stringify(data));
    }
​
    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";
            }
        }
    }
    stop_music(){
        // 暂停音乐
        this.print("停止")
        document.body.removeChild(this.audio);
    }
    play_music(src){
        // 播放音乐
        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);
    }
    goWin(url,pageParam){
        // 打开窗口
        const name = url.replace(".html","");
        if(name === "root"){
            api.openWin({"name":"root"});
            return;
        }
​
        api.openWin({
            name: name,             // 自定义窗口名称
            bounces: false,        // 窗口是否上下拉动
            reload: false,         // 如果页面已经在之前被打开了,是否要重新加载当前窗口中的页面
            url: url,              // 窗口创建时展示的html页面的本地路径[相对于当前代码所在文件的路径]
            animation:{            // 打开新建窗口时的过渡动画效果
                type: "push",                //动画类型(详见动画类型常量)
                subType: "from_right",       //动画子类型(详见动画子类型常量)
                duration:300                //动画过渡时间,默认300毫秒
            },
            pageParam: pageParam   // 传递给下一个窗口使用的参数.将来可以在新窗口中通过 api.pageParam.name 获取
        });
    }
    outWin(name){
        // 关闭窗口
        api.closeWin({"name": name});
    }
    goFrame(url,pageParam){
        // 打开帧页面
        const name = url.replace(".html","");
api.openFrame({
name: name,
url: url,
rect: {
x: 0,
y: 0,
w: 'auto',
h: 'auto'
    },
useWKWebView:true,
historyGestureEnabled:true,
bounces:false,
animation:{
type:"push",
subType:"from_right",
duration:300
    },
pageParam: pageParam
    });
    }
outFrame(name){
// 关闭帧页面
api.closeFrame({
        name: name,
    });
    }
uuid(){
// 生成UUID函数
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c)=>{
var r = Math.random()*16|0,v=c=='x'?r:r&0x3|0x8;
return v.toString(16);
    })
    }
}
View Code

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="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
    <div class="app" id="app">
        <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
        <div class="bg">
            <img src="../image/bg0.jpg">
        </div>
        <div class="form">
            <div class="form-title">
                <img src="../image/register.png">
                <img class="back" @click="open_login" src="../image/back.png">
            </div>
            <div class="form-data">
                <div class="form-data-bg">
                    <img src="../image/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="../image/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="re_password" placeholder="请再次输入密码">
                </div>
                <div class="form-item">
                    <input type="checkbox" class="agree" v-model="agree" checked>
                    <label><span class="agree_text">同意嘤鸣App的《用户协议》和《隐私协议》</span></label>
                </div>
                <div class="form-item">
                    <img class="commit" @click="game.play_music('../mp3/btn1.mp3')" src="../image/commit.png"/>
                </div>
            </div>
        </div>
    </div>
    <script>
    const app = Vue.createApp({
        data(){
            return {
                agree: true,
                mobile: "13012345678",
                code: "1234",
                password: "123456",
                re_password: "123456",
                music_play: true,  // 默认播放背景音乐的控制属性
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            },
            mobile(){
                // 监听,当mobile手机号格式没有问题,则请求服务端验证手机号是否已经注册了
                if(/^1[3-9]\d{9}$/.test(this.mobile)){
                    this.check_mobile();
                }
            }
        },
        methods: {
            open_login(){
                this.game.goFrame("login.html");
            },
            check_mobile(){
                // 校验手机号是否已经注册
                let uuid = this.game.uuid();
                axios.post(this.game.config.server_api, {
                    "jsonrpc": "2.0",
                    "id": uuid,
                    "method": "Users.mobile",
                    "params": {
                        "mobile": this.mobile
                    }
                }).then(response=>{
                    if(response.data.id === uuid){
                        if(response.data.result.errno !== 0){
                            api.alert({
                                title: "错误警告",
                                msg: response.data.result.errmsg,
                            })
                        }
                    }
                }).catch(error=>{
                    api.alert({
                        "title":"错误警告", 
                        msg: error.response.data.error.data.message
                    });
                })
            }
        }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
    </script>
</body>
</html>
 
View Code

 

保存用户注册信息接口

创建Marshmallow构造器[暂时不涉及到手机验证码功能]

users/serializers.py,代码:

from typing import List, Dict, Any
from marshmallow import Schema, fields, validate, validates, ValidationError, decorators
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field
from sqlalchemy.orm import scoped_session
from flask import request
from .models import User
from application import db, message
​
​
class MobileSchema(Schema):
    """手机号唯一校验序列化器"""
    mobile: fields.String = fields.String(required=True, validate=validate.Regexp(
        regex=r"^1[3-9]\d{9}$",
        error=message.mobile_format_error
    ), error_messages={"required": message.mobile_is_required})
​
    @validates("mobile")
    def validate_mobile(self, mobile: str) -> str:
        """到数据库查询验证是否曾经注册过当前手机号"""
        user: User = User.query.filter(User.mobile == mobile).first()
        if user:
            raise ValidationError(message=message.mobile_is_used, field_name="mobile")
​
        return mobile
​
​
class UserSchema(SQLAlchemyAutoSchema):
    """用户注册信息序列化器"""
    mobile: fields.String = auto_field(required=True, load_only=True, validates=validate.Regexp(
        regex="^1[3-9]\d{9}$",
        error=message.mobile_format_error
    ))
    password: fields.String = fields.String(required=True, load_only=True, validate=validate.Length(
        min=6,
        max=16,
        error=message.password_length_error
    ))
    re_password: fields.String = fields.String(required=True, load_only=True)
    sms_code: fields.String = fields.String(required=True, load_only=True, validate=validate.Length(
        min=4,
        max=4,
        error=message.sms_code_length_error
    ))
​
    class Meta:
        model: User = User
        include_fk: bool = True  # 启用外键关系
        include_relationships: bool = True  # 模型关系外部属性
        # 如果要全换全部字段,就不要声明fields或exclude字段即可
        fields: List[str] = ["id", "name", "mobile", "password", "re_password", "sms_code"]
        sqla_session: scoped_session = db.session
​
    @decorators.validates_schema
    def validates_schema(self, data, **kwargs) -> Dict[str, Any]:
        """全字段校验"""
        # 校验密码和确认密码
        if data["password"] != data["re_password"]:
            raise ValidationError(message=message.password_not_match, field_name="re_password")
​
        # todo 校验短信验证码
​
        data.pop("re_password")
        data.pop("sms_code")
        return data
​
    @decorators.post_load
    def save_object(self, data, **kwargs) -> User:
        data["name"] = data["mobile"]
        print(request.environ)
        data["ip_address"] = request.environ["REMOTE_ADDR"] # 客户端本次请求的IP地址
        user: User = User(**data)
        self.session.add(user)
        self.session.commit()
        return user
View Code
 

users/api.py,视图代码:

from typing import Dict, Union, Any
from . import serializers, models
from application import message, code

def check_mobile(mobile: str) -> Dict[str, Any]:
    """
    验证手机号格式与是否唯一
    :param mobile: 手机号码
    :return:
    """
    # 调用构造器反序列化验证
    ms: serializers.MobileSchema = serializers.MobileSchema()
    try:
        ms.load({"mobile": mobile})
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": message.success}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {'errno': code.CODE_VALIDATE_ERROR, "errmsg": e.messages["mobile"][0]}
    return result


def register(mobile: str, password: str, re_password: str, sms_code: str) -> Dict[str, Any]:
    """
    用户信息注册
    :param mobile: 手机号
    :param password: 登录密码
    :param re_password: 确认密码
    :param sms_code: 短信验证码
    :return:
    """
    # 先校验手机号
    result = check_mobile(mobile=mobile)
    if result["errno"] != 0:
        return result
    try:
        us: serializers.UserSchema = serializers.UserSchema()
        user: models.User = us.load({
            "mobile": mobile,
            "password": password,
            "re_password": re_password,
            "sms_code": sms_code
        })
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": us.dump(user)}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {"errno": code.CODE_VALIDATE_ERROR, "errmsg": e.messages}

    return result
View Code

 

users/urls.py,代码:

from typing import List
from application import path
from . import api


apipatterns: List = [
    path("mobile", api.check_mobile),
    path("register", api.register),
]
View Code

 

utils/message.py,代码:


success: str = "成功!"

"""用户模块"""
# 注册相关
mobile_is_required: str = "手机号不能为空!"
mobile_format_error: str = "手机号格式有误!"
mobile_is_used: str = "当前手机号已经被使用!"
password_length_error: str = "密码长度必须在{min}~{max}个字符之间!"
sms_code_length_error: str = "验证长度必须时{min}个字符!"
password_not_match: str = "确认密码与密码不一致!"
View Code

 

提交版本

git add .
git commit -m "api: user register"
git push
 

 

客户端发送用户进行注册

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="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
    <div class="app" id="app">
        <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
        <div class="bg">
            <img src="../image/bg0.jpg">
        </div>
        <div class="form">
            <div class="form-title">
                <img src="../image/register.png">
                <img class="back" @click="open_login" src="../image/back.png">
            </div>
            <div class="form-data">
                <div class="form-data-bg">
                    <img src="../image/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="sms_code" placeholder="请输入验证码">
                    <img class="refresh" src="../image/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="re_password" placeholder="请再次输入密码">
                </div>
                <div class="form-item">
                    <input type="checkbox" class="agree" v-model="agree" checked>
                    <label><span class="agree_text">同意嘤鸣App的《用户协议》和《隐私协议》</span></label>
                </div>
                <div class="form-item">
                    <img class="commit" @click="registerhandle" src="../image/commit.png"/>
                </div>
            </div>
        </div>
    </div>
    <script>
    const app = Vue.createApp({
        data(){
            return {
agree: true,
mobile: "13012345678",
sms_code: "1234",
password: "123456",
re_password: "123456",
                music_play: true,  // 默认播放背景音乐的控制属性
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            },
mobile(){
// 监听,当mobile手机号格式没有问题,则请求服务端验证手机号是否已经注册了
if(/^1[3-9]\d{9}$/.test(this.mobile)){
this.check_mobile();
    }
    }
        },
methods: {
open_login(){
this.game.goFrame("login.html");
    },
check_mobile(){
// 校验手机号是否已经注册
let uuid = this.game.uuid();
axios.post(this.game.config.server_api, {
"jsonrpc": "2.0",
"id": uuid,
"method": "Users.mobile",
"params": {
"mobile": this.mobile
    }
    }).then(response=>{
if(response.data.id === uuid){
if(response.data.result.errno !== 0){
api.alert({
title: "错误警告",
msg: response.data.result.errmsg,
    })
    }
    }
    }).catch(error=>{
api.alert({
"title":"错误警告", 
msg: error.response.data.error.data.message
    });
    })
    },
registerhandle(){
// 注册处理
this.game.play_music('../mp3/btn1.mp3');
// 发送请求
// 校验手机号是否已经注册
let uuid = this.game.uuid();
axios.post(this.game.config.server_api, {
"jsonrpc": "2.0",
"id": uuid,
"method": "Users.register",
"params": {
"mobile": this.mobile,
"sms_code": this.sms_code,
"password": this.password,
"re_password": this.re_password,
    }
    }).then(response=>{
if(response.data.id === uuid){
if(response.data.result.errno !== 0){
api.alert({
title: "错误警告",
msg: response.data.result.errmsg,
    })
    }else{
api.confirm({
title: '系统提示',
msg: '注册成功',
buttons: ['返回首页', '个人中心']
    }, (ret, err)=>{
var index = ret.buttonIndex;
if(index === 1){
// 跳转到首页
this.game.goWin("root");
    }else{
// 跳转到个人中心
this.game.goWin("user.html");
    }
// 2秒后关闭当前窗口
setTimeout(() => {
this.game.outWin();
    }, 2000);
    });
    }
    }
    }).catch(error=>{
api.alert({
"title":"错误警告", 
msg: error.response.data.error.data.message
    });
    })
    }
    }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
</script>
</body>
</html>
View Code

 

html/user.html,代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>用户中心!!!</h1>
</body>
</html>
View Code

 

html/index.html,新增用户中心页面user.html的链接跳转,代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>首页</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <meta name="format-detection" content="telephone=no,email=no,date=no,address=no">
    <link rel="stylesheet" href="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
  <div class="app" id="app">
    <!-- 背景音乐 -->
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
    <!--  背景图片 -->
    <div class="bg">
      <img src="../image/bg0.jpg">
    </div>
    <!-- 菜单 -->
    <ul>
      <li><img class="module1" src="../image/image1.png"></li> <!-- 果园 -->
      <li><img class="module2" @click="open_user" src="../image/image2.png"></li> <!-- 会员 -->
      <li><img class="module3" @click="get_windows" src="../image/image3.png"></li> <!-- 娱乐 -->
      <li><img class="module4" @click="open_login" src="../image/image4.png"></li> <!-- 签到 -->
    </ul>
  </div>
  <script>
    const app = Vue.createApp({
        data(){
            return {
                music_play: true,  // 默认播放背景音乐的控制属性
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            }
        },
        methods: {
            open_login(){
                this.game.goWin("login.html");
            },
            open_user(){
                this.game.goWin("user.html");
            },
            get_windows(){
                this.game.print(api.windows());
            }
        }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
    </script>
</body>
</html>
 
View Code

 

使用云通讯发送短信验证码

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

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

ACCOUNT SID:8a216da863f8e6c20164139687e80c1b
AUTH TOKEN : 6dd01b2b60104b3dbc88b2b74158bac6
AppID(默认):8a216da863f8e6c20164139688400c21
Rest URL(短信服务器): https://app.cloopen.com:8883

 

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

 

安装sdk

pip install ronglian_sms_sdk -i https://pypi.douban.com/simple

 

 

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

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

"""短信相关配置"""
SMS_ACCOUNT_ID: str = "8a216da863f8e6c20164139687e80c1b"  # 接口主账号
SMS_ACCOUNT_TOKEN: str = "6dd01b2b60104b3dbc88b2b74158bac6"  # 认证token令牌
SMS_APP_ID: str = "8a216da863f8e6c20164139688400c21"  # 应用ID
SMS_TEMPLATE_ID: int = 1  # 短信模板ID[测试短信使用的短信模板ID固定为1,模板内容:【云通讯】您的验证码是{1},请于{2}分钟内正确输入
SMS_EXPIRE_TIME: int = 60 * 10  # 短信有效时间,单位:秒/s
SMS_INTERVAL_TIME: int = 60  # 短信发送冷却时间,单位:秒/s

 

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

import random, json
from typing import Dict, Union, Any
from flask import current_app
from redis.client import Pipeline
from ronglian_sms_sdk import SmsSDK
from . import serializers, models
from application import message, code, redis_check
​
​
def check_mobile(mobile: str) -> Dict[str, Any]:
    """
    验证手机号格式与是否唯一
    :param mobile: 手机号码
    :return:
    """
    # 调用构造器反序列化验证
    ms: serializers.MobileSchema = serializers.MobileSchema()
    try:
        ms.load({"mobile": mobile})
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": message.success}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {'errno': code.CODE_VALIDATE_ERROR, "errmsg": e.messages["mobile"][0]}
    return result
​
​
def register(mobile: str, password: str, re_password: str, sms_code: str) -> Dict[str, Any]:
    """
    用户信息注册
    :param mobile: 手机号
    :param password: 登录密码
    :param re_password: 确认密码
    :param sms_code: 短信验证码
    :return:
    """
    # 先校验手机号
    result = check_mobile(mobile=mobile)
    if result["errno"] != 0:
        return result
    try:
        us: serializers.UserSchema = serializers.UserSchema()
        user: models.User = us.load({
            "mobile": mobile,
            "password": password,
            "re_password": re_password,
            "sms_code": sms_code
        })
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": us.dump(user)}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {"errno": code.CODE_VALIDATE_ERROR, "errmsg": e.messages}
​
    return result
​
​
def sms(mobile: str) -> Dict[str, Any]:
    """
       发送注册短信验证码
       :param mobile: 手机号
       :return:
       """
    # 先校验手机号
    result: Dict[str, Any] = check_mobile(mobile=mobile)
    if result["errno"] != 0:
        return result
​
    # 获取当前客户端手机号的短信发送冷却时间
    ret: int = redis_check.ttl("int_%s" % mobile)
    if ret > 0:
        return {
            "errno": code.CODE_INTERVAL_TIME,
            "errmsg": message.sms_interval_time,
            "time": ret,
        }
​
    # 生成验证码
    sms_code: str = "%04d" % random.randint(100, 9999)
​
    # 实例化SDK
    sdk: SmsSDK = SmsSDK(
        current_app.config.get("SMS_ACCOUNT_ID"),
        current_app.config.get("SMS_ACCOUNT_TOKEN"),
        current_app.config.get("SMS_APP_ID")
    )
​
    # 发送短信
    ret: str = sdk.sendMessage(
        current_app.config.get("SMS_TEMPLATE_ID"),
        mobile,
        (sms_code, current_app.config.get("SMS_EXPIRE_TIME") // 60)
    )
​
    # 获取接口操作的结果
    result: Dict[str, Any] = json.loads(ret)
    if result["statusCode"] == "000000":
        pipe: Pipeline = redis_check.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()  # 提交事务
# 返回结果
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": message.success}
    else:
        result: Dict[str, Any] = {"errno": code.CODE_SMS_ERROR, "errmsg": message.sms_send_error}
​
    return result
​
 
View Code

 

users/urls.py,代码:

from typing import List
from application import path
from . import api
​
​
apipatterns: List = [
    path("mobile", api.check_mobile),
    path("register", api.register),
    path("sms", api.sms),
]
View Code

 

message,代码:

sms_interval_time: str = "当前功能请求过于频繁!"
sms_send_error: str = "短信发送有误!"
View Code

 

code.py,代码:

CODE_OK: int = 0  # 成功
CODE_VALIDATE_ERROR: int = 1001  # 验证有误!
CODE_INTERVAL_TIME: int = 1002   # 接口操作过于频繁
​
CODE_SMS_ERROR: int = 1100       # 短信发送有误!
View Code

 

提交版本

git add .
git commit -m "api : send sms"
git push
 

 

客户端实现点击发送短信

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="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
    <div class="app" id="app">
        <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
        <div class="bg">
            <img src="../image/bg0.jpg">
        </div>
        <div class="form">
            <div class="form-title">
                <img src="../image/register.png">
                <img class="back" @click="open_login" src="../image/back.png">
            </div>
            <div class="form-data">
                <div class="form-data-bg">
                    <img src="../image/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="sms_code" placeholder="请输入验证码">
                    <img class="refresh" @click="send_sms_code" src="../image/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="re_password" placeholder="请再次输入密码">
                </div>
                <div class="form-item">
                    <input type="checkbox" class="agree" v-model="agree" checked>
                    <label><span class="agree_text">同意嘤鸣App的《用户协议》和《隐私协议》</span></label>
                </div>
                <div class="form-item">
                    <img class="commit" @click="registerhandle" src="../image/commit.png"/>
                </div>
            </div>
        </div>
    </div>
    <script>
    const app = Vue.createApp({
        data(){
            return {
agree: true,
mobile: "13928835901",
sms_code: "1234",
password: "123456",
re_password: "123456",
                music_play: true,  // 默认播放背景音乐的控制属性
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            },
mobile(){
// 监听,当mobile手机号格式没有问题,则请求服务端验证手机号是否已经注册了
if(/^1[3-9]\d{9}$/.test(this.mobile)){
this.check_mobile();
    }
    }
        },
methods: {
open_login(){
this.game.goFrame("login.html");
    },
check_mobile(){
// 校验手机号是否已经注册
let uuid = this.game.uuid();
axios.post(this.game.config.server_api, {
"jsonrpc": "2.0",
"id": uuid,
"method": "Users.mobile",
"params": {
"mobile": this.mobile
    }
    }).then(response=>{
if(response.data.id === uuid){
if(response.data.result.errno !== 0){
api.alert({
title: "错误警告",
msg: response.data.result.errmsg,
    })
    }
    }
    }).catch(error=>{
api.alert({
"title":"错误警告", 
msg: error.response.data.error.data.message
    });
    })
    },
registerhandle(){
// 注册处理
this.game.play_music('../mp3/btn1.mp3');
// 发送请求
// 校验手机号是否已经注册
let uuid = this.game.uuid();
axios.post(this.game.config.server_api, {
"jsonrpc": "2.0",
"id": uuid,
"method": "Users.register",
"params": {
"mobile": this.mobile,
"sms_code": this.sms_code,
"password": this.password,
"re_password": this.re_password,
    }
    }).then(response=>{
if(response.data.id === uuid){
if(response.data.result.errno !== 0){
api.alert({
title: "错误警告",
msg: response.data.result.errmsg,
    })
    }else{
api.confirm({
title: '系统提示',
msg: '注册成功',
buttons: ['返回首页', '个人中心']
    }, (ret, err)=>{
var index = ret.buttonIndex;
if(index === 1){
// 跳转到首页
this.game.goWin("root");
    }else{
// 跳转到个人中心
this.game.goWin("user.html");
    }
// 2秒后关闭当前窗口
setTimeout(() => {
this.game.outWin();
    }, 2000);
    });
    }
    }
    }).catch(error=>{
api.alert({
"title":"错误警告", 
msg: error.response.data.error.data.message
    });
    })
    },
send_sms_code(){
// 发送注册短信验证码
// 校验手机号是否已经注册
let uuid = this.game.uuid();
axios.post(this.game.config.server_api, {
"jsonrpc": "2.0",
"id": uuid,
"method": "Users.sms",
"params": {
"mobile": this.mobile
    }
    }).then(response=>{
if(response.data.id === uuid){
if(response.data.result.errno !== 0){
if(response.data.result.errno === 1002){
// 操作过于频繁
api.toast({
msg: `操作过于频繁,请等待${response.data.result.time}秒再次点击`,
duration: 5000,
location: 'top'
    });
    }else{
// 其他錯誤
api.toast({
duration: 5000,
location: 'top',
msg: response.data.result.errmsg,
    })
    }
​
    }else{
api.toast({
msg: '验证码发送成功!请留意手机!',
duration: 5000,
location: 'top'
    });
    }
    }
    }).catch(error=>{
api.alert({
"title":"错误警告", 
msg: error.response.data.error.data.message
    });
    })
    }
    }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
</script>
</body>
</html>
 
View Code

 

完成短信验证码的校验

application.apps.ursers.serialziers,代码:

from typing import List, Dict, Any, Optional
from marshmallow import Schema, fields, validate, validates, ValidationError, decorators
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field
from sqlalchemy.orm import scoped_session
from flask import request
from .models import User
from application import db, message, redis_check


class MobileSchema(Schema):
    """手机号唯一校验序列化器"""
    mobile: fields.String = fields.String(required=True, validate=validate.Regexp(
        regex=r"^1[3-9]\d{9}$",
        error=message.mobile_format_error
    ), error_messages={"required": message.mobile_is_required})

    @validates("mobile")
    def validate_mobile(self, mobile: str) -> str:
        """到数据库查询验证是否曾经注册过当前手机号"""
        user: User = User.query.filter(User.mobile == mobile).first()
        if user:
            raise ValidationError(message=message.mobile_is_used, field_name="mobile")

        return mobile


class UserSchema(SQLAlchemyAutoSchema):
    """用户注册信息序列化器"""
    mobile: fields.String = auto_field(required=True, load_only=True, validates=validate.Regexp(
        regex="^1[3-9]\d{9}$",
        error=message.mobile_format_error
    ))
    password: fields.String = fields.String(required=True, load_only=True, validate=validate.Length(
        min=6,
        max=16,
        error=message.password_length_error
    ))
    re_password: fields.String = fields.String(required=True, load_only=True)
    sms_code: fields.String = fields.String(required=True, load_only=True, validate=validate.Length(
        min=4,
        max=4,
        error=message.sms_code_length_error
    ))

    class Meta:
        model: User = User
        include_fk: bool = True  # 启用外键关系
        include_relationships: bool = True  # 模型关系外部属性
        # 如果要全换全部字段,就不要声明fields或exclude字段即可
        fields: List[str] = ["id", "name", "mobile", "password", "re_password", "sms_code"]
        sqla_session: scoped_session = db.session

    @decorators.validates_schema
    def validates_schema(self, data, **kwargs) -> Dict[str, Any]:
        """全字段校验"""
        # 校验密码和确认密码
        if data["password"] != data["re_password"]:
            raise ValidationError(message=message.password_not_match, field_name="re_password")

        # 校验短信验证码
        redis_sms_code: Optional[bytes] = redis_check.get(f"sms_{data['mobile']}")
        if redis_sms_code is None:
            raise ValidationError(message=message.sms_code_expired, field_name="sms_code")

        sms_code: str = redis_sms_code.decode()
        if sms_code != data["sms_code"]:
            raise ValidationError(message=message.sms_code_not_match, field_name="sms_code")

        # 3. 删除redis中的验证码
        redis_check.delete("sms_%s" % data["mobile"])

        data.pop("re_password")
        data.pop("sms_code")
        return data

    @decorators.post_load
    def save_object(self, data, **kwargs) -> User:
        data["name"] = data["mobile"]
        print(request.environ)
        data["ip_address"] = request.environ["REMOTE_ADDR"] # 客户端本次请求的IP地址
        user: User = User(**data)
        self.session.add(user)
        self.session.commit()
        return user
View Code

 

message.py, 代码:

sms_code_expired: str = "短信超时!"
sms_code_not_match: str = "短信验证码不匹配!"
View Code

 

提交版本

git add .
git commit -m "fix: virify sms code"
git push
 

 

基于Celery实现短信异步发送

Celery:python编写的异步任务框架,完成项目中一些耗时任务的异步执行。

消息中间件:queue,redis或者RabbitMQ

安装

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

 

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

from pathlib import Path
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
from flask_pymongo import PyMongo
from flask_jsonrpc import JSONRPC
from celery import Celery

from application.utils.config import Config
from application.utils.logger import Logger
from application.utils.commands import Command
from application.utils.blueprint import AutoBluePrint, path
from application.utils import message, code  # 错误提示,状态码

# 实例化配置加载类
config: Config = Config()
# 实例化SQLAlchemy
db: SQLAlchemy = SQLAlchemy()
# 实例化redis
redis_cache: FlaskRedis = FlaskRedis(config_prefix="REDIS")
redis_check: FlaskRedis = FlaskRedis(config_prefix="CHECK")

# mongoDB实例化
mongo: PyMongo = PyMongo()
# 实例化日志配置类
logger: Logger = Logger()

# 实例化终端命令管理类
command: Command = Command()

# 实例化自动化蓝图类
blueprint: AutoBluePrint = AutoBluePrint()

# 实例化jsonrpc
jsonrpc = JSONRPC()


# 实例化celery
celery = Celery()


def init_app(config_path: str) -> Flask:
    """用于创建app实例对象并完成初始化过程的工厂函数"""
    # 实例化flask应用对象
    app: Flask = Flask(__name__)
    # 全局路径常量,指向项目根目录
    app.BASE_DIR: Path = Path(__file__).resolve().parent.parent

    # 加载配置
    config.init_app(app, config_path)
    # SQLAlchemy加载配置
    db.init_app(app)
    # redis加载配置
    redis_cache.init_app(app)
    redis_check.init_app(app)
    # # pymongo加载配置
    mongo.init_app(app)

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

    # 终端命令管理类加载配置
    command.init_app(app)

    # jsonrpc注册到项目中
    # 开启rpc接口的web调试界面:/api/browse
    jsonrpc.browse_url = app.config.get("API_BROWSE_URL", "/api/browse")
    # 是否允许浏览器访问web调试界面,与DEBUG的值一致即可
    jsonrpc.enable_web_browsable_api = app.config.get("DEBUG", False)
    jsonrpc.init_app(app)

    # 自动化蓝图类加载配置
    blueprint.init_app(app, jsonrpc)

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

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

    return app
View Code

 

celery配置,application/settings/dev.py,代码:

"""celery相关配置"""
# 某些情况下可以防止死锁
CELERY_FORCE_EXECV: bool = True
# 设置并发的worker数量
CELERYD_CONCURRENCY: int = 20
# 设置失败允许重试
CELERY_ACKS_LATE: bool = True
# 每个worker最多执行500个任务被销毁,可以防止内存泄漏
CELERYD_MAX_TASKS_PER_CHILD: int = 500
# 单个任务的最大运行时间,超时会被杀死【注意:如果异步任务中有IO操作则建议不要设置这个数字太小,或者建议不要设置了】
CELERYD_TIME_LIMIT: int = 10 * 60
# 任务发出后,经过一段时间还未收到acknowledge , 就将任务重新交给其他worker执行
CELERY_DISABLE_RATE_LIMITS: bool = True
# celery的任务结果内容格式
CELERY_ACCEPT_CONTENT: List = ['json', 'pickle']
# celery的任务队列地址
BROKER_URL: str = "redis://:@127.0.0.1:6379/15"
# celery的结果队列地址
CELERY_RESULT_BACKEND: str = "redis://:@127.0.0.1:6379/14"
# celery的定时任务调度器配置
BEAT_SCHEDULE: Dict[str, Any] = {
    # "test": {
    #     "task": "get_sendback",
    #     "schedule": 10,
    # }
}
View Code

 

启动文件中引入celery实例对象,manage.py,代码:

from flask import Flask, jsonify
from application import init_app, celery

app: Flask = init_app("application.settings.dev")


@app.route('/')
def index():
    app.logger.debug("hello, debug")
    app.logger.info("hello, info")
    app.logger.warning("hello, warning")
    app.logger.error("hello, error")
    app.logger.critical("hello, critical")
    return jsonify({"name": "yingmingApp", "version": "0.0.3"})


if __name__ == '__main__':
    app.run()
View Code

 

终端提供启动celery的worker进程与beat进程的操作命令,command.sh,代码:

#!/usr/bin/env bash
export FLASK_APP="/home/moluo/Desktop/yingmingapi/manage.py"
export FLASK_DEBUG=True

if [ $1 ]; then
  if [ $1 == "run" ]; then
    flask run --host=0.0.0.0 --port=5000
  elif [ $1 == "blue" ]; then
    cd application/apps
    flask $1 --name=$2
  elif [ $1 == "celery" ]; then
    celery -A manage.celery worker -l info
  elif [ $1 == "beat" ]; then
    celery -A manage.celery beat -l info
  else
    flask $1
  fi
fi
View Code

 

启动celery命令如下:

./command.sh celery
./command.sh beat
 

 

编写发送短信的异步任务

在users蓝图下的tasks.py任务文件中,创建异步任务,users/tasks.py,代码:

from application import celery


@celery.task(name="send_sms")
def send_sms(mobile:str, sms_code: str):
    return f"ok!!!mobile={mobile}, code={sms_code}"
View Code

 

终端下重启celery,效果如下:

 

 

同时,celery在任务执行过程中, 基于监听器和任务bind属性对失败任务进行记录和重新执行, users/tasks.py

pip install orjson -i https://pypi.douban.com/simple  # rust编写的高性能json解析库

 

代码:

import orjson
from typing import Dict, List, Any
from ronglian_sms_sdk import SmsSDK
from redis.client import Pipeline
from celery import Task
from application import celery, redis_check, code, message


class SMSTask(Task):
    """异步任务的监听器类"""
    def on_success(self, retval, task_id, args, kwargs):
        print(f'任务执行成功以后自动执行这里的代码! retval={retval}, task_id={task_id}')
        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(f'任务执行的回调操作,不管执行的结果是成功还是失败,都会执行这里,status={status}')
        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_sms", base=SMSTask)
def send_sms(mobile: str, sms_code: str): # 当被装饰的异步任务存在bind属性,首个参数必须是self
    """发送短信验证码"""
    # 实例化SDK
    sdk: SmsSDK = SmsSDK(
        celery.app.config.get("SMS_ACCOUNT_ID"),
        celery.app.config.get("SMS_ACCOUNT_TOKEN"),
        celery.app.config.get("SMS_APP_ID")
    )

    try:
        # 发送短信
        ret: str = sdk.sendMessage(
            celery.app.config.get("SMS_TEMPLATE_ID"),
            mobile,
            (sms_code, celery.app.config.get("SMS_EXPIRE_TIME") // 60)
        )

        # 获取接口操作的结果
        result: Dict[str, Any] = orjson.loads(ret)

        if result["statusCode"] == "000000":
            pipe: Pipeline = redis_check.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()  # 提交事务
        else:
            raise Exception(f"{result}")
        return result

    except Exception as exc:
        celery.app.logger.error(f"短信发送失败!\r\n{locals()}")
        return {"error": f"{exc}"}
View Code

 

进入flask提供的终端交互环境,测试上面编写的异步任务是否有问题。注意:要重启celery

./command.sh celery

# 新建一个终端测试celery异步任务
./command.sh shell

from application.apps.users.tasks import send_sms
# 发送异步任务,让celery尽快异步执行
ret = send_sms.delay("13928835901", "1236")

# 30秒后异步执行任务
ret = send_sms.apply_async(kwargs={'mobile':'13928835901', 'sms_code': '1236'}, countdown=30)
ret = send_sms.apply_async(args=('13928835901', '1236'), countdown=30)

ret.id     # 获取异步任务ID
ret.status # 获取异步任务的当前执行装填 SUCCESS表示执行成功 FAIL表示执行失败 PENDING表示等待
ret.get()  # 以同步阻塞的方式获取任务结果,慎用!!!

 

经过上面的测试用,异步任务的执行没有问题,所以我们需要在项目中调用异步任务发送短信,application.apps/users/api.py,代码:

import random, json
from typing import Dict, Union, Any
from flask import current_app
from redis.client import Pipeline
from ronglian_sms_sdk import SmsSDK
from . import serializers, models, tasks
from application import message, code, redis_check


def check_mobile(mobile: str) -> Dict[str, Any]:
    """
    验证手机号格式与是否唯一
    :param mobile: 手机号码
    :return:
    """
    # 调用构造器反序列化验证
    ms: serializers.MobileSchema = serializers.MobileSchema()
    try:
        ms.load({"mobile": mobile})
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": message.success}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {'errno': code.CODE_VALIDATE_ERROR, "errmsg": e.messages["mobile"][0]}
    return result


def register(mobile: str, password: str, re_password: str, sms_code: str) -> Dict[str, Any]:
    """
    用户信息注册
    :param mobile: 手机号
    :param password: 登录密码
    :param re_password: 确认密码
    :param sms_code: 短信验证码
    :return:
    """
    # 先校验手机号
    result = check_mobile(mobile=mobile)
    if result["errno"] != 0:
        return result
    try:
        us: serializers.UserSchema = serializers.UserSchema()
        user: models.User = us.load({
            "mobile": mobile,
            "password": password,
            "re_password": re_password,
            "sms_code": sms_code
        })
        result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": us.dump(user)}
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {"errno": code.CODE_VALIDATE_ERROR, "errmsg": e.messages}

    return result


def sms(mobile: str) -> Dict[str, Any]:
    """
       发送注册短信验证码
       :param mobile: 手机号
       :return:
       """
    # 先校验手机号
    result: Dict[str, Any] = check_mobile(mobile=mobile)
    if result["errno"] != 0:
        return result

    # 获取当前客户端手机号的短信发送冷却时间
    ret: int = redis_check.ttl("int_%s" % mobile)
    if ret > 0:
        return {
            "errno": code.CODE_INTERVAL_TIME,
            "errmsg": message.sms_interval_time,
            "time": ret,
        }

    # 生成验证码
    sms_code: str = "%04d" % random.randint(100, 9999)

    # 异步发送短信验证码
    tasks.send_sms.delay(mobile=mobile, sms_code=sms_code)
    # 返回结果
    result: Dict[str, Any] = {"errno": code.CODE_OK, "errmsg": message.success}
    return result
View Code

 

提交版本

git add .
git commit -m "api: async send sms by celery"
git push
 

 

用户登录

服务端实现jwt登陆认证

当前我们开发的项目属于前后端分离,而目前最适合我们使用的认证方式就是jwt认证,也有些公司采用 Oauth2.0认证。

在flask中,我们可以通过flask_jwt_extended模块来快速实现jwt用户登录认证。当然也可以采用原生提供的pyjwt模块。

注意:
1.  flask_jwt_extended 的作者开发当前模块主要适用于flask的普通视图认证的。其认证方式主要通过装饰器来完成。
    而我们当前所有服务端接口都改造成了jsonrpc规范接口,所以我们在使用过程中,需要对部分源代码进行调整才能正常使用。
    
2.  事实上,在我们当前使用的`flask_jsonrpc`也提供了基于用户名username和密码password进行的用户登陆认证功能,
    但是这个功能是依靠用户账户`username`和密码`password`来实现。如果我们基于当前这种方式,也可以实现jwt登陆认证,
    只是相对于上面的`flask_jwt_extended`模块而言,要补充的源代码会更多,所以在此,我们放弃这块功能的使用。
 

 

模块安装

pip install flask-jwt-extended
# 添加了jwt认证以后,会出现跨域问题,解决下跨域问题
# pip install flask-cors

 

官网文档:https://flask-jwt-extended.readthedocs.io/en/latest/

配置说明:https://flask-jwt-extended.readthedocs.io/en/latest/options/

快速使用

在魔方APP项目中对模块进行初始化,application/__init__.py,代码:

from pathlib import Path
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
from flask_pymongo import PyMongo
from flask_jsonrpc import JSONRPC
from celery import Celery
from flask_jwt_extended import JWTManager

from application.utils.config import Config
from application.utils.logger import Logger
from application.utils.commands import Command
from application.utils.blueprint import AutoBluePrint, path
from application.utils import message, code  # 错误提示,状态码

# 实例化配置加载类
config: Config = Config()
# 实例化SQLAlchemy
db: SQLAlchemy = SQLAlchemy()
# 实例化redis
redis_cache: FlaskRedis = FlaskRedis(config_prefix="REDIS")
redis_check: FlaskRedis = FlaskRedis(config_prefix="CHECK")

# mongoDB实例化
mongo: PyMongo = PyMongo()
# 实例化日志配置类
logger: Logger = Logger()

# 实例化终端命令管理类
command: Command = Command()

# 实例化自动化蓝图类
blueprint: AutoBluePrint = AutoBluePrint()

# 实例化jsonrpc
jsonrpc = JSONRPC()


# 实例化celery
celery = Celery()

# jwt认证模块实例化
jwt = JWTManager()

def init_app(config_path: str) -> Flask:
    """用于创建app实例对象并完成初始化过程的工厂函数"""
    # 实例化flask应用对象
    app: Flask = Flask(__name__)
    # 全局路径常量,指向项目根目录
    app.BASE_DIR: Path = Path(__file__).resolve().parent.parent

    # 加载配置
    config.init_app(app, config_path)
    # SQLAlchemy加载配置
    db.init_app(app)
    # redis加载配置
    redis_cache.init_app(app)
    redis_check.init_app(app)
    # # pymongo加载配置
    mongo.init_app(app)

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

    # 终端命令管理类加载配置
    command.init_app(app)

    # jsonrpc注册到项目中
    # 开启rpc接口的web调试界面:/api/browse
    jsonrpc.browse_url = app.config.get("API_BROWSE_URL", "/api/browse")
    # 是否允许浏览器访问web调试界面,与DEBUG的值一致即可
    jsonrpc.enable_web_browsable_api = app.config.get("DEBUG", False)
    jsonrpc.init_app(app)

    # jwt初始化,必须写在蓝图注册代码的上方
    jwt.init_app(app)

    # 自动化蓝图类加载配置
    blueprint.init_app(app, jsonrpc)

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

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

    return app
View Code

 

配置文件,application/settings/dev.py,代码:

# jwt 相关配置
# 加密算法,默认: HS256
JWT_ALGORITHM = "HS256"
# 秘钥,默认是flask配置中的SECRET_KEY
JWT_SECRET_KEY = "y58Rsqzmts6VCBRHes1Sf2DHdGJaGqPMi6GYpBS4CKyCdi42KLSs9TQVTauZMLMw"
# token令牌有效期,单位: 秒/s,默认: datetime.timedelta(minutes=15) 或者 15 * 60
JWT_ACCESS_TOKEN_EXPIRES = 60
# refresh刷新令牌有效期,单位: 秒/s,默认:datetime.timedelta(days=30) 或者 30*24*60*60
JWT_REFRESH_TOKEN_EXPIRES = 30 * 24 * 60 * 60
# 设置通过哪种方式传递jwt,默认是http请求头,也可以是query_string,json,cookies
JWT_TOKEN_LOCATION = ["headers", "json", "query_string"]
# 当通过http请求头传递jwt时,请求头参数名称设置,默认值: Authorization
JWT_HEADER_NAME = "Authorization"
# 当通过http请求头传递jwt时,令牌的前缀。
# 默认值为 "Bearer",例如:Authorization: Bearer <JWT>
JWT_HEADER_TYPE = "jwt"
# 当通过json请求体传递jwt时,access_token令牌参数名称
JWT_JSON_KEY = "access_token"
# 当通过json请求体传递jwt时,refresh_token令牌参数名称
JWT_REFRESH_JSON_KEY = "refresh_token"
# 当通过查询字符串query_string传递jwt时,地址栏的参数名称设置,默认值: Authorization
JWT_QUERY_STRING_NAME = "token"
# 当通过查询字符串query_string传递jwt时,令牌的前缀。
# 默认值为 "Bearer",例如:Authorization: Bearer <JWT>
JWT_QUERY_STRING_VALUE_PREFIX = "jwt "
View Code

 

视图提供基本操作jwt的rpc接口

refresh_token只能用于给客户端换取新的access_token,有效期都会比access_token长

access_token只能用于服务端提供的其他api接口数据,但是有效期会很短,当access_token过期,则允许客户端凭借refresh_token来换取新的access_token。

序列化器,用户提供登陆数据的返回以及校验,users/serializers.py,代码:

from typing import List, Dict, Any, Optional
from marshmallow import Schema, fields, validate, validates, ValidationError, decorators
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field
from sqlalchemy.orm import scoped_session
from sqlalchemy import or_
from flask import request
from .models import User
from application import db, message, redis_check


class UserLoginSchema(SQLAlchemyAutoSchema):
    """用户登录的序列化器"""
    account = fields.String(required=True, load_only=True)
    password = fields.String(required=True, load_only=True)
    money = fields.Float(dump_only=True)
    credit = fields.Float(dump_only=True)
    avatar = fields.String(dump_only=True)
    nickname = fields.String(dump_only=True)

    class Meta:
        model: User = User
        # 如果要转换模型的全部字段,就不要声明fields或exclude字段即可
        fields = ["id", "nickname", "account", "password", "money", "credit", "avatar"]
        sqla_session = db.session
        include_fk: bool = True  # 启用外键关系
        include_relationships: bool = True  # 模型关系外部属性

    # 钩子,验证成功以后,自动调用
    @decorators.post_load
    def save_object(self, data, **kwargs):
        account = data.get("account")
        password = data.get("password")
        user = User.query.filter(
            or_(
                User.mobile == account,
                User.name == account,
                User.email == account
            )
        ).first()  # 实例化模型

        if not user:
            raise ValidationError(message=message.user_not_exists, field_name="username")

        if not user.check_password(password):
            raise ValidationError(message=message.password_error, field_name="password")

        return user
View Code

 

提供生成access_token以及refresh_token的工具方法,users/serivces.py,代码:

from typing import Dict, List, Any, Tuple
from flask import current_app
from flask_jwt_extended import create_access_token, create_refresh_token
from application import redis_cache
from .models import User


def gen_token(payload: Dict[str, Any]) -> Tuple:
    """
    生成access_token和refresh_token
    :param payload: 载荷
    :return:
    """
    access_token: str = create_access_token(identity=payload)  # identity 就是载荷
    refresh_token: str = create_refresh_token(identity=payload)
    # 缓存token到redis中,表示当前用户在服务端的登录状态,
    # 将来如果token或者删除数据库中用户信息时,会删除调用当前redis中保存的token
    redis_cache.setex(f"access_token_{payload['id']}", current_app.config["JWT_ACCESS_TOKEN_EXPIRES"], access_token)

    return access_token, refresh_token


def get_user_by_id(id: int) -> User:
    """
    根据用户ID来获取用户模型对象
    :param id: 用户ID
    """
    return User.query.filter(User.id==id).first()
View Code

 

实现三个接口,分别是登陆,获取用户信息,刷新access_token的功能。

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

import random, json
from typing import Dict, Union, Any, Tuple
from flask_jwt_extended import jwt_required, get_jwt_identity
from . import serializers, models, tasks
from application import message, code, redis_check
from .services import gen_token, get_user_by_id
​
# 中间代码省略。。。。
def login(account: str, password: str) -> Dict[str, Any]:
    """
    用户jwt登录
    :param account: 账户名[可以是手机号、邮箱、用户名]
    :param password: 登录密码
    :return
    """
    try:
        uls: serializers.UserLoginSchema = serializers.UserLoginSchema()
        user: models.User = uls.load({
            "account": account,
            "password": password
        })
​
        # 序列化
        payload: Dict[str, Any] = uls.dump(user)
        # 2. 生成jwt assess token 和 refresh token
        access_token: str = ""
        refresh_token: str = ""
        access_token, refresh_token = gen_token(payload=payload)
​
        # 3. 返回2个token给客户端
        result: Dict[str, Any] = {
            "errno": code.CODE_OK,
            "errmsg": message.success,
            "access_token": access_token,
            "refresh_token": refresh_token
        }
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {"errno": code.CODE_VALIDATE_ERROR, "errmsg": e.messages}
​
    return result
​
​
@jwt_required()  # 验证access_token
def info() -> Dict[str, Any]:
    """
    获取用户身份信息
    :return
    """
    user_info: Dict[str, Any] = get_jwt_identity()  # 获取access token中的载荷
    return {
        "errno": code.CODE_OK,
        "errmsg": message.success,
        **user_info
    }
​
​
@jwt_required(refresh=True)  # 验证refresh_token
def refresh() -> Dict[str, Any]:
    """
    使用refresh_token 换取/刷新 assess_token
    """
    user_info: Dict[str, Any] = get_jwt_identity()  # 获取access token中的载荷
    user: models.User = get_user_by_id(user_info["id"])
    if user is None:
        return {
            "errno": code.CODE_USER_NOT_EXISTS,
            "errmsg": message.user_not_exists
        }
​
    # 再次生成token
    access_token: str = ""
    access_token, _ = gen_token(payload=user_info)
​
    return {
        "errno": code.CODE_OK,
        "errmsg": message.success,
        "access_token": access_token
    }
​
View Code

 

users/urls.py,代码:

from typing import List
from application import path
from . import api
​
​
apipatterns: List = [
    path("mobile", api.check_mobile),
    path("register", api.register),
    path("sms", api.sms),
    path("login", api.login),
    path("info", api.info),
    path("refresh", api.refresh),
]
View Code

 

message,代码:

# 登录相关
user_not_exists: str = "当前用户不存在!"
password_error: str = "用户不存在或密码错误!"
View Code

 

code,代码:

CODE_USER_NOT_EXISTS: int = 1101  # 用户不存在!
View Code

 

提交代码版本

git add .
git commit -m "api: jwt auth"
git push
 

 

针对token验证失败、过期、无效的错误提示,调整返回的数据格式和状态码

装饰器jwt_required就是用于判断客户端提交的数据中的jwt token是否有效,这里我们还需要进行2处的源码调整。以方便它更好的展示错误信息。

注意: 修改源码之前,要把flask_jwt_extended目录从site_packages中复制出来到当前项目根目录下。

flask_jwt_extended/view_decorators.py调整jwt_required函数的代码,代码:

from jwt.exceptions import DecodeError, PyJWTError
from flask_jwt_extended.exceptions import JWTExtendedException
from application.utils import message, code
​
def jwt_required(
    optional: bool = False,
    fresh: bool = False,
    refresh: bool = False,
    locations: LocationType = None,
    verify_type: bool = True,
) -> Any:
    """
    A decorator to protect a Flask endpoint with JSON Web Tokens.
​
    Any route decorated with this will require a valid JWT to be present in the
    request (unless optional=True, in which case no JWT is also valid) before the
    endpoint can be called.
​
    :param optional:
        If ``True``, allow the decorated endpoint to be accessed if no JWT is present in
        the request. Defaults to ``False``.
​
    :param fresh:
        If ``True``, require a JWT marked with ``fresh`` to be able to access this
        endpoint. Defaults to ``False``.
​
    :param refresh:
        If ``True``, requires a refresh JWT to access this endpoint. If ``False``,
        requires an access JWT to access this endpoint. Defaults to ``False``.
​
    :param locations:
        A location or list of locations to look for the JWT in this request, for
        example ``'headers'`` or ``['headers', 'cookies']``. Defaults to ``None``
        which indicates that JWTs will be looked for in the locations defined by the
        ``JWT_TOKEN_LOCATION`` configuration option.
​
    :param verify_type:
        If ``True``, the token type (access or refresh) will be checked according
        to the ``refresh`` argument. If ``False``, type will not be checked and both
        access and refresh tokens will be accepted.
    """def wrapper(fn):
        @wraps(fn)
        def decorator(*args, **kwargs):
            try:
                verify_jwt_in_request(optional, fresh, refresh, locations, verify_type)
            except DecodeError:
                return {"errno": code.CODE_JWT_INVALID, "errmsg": message.authorization_is_invalid}
            except JWTExtendedException:
                return {"errno": code.CODE_JWT_NOT_TOKEN, "errmsg": message.no_authorization}
            except PyJWTError:
                return {"errno": code.CODE_JWT_EXPIRED, "errmsg": message.authorization_has_expired}
            return current_app.ensure_sync(fn)(*args, **kwargs)
​
        return decorator
​
    return wrapper
​
View Code

 

application.utils.code,代码:

CODE_JWT_INVALID: int = 1010    # 无效的jwt token
CODE_JWT_NOT_TOKEN: int = 1011  # 没有找到jwt token
CODE_JWT_EXPIRED: int = 1012    # jwt登录已超时
View Code

 

application.utils.message,代码:

no_authorization: str = "没有认证令牌"
authorization_has_expired: str = "过期的认证令牌"
authorization_is_invalid: str = "无效的认证令牌"
 
View Code

 

客户端提交用户登录信息

html/login.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="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
    <div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
    <div class="bg">
            <img src="../image/bg0.jpg">
        </div>
        <div class="form">
            <div class="form-title">
                <img src="../image/login.png">
                <img class="back" @click="open_root" src="../image/back.png">
            </div>
            <div class="form-data">
                <div class="form-data-bg">
                    <img src="../image/bg1.png">
                </div>
                <div class="form-item">
                    <label class="text">账号</label>
                    <input type="text" v-model="account" placeholder="请输入手机号/邮箱/用户名">
                </div>
                <div class="form-item">
                    <label class="text">密码</label>
                    <input type="password" v-model="password" placeholder="请输入密码">
                </div>
                <div class="form-item">
                    <input type="checkbox" class="agree remember" v-model="remember">
                    <label><span class="agree_text ">记住密码,下次免登录</span></label>
                </div>
                <div class="form-item">
                    <img class="commit" @click="loginhandle" src="../image/commit.png">
                </div>
                <div class="form-item">
                    <p class="toreg" @click="open_register">立即注册</p>
                    <p class="tofind">忘记密码</p>
                </div>
            </div>
        </div>
    </div>
    <script>
    const app = Vue.createApp({
        data(){
            return {
                music_play: true,    // 默认播放背景音乐的控制属性
                account: "",         // 账号,支持手机号、邮箱、用户名
                password: "",        // 登陆密码
                remember: false,     // 是否记住密码
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            }
        },
methods: {
open_root(){
this.game.goWin("root");
    },
open_register(){
this.game.goFrame("register.html");
    },
loginhandle(){
// 登陆处理
this.game.play_music('../mp3/btn1.mp3');
// 验证数据
if(this.account.length < 1 || this.password.length < 1){
this.game.tips("账号或者密码不能为空!");
return false; // 结束函数,阻止代码继续往下执行
    }
// 发送请求
let uuid = this.game.uuid();
axios.post(this.game.config.server_api, {
"jsonrpc": "2.0",
"id": uuid,
"method": "Users.login",
"params": {
"account": this.account,
"password": this.password
    }
    }).then(response=>{
if(response.data.id === uuid){
if(response.data.result.errno !== 0){
api.alert({
title: "错误警告",
msg: response.data.result.errmsg,
    })
    }else{
// 清除上次登陆遗留的token
​
// 保存登陆token
// 保存认证令牌
if(this.remember){
// 记住登陆状态
​
    }else{
// 不记住登陆状态
​
    }
​
api.confirm({
title: '系统提示',
msg: '登陆成功',
buttons: ['返回首页', '个人中心']
    }, (ret, err)=>{
var index = ret.buttonIndex;
if(index === 1){
// 跳转到首页
this.game.goWin("root");
    }else{
// 跳转到个人中心
this.game.goWin("user.html");
    }
// 2秒后关闭当前窗口
setTimeout(() => {
this.game.outWin();
    }, 2000);
    });
    }
    }
    }).catch(error=>{
api.alert({
"title":"错误警告", 
msg: error.response.data.error.data.message
    });
    })
    }
    }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
</script>
</body>
</html>
View Code

 

main.js,代码:

class Game{
    constructor(bg_music){
        // 构造函数,相当于 python的 __init__方法
        this.init();
        this.play_music(bg_music);
    }
    init(){
        // 初始化函数
        console.log("系统初始化");
        this.rem();
        this.init_config();
    }
    init_config(){
        // 初始化配置
        this.config = {
            "server_api": "http://192.168.21.253:5000/api", // api服务端的网关地址
        }
    }
    print(data){
        // 打印数据
        console.log(JSON.stringify(data));
    }

    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";
            }
        }
    }
    stop_music(){
        // 暂停音乐
        this.print("停止")
        document.body.removeChild(this.audio);
    }
    play_music(src){
        // 播放音乐
        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);
    }
    goWin(url,pageParam){
        // 打开窗口
        const name = url.replace(".html","");
        if(name === "root"){
            api.openWin({"name":"root"});
            return;
        }

        api.openWin({
            name: name,             // 自定义窗口名称
            bounces: false,        // 窗口是否上下拉动
            reload: false,         // 如果页面已经在之前被打开了,是否要重新加载当前窗口中的页面
            url: url,              // 窗口创建时展示的html页面的本地路径[相对于当前代码所在文件的路径]
            animation:{            // 打开新建窗口时的过渡动画效果
                type: "push",                //动画类型(详见动画类型常量)
                subType: "from_right",       //动画子类型(详见动画子类型常量)
                duration:300                //动画过渡时间,默认300毫秒
            },
            pageParam: pageParam   // 传递给下一个窗口使用的参数.将来可以在新窗口中通过 api.pageParam.name 获取
        });
    }
    outWin(name){
        // 关闭窗口
        api.closeWin({"name": name});
    }
    goFrame(url,pageParam){
        // 打开帧页面
        const name = url.replace(".html","");
        api.openFrame({
                name: name,
                url: url,
                rect: {
                    x: 0,
                    y: 0,
                    w: 'auto',
                    h: 'auto'
                },
                useWKWebView:true,
                historyGestureEnabled:true,
                bounces:false,
                animation:{
                    type:"push",
                    subType:"from_right",
                    duration:300
                },
                pageParam: pageParam
        });
    }
    outFrame(name){
        // 关闭帧页面
        api.closeFrame({
            name: name,
        });
    }
    uuid(){
        // 生成UUID函数
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c)=>{
            var r = Math.random()*16|0,v=c=='x'?r:r&0x3|0x8;
            return v.toString(16);
        })
    }
    tips(msg){
        // 提示
        api.toast({
            msg: msg,
            duration: 5000,
            location: "top"
        })
    }
}
 
View Code

 

保存用户登录状态

基于APICloud提供的本地存储可以有效保存数据

// 保存数据到系统内存中
api.setGlobalData({
    key: 'userName',
    value: 'api'
});

var userName = api.getGlobalData({
    key: 'userName'
});

// 保存数据到系统文件中
api.setPrefs({ // 储存
    key: 'userName',
    value: 'api'
});

api.getPrefs({ // 获取
    key: 'userName'
}, function(ret, err) {
    ...
});
// 注意:基于api.getPrefs获取数组时,会出现转义格式的字符

api.removePrefs({//删除
    key: 'userName'
});
View Code

 

main.js封装对于api对象提供的存储数据方法,main.js,代码:

class Game{
    constructor(bg_music){
        // 构造函数,相当于 python的 __init__方法
        this.init();
        this.play_music(bg_music);
    }
    init(){
        // 初始化函数
        console.log("系统初始化");
        this.rem();
        this.init_config();
    }
    init_config(){
        // 初始化配置
        this.config = {
            "server_api": "http://192.168.21.253:5000/api", // api服务端的网关地址
        }
    }
    print(data){
        // 打印数据
        console.log(JSON.stringify(data));
    }

    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";
            }
        }
    }
    stop_music(){
        // 暂停音乐
        this.print("停止")
        document.body.removeChild(this.audio);
    }
    play_music(src){
        // 播放音乐
        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);
    }
    goWin(url,pageParam){
        // 打开窗口
        const name = url.replace(".html","");
        if(name === "root"){
            api.openWin({"name":"root"});
            return;
        }

        api.openWin({
            name: name,             // 自定义窗口名称
            bounces: false,        // 窗口是否上下拉动
            reload: false,         // 如果页面已经在之前被打开了,是否要重新加载当前窗口中的页面
            url: url,              // 窗口创建时展示的html页面的本地路径[相对于当前代码所在文件的路径]
            animation:{            // 打开新建窗口时的过渡动画效果
                type: "push",                //动画类型(详见动画类型常量)
                subType: "from_right",       //动画子类型(详见动画子类型常量)
                duration:300                //动画过渡时间,默认300毫秒
            },
            pageParam: pageParam   // 传递给下一个窗口使用的参数.将来可以在新窗口中通过 api.pageParam.name 获取
        });
    }
    outWin(name){
        // 关闭窗口
        api.closeWin({"name": name});
    }
    goFrame(url,pageParam){
        // 打开帧页面
        const name = url.replace(".html","");
        api.openFrame({
                name: name,
                url: url,
                rect: {
                    x: 0,
                    y: 0,
                    w: 'auto',
                    h: 'auto'
                },
                useWKWebView:true,
                historyGestureEnabled:true,
                bounces:false,
                animation:{
                    type:"push",
                    subType:"from_right",
                    duration:300
                },
                pageParam: pageParam
        });
    }
    outFrame(name){
        // 关闭帧页面
        api.closeFrame({
            name: name,
        });
    }
    uuid(){
        // 生成UUID函数
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c)=>{
            var r = Math.random()*16|0,v=c=='x'?r:r&0x3|0x8;
            return v.toString(16);
        })
    }
    tips(msg){
        // 提示
        api.toast({
            msg: msg,
            duration: 5000,
            location: "top"
        })
    }
    setfs(data){
        // 保存数据到本地文件系统中
        for(let key in data){
            api.setPrefs({ // 储存
                 key: key,
                 value: data[key]
            });
        }
    }
    getfs(key){ // key="access_token"    keys = ["access_token","refresh_token"]
        // 根据key值来获取本地文件系统中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        let data = {}
        for(let item of keys){
            data[item] = api.getPrefs({
                key: item,
                sync: true,
            });
        }

        if(key instanceof Array){
            return data;
        }

        return data[key];
    }
    delfs(key){
        // 根据key值来删除本地文本系统中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        for(let item of keys){
            api.removePrefs({
                key: item,
            });
        }
    }
    setdata(data){
        // 保存数据到系统内存中
        for(let key in data){
            api.setGlobalData({ // 储存
                key: key,
                value: data[key]
            });
        }
    }
    getdata(key){
        // 根据key值来获取内存中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        let data = {}
        for(let item of keys){
            data[item] = api.getGlobalData({
                key: item
            });
        }

        if(key instanceof Array){
            return data;
        }

        return data[key];
    }

    deldata(key){
        // 根据key值来删除内存中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        for(let item of keys){
            api.setGlobalData({
                key: item,
                value: "",
            });
        }
    }

}
View Code

 

最后,把登陆状态根据用户选择是否记住密码来采用不同的存储方式保存信息,login.html,代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>首页</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <meta name="format-detection" content="telephone=no,email=no,date=no,address=no">
    <link rel="stylesheet" href="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
  <div class="app" id="app">
    <!-- 背景音乐 -->
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
    <!--  背景图片 -->
    <div class="bg">
      <img src="../image/bg0.jpg">
    </div>
    <!-- 菜单 -->
    <ul>
      <li><img class="module1" src="../image/image1.png"></li> <!-- 果园 -->
      <li><img class="module2" @click="open_user" src="../image/image2.png"></li> <!-- 会员 -->
      <li><img class="module3" @click="get_data" src="../image/image3.png"></li> <!-- 娱乐 -->
      <li><img class="module4" @click="open_login" src="../image/image4.png"></li> <!-- 签到 -->
    </ul>
  </div>
  <script>
    const app = Vue.createApp({
        data(){
            return {
                music_play: true,  // 默认播放背景音乐的控制属性
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            }
        },
        methods: {
            open_login(){
                this.game.goWin("login.html");
            },
            open_user(){
                this.game.goWin("user.html");
            },
            get_data(){
                this.game.print(api.windows());
                this.game.print(this.game.getdata(["access_token", "refresh_token"]));
                this.game.print(this.game.getfs(["access_token", "refresh_token"]));
            }
        }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
    </script>
</body>
</html>
 
View Code

 

Base64编码方法

有了本地保存token以后,将来我们可以通过window提供的base64算法方法来提取token内嵌的载荷数据。对于这块,我们也可以在main.js中针对base64编码的相关操作,可以封装成方法。

class Game{
    constructor(bg_music){
        // 构造函数,相当于 python的 __init__方法
        this.init();
        this.play_music(bg_music);
    }
    init(){
        // 初始化函数
        console.log("系统初始化");
        this.rem();
        this.init_config();
    }
    init_config(){
        // 初始化配置
        this.config = {
            "server_api": "http://192.168.21.253:5000/api", // api服务端的网关地址
        }
    }
    print(data){
        // 打印数据
        console.log(JSON.stringify(data));
    }

    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";
            }
        }
    }
    stop_music(){
        // 暂停音乐
        this.print("停止")
        document.body.removeChild(this.audio);
    }
    play_music(src){
        // 播放音乐
        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);
    }
    goWin(url,pageParam){
        // 打开窗口
        const name = url.replace(".html","");
        if(name === "root"){
            api.openWin({"name":"root"});
            return;
        }

        api.openWin({
            name: name,             // 自定义窗口名称
            bounces: false,        // 窗口是否上下拉动
            reload: false,         // 如果页面已经在之前被打开了,是否要重新加载当前窗口中的页面
            url: url,              // 窗口创建时展示的html页面的本地路径[相对于当前代码所在文件的路径]
            animation:{            // 打开新建窗口时的过渡动画效果
                type: "push",                //动画类型(详见动画类型常量)
                subType: "from_right",       //动画子类型(详见动画子类型常量)
                duration:300                //动画过渡时间,默认300毫秒
            },
            pageParam: pageParam   // 传递给下一个窗口使用的参数.将来可以在新窗口中通过 api.pageParam.name 获取
        });
    }
    outWin(name){
        // 关闭窗口
        api.closeWin({"name": name});
    }
    goFrame(url,pageParam){
        // 打开帧页面
        const name = url.replace(".html","");
        api.openFrame({
                name: name,
                url: url,
                rect: {
                    x: 0,
                    y: 0,
                    w: 'auto',
                    h: 'auto'
                },
                useWKWebView:true,
                historyGestureEnabled:true,
                bounces:false,
                animation:{
                    type:"push",
                    subType:"from_right",
                    duration:300
                },
                pageParam: pageParam
        });
    }
    outFrame(name){
        // 关闭帧页面
        api.closeFrame({
            name: name,
        });
    }
    uuid(){
        // 生成UUID函数
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c)=>{
            var r = Math.random()*16|0,v=c=='x'?r:r&0x3|0x8;
            return v.toString(16);
        })
    }
    tips(msg){
        // 提示
        api.toast({
            msg: msg,
            duration: 5000,
            location: "top"
        })
    }
    setfs(data){
        // 保存数据到本地文件系统中
        for(let key in data){
            api.setPrefs({ // 储存
                 key: key,
                 value: data[key]
            });
        }
    }
    getfs(key){ // key="access_token"    keys = ["access_token","refresh_token"]
        // 根据key值来获取本地文件系统中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        let data = {}
        for(let item of keys){
            data[item] = api.getPrefs({
                key: item,
                sync: true,
            });
        }

        if(key instanceof Array){
            return data;
        }

        return data[key];
    }
    delfs(key){
        // 根据key值来删除本地文本系统中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        for(let item of keys){
            api.removePrefs({
                key: item,
            });
        }
    }
    setdata(data){
        // 保存数据到系统内存中
        for(let key in data){
            api.setGlobalData({ // 储存
                key: key,
                value: data[key]
            });
        }
    }
    getdata(key){
        // 根据key值来获取内存中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        let data = {}
        for(let item of keys){
            data[item] = api.getGlobalData({
                key: item
            });
        }

        if(key instanceof Array){
            return data;
        }

        return data[key];
    }

    deldata(key){
        // 根据key值来删除内存中存储的数据
        let keys = key;
        if(!(key instanceof Array)){ // 如果不是数组,改造成数组,统一操作
            keys = [key];
        }

        for(let item of keys){
            api.setGlobalData({
                key: item,
                value: "",
            });
        }
    }
    token(){
        // 获取token
        let token = {}
        let data = this.getdata("access_token");
        let fs   = this.getfs("access_token");
        if(data){
            token["access_token"] = data
            token["remember"] = false
        }else if (fs){
            token["access_token"] = fs
            token["remember"] = true
        }else{
            token["access_token"] = ""
            token["remember"] = false
        }
        return token;
    }
    payload(){
        let token = this.token().access_token
        // 获取载荷数据
        let arr = token.split(".")
        if(!arr[0]){
            return {}
        }
        let payload = JSON.parse( window.atob(arr[1]) )
        // 判断token是否已经过期了
        let current_time = parseInt((new Date()-0)) / 1000;
        if( current_time > payload.exp){
            this.delfs("access_token");
            this.deldata("access_token");
            return {}
        }
        return payload
    }
    user_info(){
        // 获取载荷中的用户信息
        if(this.payload().sub){
            return this.payload().sub
        }else{
            return {}
        }
    }
}
View Code

 

基于上面封装的方法,我们可以在index.html使用测试代码进行测试,代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>首页</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <meta name="format-detection" content="telephone=no,email=no,date=no,address=no">
    <link rel="stylesheet" href="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
</head>
<body>
  <div class="app" id="app">
    <!-- 背景音乐 -->
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
    <!--  背景图片 -->
    <div class="bg">
      <img src="../image/bg0.jpg">
    </div>
    <!-- 菜单 -->
    <ul>
      <li><img class="module1" src="../image/image1.png"></li> <!-- 果园 -->
      <li><img class="module2" @click="open_user" src="../image/image2.png"></li> <!-- 会员 -->
      <li><img class="module3" @click="get_data" src="../image/image3.png"></li> <!-- 娱乐 -->
      <li><img class="module4" @click="open_login" src="../image/image4.png"></li> <!-- 签到 -->
    </ul>
  </div>
  <script>
    const app = Vue.createApp({
        data(){
            return {
                music_play: true,  // 默认播放背景音乐的控制属性
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            }
        },
        methods: {
            open_login(){
                this.game.goWin("login.html");
            },
            open_user(){
                this.game.goWin("user.html");
            },
            get_data(){
                this.game.print(api.windows());
                this.game.print(this.game.user_info());
            }
        }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
    </script>
</body>
</html>
 
View Code

 

 

在APICloud中集成防水墙验证码

验证码登陆地址: https://console.cloud.tencent.com/captcha

快速接入:https://007.qq.com/python-access.html?ADTAG=acces.start

使用微信扫码登录腾讯云控制台,然后根据官方文档,把验证码集成到项目中

把验证码应用的ID和秘钥保存到settings/dev.py配置文件中.

# 防水墙验证码
CAPTCHA_GATEWAY="https://ssl.captcha.qq.com/ticket/verify"
CAPTCHA_APP_ID="193210601"
CAPTCHA_APP_SECRET_KEY="wzNdeztd1DjmmTjumwsKfRKRl"
 

 

前端获取显示并校验验证码

把防水墙的前端核心js文件(TCaptcha.js)下载并保存在script目录下,并在当前需要验证码的页面中使用script引入。

下载地址:https://ssl.captcha.qq.com/TCaptcha.js

在客户端项目的main.js中init_config方法中添加配置项app_id。

    init_config(){
        return {
            "server_api": "http://192.168.12.253:5000/api",
            "app_id": "193210601",  // 防水墙验证码的应用ID
        }
    }
 
View Code

 

客户端展示验证码

html/login.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="../css/main.css">
    <script src="../script/vue.3.2.41.js"></script>
    <script src="../script/axios.1.1.3.min.js"></script>
    <script src="../script/main.js"></script>
    <script src="../script/TCaptcha.js"></script>
</head>
<body>
    <div class="app" id="app">
    <img class="music" :class="music_play?'music2':''" @click="music_play=!music_play" src="../image/player.png">
    <div class="bg">
            <img src="../image/bg0.jpg">
        </div>
        <div class="form">
            <div class="form-title">
                <img src="../image/login.png">
                <img class="back" @click="open_root" src="../image/back.png">
            </div>
            <div class="form-data">
                <div class="form-data-bg">
                    <img src="../image/bg1.png">
                </div>
                <div class="form-item">
                    <label class="text">账号</label>
                    <input type="text" v-model="account" placeholder="请输入手机号/邮箱/用户名">
                </div>
                <div class="form-item">
                    <label class="text">密码</label>
                    <input type="password" v-model="password" placeholder="请输入密码">
                </div>
                <div class="form-item">
                    <input type="checkbox" class="agree remember" v-model="remember">
                    <label><span class="agree_text ">记住密码,下次免登录</span></label>
                </div>
                <div class="form-item">
                    <img class="commit" @click="show_captcha" src="../image/commit.png">
                </div>
                <div class="form-item">
                    <p class="toreg" @click="open_register">立即注册</p>
                    <p class="tofind">忘记密码</p>
                </div>
            </div>
        </div>
    </div>
    <script>
    const app = Vue.createApp({
        data(){
            return {
                music_play: true,    // 默认播放背景音乐的控制属性
                account: "",         // 账号,支持手机号、邮箱、用户名
                password: "",        // 登陆密码
                remember: false,     // 是否记住密码
            }
        },
        watch:{
            music_play(){
                if(this.music_play){
                    this.game.play_music("../mp3/bg1.mp3");
                }else{
                    this.game.stop_music();
                }
            }
        },
        methods: {
            open_root(){
                this.game.goWin("root");
            },
            open_register(){
                this.game.goFrame("register.html");
            },
            show_captcha(){
                // 显示滑动验证码
                var captcha = new TencentCaptcha(this.game.config.app_id, (res)=>{
                    if(res.ret === 0){
                        // 验证码验证成功,返回4个数据,ret,appid,ticket和randstr
                        this.loginhandle(res); // 提交登录数据
                    }
                });
                captcha.show(); // 显示验证码
            },
            loginhandle(res){
                // 登陆处理
                this.game.play_music('../mp3/btn1.mp3');
                // 验证数据
                if(this.account.length < 1 || this.password.length < 1){
                    this.game.tips("账号或者密码不能为空!");
                    return false; // 结束函数,阻止代码继续往下执行
                }
                // 发送请求
                let uuid = this.game.uuid();
                axios.post(this.game.config.server_api, {
                    "jsonrpc": "2.0",
                    "id": uuid,
                    "method": "Users.login",
                    "params": {
                        "account": this.account,
                        "password": this.password,
                        "ticket": res.ticket,
                        "randstr": res.randstr,
                    }
                }).then(response=>{
                    if(response.data.id === uuid){
                        if(response.data.result.errno !== 0){
                            api.alert({
                                title: "错误警告",
                                msg: response.data.result.errmsg,
                            })
                        }else{
                            // 清除上次登陆遗留的token
                            this.game.delfs(["access_token","refresh_token"]);
                            this.game.deldata(["access_token","refresh_token"]);
                            let data = {
                                'access_token': response.data.result.access_token,
                                'refresh_token': response.data.result.refresh_token
                            }
                            // 保存认证令牌
                            if(this.remember){
                                // 记住登陆状态
                                this.game.setfs(data);
                            }else{
                                // 不记住登陆状态
                                this.game.setdata(data);
                            }

                            api.confirm({
                                title: '系统提示',
                                msg: '登陆成功',
                                buttons: ['返回首页', '个人中心']
                            }, (ret, err)=>{
                                var index = ret.buttonIndex;
                                if(index === 1){
                                    // 跳转到首页
                                    this.game.goWin("root");
                                }else{
                                    // 跳转到个人中心
                                    this.game.goWin("user.html");
                                }
                                // 2秒后关闭当前窗口
                                setTimeout(() => {
                                    this.game.outWin();
                                }, 2000);
                            });
                        }
                    }
                }).catch(error=>{
                    api.alert({
                        "title":"错误警告", 
                        msg: error.response.data.error.data.message
                    });
                })
            }
        }
    })
    app.mount("#app");
    app.config.globalProperties.game = new Game("../mp3/bg1.mp3");
    </script>
</body>
</html>
 
View Code

 

服务端登陆接口中校验验证码回调是否正确

安装腾讯云所有API接口的SDK模块,自然也包括了操作验证码的。

pip install -U tencentcloud-sdk-python

 

settings/dev.py,添加腾讯云的相关配置,代码:

# 腾讯云API接口配置
TENCENTCLOUD = {
    # 腾讯云访问秘钥ID
    "SecretId": "AKIDhHet3DIB0f7NSL7seeWFl6GKMKF3mOyX",
    # 腾讯云访问秘钥key
    "SecretKey": "Wqz0rWxCICB3jDYmwy22wlR4Zw9KMCoA",
    # 验证码API配置
    "Captcha": {
        "endpoint": "captcha.tencentcloudapi.com", # 验证码校验服务端域名
        "CaptchaType": 9,  # 验证码类型,固定为9
        "CaptchaAppId": 193210601,  # 验证码应用ID,务必保证和客户端一致!!!
        "AppSecretKey": "wzNdeztd1DjmmTjumwsKfRKRl",  # 验证码应用key
    },
}
View Code

 

封装了一个验证码验证工具函数,application/utils/tencent_api,代码:

import json
from typing import Dict, Any
from flask import Flask
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.captcha.v20190722 import captcha_client, models


class TencentCloudAPI(object):
    """腾讯云API操作工具类"""
    def __init__(self, app: Flask=None):
        if app is not None:
            self.init_app(app)

    def init_app(self, app: Flask):
        self.cred: credential.Credential = credential.Credential(app.config["TENCENTCLOUD"]["SecretId"], app.config["TENCENTCLOUD"]["SecretKey"])
        self.app = app

    def captcha(self, ticket: str, randstr: str, user_ip: str) -> bool:
        """
        验证码校验工具方法
        :ticket  客户端验证码操作成功以后得到的临时验证票据
        :randstr 客户端验证码操作成功以后得到的随机字符串
        :user_ip 客户端的IP地址
        """
        try:
            CaptchaConfig: Dict = self.app.config["TENCENTCLOUD"]["Captcha"]

            # 实例化http请求工具类
            httpProfile: HttpProfile = HttpProfile()
            # 设置API所在服务器域名
            httpProfile.endpoint = CaptchaConfig["endpoint"]
            # 实例化客户端工具类
            clientProfile: ClientProfile = ClientProfile()
            # 给客户端绑定请求的服务端域名
            clientProfile.httpProfile = httpProfile
            # 实例化验证码服务端请求工具的客户端对象
            client: captcha_client.CaptchaClient = captcha_client.CaptchaClient(self.cred, "", clientProfile)
            # 客户端请求对象参数的初始化
            req: models.DescribeCaptchaResultRequest = models.DescribeCaptchaResultRequest()

            params: Dict[str, Any] = {
                # 验证码类型固定为9
                "CaptchaType": CaptchaConfig["CaptchaType"],
                # 客户端提交的临时票据
                "Ticket": ticket,
                # 客户端ip地址
                "UserIp": user_ip,
                # 随机字符串
                "Randstr": randstr,
                # 验证码应用ID
                "CaptchaAppId": CaptchaConfig["CaptchaAppId"],
                # 验证码应用key
                "AppSecretKey": CaptchaConfig["AppSecretKey"],
            }
            # 发送请求
            req.from_json_string(json.dumps(params))
            # 获取腾讯云的响应结果
            resp = client.DescribeCaptchaResult(req)
            # 把响应结果转换成json格式数据
            result = json.loads(resp.to_json_string())
            if result.get("CaptchaCode") != 1:
                self.app.logger.error(f"验证码验证结果发生异常,腾讯云返回的验证结果:{result}")
            return result and result.get("CaptchaCode") == 1

        except Exception as err:
            self.app.logger.error(f"验证码接口异常:{err}")
            return False
View Code

 

application/__init__.py,代码:

from pathlib import Path
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
from flask_pymongo import PyMongo
from flask_jsonrpc import JSONRPC
from celery import Celery
from flask_jwt_extended import JWTManager

from application.utils.config import Config
from application.utils.logger import Logger
from application.utils.commands import Command
from application.utils.blueprint import AutoBluePrint, path
from application.utils import message, code  # 错误提示,状态码
from application.utils.tencent_api import TencentCloudAPI

# 实例化配置加载类
config: Config = Config()
# 实例化SQLAlchemy
db: SQLAlchemy = SQLAlchemy()
# 实例化redis
redis_cache: FlaskRedis = FlaskRedis(config_prefix="REDIS")
redis_check: FlaskRedis = FlaskRedis(config_prefix="CHECK")

# mongoDB实例化
mongo: PyMongo = PyMongo()
# 实例化日志配置类
logger: Logger = Logger()

# 实例化终端命令管理类
command: Command = Command()

# 实例化自动化蓝图类
blueprint: AutoBluePrint = AutoBluePrint()

# 实例化jsonrpc
jsonrpc = JSONRPC()


# 实例化celery
celery = Celery()

# jwt认证模块实例化
jwt = JWTManager()

# 实例化腾讯云API接口工具类
tencent_api = TencentCloudAPI()


def init_app(config_path: str) -> Flask:
    """用于创建app实例对象并完成初始化过程的工厂函数"""
    # 实例化flask应用对象
    app: Flask = Flask(__name__)
    # 全局路径常量,指向项目根目录
    app.BASE_DIR: Path = Path(__file__).resolve().parent.parent

    # 加载配置
    config.init_app(app, config_path)
    # SQLAlchemy加载配置
    db.init_app(app)
    # redis加载配置
    redis_cache.init_app(app)
    redis_check.init_app(app)
    # # pymongo加载配置
    mongo.init_app(app)

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

    # 终端命令管理类加载配置
    command.init_app(app)

    # jsonrpc注册到项目中
    # 开启rpc接口的web调试界面:/api/browse
    jsonrpc.browse_url = app.config.get("API_BROWSE_URL", "/api/browse")
    # 是否允许浏览器访问web调试界面,与DEBUG的值一致即可
    jsonrpc.enable_web_browsable_api = app.config.get("DEBUG", False)
    jsonrpc.init_app(app)

    # jwt初始化,必须写在蓝图注册代码的上方
    jwt.init_app(app)

    # 自动化蓝图类加载配置
    blueprint.init_app(app, jsonrpc)

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

    # 腾讯云API工具类加载配置
    tencent_api.init_app(app)

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

    return app
 
View Code

 

users/api.py,视图调用验证码验证工具函数,代码:

import random, json
from typing import Dict, Union, Any, Tuple
from flask_jwt_extended import jwt_required, get_jwt_identity
from flask import request
from . import serializers, models, tasks
from application import message, code, redis_check, tencent_api
from .services import gen_token, get_user_by_id

# 代码省略。。。。

def login(account: str, password: str, ticket: str, randstr: str) -> Dict[str, Any]:
    """
    用户jwt登录
    :param account: 账户名[可以是手机号、邮箱、用户名]
    :param password: 登录密码
    :param ticket: 滑块验证码的验证票据
    :param randstr: 滑块验证码的安全随机数
    :return
    """
    # 校验滑块验证码是否正确
    ret: bool = tencent_api.captcha(ticket, randstr, user_ip=request.environ["REMOTE_ADDR"])
    if not ret:
        result: Dict[str, Any] = {
            "errno": code.CODE_VALIDATE_ERROR,
            "errmsg": message.captcha_error
        }
        return result

    try:
        uls: serializers.UserLoginSchema = serializers.UserLoginSchema()
        user: models.User = uls.load({
            "account": account,
            "password": password
        })

        # 序列化
        payload: Dict[str, Any] = uls.dump(user)
        # 2. 生成jwt assess token 和 refresh token
        access_token: str = ""
        refresh_token: str = ""
        access_token, refresh_token = gen_token(payload=payload)

        # 3. 返回2个token给客户端
        result: Dict[str, Any] = {
            "errno": code.CODE_OK,
            "errmsg": message.success,
            "access_token": access_token,
            "refresh_token": refresh_token
        }
    except serializers.ValidationError as e:
        result: Dict[str, Any] = {"errno": code.CODE_VALIDATE_ERROR, "errmsg": e.messages}

    return result

# 代码省略。。。。
 
View Code

 

错误提示,message.py,代码:

captcha_error: str = "验证码错误!"
View Code

 

 
posted @ 2023-06-02 08:18  贰号猿  阅读(36)  评论(0)    收藏  举报