0.简介
大家好!我是一名九零后中年大叔,搬砖了好几年,前些年用PHP开发,后面因为开发需要就转java了。为了不让自己幸苦自学的php丢掉,趁一些空余时间,写一些教程,分享一下自己的工作经验。
这个教程打算以一个商城的项目为例,我会教大家一步一步完成功能的开发。这个教程会很讲得很细致,主要涉及到架构的设计、开发过程中的规范、常用的技术知识以及最重要的就是如何避坑。可以说是把自己的干货无私的奉献给大家,如果对你有用,请点个赞~
如果教程中有些代码写得不好,欢迎指正,大家一起学习学习~
教程会持续更新~
源码:https://gitee.com/myha/demo-shop
1.架构搭建
1.1环境搭建
PHP版本:php7.3.4
mysql版本:5.7
redis版本:3.0
这里推荐使用phpstudy集成环境
1.2框架选型
国内外都有很多优秀的框架,例如thinkphp、laravel、yii等。这些都是很优秀的框架,对于框架的选择,每个公司都有自己的技术栈,作为一个php程序员,我认为掌握thinkphp和laravel是必须的。
本教程的项目使用了Thinkphp6.1的版本,是当前最新版本。
官方文档链接:https://www.kancloud.cn/manual/thinkphp6_0/1037479
安装
composer create-project topthink/think demo-shop
默认是单应用模式的,这里我们开发的项目是多应用,因此还需执行以下命令
composer require topthink/think-multi-app
目录结构
把框架下载下来后,我们认识一下里面的一些目录,下面是官网给出的目录结构,实际上下载下来的目录比较简洁
www WEB部署目录(或者子目录)
├─app 应用目录
│ ├─app_name 应用目录
│ │ ├─common.php 函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ ├─config 配置目录
│ │ ├─route 路由目录
│ │ └─ ... 更多类库目录
│ │
│ ├─common.php 公共函数文件
│ └─event.php 事件定义文件
│
├─config 全局配置目录
│ ├─app.php 应用配置
│ ├─cache.php 缓存配置
│ ├─console.php 控制台配置
│ ├─cookie.php Cookie配置
│ ├─database.php 数据库配置
│ ├─filesystem.php 文件磁盘配置
│ ├─lang.php 多语言配置
│ ├─log.php 日志配置
│ ├─middleware.php 中间件配置
│ ├─route.php URL和路由配置
│ ├─session.php Session配置
│ ├─trace.php Trace配置
│ └─view.php 视图配置
│
├─public WEB目录(对外访问目录)
│ ├─index.php 入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于apache的重写
│
├─extend 扩展类库目录
├─runtime 应用的运行时目录(可写,可定制)
├─vendor Composer类库目录
├─.example.env 环境变量示例文件
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件
稍微留意一下框起来的部分,后面会用到
URL重写
重写url主要是为了隐藏入口index.php,我们可以发现很多网站的地址并没有带上入口文件,比如说tp的官网
https://www.kancloud.cn/manual/thinkphp6_0/1037479 而不是
https://www.kancloud.cn/index.php/manual/thinkphp6_0/1037479
官网文档里面也提到了,它也给出了重写的规则,但是最后会发现有点问题,因此做了一下调整
<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
#####RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L] 这是官网的
RewriteRule ^(.*)$ index.php [L,E=PATH_INFO:$1]
</IfModule>
注意上面是基于Apache的规则,我们在根目录(入口文件所在的目录),新建一个 .htaccess 文件,把规则写进里面去即可
2.会员管理
会员管理模块是商城项目中极其重要的模块,我们就从这个模块开始吧。
2.1功能简介
下面是我根据某商城项目,规划的一些功能,前期从简单的开始,后面再来丰富它的功能
功能 | 描述 |
---|---|
会员注册 | 手机号注册、手机验证码 |
会员登录 | 账号密码登录、手机号登录 |
会员信息 | 查询、更新、启用/禁用、删除 |
会员等级 | 新增、查询、更新、删除 |
会员积分 | 签到、下单 |
会员成长值 | 下单 |
2.2设计数据表
我认为建表是整个项目中最关键的一步,如果表建的不合理,就会影响到你后面代码的逻辑以及系统的稳定性。如何合理的建表,主要是取决于你对mysql优化知识的掌握和自己的工作经验,知识自己可以去学习,经验的话就得靠自己项目的积累了,只有你接触了足够的项目,你才能知道怎么根据需求去设计数据表。
根据上面的功能需求,我们就开始建表了
从上面的需求出发,我们需要建的表有4张,分别为会员表、会员等级表、会员积分流水表、会员成长值流水表
前面两张表很容易理解,后面两张表有必要吗?
答案当然是很有必要的,虽然说我们在会员表会记录个总积分、总成长值,但这个积分的明细是很有必要的,因为你可以清楚的知道这些积分创建的时间,方便我们后期纠错。
建表的规范
下面简单的列出几条建议
- 无论是表名还是字段名,使用小写加下划线方式命名,例如
ds_user
。但注意字名不要以下划线开头 。同时 不建议使用驼峰和中文作为数据表及字段命名。 - 合理选择数据类型及长度设置,例如会员性别,我们可以用1表示男,2表示女,我们选择的类型是tinyint而不是int。再比如会员登陆密码,一般30个字符以内,因此没必要给到255长度。
- 字符型字段和整型字段最好给个默认值,例如空字符串、0
- 新建索引,一般来说根据需求,我们大概会知道有哪些字段需要建立索引,但需要注意的是一张表最好把索引控制在5个以内
- 每张表自增ID是必须的,同时建议每张表都加上创建人、更新人、创建时间、更新时间这四个字段。
数据库设计的规范远不止这些,但是每家公司的要求可能不尽相同。比如说我大学同学,在大厂里面管的仓库系统,数据都是亿为单位的,有些表字段超过100个、索引也是加了很多。
好了,话说回来,具体的字段我就不一一讲解,大家可以按照语句去创建表
建表语句和字段说明查看最后的附录
最后我们在项目中新增数据库的配置,在最外层目录新建一个.env
,参考其给出的示例.example.env
,具体内容如下:
#数据库配置配置
[DATABASE]
TYPE = mysql
HOSTNAME = 127.0.0.1
DATABASE = demo-shop
USERNAME = root
PASSWORD = root
HOSTPORT = 3306
CHARSET = utf8
DEBUG = true
2.3手机验证码
2.3.3免费短信服务
这里我们使用阿里云的短信服务,上面提供了免费服务供我们学习测试打开如下
地址:https://dysms.console.aliyun.com/quickstart,然后获取相对应的签名和模板
点击来到
这里边是示例代码,有几个比较重要的参数,accessKeyId,accessKeySecret,signName,templateCode
其中signName,templateCode示例代码有
至于accessKeyId,accessKeySecretd的获取,从下面入口进入即可
做完这些准备工作后,下面开始写代码了
2.3.4策略模式
这里我们学习一种php的设计模式-策略模式,来封装发送验证码的核心代码,我们先了解一下它的概念
策略模式又叫做政策模式,用于如何组织和调用算法的,是属于行为型模式的一种。 策略模式需要三个角色构成:
- Context 封装角色:也叫做上下文角色,起承上启下封装作用,屏蔽高层模块对策略、算法的直接访问,封装可能存在的变化
- Strategy 抽象策略角色:通常为接口,指定规则
- ConcreteStrategy 具体策略角色:实现抽象策略中的操作,该类含有具体的算法
优点
算法可以通过参数自由切换, 方便扩展,增加策略只需要实现接口就行了 。
第一步:Strategy 抽象策略角色
它就是定义一个基础接口,这样做的目的就是规范代码,不管你用哪个服务商的代码,但都必须实现这个基础接口
我们在app目录下新建一个公共应用common,这里主要存放一些公共资源,供其它模块调用。在common目录下新建一个lib目录(第三方组件)—>msg目录(手机发送信息组件)—>Message.php
namespace app\common\lib\msg;
interface Message
{
/**
* 发送验证码
* @param string $mobile 手机号
* @param string $code 验证码
* @return bool
*/
public function send($mobile,$code);
}
这里定义了一个send()
方法,后续接入进来的服务商代码都要继承这个接口,实现send()
方法
**第二步:ConcreteStrategy 具体策略角色 **
以阿里云的服务商为例,我们把代码复制过来
class AliYunMessage implements Message
{ //实现接口
public function send($mobile, $code){
//读取配置文件的配置
$config = new Config([
"accessKeyId" => config('aliyun.accessKeyId'),
"accessKeySecret" => config('aliyun.accessKeySecret')
]);
$config->endpoint = config('aliyun.dysmsapi');
$client = new Dysmsapi($config);
$sendSmsRequest = new SendSmsRequest([
"phoneNumbers" => $mobile,
"signName" => config('aliyun.signName'),
"templateCode" => config('aliyun.templateCode'),
"templateParam" => json_encode(['code'=>$code])
]);
$runtime = new RuntimeOptions([]);
try {
// 复制代码运行请自行打印 API 的返回值
$rs = $client->sendSmsWithOptions($sendSmsRequest, $runtime);
Log::write("获取手机验证码:".json_encode($rs));
return true;
}
catch (Exception $error) {
if (!($error instanceof TeaError)) {
$error = new TeaError([], $error->getMessage(), $error->getCode(), $error);
}
Log::write('获取手机验证码异常:'.$error->message);
return false;
}
}
}
代码中第一步就是读取配置文件的一些关键信息,这里需要在config目录新建aliyun.php
,内容如下
<?php
// +----------------------------------------------------------------------
// | 阿里云相关配置
// +----------------------------------------------------------------------
return [
//AccessKey ID
'accessKeyId' => '',
// AccessKey Secret
'accessKeySecret' => '',
/***短信配置****/
// 签名
'signName' => '阿里云短信测试',
// 短信模板CODE
'templateCode' => 'SMS_154950909',
// 访问的域名
'dysmsapi' => 'dysmsapi.aliyuncs.com'
];
注意这里的config目录是app同级的目录,而不是common应用下的目录。刚开始我也放在这个目录下的,但是不生效,后来就放到外面来了
第三步:Context 封装角色
namespace app\common\lib\msg;
class MessageContext
{
private $message;
public function __construct(Message $msg)
{
$this->message = $msg;
}
public function sendMessage($mobile,$code)
{
return $this->message->send($mobile,$code);
}
}
这个类里面的构造函数的参数实际就是策略类,然后再调用其里面的send()
方法
最后看看调用示例
$msgCtx = new MessageContext(new AliYunMessage);
$msgCtx->sendMessage($mobile,$code);
有些人可能会觉得,如果直接在AliYunMessage
类定义一个静态方法,然后直接调用是不是更加方便?
显然,确实是更加方便。
这里我们使用这种策略模式,其目的就是多学习一下php的设计模式,它本身的代码也不复杂,适合在项目中使用。另外它的一个优点就是调用方并不是直接调用算法,而是通过上下文角色进行调用,这样我们就不需要关注算法本身,如果我们要加策略,直接新增类就行。
2.3.5redis缓存
redis缓存可以说是项目中最常用的技术了,本项目是一个前后端分离项目,因此我们需要借助缓存去保存一些信息,例如验证码。
新增配置
打开config/cache.php
//定义个保存验证码的常量KEY
define('MOBILE_CODE_','mobile_code_');
return [
// 默认缓存驱动---默认是文件
'default' => env('cache.driver', 'file'),
// 缓存连接方式配置
'stores' => [
'file' => [
// 驱动方式
'type' => 'File',
// 缓存保存目录
'path' => '',
// 缓存前缀
'prefix' => '',
// 缓存有效期 0表示永久缓存
'expire' => 0,
// 缓存标签前缀
'tag_prefix' => 'tag:',
// 序列化机制 例如 ['serialize', 'unserialize']
'serialize' => [],
],
// 这里面是新加的redis配置
'redis' => [
// 驱动方式
'type' => 'redis',
//服务地址
'host' => env('cache.host'),
//端口
'port' => env('cache.port'),
//密码
'password' => env('cache.password'),
//节点
'select' => env('cache.select'),
// 缓存前缀
'prefix' => env('cache.prefix'),
// 缓存有效期 0表示永久缓存
'expire' => env('cache.expire')
],
],
];
配置文件中默认配置了本地文件的缓存方式,因此需要新增redis缓存配置
打开项目中的.env文件,新增如下配置
#redis配置
[CACHE]
DRIVER = redis
HOST = 127.0.0.1
PORT = 6379
PASSWORD =
SELECT = 0
PREFIX =
EXPIRE = 0
2.3.6短信防刷
短信防刷的方式有很多,比如一个手机一天最多能发送多少次,一个ip一天最多能发送多少次,每一次发送后隔多长时间才能发送
if(cache(MOBILE_CODE_.$mobile)){
return $this->failure(config('error.er3')['code'],config('error.er3')['msg']);
}
cache(MOBILE_CODE_.$mobile,$code,60);
这段代码其实就是同一个手机号至少每隔60秒后才能发送一次验证码
2.3.7返回值的封装
后端的接口返回数据给前端,一般都有固定的json格式,其中包含3个常见的字段
- code:状态码
- msg:提示信息
- data:返回的数据
thinkphp自带了一个json()
,但这里我对其进行了封装,方便后面调用
打开app目录下的BaseController.php
,这个是基础控制器,框架自带的,我们可以对它进行扩展,这里我封装了三个方法
//成功的返回函数
protected function success($data = []){
return json(['code'=>200, 'msg'=>'操作成功','data'=>$data]);
}
//失败的返回函数
protected function failure($code=201, $msg='操作失败'){
return json(['code'=>$code, 'msg'=> $msg]);
}
//异常的返回函数
protected function error(){
return json(['code'=>500, 'msg'=>'服务器异常~']);
}
这样后续我们可以在业务控制器中直接使用上面的方法。
这三个方法只能在继承了BaseController的控制器才能使用,但实际开发中们可能需要在任何地方都可以抛出异常,因此我们需要定义一个函数,打开app目录下的common.php
,这里面定义全局函数
/**
* 自定义异常
* @param string $code 错误码
* @param string $msg 提示信息
*/
function customException($code=500, $msg='服务器异常~'){
$response = [
'code'=>$code,
'msg'=> $msg
];
echo json_encode($response);
exit();
}
这个函数后续很多地方用到
2.3.8PC端基础控制器
之前已经讲过,本项目是一个多应用的项目,有admin应用、有提供给pc端网页接口的应用,因此这里面我的设计是这样的:为每个应用建立一个基础控制,而这个控制器又是继承BaseController.php
这个底层控制器,这样的话既可以使用底层的一些方法又可以为当前应用开发自己的公用功能。
在app目录新建controller,再新建PcController.php
,内容如下:
<?php
namespace app\pc\controller;
use think\App;
use app\BaseController;
class PcController extends BaseController
{
public function __construct(App $app)
{
parent::__construct($app);
}
//初始化方法
protected function initialize()
{
}
}
2.3.9控制器代码
在app–>pc–>controller下新建一个User.php
,它继承PcController.php
<?php
namespace app\pc\controller;
use app\common\lib\msg\MessageContext;
use app\common\lib\msg\AliYunMessage;
class User extends PcController
{
//获取验证码
public function sendMsg(){
$mobile = $this->request->get("mobile");
//验证手机号是否为空
if(empty($mobile)){
return $this->failure(config('error.er1')['code'],config('error.er1')['msg']);
}
//验证手机格式是否正确
if(!matchMobile($mobile)){
return $this->failure(config('error.er2')['code'],config('error.er2')['msg']);
}
//判断该手机号是否存在于缓存中
if(cache(MOBILE_CODE_.$mobile)){
return $this->failure(config('error.er3')['code'],config('error.er3')['msg']);
}
//生成4位数随机验证码
$code = rand(0000,9999);
$msgCtx = new MessageContext(new AliYunMessage);
if($msgCtx->sendMessage($mobile,$code)){
//把验证码保存在缓存中,并且设置60秒过期时间
cache(MOBILE_CODE_.$mobile,$code,60);
return $this->success();
}else{
return $this->failure(config('error.er4')['code'],config('error.er4')['msg']);
}
}
}
这段代码逻辑很简单,看注释都看得懂。这里面验证码发送成功了,就直接调用封装好的success()
,失败的话就调用failure()
,这里有个地方需要建议一下的,我们尽量把错误码和提示信息写到配置里面,在代码中尽量不要这样写
$this->failure(1003,"获取验证码失败");
而是建议这样
$this->failure(config('error.er4')['code'],config('error.er4')
错误码的定义如下
config/error.php
return [
'er1' =>['code'=>'1000','msg'=>'请输入手机号!'],
'er2' =>['code'=>'1001','msg'=>'手机格式不正常!'],
'er3' =>['code'=>'1002','msg'=>'60秒后才能重新发送短信验证码!'],
'er4' =>['code'=>'1003','msg'=>'获取验证码失败!'],
];
2.4.10路由配置
我们在当前应用pc下新建一个route目录,新建app.php
,内容如下
//发送手机验证码
Route::get('mobile/msg', 'user/sendMsg');
我们只需这样访问接口即可:http://demo.shop.com/pc/mobile/msg?mobile=13800138000
跟app同级的route目录,这里的话不在里面配置路由
2.4用户注册
2.4.1字段验证器
用户注册一般要求账号、密码、手机号必填,并且密码和手机号格式都有要求,如果我们每个都写一个if判断,那将会很繁琐,因此thinkphp给我提供了简便的方法。
还是打开BaseController.php
这个控制,其里面有这样的一个方法
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
但是这里有点小问题,如果验证不通过的话是抛出异常,而不是返回错误信息,因此做了一点小改动
if(!$v->check($data)){
//如果验证码不通过,直接返回一个对象
return $v;
}
//通过就返回true
return true;
接下来看看是如何调用的
//前端传过来的数据
$data = $this->request->post();
//验证规则
$validate = [
'account' => 'require',
];
//提示信息
$message = [
'account.require' => '账号不能为空!',
];
//去除账号的前后空格
$data['account'] = trim($data['account']);
//把相关参数传给验证器
$result = $this->validate($data, $validate, $message);
if($result !== true){
//如果验证验证失败,则通过$result->getError()把错误信息返回给前端
return $this->failure(config('error.er5')['code'],$result->getError());
}
例如
{
"code": "1004",
"msg": "账号不能为空!"
}
2.4.2用户模型
thinkphp的模型,我的理解就是跟数据库打交道,我看了文档确实是比较方便,因此整个项目都会用模型。一个模型对应一张表,无论是pc端、app端、后台的接口都需要操作模型,因此把模型全部放在公共应用common里面。
在common目录下新建model目录,在新建UserModel.php
,内容如下
namespace app\common\model;
use think\Model;
use think\model\concern\SoftDelete;
class UserModel extends Model
{
//映射到那张表
protected $table = 'ds_user';
//软删除字段,新增了这个属性后,查询语句都会带上delete_time is null这个条件
use SoftDelete;
protected $deleteTime = 'delete_time';
}
2.4.3业务逻辑层
一般来说我们是不会把业务的具体逻辑写在控制器里面,控制器的职责只是做些参数的验证以及结果的返回,具体的业务逻辑一般写在另外一个地方。
本项目是把业务逻辑写在了公共应用common里面,同样的pc端、app端、后台都可以调用,当然如果是pc端特有的业务逻辑,你也可以在pc应用的目录下新建一个service目录作为该应用特有的,本项目的话因为自己一个人开发,也就没那么多讲究,直接写在了公共应用里面。
在common目录下新建service目录,再新建UserService.php
,内容如下
<?php
// +----------------------------------------------------------------------
// | 用户模块业务逻辑
// +----------------------------------------------------------------------
// | Author: myh
// +----------------------------------------------------------------------
namespace app\common\service;
use app\common\model\UserModel;
class UserService
{
/**
* 新增用户信息
* @param array $data 新增的数据
* @return int
*/
public static function save($data){
//验证该账号是否存在
if(UserModel::getByAccount($data['account'])){
customException(config('error.er8')['code'],config('error.er8')['msg']);
}
//验证手机号是否存在
if(UserModel::getByMobile($data['mobile'])){
customException(config('error.er9')['code'],config('error.er9')['msg']);
}
$data['password'] = createPassword($data['password']);
$user = new UserModel;
$user->save($data);
return $user->id;
}
}
这段代码首先判断账号、手机号是否被注册过,如果没有则抛出异常,前面封装返回值的时候讲过customException()
就在这里使用上了
另外这里我们要注意的是密码需要加密才能存储到数据表里面,在common.php
文件里面新增如下方法
/**
* 密码加密
* @param string $pw 要加密的原始密码
* @param string $authCode 加密字符串
* @return string
*/
function createPassword($pw, $authCode = '')
{
if (empty($authCode)) {
$authCode = config('app.authcode');
}
$result = "***" . md5(md5($authCode . $pw));
return $result;
}
/**
* 密码比较方法,所有涉及密码比较的地方都用这个方法
* @param string $password 要比较的密码
* @param string $passwordInDb 数据库保存的已经加密过的密码
* @return boolean 密码相同,返回true
*/
function comparePassword($password, $passwordInDb)
{
return createPassword($password) == $passwordInDb;
}
一个是加密函数,一个是在登录时用到的密码校验函数
注
这里无论是方法名还是字段的命名采用了驼峰式,这点主要受java的影响
2.4.4控制器代码
接下来就是控制器端的代码,打开User.php
新增如下代码
//用户注册
public function register(){
$data = $this->request->post();
//验证规则
$validate = [
'account' => 'require',
'password' => 'require|regex:/^[a-zA-Z0-9_]{8,20}$/',
'repassword' => 'require',
'mobile' => 'require|regex:/^1[3-9]\d{9}$/',
'code' => 'require'
];
//提示信息
$message = [
'account.require' => '账号不能为空!',
'password.require' => '密码不能为空!',
'password.regex' => '密码长度8~20位,包含字母数字下划线!',
'repassword.require' => '确认密码不能为空!',
'mobile.require' => '手机号不能为空!',
'mobile.regex' => '手机号格式不正确!',
'code.require' => '验证码不能为空!',
];
$data['account'] = trim($data['account']);
$result = $this->validate($data, $validate, $message);
if($result !== true){
return $this->failure(config('error.er5')['code'],$result->getError());
}
//验证手机验证码是否正确
if($data['code'] != cache(MOBILE_CODE_.$data['mobile'])){
return $this->failure(config('error.er7')['code'],config('error.er7')['msg']);
}
//验证两次输入的密码是否一致
if($data['password'] != $data['repassword']){
return $this->failure(config('error.er6')['code'],config('error.er6')['msg']);
}
if(UserService::save($data)){
//注册成功后清除验证码缓存
cache(MOBILE_CODE_.$data['mobile'],null);
return $this->success();
}else{
return $this->failure();
}
}
控制器的代码逻辑也比较清晰,第一就是做参数的判断,第二就是调用业务逻辑的方法获取相应的结果
最后我们为其配置路由
//用户注册
Route::post('user/register','user/register');
2.5用户登录
2.5.1Jwt(token)
传统项目一般使用session保存用户信息,但前后端分离的项目,一般都是通过token来保存和验证用户信息,因此在讲用户登录时必须先了解一下如何生成token
jwt组件
文档地址:https://github.com/firebase/php-jwt
安装
composer require firebase/php-jwt
示例
//用户信息
$token = $user->toArray();
//一天后过期
$token['exp'] = time() + 24*3600;
//生成token,config('app.jwt_code_pc')加密字符串
$jwt = JWT::encode($token,config('app.jwt_code_pc'),"HS256");
//解析token
JWT::decode($token,new Key(config('app.jwt_code_pc'),'HS256'));
2.5.2业务逻辑
登录的话主要分为两种
- 手机验证码登录
- 账号密码登录,这里的账号也可以是手机号
打开UserService.php
,新增如下内容
/**
* 用户登录
* @param array $data 请求参数
* @return array
*/
public static function login($data){
if(isset($data['mobile']) && !empty($data['mobile'])){
//手机号登录
if($data['code'] != cache(MOBILE_CODE_.$data['mobile'])){
customException(config('error.er7')['code'],config('error.er7')['msg']);
}
$user = UserModel::getByMobile($data['mobile']);
if(empty($user)){
customException(config('error.er10')['code'],config('error.er10')['msg']);
}
//验证码缓存清楚
cache(MOBILE_CODE_.$data['mobile'],null);
}else{
//账号(包含手机号)密码登录
$user = UserModel::getByMobile($data['account']);
if(empty($user)){
//如果输入的账号不是手机号,则通过账号字段查询
$user = UserModel::getByAccount($data['account']);
}
//如果都为空,则抛出提示信息
if(empty($user)){
customException(config('error.er13')['code'],config('error.er13')['msg']);
}
//验证密码是否正确
if(!comparePassword($data['password'],$user['password'])){
customException(config('error.er11')['code'],config('error.er11')['msg']);
}
}
if($user['status'] == 0){
customException(config('error.er12')['code'],config('error.er12')['msg']);
}
//下面是验证成功后的处理逻辑
//登录成功后更新用户信息
$user->is_first_login = 1;
$user->login_time = date('Y-m-d H:i:s',time());
$user->login_ip = getClientIp();
$user->save();
//生成token
$token = $user->toArray();
//一天后过期
$token['exp'] = time() + 24*3600;
$jwt = JWT::encode($token,config('app.jwt_code_pc'),"HS256");
return [
'token' => $jwt,
'is_first_login' => $user['is_first_login']
];
}
这里通过前端是否传mobile这个字段判断是手机验证码登录还是账号密码登录,接下来判断该账号是否属于正常状态,最后生成token返回给前端,注意在使用JWT时要在前面引入相关类
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
2.5.3控制器代码
打开User.php
,新增如下方法
//用户登录
public function login(){
$data = $this->request->post();
if($data['type'] == 'mobile'){
//验证规则
$validate = [
'mobile' => 'require',
'code' => 'require'
];
//提示信息
$message = [
'mobile.require' => '手机号不能为空!',
'code.require' => '验证码不能为空!'
];
}else{
//账号密码登录
//验证规则
$validate = [
'account' => 'require',
'password' => 'require',
];
//提示信息
$message = [
'account.require' => '账号不能为空!',
'password.require' => '密码不能为空!',
];
}
$result = $this->validate($data, $validate, $message);
if($result !== true){
return $this->failure(config('error.er5')['code'],$result->getError());
}
return $this->success(UserService::login($data));
}
我们发现登录的代码跟注册的代码形式都是一样的,验证参数、调用业务逻辑的方法,后面的话就不再重复叙述,除非有特殊的地方需要提出来讲一下。
最后我们为其配置路由
//用户登录
Route::post('user/login','user/login');
2.6用户编辑
2.6.1用户验证
用户想修改自己的资料或者密码,那一定是要登录的,后端的接口是如何知道用户是否登录了呢?
上节讲到用户登录成功后,会返回一串token给前端,这个token就是一个登录凭证。当我们调用修改资料或修改密码的接口时一定要带上这个凭证,否则你是无法修改的。
一般情况下我们都是通过header头来携带token信息,拿到这窜token后,我们在那里解析它呢??
接下来路由中间件要出场了
2.6.2用户验证中间件
我们在当前应用pc目录下新建一个middleware目录,再新建一个Auth.php
namespace app\pc\middleware;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class Auth
{
public function handle($request, \Closure $next)
{
try{
$token = $request->header('token');
$device = $request->header('device');
if($device == 'pc' && $token){
JWT::decode($token,new Key(config('app.jwt_code_pc'),'HS256'));
}else{
customException(config('error.er14')['code'],config('error.er14')['msg']);
}
}catch (\Exception $e) {
customException(config('error.er14')['code'],config('error.er14')['msg']);
}
return $next($request);
}
}
这里面有两个地方要注意的,前端通过header头,同时把token和device传递过来,这里之所以要把$device传过来主要时标识这窜token是哪个应用的token。同时解析token的时候,一定要带上app.php
文件中配置的加密窜
//jwt加密字符串
'jwt_code_pc' => 'pc-demo-shop'
不同的应用配置不一样的加密串进行区分。
只有解析成功了才会往下走,否则会提示如下信息
{
"code": "2000",
"msg": "token非法!"
}
接下来把中间件注册到路由中,打开pc–>route->app.php,在后面添加如下
//路由分组,user组里面的请求都要登录后才能访问
Route::group('user', function(){
})->middleware(app\pc\middleware\Auth::class);
之后凡是要登录验证的接口都要注册在user这个组里面
2.6.3用户信息封装
解析token后会得到一个保存用户信息的数组,为了方便后续方法里面调用用户信息,因此需要把它封装起来。这个时候PcController.php
派上用场了,打开PcController.php
新增如下内容
namespace app\pc\controller;
use think\App;
use app\BaseController;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use think\facade\Log;
class PcController extends BaseController
{
//定义一个pc应用的全局user,保存用户信息
protected $user;
public function __construct(App $app)
{
parent::__construct($app);
}
//这个初始化方法在BaseController里面,这里重写它
protected function initialize()
{
try{
$token = $this->request->header('token');
$device = $this->request->header('device');
if($device == 'pc' && $token){
$this->user = JWT::decode($token,new Key(config('app.jwt_code_pc'),'HS256'));
}
}catch (\Exception $e) {
Log::write($e->getMessage());
}
}
}
这里我们定义了一个全局变量 u s e r ,用于保存用户信息,只要继承了 ‘ P c C o n t r o l l e r . p h p ‘ ,都可以使用 user,用于保存用户信息,只要继承了`PcController.php`,都可以使用 user,用于保存用户信息,只要继承了‘PcController.php‘,都可以使用this->user获取用户信息
2.6.4日志记录
上面我们代码中使用tp的静态类Log去记录日志,开发过程中日志的记录是非常重要的,特别是生产环境,因为我们不可能把异常信息通过前端返回,只有把日志记录到文件中,才方便我们定位问题。
日志文件一般位于runtime目录下,如图
我们会发现日志里面不但记录了error级别的错误信息,还会把sql给打印出来,让开发非常的方便
如果日志都记录在文件里面,那以后是不是会占用空间?
生产环境中,一般运维会要求日志记录只保存7天,只要超过7天,会自动删除文件
2.6.5业务逻辑代码
打开UserService.php
,新增如下代码
/**
* 更新用户信息
* @param array $data 更新数据
* @return array
*/
public static function update($data){
$user = UserModel::find($data['id']);
if(!$user){
//判断当前用户是否存在于表中,不存在则抛出错误信息
customException(config('error.er15')['code'],config('error.er15')['msg']);
}
if($user->save($data)){
return true;
}else{
return false;
}
}
这个逻辑就比较简单,直接看代码即可
2.6.6控制器代码
打开User.php
,新增如下内容
//编辑用户信息
public function edit(){
$data = $this->request->post();
$data['id'] = $this->user->id;
if(UserService::update($data)){
return $this->success();
}else{
return $this->failure();
}
}
这里的话并没有参数验证,因为用户资料并不是要求必填项。另外我们需要通过$this->user去获取当前登录的用户ID,再根据ID去更新当前登录用户的信息。
接下来把路由写到user分组里
//路由分组,user组里面的请求都要登录后才能访问
Route::group('user', function(){
Route::post('edit','user/edit');
})->middleware(app\pc\middleware\Auth::class);
2.7修改用户密码
2.7.1业务逻辑代码
修改密码的逻辑是这样的
- 输入密码和确认密码
- 输入手机号
- 获取验证码
打开UserService.php
,新增如下方法
/**
* 更新密码
* @param array $data 更新数据
* @return array
*/
public static function updatePassword($data){
$user = UserModel::find($data['id']);
if(!$user){
customException(config('error.er15')['code'],config('error.er15')['msg']);
}
//创建密码
$data['password'] = createPassword($data['password']);
//调用模型save方法
if($user->save($data)){
return true;
}else{
return false;
}
}
通过用户ID去更新密码
2.7.2控制器代码
打开User.php
,新增如下内容
//更新密码
public function updatePwd(){
$data = $this->request->post();
//验证规则
$validate = [
'password' => 'require|regex:/^[a-zA-Z0-9_]{8,20}$/',
'repassword' => 'require',
'mobile' => 'require|regex:/^1[3-9]\d{9}$/',
'code' => 'require'
];
//提示信息
$message = [
'password.require' => '密码不能为空!',
'password.regex' => '密码长度8~20位,包含字母数字下划线!',
'repassword.require' => '确认密码不能为空!',
'mobile.require' => '手机号不能为空!',
'mobile.regex' => '手机号格式不正确!',
'code.require' => '验证码不能为空!',
];
$data['id'] = $this->user->id;
$result = $this->validate($data, $validate, $message);
if($result !== true){
return $this->failure(config('error.er5')['code'],$result->getError());
}
//验证手机验证码是否正确
if($data['code'] != cache(MOBILE_CODE_.$data['mobile'])){
return $this->failure(config('error.er7')['code'],config('error.er7')['msg']);
}
if(UserService::updatePassword($data)){
return $this->success();
}else{
return $this->failure();
}
}
接下来新增路由
//路由分组,user组里面的请求都要登录后才能访问
Route::group('user', function(){
//修改资料
Route::post('edit','user/edit');
//修改密码
Route::post('updatePwd','user/updatePwd');
})->middleware(app\pc\middleware\Auth::class);
2.8上传头像
文件上传每个商城项目都会涉及到,一般情况下我们不会把文件上传到服务器上,而是借助第三方存储服务,例如阿里的OSS、腾讯COS、还有华为的OBS等等。另外文件上传可以通过服务器端上传,也可以通过web直传(推荐)
2.8.1服务端上传
通过官方文档:https://help.aliyun.com/document_detail/88473.html?spm=a2c4g.32103.0.0.1f337ecfqL2KTv
安装oss组件
composer require aliyuncs/oss-sdk-php
配置
打开config–>aliyun.php
<?php
// +----------------------------------------------------------------------
// | 阿里云相关配置
// +----------------------------------------------------------------------
return [
//AccessKey ID
'accessKeyId' => '你的accessKeyId',
// AccessKey Secret
'accessKeySecret' => '你的accessKeySecret',
/***短信配置****/
// 签名
'signName' => '阿里云短信测试',
// 短信模板CODE
'templateCode' => 'SMS_154950909',
// 访问的域名
'dysmsapi' => 'dysmsapi.aliyuncs.com',
/***上传配置****/
// 填写Bucket名称,例如examplebucket。
'bucket' => '你的bucket',
// Region请按实际情况填写
'endpoint' => '你的endpoint',
];
上传文件会用到以下四个配置accessKeyId
、accessKeySecret
、bucket
、endpoint
接下来我们来到commom应用,在lib目录下创建oss目录,然后再创建一个Oss.php
,内容如下
<?php
// +----------------------------------------------------------------------
// | OSS存储
// +----------------------------------------------------------------------
// | Author: myh
// +----------------------------------------------------------------------
namespace app\common\lib\oss;
use OSS\OssClient;
use OSS\Core\OssException;
use think\facade\Log;
class Oss{
//初始化oss客户端
private static function createOssClient(){
$accessKeyId = config('aliyun.accessKeyId');
$accessKeySecret = config('aliyun.accessKeySecret');
$endpoint = config('aliyun.endpoint');
return new OssClient($accessKeyId, $accessKeySecret, $endpoint);
}
/**
* 上传文件
* @param string $object 目标文件
* @param string $content 源文件
* @return bool
*/
public static function uploadFile($object,$filePath){
$bucket = config('aliyun.bucket');
try {
$ossClient = self::createOssClient();
$result = $ossClient->putObject($bucket, $object, $filePath);
return $result['info'];
}catch (OssException $e) {
Log::write("文件上传失败:".$e->getMessage());
return false;
}
}
}
首先是初始化一个客户端,然后调用其上传的api,成功就返回文件信息,失败则返回false
打开User.php
新增如下内容
//上传头像
public function updateAvatar(){
$file = $this->request->post('file');
$res = Oss::uploadFile('test/1.txt',$file);
if($res !== false){
return $this->success($res);
}else{
return $this->failure();
}
}
其路由设置
//上传头像
Route::get('updateAvatar','user/updateAvatar');
2.8.2web端直传
web端直传,它是由前端直接调用oss的api,直接上传文件。服务端主要做的事情是要提供一个临时密钥,因为如果把一些关键配置写在web端的话可能不安全,因此就调用服务端的接口去获取一个临时的密钥。
具体的服务端代码可以参考https://help.aliyun.com/document_detail/91771.html?spm=a2c4g.85580.0.0.4892468fuL8i7J
下面我们打开Oss.php
,添加如下内容
//获取临时密钥
public static function getTemKey(){
$id = config('aliyun.accessKeyId');
$key = config('aliyun.accessKeySecret');
// $host的格式为 bucketname.endpoint,请替换为您的真实信息。
$host = 'http://'.config('aliyun.bucket').config('aliyun.endpoint');
// $callbackUrl为上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实URL信息。
$callbackUrl = '';
$dir = 'test/'; // 用户上传文件时指定的前缀。
$callback_param = array(
'callbackUrl' => $callbackUrl,
'callbackBody' => 'filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}',
'callbackBodyType' => "application/x-www-form-urlencoded"
);
$callback_string = json_encode($callback_param);
$base64_callback_body = base64_encode($callback_string);
$now = time();
$expire = 30; //设置该policy超时时间是10s. 即这个policy过了这个有效时间,将不能访问。
$end = $now + $expire;
$expiration = str_replace('+00:00', '.000Z', gmdate('c', $now));;
//最大文件大小.用户可以自己设置
$condition = array(0 => 'content-length-range', 1 => 0, 2 => 1048576000);
$conditions[] = $condition;
// 表示用户上传的数据,必须是以$dir开始,不然上传会失败,这一步不是必须项,只是为了安全起见,防止用户通过policy上传到别人的目录。
$start = array(0 => 'starts-with', 1 => '$key', 2 => $dir);
$conditions[] = $start;
$arr = array('expiration' => $expiration, 'conditions' => $conditions);
$policy = json_encode($arr);
$base64_policy = base64_encode($policy);
$string_to_sign = $base64_policy;
$signature = base64_encode(hash_hmac('sha1', $string_to_sign, $key, true));
//关键这些信息
$response = array();
$response['accessid'] = $id;
$response['host'] = $host;
$response['policy'] = $base64_policy;
$response['signature'] = $signature;
$response['expire'] = $end;
$response['callback'] = $base64_callback_body;
$response['dir'] = $dir; // 这个参数是设置用户上传文件时指定的前缀。
return $response;
}
接下来我们在pc–>controller目录里面新建一个common.php
控制器,其内容如下
<?php
namespace app\pc\controller;
use app\common\lib\oss\Oss;
class common extends PcController
{
//获取oss的临时签名
public function getOssTemKey(){
try{
return $this->success(Oss::getTemKey());
}catch(\Exception $e){
return $this->error();
}
}
}
2.9会员签到
会员签到也是常见功能之一,它可以设置不同规则:签到一次加多少积分,连续签到加多少积分等等。这里的话我就先简单实现一下它的功能,先把规则写死,到后面再进行优化。
2.9.1业务逻辑代码
再次之前新建一个积分明细的模型,用于跟数据库交互
打开app–>common–>model,新建UserIntegralModel.php
<?php
namespace app\common\model;
use think\Model;
use think\model\concern\SoftDelete;
class UserIntegralModel extends Model
{
protected $table = 'ds_user_integral_log';
use SoftDelete;
protected $deleteTime = 'delete_time';
}
接下来就是业务层的代码,在app–>common–>service新建UserIntegralService.php
<?php
namespace app\common\service;
use app\common\model\UserIntegralModel;
class UserIntegralService{
//明细类型,1-签到
public static $signType = 1;
/**
* 新增积分明细
* @param array $data 新增的数据
* @return int
*/
public static function save($data){
$userIntegral = new UserIntegralModel;
$userIntegral->save($data);
return $userIntegral->id;
}
}
内容目前只有一个新增的方法,之后扩展一些其它的方法,例如明细列表
最后还是回到UserService.php
,新增如下的方法
/**
* 签到
* @param array $data 请求参数
* @return bool
*/
public static function sign($data){
Db::startTrans();
try {
//假设每次签到获取10积分,后面会完善签到功能
//更新用户表的积分总数
$user = UserModel::find($data['id']);
$user->user_integral = $user->user_integral + 10;
$user->save();
//新增一条积分明细
$integral['user_id'] = $data['id'];
$integral['type'] = UserIntegralService::$signType;
$integral['integral'] = 10;
$integral['creator'] = $data['id'];
UserIntegralService::save($integral);
Db::commit();
return true;
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
return false;
}
}
这里分为两步,第一是累加ds_user
表的积分字段,第二是新增一条积分明细。新增积分明细,我们直接调用UserIntegralService.php
里面的save()
,之所以这里不直接操作UserIntegralModel
一是为了代码更简洁,二是让一个业务类只操作一个模型。
这里还开启了数据库事务,因为操作了两个表,主要是防止第一个插入成功了,而第二个表插入失败的情况
2.9.2控制器代码
打开User.php
新增如下内容
//签到
public function sign(){
$data['id'] = $this->user->id;
if(UserService::sign($data)){
return $this->success();
}else{
return $this->failure();
}
}
接下来新增路由
//签到
Route::post('sign','user/sign');
2.10用户列表查询
用户列表一般都是后台应用,因此我们先暂时转战后台应用(admin),我们先看看后台列表查询的逻辑
- 搜索,根据账号、手机号、用户状态等条件进行搜索
- 分页、排序
后台先不做登录,讲完会员管理后再接入
2.8.1搜索器
根据tp的官方文档,我们可以在用户模型UserModel.php
中定义一系列的搜索条件
class UserModel extends Model
{
protected $table = 'ds_user';
use SoftDelete;
protected $deleteTime = 'delete_time';
public static $status = [0=>'禁用',1=>'正常'];
//根据账号搜索
public function searchAccountAttr($query, $value, $data)
{
$query->where('account', $value);
}
//根据手机搜索
public function searchMobileAttr($query, $value, $data)
{
$query->where('mobile', $value);
}
//根据状态搜索
public function searchStatusAttr($query, $value, $data)
{
$query->where('status', $value);
}
}
之前我们提到过,用户模型是全局的,所有的应用都可以调用它,这里无论是哪个应用的搜索条件都可以写在这里,它取决于你传过来的搜索字段,甚至你可以把一张表的字段都写成搜索条件
调用示例
UserModel::withSearch(array_keys($param),$param)->select();
withSearch()
第一个参数是搜索的字段,是一个一维数组,例如[‘account’,‘mobile’]
第二个参数是搜索字段对应的条件,也是一个一维数组,例如[‘account’=>‘test’]
2.8.2业务逻辑代码
打开UserService.php
,新增如下内容
/**
* 用户列表
* @param array $param 请求参数
* @return array
*/
public static function list($param){
$page = !isset($param['page']) ? 1 : $param['page'];
//默认查10条数据,如果传过来的条数超过100条,强制转化成10条
$pageSize = !isset($param['page_size']) || $param['page_size'] > 100 ? 10 : $param['page_size'];
$query = UserModel::withSearch(array_keys($param),$param);
$data['total'] = $query->count();
$data['list'] = [];
if($data['total'] > 0){
$data['list'] = $query->withoutField('password')->order('id','desc')->limit(($page-1)*$pageSize,$pageSize)->select()->toArray();
foreach ($data['list'] as $k => $v) {
$data['list'][$k]['status_text'] = UserModel::$status[$v['status']];
}
}
return $data;
}
这段代码有两个点要提一下的
$param请求参数,格式如下
['account'=>'test','status'=>1]
搜索账号位test,且是正常状态的用户
当我使用array_keys($param),获取到的值就是[‘account’,‘status’],这正是withSearch()
的第一个参数,如果多传一个不存在的字段是否会有问题?答案是不会有问题。
withSearch()
第二个参数我们直接使用前端传过来的数组,当然有些特殊情况比如条件中带有大于或等于的,这时需要稍微调整一下即可
第二点就是列表查询一般会查总数我这里稍微优化了一下,先定义一个$query变量
$query = UserModel::withSearch(array_keys($param),$param);
这段代码无论获取总数还是取数据都是一样的,因此先
$data['total'] = $query->count();
如果数量大于0就继续查列表数据,否则就直接返回空数组
2.8.3控制器代码
因为这是后台应用,因此新建要给admin
应用,在admin下新建controller目录,再建一个AdminController.php
基础控制器,其内容如下
<?php
namespace app\admin\controller;
use think\App;
use app\BaseController;
class AdminController extends BaseController
{
protected $user;
public function __construct(App $app)
{
parent::__construct($app);
}
protected function initialize()
{
}
}
之前提到过,每个应用需要新建一个当前应用的基础控制器
当前controller目录下,再新建User.php
它继承了AdminController
,内容如下
<?php
namespace app\admin\controller;
use app\common\service\UserService;
class User extends AdminController
{
//用户列表
public function list(){
$param = $this->request->get();
return $this->success(UserService::list($param));
}
}
接下来还是新建路由
在admin应用下新建route目录,再新建app.php
use think\facade\Route;
//路由分组
Route::group('user', function(){
Route::get('list','user/list');
});
2.11禁用/启用/删除
2.11.1禁用/启用
禁用和启用的本质就是修改用户信息,因此只需在user.php
新增如下内容
//启用、禁用
public function updateStatus(){
$data = $this->request->post();
//验证规则
$validate = [
'id' => 'require',
'status' => 'require'
];
//提示信息
$message = [
'id.require' => '请选择要禁用的数据!',
'status.require' => '状态不能为空!',
];
$result = $this->validate($data, $validate, $message);
if($result !== true){
return $this->failure(config('error.er5')['code'],$result->getError());
}
//启用和禁用本质就是修改用户信息,直接调用业务层update即可
if(UserService::update($data)){
return $this->success();
}else{
return $this->failure();
}
}
这里的
user.php
是后台admin
应用里面的,别搞错了
2.11.2删除
删除一般都是软删除,一般网站都不会选择真把数据删除,tp中也提供了现场的方法,打开UserService.php
,新增如下方法
/**
* 删除
* @param string $ids 需要删除数据的id
*/
public static function destroy($ids){
if(!empty($ids)){
UserModel::destroy(explode(',',$ids));
}
}
控制器User.php
代码如下:
//删除
public function delete(){
$ids = $this->request->get("ids");
if(empty($ids)){
return $this->failure(config('error.er5')['code'],"请选择要删除的数据");
}
UserService::destroy($ids);
return $this->success();
}
2.12用户等级
2.12.1增删改查
用户等级这个模块比较简单,按照我们之间的思路,新建模型UserLevelModel.php
、业务逻辑类UserLevelService.php
UserLevelModel.php
内容如下
<?php
namespace app\common\model;
use think\Model;
use think\model\concern\SoftDelete;
class UserLevelModel extends Model
{
protected $table = 'ds_user_level';
use SoftDelete;
protected $deleteTime = 'delete_time';
}
UserLevelService.php
内容如下
<?php
namespace app\common\service;
use app\common\model\UserLevelModel;
class UserLevelService{
/**
* 新增等级数据
* @param array $data 新增的数据
* @return int
*/
public static function save($data){
$userLevel = new UserLevelModel;
$userLevel->creator = isset($data['user_id']) ? $data['user_id'] : 0;
$userLevel->save($data);
//清空缓存
cache(USER_LEVEL_LIST,null);
return $userLevel->id;
}
/**
* 更新等级数据
* @param array $data 更新的数据
* @return int
*/
public static function update($data){
$user = UserLevelModel::find($data['id']);
if(!$user){
customException(config('error.er15')['code'],config('error.er15')['msg']);
}
if($user->save($data)){
//清空缓存
cache(USER_LEVEL_LIST,null);
return true;
}else{
return false;
}
}
/**
* 删除
* @param string $ids 需要删除数据的id
*/
public static function destroy($ids){
if(!empty($ids)){
//清空缓存
cache(USER_LEVEL_LIST,null);
UserLevelModel::destroy(explode(',',$ids));
}
}
/**
* 会员等级列表
* @return array
*/
public static function list(){
if(cache(USER_LEVEL_LIST)){
return cache(USER_LEVEL_LIST);
}
$list = UserLevelModel::order('growth_value','asc')->select();
if(!empty($list)){
//永久缓存
cache(USER_LEVEL_LIST,$list,0);
}
return $list;
}
}
里面有写了增删改查四个方法,这里面用到了缓存,因为会员等级不经常变动,把它放到缓存里面是一个很好的选择。
接下来新建一个UserLevel.php
控制器,内容如下
<?php
namespace app\admin\controller;
use app\common\service\UserLevelService;
class UserLevel extends AdminController
{
//列表
public function list(){
$param = $this->request->get();
return $this->success(UserLevelService::list($param));
}
//新增
public function add(){
$data = $this->request->post();
//验证规则
$validate = [
'level_name' => 'require',
'growth_value' => 'require',
];
//提示信息
$message = [
'level_name.require' => '等级名称不能为空!',
'growth_value.require' => '成长值不能为空!',
];
$data['creator'] = isset($this->user->id) ? $this->user->id : 0;
$result = $this->validate($data, $validate, $message);
if($result !== true){
return $this->failure(config('error.er5')['code'],$result->getError());
}
if(UserLevelService::save($data)){
return $this->success();
}else{
return $this->failure();
}
}
//更新
public function edit(){
$data = $this->request->post();
//验证规则
$validate = [
'id' => 'require',
'level_name' => 'require',
'growth_value' => 'require',
];
//提示信息
$message = [
'id.require' => '请选择要更新的数据!',
'level_name.require' => '等级名称不能为空!',
'growth_value.require' => '成长值不能为空!',
];
$result = $this->validate($data, $validate, $message);
if($result !== true){
return $this->failure(config('error.er5')['code'],$result->getError());
}
if(UserLevelService::update($data)){
return $this->success();
}else{
return $this->failure();
}
}
//删除
public function delete(){
$ids = $this->request->get("ids");
if(empty($ids)){
return $this->failure(config('error.er5')['code'],"请选择要删除的数据");
}
UserLevelService::destroy($ids);
return $this->success();
}
}
这里面也是增删改查四个方法,一看就明白
最后新增路由
//路由分组
Route::group('user', function(){
//用户列表
Route::get('list','user/list');
//启用、禁用
Route::get('updateStatus','user/updateStatus');
//删除用户
Route::get('delete','user/delete');
//用户等级列表
Route::get('level/list','userlevel/list');
//用户等级新增
Route::post('level/add','userlevel/add');
//用户等级更新
Route::post('level/edit','userlevel/edit');
//用户等级删除
Route::get('level/delete','userlevel/delete');
});
2.12.2完善会员列表
之前我们查询用户列表的时候,返回的用户等级level字段是一个整型,它存储的是ds_user_level
这个表的自增ID,因此我们需要在查询用户列表的时候把level_name给查出来。
这里我们打开UserLevelService.php
,新增以下内容
/**
* 根据ID获取名称
* @param string $id 等级ID
* @return string
*/
public static function getLevelNameById($id){
$list = self::list();
$levelName = '';
foreach($list as $v){
if($v['id'] == $id){
$levelName = $v['level_name'];
break;
}
}
return $levelName;
}
这段代码先调用list()
方法,获取所有用户等级,然后再根据传进来的ID去获取相对于的等级名称
接下来打开UserService.php
,找到之前写好的list($param)
方法,在循环体里面新增如下一行代码即可
if($data['total'] > 0){
$data['list'] = $query->withoutField('password')->order('id','desc')->limit(($page-1)*$pageSize,$pageSize)->select()->toArray();
foreach ($data['list'] as $k => $v) {
$data['list'][$k]['status_text'] = UserModel::$status[$v['status']];
//新加这一行获取等级
$data['list'][$k]['level_name'] = UserLevelService::getLevelNameById($v['level']);
}
}
这里我并没有使用连表查询,其实开发过程中尽量不要连表查,特别是表数据量大,连的表比较多的时候更加不要连表。像上面这种情况是可以连表的,因为等级表的数据量就是那么几条,连表也无所谓。
另外还有一个地方,上诉代码中在循环体中调用了getLevelNameById()
这个方法,需要注意的是如果这个方法里面每次都要访问数据库,就不建议这样去做,因为这会增加数据库的压力,千万别怀疑这个东西,当你的循环体有好几个都是这么写,系统越来越庞大,数据量越来越多,性能肯定会下降的,因此我们从一开始就要杜绝这种情况的发生。
这里我之所有那么写,是因为getLevelNameById($id)
这个方法的实现理论上是不用查数据库的,即使要查也是最多查一次,因为用户等级全部数据都是存储在redis缓存中。
相信大家也知道为啥我不这样实现这个方法
public static function getLevelNameById($id){
$level = UserLevelModel::find($id);
return $level->level_name;
}
上面这种实现逻辑主要是针对表数据比较少的情况,如果说用户等级的数据也几十万条,甚至更多,那这里就不能这样实现,因为你不太可能一次性查出所有数据
2.12.3注册默认会员等级
现在回到注册那一块,注册的时候我们需要给一个默认的会员等级,一般都是最普通的等级。
打开UserService.php
,找到之前写的save($data)
方法,新增如下代码
public static function save($data){
//验证该账号是否存在
if(UserModel::getByAccount($data['account'])){
customException(config('error.er8')['code'],config('error.er8')['msg']);
}
//验证手机号是否存在
if(UserModel::getByMobile($data['mobile'])){
customException(config('error.er9')['code'],config('error.er9')['msg']);
}
//默认会员等级
$level = UserLevelService::list();
$data['level'] = empty($level) ? 0 : $level[0]['id'];
$data['password'] = createPassword($data['password']);
$user = new UserModel;
$user->save($data);
return $user->id;
}
这里调用UserLevelService::list()
获取会员等级列表,这里的会员等级列表获取是根据成长值
字段排序输出的,排在第一个的肯定是最开始的一个等级。
3.后台用户管理
附录
1.建表语句
1.1会员表
CREATE TABLE `ds_user` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`user_sn` char(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '会员码',
`account` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '账号',
`password` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '密码',
`nickname` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '昵称',
`avatar` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '头像',
`mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '手机',
`level` tinyint(4) NULL DEFAULT 0 COMMENT '等级',
`sex` tinyint(1) NULL DEFAULT 0 COMMENT '性别:0-未知;1-男;2-女',
`birthday` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '生日',
`user_integral` int(11) NULL DEFAULT 0 COMMENT '积分',
`user_growth` int(11) NULL DEFAULT 0 COMMENT '成长值',
`login_time` datetime(0) NULL DEFAULT NULL COMMENT '最后登录时间',
`login_ip` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '最后登录IP',
`status` tinyint(4) NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`is_first_login` tinyint(1) NULL DEFAULT 0 COMMENT '第一次登录:0-未登录 1-已经登录过',
`creator` int(11) NULL DEFAULT 0 COMMENT '创建人',
`updator` int(11) NULL DEFAULT 0 COMMENT '更新人',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime(1) NULL DEFAULT NULL COMMENT '删除时间,默认为空',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_account`(`account`) USING BTREE,
INDEX `idx_mobile`(`mobile`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
字段 | 类型 | 备注 |
---|---|---|
id | int(11) unsigned | 主键(PRIMARY) |
user_sn | char(20) | 会员码 |
account | varchar(30) | 账号 |
password | varchar(80) | 密码 |
nickname | varchar(30) | 昵称 |
avatar | varchar(200) | 头像 |
mobile | varchar(11) | 手机 |
level | tinyint(4) | 等级 |
sex | tinyint(1) | 性别:0-未知;1-男;2-女 |
birthday | varchar(20) | 生日 |
user_integral | int(11) | 积分 |
user_growth | int(11) | 成长值 |
login_time | datetime | 最后登录时间 |
login_ip | varchar(30) | 最后登录IP |
status | tinyint(4) | 状态:0-禁用 1-启用 |
is_first_login | tinyint(1) | 第一次登录:0-未登录 1-已经登录过 |
creator | int(11) | 创建人 |
updator | int(11) | 更新人 |
create_time | datetime | 创建时间 |
update_time | datetime | 更新时间 |
delete_time | datetime(1) | 删除时间,默认为空 |
1.2会员积分明细表
CREATE TABLE `ds_user_integral_log` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`type` tinyint(4) NULL DEFAULT 0 COMMENT '类型:1-签到,2-下单',
`user_id` int(11) NULL DEFAULT 0 COMMENT '用户ID',
`integral` int(11) NULL DEFAULT 0 COMMENT '积分',
`creator` int(11) NULL DEFAULT 0 COMMENT '创建者ID',
`updator` int(1) NULL DEFAULT 0 COMMENT '更新者ID',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户积分明细表' ROW_FORMAT = Dynamic;
字段 | 类型 | 备注 |
---|---|---|
id | int(10) unsigned | 主键(PRIMARY) |
type | tinyint(4) | 类型:1-签到,2-下单 |
user_id | int(11) | 用户ID |
integral | int(11) | 积分 |
creator | int(11) | 创建者ID |
updator | int(1) | 更新者ID |
create_time | datetime | 创建时间 |
update_time | datetime | 更新时间 |
delete_time | datetime | 删除时间 |
1.3会员等级表
CREATE TABLE `ds_user_level` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`level_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '等级名称',
`growth_value` int(11) NULL DEFAULT 0 COMMENT '成长值',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '等级备注',
`image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '等级图标',
`privilege` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '等级权益',
`discount` decimal(11, 1) 1.0 COMMENT '等级折扣',
`creator` int(11) NULL DEFAULT 0 COMMENT '创建者',
`updator` int(11) NULL DEFAULT 0 COMMENT '更新者',
`create_time` datetime NULL COMMENT '创建时间',
`update_time` datetime NULL COMMENT '更新时间',
`delete_time` datetime NULL COMMENT '删除时间',
PRIMARY KEY (`id`)
) COMMENT = '会员等级表';
字段 | 类型 | 备注 |
---|---|---|
id | int(10) unsigned | 主键(PRIMARY) |
level_name | varchar(30) | 等级名称 |
growth_value | int(11) | 成长值 |
remark | varchar(255) | 等级备注 |
image | varchar(255) | 等级图标 |
privilege | varchar(255) | 等级权益 |
discount | decimal(11,1) | 等级折扣 |
creator | int(11) | 创建者 |
updator | int(11) | 更新者 |
create_time | datetime | 创建时间 |
update_time | datetime | 更新时间 |
delete_time | datetime | 删除时间 |