电商小程序后端开发文档


作者:Seiya

时间:2019年05月11日


项目介绍

这不是一个真实的项目,只是自己在进行PHP后端开发学习过程中的一次实战练习,目的是了解一些后端开发时的思路,熟悉ThinkPHP框架在实际开发过程中的一些应用。同时记录下自己的开发历程、开发过程中的思路以及一些功能的实现细节,便于自己后续进行回顾。



技术栈

  • ThinkPHP:快速、简单的面向对象的轻量级PHP开发框架

  • MySQL:一个关系型数据库管理系统

  • Apache2:Web服务器软件



项目目录结构

.
├── application                             # 应用目录
│   ├── api                                 # 模块目录
│   │   ├── behavior
│   │   ├── common.php                      # 模块公共函数文件
│   │   ├── config.php                      # 模块配置文件
│   │   ├── controller                      # 控制器层目录
│   │   ├── service                         # 应用服务层目录
│   │   ├── model                           # 模型层目录
│   │   └── validate                        # 校验层目录
│   ├── lib                                 # 自定义类库目录
│   │   ├── enum
│   │   │   ├── OrderStatusEnum.php
│   │   │   └── ScopeEnum.php
│   │   └── exception                       # 全局异常定义目录
│   ├── extra                               # 应用扩展配置文件
│   ├── route.php                           # 路由配置文件
│   ├── command.php                         # 命令行定义文件
│   ├── common.php                          # 应用公共函数文件
│   ├── config.php                          # 应用配置文件
│   ├── database.php                        # 数据库配置文件
│   └── test
│       ├── common.php
│       ├── config.php
│       ├── controller
│       └── model
├── build.php                               # 自动生成定义文件
├── extend                                  # 扩展类库目录
│   ├── Firebase                            #
│   │   └── JWT
│   ├── ItsDangerous
│   │   ├── BadData
│   │   ├── Signer
│   │   └── Support
│   └── WxPay                               # 微信支付扩展
├── log                                     # 应用日志目录
├── public                                  # WEB目录(对外访问目录)
├── think                                   # 命令行入口文件
├── thinkphp                                # 框架系统目录
└── vendor                                  # 第三方类库目录(Composer依赖库)


项目开发总结


RESTFul API实践

REST全称是Representational State Transfer,中文意思是表述性状态转移。它是一种互联网应用程序的API设计理念:URL定位资源,用HTTP动词(GET,POST,DELETE,DETC)描述操作。




  • 五种 HTTP 方法,对应 CRUD 操作

    GET:读取(Read)
    
    POST:新建(Create)
    
    PUT:更新(Update)
    
    PATCH:更新(Update),通常是部分更新
    
    DELETE:删除(Delete)
    

  • 实际应用举例:

    Route::group('api/:version/theme',function(){
        Route::get('', 'api/:version.Theme/getThemeList');
        Route::get('/:id', 'api/:version.Theme/getComplexOne');
        Route::post(':theme_id/product/:product_id', 'api/:version.Theme/addThemeProduct');
        Route::delete(':theme_id/product/:product_id', 'api/:version.Theme/deleteThemeProduct');
    });
    


AOP思想实践

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。



  • 实际应用举例:

    先来看看在实际开发过程中遇到的问题:

    可以看到这些业务逻辑(开始、结束、提交事务)依附在业务类的方法逻辑中!

    上面的图也很清晰了,将重复性的逻辑代码横切出来其实很容易(我们简单可认为就是封装成一个类就好了),但我们要将这些被我们横切出来的逻辑代码融合到业务逻辑中,来完成和之前(没抽取前)一样的功能!这就是AOP首要解决的问题了!


    像下面总结的“全局异常处理”和“参数校验处理”就是AOP思想的典型应用,如下:





     
     

     
     

     
     
     
     






    class Banner
    {
        public function getBanner($id)
        {
            // 参数验证处理
            (new IsMustBeInt())->goCheck();
    
            // 业务代码
            $banner = BannerModel::getBannerById($id);
    
            // 全局异常处理
            if (!$banner) {
                throw new BannerMissException();
            }
    
            // 返回最终结果
            return json($banner);
        }
    }
    

    解释:

    上面这个案例就是AOP思想的典型应用,本质上就是将分散在业务逻辑代码中相同的代码通过切割的方式抽取到一个独立的模块中!



ORM思想实践

简单说,ORM就是通过实例对象的语法,完成关系型数据库的操作的技术,是"对象-关系映射"(Object/Relational Mapping) 的缩写。

ORM把数据库映射成对象:

  • 数据库的表(table) --> 类(class)

  • 记录(record,行数据)--> 对象(object)

  • 字段(field)--> 对象的属性(attribute)



  • 实际应用举例:

    /**
     * Product类自动映射数据库中的Product表(注意类名和表名保持一致)
     */
    class Product extends BaseModel {
        // ThinkPHP框架自带的操作(自动更新datetime时间戳)
        protected $autoWriteTimestamp = 'datetime';
    
        // 手动关联数据库表
        // protected $table = 'product';
    
        // 自定义返回Json数据中需要隐藏的字段
        protected $hidden = [
            'delete_time', 'main_img_id', 'pivot', 'from', 'category_id',
            'create_time', 'update_time'];
    
        // 定义关联关系(Product表和ProductImage表为一对多的关系)
        // ProductImage是关联模型名,product_id是关联模型外键,id是当前模型关联主键
        public function imgs() {
            return $this->hasMany('ProductImage', 'product_id', 'id');
        }
    
        // 自动拼接图片url地址
        public function getMainImgUrlAttr($value, $data) {
            return $this->prefixImgUrl($value, $data);
        }
    
        // 数据库操作
        public static function getProductDetail($id) {
            $product = self::with([
                    'imgs' => function ($query)
                    { $query->with(['imgUrl'])->order('order', 'asc'); }
                ])->with('properties')->find($id);
            return $product;
        }
    }
    

    可以看到 ORM 使用对象封装了数据库操作,因此可以不碰 SQL 语言。开发者只使用面向对象编程,与数据对象直接交互,不用关心底层数据库。



参数校验

一个应用的输入应该首先要验证,在一个Web应用中,验证通常要实现2次:第一次是客户端验证,第二次是服务端验证。客户端的验证是为了更好的用户体验,通过检测表单的字段来提醒用户必须的字段;服务端的验证是更严格且无法避免的。

构建参数校验层也可以方便对项目进行解耦,这样扩展性更好,同时隐藏更多的实现细节,不用暴露更多的数据。


  • 构建校验类的基类

    所有的校验类都从基类中继承了 goCheck 函数,便于子类进行复用(如果有更多使用频率高的自定义校验函数,都可以放在基类里面)。

    /**
    * 验证类的基类
    */
    class BaseValidate extends Validate
    {
        /**
        * 检测所有客户端发来的参数是否符合验证类规则
        * 基类定义了很多自定义验证方法,这些自定义验证方法其实,也可以直接调用
        * @throws ParameterException
        * @return true
        */
        public function goCheck()
        {
            $request = Request::instance();
            $params = $request->param();
    
            if (!$this->check($params)) {
                // 抛出自定义参数异常
                $exception = new ParameterException([
                    'msg' => is_array($this->error) ? implode(';', $this->error) : $this->error,
                ]);
                throw $exception;
            }
            return true;
        }
    }
    

  • 构建校验类的子类

    根据业务来构建参数的校验方式,比如:正整数校验、参数范围校验、Token校验、分页校验、字符长度校验、是否为空校验等等,这里只记录两种校验方式,如下:

    /**
    * 正整数校验
    */
    class IDMustBeInt extends BaseValidate
    {
        // 校验规则
        protected $rule = [
            'id' => 'require|isInteger',
        ];
    
        // 自定义异常msg
        protected $message = [
            'ids' => 'ids参数必须为以逗号分隔的多个正整数'
        ];
    
        // 自定义校验方法(这个函数应该放在基类中)
        protected function isInteger($value, $rule='', $data='', $field='')
        {
            if (is_numeric($value) && is_int($value + 0) && ($value + 0) > 0) {
                return true;
            } else {
                return false;
            }
        }
    }
    
    /**
    * 分页校验
    */
    class PageParameter extends BaseValidate
    {
        protected $rule = [
            'page' => 'isInteger',
            'size' => 'isInteger'
        ];
    
        protected $message = [
            'page' => '分页参数必须是正整数',
            'size' => '分页参数必须是正整数'
        ];
    }
    

  • 使用校验层

    根据业务判断接口调用是否需要进行参数校验,以及参数校验的类型。具体调用方式如下:








     
     











    /**
     * controller层中调用参数校验
     */
    class Banner
    {
        public function getBanner($id)
        {
            // 参数验证
            (new IsMustBeInt())->goCheck();
    
            $banner = BannerModel::getBannerById($id);
    
            if (!$banner) {
                throw new BannerMissException();
            }
    
            return json($banner);
        }
    }
    


参数过滤

用户输入校验之后,还需要对传送到后端的参数进行过滤。所以,前端必须依据约定的字段名进行提交,同时也能够避免应用一些安全性方面的漏洞。


  • 具体实现









     
     
     
     





     
     
     



    /**
     * @param array $arrays 通常传入request.post变量数组
     * @return array 按照规则key过滤后的变量数组
     * @throws ParameterException
     */
    public function getDataByRule($arrays)
    {
        // 过滤user_id或者uid,防止恶意覆盖user_id外键
        if (array_key_exists('user_id', $arrays) | array_key_exists('uid', $arrays)) {
            throw new ParameterException([
                'msg' => '参数中包含有非法的参数名user_id或者uid'
            ]);
        }
        $newArray = [];
    
        // 过滤参数
        // 后端仅依据约定的key值获取参数
        foreach ($this->rule as $key => $value) {
            $newArray[$key] = $arrays[$key];
        }
        return $newArray;
    }
    

全局异常处理

在项目的开发中,不管是对底层的数据库操作过程,还是业务层的处理过程,还是控制层的处理过程,都不可避免会遇到各种可预知的、不可预知的异常需要处理。每个过程都单独处理异常,系统的代码耦合度高,工作量大且不好统一,维护的工作量也很大。为了解决这个问题,可以将所有类型的异常处理从各处理过程解耦出来,这样既保证了相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。


  • 自定义异常类型

    在进行全局异常处理的编写前,需要对整个业务中可能的异常进行梳理,如下:

    999 未知错误
    
    1 开头为通用错误
    2 商品类错误
    3 主题类错误
    4 Banner类错误
    5 类目类错误
    6 用户类错误
    8 订单类错误
    
    10000 通用参数错误
    10001 资源未找到
    10002 未授权(令牌不合法)
    10003 尝试非法操作(自己的令牌操作其他人数据)
    10004 授权失败(第三方应用账号登陆失败)
    10005 授权失败(服务器缓存异常)
    
    20000 请求商品不存在
    30000 请求主题不存在
    40000 Banner不存在
    50000 类目不存在
    60000 用户不存在
    60001 用户地址不存在
    
    80000 订单不存在
    80001 订单中的商品不存在,可能已被删除
    80002 订单还未支付,却尝试发货
    80003 订单已支付过
    

  • 重写 Handle类 的 render 方法,实现自定义异常消息

    class ExceptionHandler extends Handle
    {
        private $code;
        private $msg;
        private $errorCode;
    
        public function render(Exception $e)
        {
            if ($e instanceof BaseException)
            {
                //如果是自定义异常,则控制http状态码,不记录日志(通常是客户端传递参数错误或者用户请求造成的异常)
                $this->code = $e->code;
                $this->msg = $e->msg;
                $this->errorCode = $e->errorCode;
            }
            else{
                // 如果是服务器未处理的异常,将http状态码设置为500,并记录日志
                // 调试状态下显示ThinkPHP默认异常页面
                // 生产环境下记录错误日志
                if(config('app_debug')){
                    return parent::render($e);
                }
    
                $this->code = 500;
                $this->msg = 'sorry,we make a mistake. (^o^)Y';
                $this->errorCode = 999;
                $this->recordErrorLog($e);
            }
    
            $request = Request::instance();
            $result = [
                'msg'  => $this->msg,
                'error_code' => $this->errorCode,
                'request_url' => $request = $request->url()
            ];
            return json($result, $this->code);
        }
    
        /*
         * 将异常写入日志
         */
        private function recordErrorLog(Exception $e)
        {
            Log::init([
                'type'  =>  'File',
                'path'  =>  LOG_PATH,
                'level' => ['error']
            ]);
            Log::record($e->getMessage(),'error');
        }
    }
    

  • 自定义异常类型基类

    class BaseException extends Exception
    {
        public $code = 400;             // HTTP状态码 404、200...
        public $msg = '参数错误';        // 错误具体信息
        public $errorCode = 10000;      // 自定义错误码
    
        /**
         * 构造函数,接收一个关联数组
         * @param array $params 关联数组只应包含code、msg和errorCode,且不应该是空值
         */
        public function __construct($param = [])
        {
            if(!is_array($param)){
                return ;
            }
    
            if(array_key_exists('code', $param)) {
                $this->code = $param['code'];
            }
    
            if(array_key_exists('code', $param)) {
                $this->msg = $param['msg'];
            }
    
            if(array_key_exists('code', $param)) {
                $this->errorCode = $param['errorCode'];
            }
        }
    }
    

  • 构建异常处理类型子类

    异常处理有很多类型,同时根据业务不同会有不同的异常,需要单独进行构建,比如:参数异常、数据库异常、Token异常、业务异常等等...

    /**
     * 404时抛出此异常
     */
    class MissException extends BaseException
    {
        public $code = 404;
        public $msg = 'global:your required resource are not found';
        public $errorCode = 10001;
    }
    
    /**
     * token验证失败时抛出此异常
     */
    class ForbiddenException extends BaseException
    {
        public $code = 403;
        public $msg = '权限不够';
        public $errorCode = 10001;
    }
    
    /**
     * 通用参数类异常错误
     */
    class ParameterException extends BaseException
    {
        public $code = 400;
        public $errorCode = 10000;
        public $msg = "invalid parameters";
    }
    
    class OrderException extends BaseException
    {
        public $code = 404;
        public $msg = '订单不存在,请检查ID';
        public $errorCode = 80000;
    }
    

  • 调用异常处理

    根据具体情况判断需要调用的异常类型。具体调用方式如下:













     
     






    /**
     * 业务异常类型处理
     */
    class Banner
    {
        public function getBanner($id)
        {
            (new IsMustBeInt())->goCheck();
    
            $banner = BannerModel::getBannerById($id);
    
            if (!$banner) {
                // 若返回的$banner为空,抛出异常
                throw new BannerMissException();
            }
    
            return json($banner);
        }
    }
    








     
     
     
     









    /**
     * 参数异常类型处理
     */
    class BaseValidate extends Validate
    {
        public function goCheck()
        {
            $request = Request::instance();
            $params = $request->param();
    
            if (!$this->check($params)) {
                // 抛出自定义参数异常
                $exception = new ParameterException([
                    'msg' => is_array($this->error) ? implode(';', $this->error) : $this->error,
                ]);
                throw $exception;
            }
            return true;
        }
    }
    


业务开发细节


生成 Token 令牌

整体 token 身份体系如下图所示:

  • Controller层

    /**
     * 用户获取令牌(登陆)
     * @url /token
     * @POST code
     * @note 虽然查询应该使用get,但为了稍微增强安全性,所以使用POST
     */
    public function getToken($code='')
    {
        // 参数校验
        (new TokenGet())->goCheck();
    
        $wx = new UserToken($code);
        $token = $wx->get();
        return [
            'token' => $token
        ];
    }
    

  • Service层

    由于登录逻辑相当复杂,所以增加了 Service 层,用于封装复杂的业务逻辑,如果业务逻辑比较简单,可以直接通过 Model 层实现。

    class UserToken extends Token
    {
        protected $code;
        protected $wxLoginUrl;
        protected $wxAppID;
        protected $wxAppSecret;
    
        // 接收 code 拼接成 loginurl
        function __construct($code)
        {
            $this->code = $code;
            $this->wxAppID = config('wx.app_id');
            $this->wxAppSecret = config('wx.app_secret');
            $this->wxLoginUrl = sprintf(
                config('wx.login_url'), $this->wxAppID, $this->wxAppSecret, $this->code);
        }
    
        /**
        * 登陆
        * 思路1:每次调用登录接口都去微信刷新一次session_key,生成新的Token,不删除久的Token
        * 思路2:检查Token有没有过期,没有过期则直接返回当前Token
        * 思路3:重新去微信刷新session_key并删除当前Token,返回新的Token
        */
        public function get()
        {
            // 向微信服务器发送 http 请求
            $result = curl_get($this->wxLoginUrl);
    
            // 注意json_decode的第一个参数true
            // 这将使字符串被转化为数组而非对象
            $wxResult = json_decode($result, true);
    
            // 微信返回结果判断(这里有两层)
            if (empty($wxResult)) {
                // 空值异常判断
                throw new Exception('获取session_key及openID时异常,微信内部错误');
            } else {
                // 建议用明确的变量来表示是否成功
                // 微信服务器并不会将错误标记为400,无论成功还是失败都标记成200
                // 这样非常不好判断,只能使用errcode是否存在来判断
                $loginFail = array_key_exists('errcode', $wxResult);
                if ($loginFail) {
                    // 抛出异常,返回给客户端
                    $this->processLoginError($wxResult);
                }
                else {
                    // 验证成功则颁发令牌
                    return $this->grantToken($wxResult);
                }
            }
        }
    
        // 颁发令牌
        // 只要调用登陆就颁发新令牌,但旧的令牌依然可以使用
        // 所以通常令牌的有效时间比较短,目前微信的express_in时间是7200秒
        // 在不设置刷新令牌(refresh_token)的情况下,只能延迟自有token的过期时间超过7200秒
        // 目前还无法确定,在express_in时间到期后,还能否进行微信支付
        // 没有刷新令牌会有一个问题,就是用户的操作有可能会被突然中断
        private function grantToken($wxResult)
        {
            // 此处生成令牌使用的是TP5自带的令牌
            // 如果想要更加安全可以考虑自己生成更复杂的令牌
            // 比如使用JWT并加入盐,如果不加入盐有一定的几率伪造令牌
            // $token = Request::instance()->token('token', 'md5');
            $openid = $wxResult['openid'];
    
            // 查看数据库openID是否已存在
            $user = User::getByOpenID($openid);
            if (!$user) {
                // 借助微信的openid作为用户标识,但在系统中的相关查询还是使用自己的uid
                // 创建新用户
                $uid = $this->newUser($openid);
            } else {
                $uid = $user->id;
            }
            // 将微信返回结果和内部的 userID 拼接成 value 值
            $cachedValue = $this->prepareCachedValue($wxResult, $uid);
            $token = $this->saveToCache($cachedValue);
            return $token;
        }
    
        // 由于性能考虑,将 value 写入缓存,而不是数据库
        private function saveToCache($wxResult) {
            $key = self::generateToken();
            $value = json_encode($wxResult);
            $expire_in = config('setting.token_expire_in');
    
            // 将数据写入缓存
            $result = cache($key, $value, $expire_in);
    
            if (!$result){
                throw new TokenException([
                    'msg' => '服务器缓存异常',
                    'errorCode' => 10005
                ]);
            }
            return $key;
        }
    
        // 生成 token 令牌
        public static function generateToken() {
            // 随机生成一个32位的字符串(若不考虑安全问题,可直接返回此字符串表示 token)
            $randChar = getRandChar(32);
    
            // 当前时间戳
            $timestamp = $_SERVER['REQUEST_TIME_FLOAT'];
    
            // 取出内部定义的 salt 配置
            $tokenSalt = config('secure.token_salt');
    
            // 用三组字符串进行 md5 加密
            return md5($randChar . $timestamp . $tokenSalt);
        }
    
        // 随机拼接一个32位字符串
        function getRandChar($length) {
            $str = null;
            $strPol = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
            $max = strlen($strPol) - 1;
    
            for ($i = 0; $i < $length; $i++) {
                $str .= $strPol[rand(0, $max)];
            }
            return $str;
        }
    }
    

  • Model层

    class User extends BaseModel
    {
        protected $autoWriteTimestamp = true;
    //    protected $createTime = ;
    
        public function orders() {
            return $this->hasMany('Order', 'user_id', 'id');
        }
    
        public function address() {
            return $this->hasOne('UserAddress', 'user_id', 'id');
        }
    
        /**
        * 用户是否存在
        * 存在返回uid,不存在返回0
        */
        public static function getByOpenID($openid) {
            $user = User::where('openid', '=', $openid)
                ->find();
            return $user;
        }
    }
    


校验 Token 令牌

当用户访问受保护的接口时,必须要传入 token 令牌。服务端拿到 token 令牌后,需要验证 token 是否合法,若 token 合法,则到缓存中取出对应的 value 值,并验证 token 是否过期。验证通过后,通过 value 值解析出 userID 并执行相应的业务操作。下面拿创建地址来举例:


  • Controller层

    /**
    * 更新或者创建用户收获地址
    */
    public function createOrUpdateAddress()
    {
        $validate = new AddressNew();
        $validate->goCheck();
    
        // 根据 token 获取 userID
        $uid = TokenService::getCurrentUid();
    
        // 拿到 userID 后完成后续业务逻辑
    
        return new SuccessMessage();
    }
    

  • Service层

    /**
     * 当需要获取全局UID时,应当调用此方法,而不应当自己解析UID
     */
    public static function getCurrentUid()
    {
        $uid = self::getCurrentTokenVar('uid');
        return $uid;
    }
    
    public static function getCurrentTokenVar($key)
    {
        $token = Request::instance()->header('token');
        $vars = Cache::get($token);
        if (!$vars) {
            throw new TokenException();
        } else {
            if(!is_array($vars)) {
                $vars = json_decode($vars, true);
            }
            if (array_key_exists($key, $vars)) {
                // 这里判断通过后直接返回了值,没有对 token 进行过期判断
                return $vars[$key];
            }
            else {
                throw new Exception('传入Token不合法');
            }
        }
    }
    


用户身份权限控制

这里我们自己实现了用户的权限控制,更成熟的权限体系可以了解 RBAC(基于角色的访问控制)。

  • 权限控制基类
/**
 * 接口访问权限枚举
 * 这种权限涉及是逐级式,虽然简单,但不够灵活
 * 最完整的权限控制方式是作用域列表式权限,给每个令牌划分到一个SCOPE作用域,每个作用域
 * 可访问多个接口
 */
class ScopeEnum
{
    // 普通用户权限
    const User = 16;

    // 管理员是给CMS准备的权限
    const Super = 32;
}

  • 权限颁发




 



private function prepareCachedValue($wxResult, $uid)
{
    $cachedValue = $wxResult;
    $cachedValue['uid'] = $uid;
    $cachedValue['scope'] = ScopeEnum::User;
    return $cachedValue;
}

拿到微信服务器返回的数据后,在拼接上用户的 userID 以及用户的权限 scope,然后将拼接后的数据生成Token。


  • 权限拦截封装

protected function checkPrimaryScope() {
    $scope = self::getCurrentTokenVar('scope');
    if ($scope) {
        if ($scope >= ScopeEnum::User) {
            return true;
        }
        else{
            throw new ForbiddenException();
        }
    } else {
        throw new TokenException();
    }
}

  • 权限控制使用

    protected function checkPrimaryScope() {
        $scope = self::getCurrentTokenVar('scope');
        if ($scope) {
            if ($scope >= ScopeEnum::User) {
                return true;
            }
            else{
                // 权限不够
                throw new ForbiddenException();
            }
        } else {
            // 没有权限
            throw new TokenException();
        }
    }
    


微信支付



订单系统

用户选择商品后,向 API 提交包含所选商品的相关信息,API 接收到信息后,需要检查订单相关商品库存量(第一次检测库存)。如果有库存,把订单数据存入数据库中(下单成功),返回客户端消息,告诉客户端可以支付了。

客户端调用支付接口进行支付,同时再次检测库存量(第二次检测库存)。通过后,服务端就可以调用微信支付接口进行支付,根据微信返回的结果判断是否支付成功。成功就再一次检测库存(第三次检测库存),并进行库存量的扣除,失败则返回一个支付失败的结果。

小程序由于微信返回的支付结果是滞后的(异步),所以在进行小程序后端开发时,只处理订单支付成功的逻辑。具体业务流程如下图所示:



分页

最后更新时间: 7/7/2019, 9:55:38 PM