8 Star 48 Fork 10

非凡X/demo-shop

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
2年前
2年前
2年前
Loading...
README
Apache-2.0

0.开篇

你好!很高兴你能点开这个教程,相信你对这个教程有了那么一点点兴趣,接下来占用你一点点时间,邀你浏览一下本章内容,希望能够让你更加有兴趣去学完这个教程。

作者我是一名九零后程序员,搬砖了好几年,主要使用PHPJava作为开发语言,另外也懂些前端,毕竟刚工作那会写了不少htmlcssjs,到后面也学习了vue。作为一名开发人员,自己喜欢看一些技术文章,也喜欢分享一些东西,这样才能提高自己的技术。

为什么要写这个教程?

其实前两年就想写了,可能是因为项目的原因,也可能是自己还没想好写些什么。不过今年还是下定决心要写一下,一来是能够学习和巩固自己的知识,二来也是分享一下自己的经验。

课程简介

本教程打算以一个商城项目为例,会实现后台权限管理、会员管理、商品管理、商品团购、抢购……反正是一些常用的功能都给它实现了。

本教程不同于网上的一些视频教程,只是为了实现而实现,说真的网上的一些教程,都是按部就班的实现功能,很少会提及为什么要这样做,这样做的好处是啥,更不用说会踩到什么坑了,因此有些人自学了,效果也好不到哪去。

在这个教程中模块与功能的代码实现是次要的,更重要的是我们是如何去规划、设计这些模块,我们要站在一个架构师的角度实现我们的系统。

你能学到什么?

第一、进一步掌握Thinkphp,让你知道如何阅读文档,让你知道在学习了thinkphp后如何快速的掌握其它框架。

第二、数据库的设计,如何合理的设计数据表,表设计的越合理,sql就会写得越简单,让你轻松应对上千万的数据。

第三、如何搭建自己的系统,完善基础架构。

第四、在实际工作中是如何处理需求,积累经验。

其实你学到的东西远不止这些,每个模块中所涉及到的知识就很多了。但这些不是重点,重点你是怎么实现的,比如说手机验证码,我要分享的是你要如何阅读第三方的文档,快速提取有效的信息,快速实现手机发送验证码功能。最终的目的是你在掌握了接入阿里云短信功能的同时,自己能够阅读其它第三方短信平台的文档而接入短信发送的功能,例如百度。

总之本教程重在引导。

适合人群

工作一两年的,有接触thinkphp的同学即可

最后

如果说对你有帮助,记得点个赞~~~~

1.技术选型

1.1环境搭建

环境 版本
语言 PHP 7.3.4
数据库 mysql 5.7
缓存 redis 3.0.5

这里推荐使用phpstudy集成环境

接口测试工具:ApiPost

后台管理端vue框架: vue-next-admin

1.2后端框架

到目前为止,国内使用最多的PHP框架当属ThinkphpLaravel,这两款框架很相似,文档也是比较齐全。作为一个PHP程序员至少要掌握其中一个,当然最好都掌握。

本教程的项目使用了Thinkphp6.1的版本,是当前最新版本。

官方文档链接:https://www.kancloud.cn/manual/thinkphp6_0/1037479

安装

composer create-project topthink/think demo-shop

默认是单应用模式的,这里我们开发的项目是多应用,因此还需执行以下命令

composer require topthink/think-multi-app

本项目有后台模块、提供给web端的API模块,因此就需要用到多应用模型、这样有利于我们后期项目的管理和维护

目录结构

把框架下载下来后,我们认识一下里面的一些目录,下面是官网给出的目录结构,实际上下载下来的目录比较简洁

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接口的结构

本教程是php+vue前后分离的一个项目,因此接下来都会讲接口的实现。

<?php
namespace app\controller;

class User 
{
    public function login()
    {
        return 'login';
    }
}

如上面的代码,这就是一个登录接口。对于一个新手来说,可能会把所有逻辑都写在控制器(controller)里面,这种做法对于我来说是绝对禁止的,因为这样做会使你的代码非常臃肿,并且复用性差,因此如何设计代码结构,成为你程序简洁、高效的关键。

本教程的接口的代码结构主要分为三层:控制层、业务层、模型层

控制层:接口名的定义,主要负责参数的过滤及结果的返回

业务层:实现业务功能的具体核心代码

模型层:负责与数据库交互

控制层

每个应用都拥有独立的控制层,这个很好理解,因为你总不可能两个不同的应用使用同一个接口。

业务层

每个应用都拥有独立的业务层,后台管理有后台管理的业务,商城展示端有商城展示端的业务,总之每个应用都有独立的业务层。

那有些人可能会问,比如说一个商城展示端,有pc、app、h5三个应用,难道也要分开三个业务层吗?

这得根据实际情况而定

一、大厂,它可能是后台应用、pc、app、h5,分别是四套代码,部署到不同服务器上。因为不同应用对配置及资源要求不一样,比如说app,用户访问量大,并发高,那对性能及技术要求就比较高。他们能这样实行主要是团队很大,分工很细,流程规范,逻辑清晰。

二、外包,一个商城项目,访问量也不大,就一两个人开发,那你可以把所有的业务层都写到一起,只要逻辑清晰即可,这样开发效率比较高。

三、成熟产品,某个公司自己的商城,需要长期迭代及开发维护,这样的话最好把业务层分开,这样后期会比较好维护一些。

模型层

模型层是跟数据库交互的,你可以看作是数据表,每个应用使用的模型都是一样的,这里就不用每个应用都拥有自己的模型。

或许你还不理解,不过没关系,有个印象就行,后面具体开发按照这个结构来,你就会明白了

2.2基础控制器

在文档中https://www.kancloud.cn/manual/thinkphp6_0/1067000,这里提到了基础控制器,它提供了数据验证功能 ,这里我们后面会讲到。

app目录下我们会发现框架生成的BaseController.php基础控制器,我们可以把它当作最底层的控制器。前面我们说过,我们这个项目是一个多应用项目,因此这里为每个应用都建一个基础控制器,让其继承BaseController.php,这样每个应用即可使用底层控制器提供的方法,又可以定制开发一些当前应用共用的方法。

app下新建一个admin目录,作为后台应用。接着新建controller-->AdminController.php,内容如下

<?php
// +----------------------------------------------------------------------
// | 应用基础控制器
// +----------------------------------------------------------------------
namespace app\admin\controller;

use think\App;
use app\BaseController;

class AdminController extends BaseController
{   
    public function __construct(App $app)
    {
        parent::__construct($app);
    }

    protected function initialize()
    {
        
    }
}

initialize()为初始化方法,在BaseController构造函数里面调用了它,每次请求都会执行该方法,因此我们可以在这个方法里封装一些公用信息。

2.3返回值的封装

在文档https://www.kancloud.cn/manual/thinkphp6_0/1037526中会讲到响应输出

输出类型 快捷方法 对应Response类
HTML输出 response \think\Response
渲染模板输出 view \think\response\View
JSON输出 json \think\response\Json
JSONP输出 jsonp \think\response\Jsonp
XML输出 xml \think\response\Xml
页面重定向 redirect \think\response\Redirect
附件下载 download \think\response\File

一般来说比较常见的是渲染模板输出JSON输出 ,在一些外包公司为了节省开发成本,前后端都是混到一起,他们一般才采用渲染模板输出,这也要求后端开发人员要了解html,现在随着vue前端框架的崛起,后端开发只需把数据以 JSON输出返回给前端即可。

返回给前端的数据需要固定的格式,你不能随便返回一些乱七八糟的东西,一般来说会返回给前端下面三个基本字段:

响应码 描述
code 响应码,成功返回200,异常返回500
msg 响应信息
data 响应数据

当然你还可以加别的字段,只要统一就行。

另外Thinkphp框架为止也提供了一个助手函数 json(),一般来说我们是这样使用它的

json(['code'=>200, 'msg'=>'操作成功','data'=>$data]);
json(['code'=>500, 'msg'=>'服务器异常~']);  
json(['code'=>1000, 'msg'=>'请输入用户名!']);

不知道你们有没有发现,假如说查询列表数据,每次返回成功,你都要写'code'=>200, 'msg'=>'操作成功',这样很繁琐,因此这里我们简单封装一下。

打开app目录下的全局函数文件common.php,这里面新增的函数可以全局调用

/**
 * 返回值-成功
 * @param string $code    错误码
 * @param string $msg     提示信息
 */
function success($data = []){
    return json(['code'=>200, 'msg'=>'操作成功','data'=>$data]);
}

/**
 * 返回值-失败
 * @param string $code    错误码
 * @param string $msg     提示信息
 */
function failure($code=201, $msg='操作失败'){
    return json(['code'=>$code, 'msg'=> $msg]);
}

/**
 * 返回值-异常
 * @param string $code    错误码
 * @param string $msg     提示信息
 */
function error(){
    return json(['code'=>500, 'msg'=>'服务器异常~']);
}

我们就可以这样调用:

//返回列表数据
return success($data);
//新增一条数据成功
return success();
//新增一条数据失败
return failure();
//新增一条数据,请输入用户名
return failure(1000,'请输入用户名!');
//异常只返回500
return error();

这里的话我们还需求做个小小优化,当然很多人在开发过程中并没有这样做,这也是情有可原,毕竟时间紧迫的话就会忽略掉。

一般来说我们定义一些错误码及对应信息的常量,例如本项目会在app->config下新建一个error.php

return [
    'er1'    =>['code'=>'1000','msg'=>'请输入用户名!'],
];

最后我们这样调用

return failure(config('error.er1')['code'],config('error.er1')['msg']);

2.4表单验证器

前面我们在基础控制器里面提到了验证器,实际上文档中有专门的栏目介绍它https://www.kancloud.cn/manual/thinkphp6_0/1037624

它的主要用途就是新增或修改数据对数据进行合法性验证,一般来说前端会传一些数据给后端接口,比如新增一条管理员数据,那此时就必须验证账号和密码这两个字段,这两个字段不能为空,同时密码还需要满足一定的条件,此时框架提供的验证器就派上用场了。

打开底层控制器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){
    //内容省略
}

接下来看看调用示例

$data = $this->request->post();
//验证规则
$validate = [
    'account' => 'require',
    'password'  => 'require|regex:/^[a-zA-Z0-9_]{8,20}$/',
];
//提示信息
$message = [
    'account.require' => '账号不能为空!',
    'password.require' => '密码不能为空!',
    'password.regex' => '密码长度8~20位,包含字母数字下划线!',
];
$this->validate($data, $validate, $message);

如果输入的账号为空,运行这段代码后你会发现抛出以下异常

2.5异常接管

上一节讲验证器的时候,抛出了一个账号不能为空的异常,很显然这种提示方式对于前端很不友好,前端无法获取到相应的信息,因此我们要想办法捕抓到这个异常,将提示信息json的格式返回给前端

我们打开文档https://www.kancloud.cn/manual/thinkphp6_0/1037615,文档有讲解相关异常的处理。

框架也为我们生成了app/ExceptionHandle.php,打开看看里面的内容,其中有这样的一个函数

render($request, Throwable $e),我们在里面新加参数验证码错误的代码

public function render($request, Throwable $e): Response
{
     // 参数验证错误
    if ($e instanceof ValidateException) {
        return failure(1004,$e->getError());
    }
    return parent::render($request, $e);
}

这时如果参数验证码错误的话,就会被捕抓到,同时返回json格式的数据给前端,如下

{
	"code": 1004,
	"msg": "账号不能为空!"
}

同样的我们需要接管其它的异常

// 参数验证错误
if ($e instanceof ValidateException) {
    return failure(1004,$e->getError());
}

// 其它异常
if ($e instanceof RouteNotFoundException || $e instanceof Exception 
    || $e instanceof HttpException || $e instanceof HttpResponseException || $e instanceof ModelNotFoundException
    || $e instanceof DataNotFoundException || $e instanceof RouteNotFoundException) 
{
    return error();
}

我们统一返回这样的格式给前端

{
	"code": 500,
	"msg": "服务器异常~"
}

为什么要这样返回?

因为在生产环境,我们一般不会把系统产生的异常信息返回给用户的,因为这是一种很危险的信号。

那有些人可能会问,如果都这样提示,那不是不知道哪里出问题了吗?

别慌,在app/ExceptionHandle.php中,开头有这样的一段代码

/**
     * 不需要记录信息(日志)的异常类列表
     * @var array
*/
protected $ignoreReport = [
    // HttpException::class,
    // HttpResponseException::class,
    // ModelNotFoundException::class,
    // DataNotFoundException::class,
    // ValidateException::class,
    // RouteNotFoundException::class,
    // Exception::class,
];

我们注释掉它,这样错误信息会被日志记录下来

日志一般记录在runtime下面的log目录里的xxx.log文件

[2023-11-17T11:21:39+08:00][error] [255]账号不能为空1111
[2023-11-17T11:22:36+08:00][error] [255]账号不能为空1111[F:\phpstudy_pro\WWW\demo.shop\app\common.php:84]
[2023-11-17T11:25:57+08:00][error] [8]未定义变量: c[F:\phpstudy_pro\WWW\demo.shop\app\admin\controller\SysUser.php:172]

这样我们就很容易找到问题了

生产环境也一样,运维一般会在某个地方输出这些日志,这样我们可以方便的通过浏览器可以看到这些异常信息

2.6记录日志

上面异常接管的时候,我们已经提到了日志,程序在运行过程中,如果有异常,会被拦截并且记录到文件里面,但很多时候我们需要主动去记录日志,特别是线上环境出现BUG的时候,查看日志就显得非常重要了

文档:https://www.kancloud.cn/manual/thinkphp6_0/1037616

日志的配置文件是config->log.php,默认日志记录通道是文件

<?php

// +----------------------------------------------------------------------
// | 日志设置
// +----------------------------------------------------------------------
return [
    // 默认日志记录通道
    'default'      => env('log.channel', 'file'),
    // 日志记录级别
    'level'        => [],
    // 日志类型记录的通道 ['error'=>'email',...]
    'type_channel' => [],
    // 关闭全局日志写入
    'close'        => false,
    // 全局日志处理 支持闭包
    'processor'    => null,

    // 日志通道列表
    'channels'     => [
        'file' => [
            // 日志记录方式
            'type'           => 'File',
            // 日志保存目录
            'path'           => '',
            // 单文件日志写入
            'single'         => false,
            // 独立日志级别
            'apart_level'    => [],
            // 最大日志文件数量
            'max_files'      => 0,
            // 使用JSON格式记录
            'json'           => false,
            // 日志处理
            'processor'      => null,
            // 关闭通道日志写入
            'close'          => false,
            // 日志输出格式化
            'format'         => '[%s][%s] %s',
            // 是否实时写入
            'realtime_write' => false,
        ],
        // 其它日志通道配置
    ],

];

使用示例:

// info一般是记录参数、返回值
Log::info("测试!!!");

// error一般是try catch中使用,记录可能出现的异常信息
Log::error("未知错误!");

最后日志存放的路径默认是runtime->应用名->log->日期目录->日.log,例如:runtime->admin->log->202305->27.log

[2023-05-27T11:46:00+08:00][info] 测试!!!
[2023-05-27T11:46:00+08:00][error] 未知错误!

2.7开启Redis缓存

在文档https://www.kancloud.cn/manual/thinkphp6_0/1037634中介绍了有关缓存的使用,框架默认的缓存是基于本地文件,这里我们只需配置一下,把它改成基于`redis`

打开全局的config/cache.php,新增以下内容

'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')
],

打开.env,新增以下内容

[CACHE]
DRIVER = redis
HOST = 127.0.0.1
PORT = 6379
PASSWORD = 
SELECT = 0
PREFIX = 
EXPIRE = 0

一般本地的redis配置信息都默认是这样,如果发现你的连接不上,就看看密码是否正确

框架给我们提供了助手函数 cache(),看看它的使用方式

//设置缓存,且缓存时间是永久,因为配置EXPIRE = 0,标识永久
cache("user_id",1);
//缓存10秒
cache("user_id",1,10);
//获取缓存
cache("user_id");

2.8路由配置

路由能做的东西很多,文档非常详细的介绍https://www.kancloud.cn/manual/thinkphp6_0/1037495

为什么要使用路由?个人认为主要是有下面两个好处:

  1. 访问地址写到一个地方,方便管理
  2. 使用路由中间件,可以提前过滤接口

打开config/route.php,把以下的配置项设置为true,开启强制路由

// 是否强制使用路由
'url_route_must'        => true

另外我们在admin应用下新建route/app.php,每个应用可设置单独的路由,例如

<?php
// +----------------------------------------------------------------------
// | 后台应用路由
// +----------------------------------------------------------------------
use think\facade\Route;
//图片验证码
Route::get('login/verify','sysUser/verify');

2.9数据库配置

打开config/database.php,我们可以看到有下面的内容

// 数据库类型
'type'            => env('database.type', 'mysql'),
// 服务器地址
'hostname'        => env('database.hostname', '127.0.0.1'),
// 数据库名
'database'        => env('database.database', ''),
// 用户名
'username'        => env('database.username', 'root'),
// 密码
'password'        => env('database.password', ''),
// 端口
'hostport'        => env('database.hostport', '3306'),
// 数据库连接参数
'params'          => [],
// 数据库编码默认采用utf8
'charset'         => env('database.charset', 'utf8'),
// 数据库表前缀
'prefix'          => env('database.prefix', ''),

它就是使用env函数读取相应的配置,并且都有默认值,有些人可能去改它的默认值,当然这里是可以改的,但是最佳方案是配置.env文件,我们可以看到app目录下有个.example.env,这是框架提供的样例,因此我们可以复制一份,改名为.env

我们新增如下配置

#数据库配置配置
[DATABASE]
TYPE = mysql
HOSTNAME = 127.0.0.1
DATABASE = demo-shop
USERNAME = root
PASSWORD = 123456
HOSTPORT = 3306
CHARSET = utf8
DEBUG = true

这是开发环境的,每个环境的env内容是不一样的。

使用任何一个框架,做任何一个项目都可以按照这样的思路来开发,做一些开放前的准备工作

3.后台管理员模块

我们将后台管理员及权限管理作为这个教程的开端,这一部分的内容非常重要,任何一个后台系统都有权限管理模块,不同的角色拥有不同的权限,可以看到相应权限下的内容。

管理模块主要分为三个部分:

  1. 菜单权限管理
  2. 角色管理
  3. 用户管理

下面我们一个一个模块去讲,分析一下为什么这么做。

3.1菜单权限管理

3.1.1设计表

菜单界面如下:

菜单里面包含哪些字段,取决于你用了什么前端框架,一般来说前端的框架都给你设计好了,只要你接口输出对应的数据就行。

我们根据页面的要求,去设计我们的数据表。

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
title varchar(30) 菜单名称
parent_id int(11) 上一级ID
type tinyint(1) 菜单类型:1-菜单,2-按钮
name varchar(30) 路由中的name值
icon varchar(30) 图标
is_hide tinyint(1) 是否隐藏:0-否,1-是
sort int(11) 排序
path varchar(50) 路由中的path值
component varchar(50) 组件路径
redirect varchar(50) 重定向
btn_power varchar(30) 菜单类型为按钮时,权限标识
perms varchar(50) 权限标识
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

这是我们建的第一个表,下面我们看看建表一些规范

1.表名,一般是小写字母加"_"线,例如上面的系统菜单表ds_sys_menu

2.主键,一般是自增ID作为主键

3.记录创建者、更新者、创建时间、更新时间,方便最终操作记录

4.软删除字段,我们使用delete_time作为删除标识,如果值为空,说明记录没有被删除,如果值不为空,说明记录被删除,后面所有表都用这个字段

5.字段命名,一般是小写字母加"_"线,跟表名类似

6.字段类型的选择,进来选择合适的类型

所有的建表语句都在最后的附录里面

3.1.2基础模型的封装

系统菜单的表我们设计好了,接下来就是开发这个模块了。首先我们需要定义一个菜单模型,讲接口结构的这一节也提到了,模型是用于跟数据库交互的。

我们在app目录下新建common->model目录,然后新建SysMenuModel.php,因为模型是公用的,因此放到common目录下

<?php
namespace app\common\model;

use think\Model;
use think\model\concern\SoftDelete;

class SysMenuModel extends Model
{   
    protected $table = 'ds_sys_menu';
	
    use SoftDelete;
    protected $deleteTime = 'delete_time';
}

这里我们指定了模型对应的表名ds_sys_menu,另外我们指定了软删除字段delete_time,这样我们在查询数据的时候,在where后面会自动加上这个条件,这样就不用每条sql语句都要自己加上这个条件。

不知道大家有没有发现,如果后续我们新家其它模型,是不是都要这样写,是不是都要写上这段代码

use SoftDelete;
protected $deleteTime = 'delete_time';

很明显重复了,因此我们一般新建一个基础模型,把一些公共的东西写到基础模型中。我们在当前的model目录下新建BaseModel.php

<?php
namespace app\common\model;

use think\Model;
use think\model\concern\SoftDelete;

class BaseModel extends Model
{   
    use SoftDelete;
    protected $deleteTime = 'delete_time';
}

这基础模型中会做很多东西,后面你就能体会到它的好处了。

有了基础模型,接下来改一下我们的SysMenuModel

<?php
namespace app\common\model;

class SysMenuModel extends BaseModel
{   
    protected $table = 'ds_sys_menu';
}

接下来我们后续新建的模型基础BaseModel,并且对应上表名即可。

3.1.3基础业务

每个模块,我们对表的操作一般涉及到四个方法:增、删、查、改,在app目录下新建admin应用,然后新建我们的业务层文件service->SysMenuService.php

<?php
namespace app\common\service;
use app\common\model\SysMenuModel;

class SysMenuService{
    /**
     * 新增
     * @param  array  $data  新增的数据
     * @return int
     */
    public static function save($data){
        $result = SysMenuModel::create($data);
        return success($result->id);
    }

    /**
     * 更新
     * @param  array  $data 更新的数据
     * @return int
     */
    public static function update($data){
        $result = SysMenuModel::find($data['id']);
        if(!$result){
            return failure(config('error.er15')['code'],config('error.er15')['msg']);
        }
        $result->save($data);
        return success($result->id);

    }

    /**
     * 删除
     * @param  string  $ids  需要删除数据的id
     */
    public static function destroy($ids){
        if(!empty($ids)){
            SysMenuModel::deleteById($ids);
        }
    }

    /**
     * 列表
     * @return array
     */
    public static function list(){
        $list = SysMenuModel::select()->toArray();
        return success(treeData($list));
    }
}

每个模块中的新增、更新、删除、查询,这几个方法大同小异,有的甚至只是操作的模型不一样。

3.1.4自定义删除方法

前面我们在设计表的时候有两个字段:creator和updator,数据创建者与数据更新者,我们希望新增和更新的时候能自动记录操作者。

我们项目的删除是软删除,前面也已经提到过,其本质也是更新数据,因此我们希望能够记录更新者是谁。

框架当中提供了一个数据删除的方法:delete(),但是这个方法好像不能记录是谁删除了这条数据的,因此我在基础模型BaseModel中封装了一个删除方法deleteById($ids)

<?php
namespace app\common\model;

use think\Model;
use think\model\concern\SoftDelete;

class BaseModel extends Model
{   
    use SoftDelete;
    protected $deleteTime = 'delete_time';
    
    /**
     * 删除
     * @param  string|array  $ids     需要删除数据的id
     */
    public static function deleteById($ids){
        if(!empty($ids)){
            if (!is_array($ids)) {
                $ids = explode(',',$ids);
            }
            
            foreach($ids as $id){
                $model = self::find($id);
                $model->delete_time = date('Y-m-d H:i:s',time());
                $model->save();
            }
        }
    }
}

上面的这个更新操作好像并没有更新updator这个字段,这是因为我们需要利用模型的一些事件来填充这个字段,你想想,如果每条新增与更新语句都要自己手动写上creator和updator,是不是很麻烦?

3.1.5模型事件

模型事件是指在进行模型的查询和写入操作的时候触发的操作行为,下面是模型提供的一些事件

事件 描述 事件方法名
after_read 查询后 onAfterRead
before_insert 新增前 onBeforeInsert
after_insert 新增后 onAfterInsert
before_update 更新前 onBeforeUpdate
after_update 更新后 onAfterUpdate
before_write 写入前 onBeforeWrite
after_write 写入后 onAfterWrite
before_delete 删除前 onBeforeDelete
after_delete 删除后 onAfterDelete
before_restore 恢复前 onBeforeRestore
after_restore 恢复后 onAfterRestore

根据我们前面的需要,需要在新增和更新数据的时候,填充操作者,因此我们可以在基础模型中新增下面两个方法:

<?php
namespace app\common\model;

use think\Model;
use think\model\concern\SoftDelete;

class BaseModel extends Model
{   
    use SoftDelete;
    protected $deleteTime = 'delete_time';
    
    //插入前
    public static function onBeforeInsert($data){
        return $data['creator'] = 1;
    }

    //更新前
    public static function onBeforeUpdate($data){
        return $data['updator'] = 1;
    }
}

因为我们还没做登录,因此这里我们先写个1,后续我们做完登录功能后再获取真正的登录用户ID。

定义者两个方法后,我们以后对任何模块的新增与更新操作,都会自动填充相应的操作者。

3.1.6层级递归函数

我们可以看看前面的菜单列表,它是层级递归输出的,因此我们需要写一个递归函数,打开common.php,在后面加上这个函数:

/**
 * 递归输出层级
 * @param array  $data      数据列表
 * @param array  $parentId  父级ID
 */
function treeData($data, $parentId=0){
    if(empty($data)) {
        return [];
    }

    $list = [];
    foreach ($data as $k => $v) {
        if($parentId == $v['parent_id']){
            $v['children'] = treeData($data,$v['id']);
            $list[] = $v;
        }
    }
    return $list;
}

这个函数很有用,凡是涉及到层级结构的数据都会用到这个函数,后面的商品分类的输出就用到这个函数了。注意这个函数里面的parent_id字段,传入进来的数据其父级ID的字段必须是parent_id,而不能是其它,例如pid

3.1.7控制器

接下来就写我们的控制器了,在app->admin->controller目录下新建SysMenu.php使其继承基础控制器AdminController

<?php
namespace app\admin\controller;

use app\common\service\SysMenuService;

class SysMenu extends AdminController
{
    //新增
    public function add(){
        $data = $this->request->post();
        //验证规则
        $validate = [
            'name' => 'require'
        ];
        //提示信息
        $message = [
            'name.require' => '名称不能为空!'
        ];
        $this->validate($data, $validate, $message);
        
        return SysMenuService::save($data);
    }

    //更新
    public function edit(){
        $data = $this->request->post();
        //验证规则
        $validate = [
            'id' => 'require',
            'name' => 'require'
        ];
        //提示信息
        $message = [
            'id.require' => 'ID不能为空!',
            'name.require' => '名称不能为空!'
        ];

        $this->validate($data, $validate, $message);

        return SysMenuService::update($data);
    }

    //列表
    public function list(){
        return SysMenuService::list();
    }

    //删除
    public function delete(){
        $ids = $this->request->get("ids");
        if(empty($ids)){
            return failure(config('error.er5')['code'],"请选择要删除的数据");
        }

        return SysMenuService::destroy($ids);
    }
}

可以看到我们的控制器要做的就是验证参数及返回结果给前端,一般都不会涉及业务逻辑上的东西。

最后配置一下接口路由admin->route->app.php

<?php
// +----------------------------------------------------------------------
// | 后台应用路由
// +----------------------------------------------------------------------
use think\facade\Route;
Route::group('sysuser', function(){
    //新增菜单
    Route::post('menu/add','sysMenu/add');
    //编辑菜单
    Route::post('menu/edit','sysMenu/edit');
    //菜单列表
    Route::get('menu/list','sysMenu/list');
    //删除菜单
    Route::get('menu/delete','sysMenu/delete');
});

这样就能访问接口了。

问题:有没有发现,我们新增菜单或更新菜单的时候, create_timeupdate_time 会自动写入时间?

答案在这里:https://www.kancloud.cn/manual/thinkphp6_0/1037592

你回头看看,整个过程下来,逻辑结构及代码是不是很清晰~

问题:新增和更新的时候不用判断请求是post请求吗?我看有些是这么写的if(!$this->request->isPost()){//抛出异常}

如果说每个方法都这么写,非常的繁琐,因此为了解决这个问题,我们开启了强制路由,路由配置那章有提到。同时我们也对路由异常进行捕抓,异常接管那章有提到。一旦我们请求方式不对,或者写错了,都会抛出异常

3.2角色管理

3.2.1设计表

角色管理界面如下:

表的设计比较简单

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
role_name varchar(30) 角色名称
remark varchar(255) 描述
auth_ids text 菜单权限,多个用逗号分开
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

需要说一下的这里面有个字段auth_ids用于保存这个角色可以看到哪些菜单,可能有人会觉得奇怪,这里为什么不新建一张角色关联菜单表,这个主要看个人选择吧,当然有些公司强制了数据表字段类型不能使用text,那就只能新建一张表。

3.2.2搜索器

首先我们新建两个文件SysMenuModel.phpSysRoleService.php,模型层、业务层的开发,类似于上面的菜单管理,就是那四个方法增、删、查、改,其中的增、删、改大家按照上面的步骤开发即可,下面重点讲一下分页查询中涉及到一个重要的知识点:搜索器

从上面的界面中我们可以看到有根据角色名称进行搜索数据的功能,一般来说我们在业务层中会这样写:

if($request->get('role_name')){
    $where = $query->where('role_name', $value);
}

当然这样写并没有问题,但你会发现如果条件很多,这个函数体的代码量就会很多,再说了这些搜索条件我们需要在任何地方都可以复用,为了解决这个问题,thinkphp的模型给我们提供了很便利的方法:搜索器

我们只需要在app->common->SysUserModel.php新增如下方法

<?php
namespace app\common\model;

class SysRoleModel extends BaseModel
{   
    
    protected $table = 'ds_sys_role';

    //根据角色名称搜索
    public function searchRoleNameAttr($query, $value, $data)
    {   
        if(!empty($value)){
            $query->where('role_name', $value);
        }
    }
}

这些搜索只针对当前的模型,简单的讲就是根据表的字段去查询数据,你可以随便定义搜索条件,无论哪个应用都可以共用这些搜索条件

我们调用的时候可以这样调用

SysRoleModel::withSearch(array_keys($param),$param)->select();

withSearch()第一个参数是搜索的字段,是一个一维数组,例如['role_name']

第二个参数是搜索字段对应的条件,也是一个一维数组,例如['role_name'=>'超级管理员']

3.2.3分页的封装

正常情况下,在app->admin->SysRoleService.php会这样写分页的代码

/**
 * 列表
 * @param  array  $param  请求参数
 * @return array
*/
public static function page($param){
    //分页设置
    $page = !isset($param['page']) ? 1 : $param['page'];
    $pageSize = !isset($param['page_size']) || $param['page_size'] > 100 ? 10 : $param['page_size'];
	
    //提取搜索条件
    $keyArr = array_keys($param);
    $query = SysRoleModel::withSearch(array_keys($param),$param);

    $data['total'] = $query->count();
    $data['list'] = [];
    if($data['total'] > 0){
        $data['list'] = $query->order('id','desc')->limit(($page-1)*$pageSize,$pageSize)->select()->toArray();
    }

    return success($data);
}

大家有没有发现,其实分页的代码是模式是固定的:分页设置、搜索器、查询总数、分页查询数据

后面我们的开发会大量使用到分页查询,因此为了不让我们重复去写代码,这里我们封装一个公用的查询方法。这个方法写在哪呢?

前面我们提到基础模型有大用,现在就派上用场了,打开BaseModel.php,加入这段代码

//分页查询
public static function page($param, $other = []){
    //分页默认值
    $page = !isset($param['page']) ? 1 : $param['page'];
    $pageSize = !isset($param['page_size']) || $param['page_size'] > 100 ? 10 : $param['page_size'];


    //设置排序
    if(isset($other['order']) && $other['order']){
        $arr = explode(',',$other['order']);
        $orderField = $arr[0];
        $orderValue = $arr[1];
    }else{
        $orderField = "id";
        $orderValue = "desc";
    }
	
    // 提取搜索条件
    unset($param['page']);
    unset($param['pageSize']);
    $keyArr = array_keys($param);
    $query = self::withSearch($keyArr,$param);

    $data['total'] = $query->count();
    $data['list'] = [];
    if($data['total'] > 0){
        //设置排除字段
        if(isset($other['withoutField']) && $other['withoutField']){
            $query = $query->withoutField($other['withoutField']);
        }
        //设置查询的字段
        if(isset($other['field']) && $other['field']){
            $query = $query->field($other['field']);
        }

        $data['list'] = $query->order($orderField,$orderValue)->limit(($page-1)*$pageSize,$pageSize)->select()->toArray();
    }

    return $data;
}

这个方法有两个参数

$param:分页参数及查询条件

$other:用于设置排序、设置排除字段、设置查询的字段

这个分页方法后续还会完善

完成这步之后呢,我们只需改一下业务层的代码

/**
 * 分页
 * @param  array  $param 搜索条件
 * @return array
*/
public static function page($param){
    return success(SysRoleModel::page($param));
}

我们直接这样调用就行

分页的封装仅限于单表,因此我们尽量不要连表查询,不过这就得看我们怎么设计表了

3.2.4控制器及路由

控制器的代码跟菜单权限的差不多,大家可以看源码,这里的话就贴出路由的代码

//新增角色
Route::post('role/add','sysRole/add');
//编辑角色
Route::post('role/edit','sysRole/edit');
//角色列表
Route::get('role/page','sysRole/page');
//删除角色
Route::get('role/delete','sysRole/delete');

3.3管理员管理

3.3.1设计表

管理员界面如下:

根据界面我们设计出如下表格:

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
root tinyint(1) 是否是开发账号:0-否 1-是
account varchar(30) 账号
password varchar(80) 密码
remark varchar(255) 备注
role_id int(11) 角色ID
login_time datetime 最后登录时间
login_ip varchar(30) 最后登录ip
status tinyint(1) 状态:0-禁用,1-启用
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

root这个字段表示的是否是开发账号,如果是开发账号,则拥有所有权限。

role_id保存角色的ID,注意这里不是多选,我们规定一个账号只能对应一种角色。可能有些人在网上看到有些教程,用户跟角色是一对多的关系。其实不管是一对一和一对多是一样的,只要能满足需求权限管理的需求就行。

3.3.2密码加密与验证

我们在保存账号密码的时候,并不会把密码明文保存在数据库中,而是进行了加密保存,那我们看看本项目中是如何加密密码的?

一般来说我们会写两个函数,一个是加密函数,一个是密码验证函数。打开app->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;
}

我们设计一个加密字符串,可以从配置中获取,也可以动态传入,再把密码进行两次md5加密,这样密码的安全性大大提高。

/**
 * 密码比较方法,所有涉及密码比较的地方都用这个方法
 * @param string $password     要比较的密码
 * @param string $passwordInDb 数据库保存的已经加密过的密码
 * @return boolean 密码相同,返回true
 */
function comparePassword($password, $passwordInDb)
{
    return createPassword($password) == $passwordInDb;
}

密码验证其实很简单,只要把输入的密码进行加密,然后跟数据库保存的密码进行比对,如果一样,说明输入的密码正常,否则就是错误密码。

3.3.3新增管理员

模型的新建这里就不说了,我们在新增管理员的时候,我们需要选择角色,因此这里需要提供一个获取角色列表的接口,之前我们讲角色管理的时候,只开发了分页的接口,因此我们需要开发一个获取所有正常角色的接口。

打开SysRoleService.php,新增如下方法

/**
 * 列表
 * @return array
 */
public static function list(){
   return success(SysRoleModel::where('status',1)->select()->toArray());
}

打开SysRole.php

//列表
public function list(){
    return SysRoleService::list();
}

相应的新增路由

Route::get('role/list','sysRole/list');

接下来就是新增管理员接口的开发了,新建SysUserService.php,内容如下

<?php
namespace app\admin\service;

use app\common\model\SysUserModel;

class SysUserService{

    /**
     * 新增数据
     * @param  array  $data  新增的数据
     * @return json
     */
    public static function save($data){
        if(SysUserModel::getByAccount($data['account'])){
            //如果存在
            return failure(config('error.er8')['code'],config('error.er8')['msg']);
        }

        $data['password'] = createPassword($data['password']);
        $result = SysUserModel::create($data);
        return success($result->id);
    }
}

这个代码逻辑很简单

一、先判断新增的账号是否存在

二、保存数据

3.3.4编辑管理员

打开SysUserService.php,新增

/**
 * 更新数据
 * @param  array  $data  更新的数据
 * @return json
*/
public static function update($data){
    $sysUser = SysUserModel::find($data['id']);
    if(!$sysUser){
        return failure(config('error.er15')['code'],config('error.er15')['msg']);
    }

    //如果提交过来的账号跟数据库记录的账号不一样,说明当前提交过来的账号是修改过后的账号,因此这里需要验证码修改过的账号是否在数据库里面已经存在
    if(isset($data['account']) && $data['account'] !== $sysUser->account){
        if(SysUserModel::getByAccount($data['account'])){
            //如果存在
            return failure(config('error.er8')['code'],config('error.er8')['msg']);
        }
    }

    //加密
    if(isset($data['password']) && !empty($data['password'])){
        $data['password'] = createPassword($data['password']);
    }

    $sysUser->save($data);

    return success($sysUser->id);
}

这段代码的逻辑如下:

一、首先判断更新的用户是否存在于数据表里面,即有效用户

二、判断提交过来的账号是不是新账号,如果是需要查询这个账号是否已经存在了

三、修改管理员信息时,如果提交过来新密码,则需要对密码重新加密保存

四、保存数据

3.3.5分页查询

因为需要在管理员列表中显示关联的角色名称,而我们管理员表保存的是角色的ID,因此我们需要根据id去查询名称。

打开SysUserService.php,新增

/**
 * 分页
 * @param  array  $param  请求参数
 * @return array
*/
public static function page($param){
    $data = SysUserModel::page($param,['withoutField'=>'password']);

    $roleIds = array_column($data, 'role_id');
    $roles = SysRoleService::listById($roleIds);

    foreach ($data['list']  as $k => $v) {
        $data['list'][$k]['role_name'] = '';
        foreach ($roles as $value) {
            if ($v['role_id'] == $value['id']) {
                $data['list'][$k]['role_name'] = $value['role_name'];
            }
        }
    }
    return success($data);
}

这里我们没有使用连表查询,因为前面已经封装好了分页的方法,我们先查询管理员数据,然后通过角色ID去获取角色数据,后面我们都会使用这种方式去获取数据,避免连表查询。

问题:这里有没有更简单的方式去获取角色的名称,假如说别的地方要获取名称,那是不是也要这样再写一次代码?

这里我提供一下思路,因为一个系统的角色并不会太多,因此我们可以在获取角色列表的时候,一次性获取所有,然后保存到缓存里面,然后写个函数,返回名称

function getRoleById($id){
    // 这里获取缓存数据
    $role = ['1'=>'普通管理员','2'=>'测试管理员'];
    return $role[$id];
}

那我们调用就可以这样了

foreach ($data['list']  as $k => $v) {
    $data['list'][$k]['role_name'] = getRoleById($v['id']);
}

这样就可以复用代码

3.3.6控制器及路由

这里只贴出路由的代码

//新增管理员
Route::post('add','sysUser/add');
//编辑管理员
Route::post('edit','sysUser/edit');
//管理员列表
Route::get('page','sysUser/page');
//删除
Route::get('delete','sysUser/delete');

3.4管理员登录验证

这一块是重点,里面有不少知识点。

3.4.1扩展验证码

先看看登录界面

这一章我们实现登录验证码

thinkphp提供的验证码插件是基于session,使用起来很简单

<div>{:captcha_img()}</div>

但是我们的项目前端使用的是vue,它跟后端一般是部署在不同服务器上的,因此无法通过 Session 保持会话这状态,这时我们得另想办法了。

网上也有一些插件,你可以去下载插件,但本教程会教你一个更巧妙的方法去实现验证码功能。

首先我们安装一下官网提供的验证码插件

composer require topthink/think-captcha

生成验证码

既然不能用session,那我们只能借助缓存来实现我们的验证码,下面看看这段生成验证码的代码,打开SysUserService.php,新增内容如下:

/**
 * 创建图片验证码
 * @return array
*/
public static function createCaptcha(){
    $uniqid = uniqid(rand(00000,99999)); // Ⅰ
    $rs = Captcha::create();             // Ⅱ
    $base64_image = "data:image/png;base64," . base64_encode($rs->getData());
    $key = session('captcha.key');       // Ⅲ

    cache(config('redisKey.ADMIN_LOGIN_VERIFY_').$uniqid,$key); // Ⅳ

    return success(['uniqid'=>$uniqid,'image'=>$base64_image]); // Ⅴ
}

我们来重点解释一下这段代码:

、这一步是生产一个唯一码,后面作为redis缓存key的一部分

、使用Captcha::create会得到图片的数据,再通过base64_encode加密,前端就可以直接拿到这个数据显 示,而不是链接

、这个key其实就是那串经过加密的验证码

、把第三步得到的那串验证码保存到缓存中

、把唯一码和图片数据返回给前端

返回的格式如下:

{
	"code": 200,
	"msg": "操作成功",
	"data": {
		"uniqid": "52461655736fe1ee19",
		"image": "data:image/png;base64,……………………………………………………………………"
	}
}

判断输入的验证码是否正确

判断输入验证码是否正确,是在登录那会才实现的,这里我先解释一下这个验证的代码

$data = $this->request->post();
$key = cache(ADMIN_LOGIN_VERIFY_.$data['uniqid']); // Ⅰ
if($key && password_verify(mb_strtolower($data['code'], 'UTF-8'), $key)){  // Ⅱ
    // 验证通过后,清空验证码缓存
    cache(ADMIN_LOGIN_VERIFY_.$uniqid,null);
}else{
    // 提示验证码输出错误
}

、前端把uniqid提交过来,我们通过这个key去缓存中拿到那串验证码

、使用内置函数去对比输入的验证码跟内存的验证码是否一致

为什么要使用这个函数password_verify?而不是像下面这样:

if($key == $data['code'])

这是因为我们存在缓存中的验证码是经过加密的。

我们可以看看生成验证码的源码,打开Captcha.php,里面有个generate()方法,最后有这样的一段代码

$hash = password_hash($key, PASSWORD_BCRYPT, ['cost' => 10]);
$this->session->set('captcha', [
    'key' => $hash,
]);

我们可以看到验证码是经过password_hash加密的,然后保存在captcha.key中。这也是为什么前面我们获取key的时候是这样写的:

$key = session('captcha.key');      

我们继续看源码,其中有check()这个方法,这个方法文档中也提到,是判断验证码是否正确,我们看看其中的内容

public function check(string $code): bool
{
    if (!$this->session->has('captcha')) {
        return false;
    }
	// 获取key,等同于cache(ADMIN_LOGIN_VERIFY_.$data['uniqid']); // 一
    $key = $this->session->get('captcha.key');
    
    // 判断验证码,等同于password_verify(mb_strtolower($data['code'], 'UTF-8'), $key)
    $code = mb_strtolower($code, 'UTF-8');
    $res = password_verify($code, $key);
    
    if ($res) {
        // 等同于cache(ADMIN_LOGIN_VERIFY_.$uniqid,null);
        $this->session->delete('captcha');
    }
    return $res;
}

简单来说我们就是参考框架的源码以及巧妙运用框架的某些方法实现这个验证码,这里的话并没有对源码进行改造,我看网上有些文章是改里面的源码的,真的很不可思议,这是绝对禁止的。

验证码配置

框架给我提供了验证码的配置文件app->config->captcha.php,我们配置验证码的位数、字符集、是否使用中文或者是算术

return [
    //验证码位数
    'length'   => 4,
    // 验证码字符集合
    'codeSet'  => '123456789',
    // 验证码过期时间
    'expire'   => 1800,
    // 是否使用中文验证码
    'useZh'    => false,
    // 是否使用算术验证码
    'math'     => false,
    // 是否使用背景图
   //	…………………………
];

3.4.2Jwt(token)

前后端分离项目,一般来说服务端的代码和前端代码都是放在不同服务器上,有不同的域名,因此前端无法携带与后端服务器对应的cookie,这也就是无法使用session的原因,因此一般情况下都使用jwt来保存登录的验证状态

接下来我们说说如何生成jwt

文档地址:https://github.com/firebase/php-jwt

安装

composer require firebase/php-jwt

示例

//用户信息
$user = ['id'=>1,'account'=>'admin'];
//一天后过期
$user['exp'] = time() + 24*3600;

//生成token,config('app.jwt_code_pc')加密字符串
$jwt = JWT::encode($user,config('app.jwt_code_pc'),"HS256");

最后生成一窜以下的字符串

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Miwicm9vdCI6MSwiYWNjb3VudCI6ImFkbWluIiwicGFzc3dvcmQiOiIqKioxMWIyYWFiMzcyMWU5NDAyZTJjMWQ1YmNkYmI0ZDI1ZiIsInJlbWFyayI6IiIsInJvbGVzIjpudWxsLCJsb2dpbl90aW1lIjoiMjAyMy0wNi0wMiAyMjozOToxOSIsImxvZ2luX2lwIjoiMTI3LjAuMC4xIiwic3RhdHVzIjoxLCJjcmVhdG9yIjowLCJ1cGRhdG9yIjowLCJjcmVhdGVfdGltZSI6IjIwMjMtMDUtMjUgMTU6MTg6MzYiLCJ1cGRhdGVfdGltZSI6IjIwMjMtMDYtMDIgMjI6Mzk6MTkiLCJkZWxldGVfdGltZSI6bnVsbCwiZXhwIjoxNjg1ODAzMTU5fQ.Nmay9ZTW3LTvRMJLRC1cz0wtvQS014f68epLPrH7IAo

下次用户访问后台接口的时候,把jwt通过header头传给后端接口,后端接口会对其进行解析,如果能够被解析,则说明jwt有效

//解析jwt
JWT::decode(jwt,new Key(config('app.jwt_code_pc'),'HS256'));

最后得到用户信息

$user = ['id'=>1,'account'=>'admin'];

注:

无论是生成jwt还是解析jwt,其中有个非常重要的参数:加密的密钥,这个密钥一旦泄露,那么别人就看可以轻易的解析和伪造jwt进行登录

3.4.3管理员登录

在登录之前我们先创建一个账号,根据前面的新增管理接口,我们创建一个账号密码是admin/a_123456的超级管理员,如果说你是接口创建的,那么需要打开数据表,把root字段改为1,表示开发账号。

或者是你使用下面一条sql,直接新增一个账号

INSERT INTO `demo-shop`.`ds_sys_user`(`id`, `root`, `account`, `password`, `remark`, `role_id`, `login_time`, `login_ip`, `status`, `creator`, `updator`, `create_time`, `update_time`, `delete_time`) VALUES (NULL, 1, 'admin', '***11b2aab3721e9402e2c1d5bcdbb4d25f', '', NULL, '2023-05-25 16:31:22', '127.0.0.1', 1, 0, 1, '2023-05-25 15:18:36', '2023-05-26 11:44:55', NULL);

下面我们看登录的核心业务,打开sysUserService.php新增如下方法

public static function login($data){
    $user = SysUserModel::getByAccount($data['account']);
    if(empty($user) || !comparePassword($data['password'],$user['password'])){
        //账号或者密码错误 Ⅰ
        return failure(config('error.er11')['code'],config('error.er11')['msg']);
    }
	
    // Ⅱ
    if($user['status'] == 0){
        //账号被禁用
        return failure(config('error.er12')['code'],config('error.er12')['msg']);
    } 

    //下面是验证成功后的处理逻辑  Ⅲ
    //登录成功后更新用户信息
    $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_admin'),"HS256");

    return success(['token' => $jwt]);
}

我们看看这段代码的逻辑,

、判断账号是否存在和判断提交过来的密码是否正确

、判断账号是否处于启用状态

、记录登录的时间及登录IP

、生成token返回给前端

接下来看看控制层代码,打开SysUser.php,新增如下方法

//登录
public function login(){
    $data = $this->request->post();
    //验证规则
    $validate = [
        'account' => 'require',
        'password'  => 'require',
        'code' => 'require',
        'uniqid' => 'require',
    ];
    //提示信息
    $message = [
        'account.require' => '账号不能为空!',
        'password.require' => '密码不能为空!',
        'code.require' => '验证码不能为空!',
        'uniqid.require' => 'uniqid不能为空!',
    ];

    $this->validate($data, $validate, $message);


    $key = cache(ADMIN_LOGIN_VERIFY_.$data['uniqid']);
    if($key && password_verify(mb_strtolower($data['code'], 'UTF-8'), $key)){
        cache(ADMIN_LOGIN_VERIFY_.$data['uniqid'],null);
    }else{
        return failure(config('error.er17')['code'],config('error.er17')['msg']);
    }

    return SysUserService::login($data);
}

这里主要是对参数进行验证,这里也包含了输入验证码的验证,验证通过后,再调用业务核心代码。如果你看不懂这个验证码的验证,你可以阅读3.3扩展验证码这一章节,里面讲得很明白。

最后配置一下路由,打开app.php

//获取验证码
Route::get('login/verify','sysUser/verify');
//管理员登录
Route::post('login','sysUser/login');

3.4.4用户验证中间件

用户想访问后台,那一定是要登录的,后端的接口是如何知道用户是否登录了呢?

上节讲到用户登录成功后,会返回一串token给前端,这个token就是一个登录凭证。当我们调用后台的接口时一定要带上这个凭证,否则你是无法访问。

一般情况下我们都是通过header头来携带token信息,拿到这窜token后,我们在那里解析它呢??

接下来路由中间件要出场了,你可以在https://www.kancloud.cn/manual/thinkphp6_0/1037493阅读到相关介绍

admin应用下新建middleware/Auth.php,内容如下

<?php
// +----------------------------------------------------------------------
// | 登录验证中间件
// +----------------------------------------------------------------------
namespace app\admin\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 == 'admin' && $token){
                //Ⅱ
                JWT::decode($token,new Key(config('app.jwt_code_admin'),'HS256')); 
            }else{
                return failure(config('error.er14')['code'],config('error.er14')['msg']);
            }
        }catch (\Exception $e) {
            return failure(config('error.er14')['code'],config('error.er14')['msg']);
        }

        return $next($request);
    }
}

、header头携带了token和device,这里之所以要把$device传过来主要时标识这窜token是哪个应用的token

、解析token,如果token解析不了就会抛出异常,否则会抛出异常

{
	"code": "2000",
	"msg": "token非法!"
}

注意这里一定要带上app.php文件中配置的加密窜

//jwt加密字符串
'jwt_code_admin' => 'admin-demo-shop'

用户认证中间件已经写好了,接下来是把它注册到路由中,打开admin/route/app.php,在后面添加如下

//路由分组,sysuser组里面的请求都要登录后才能访问
Route::group('sysuser', function(){
    //新增管理员
    Route::post('add','sysUser/add');
    //编辑管理员
    Route::post('edit','sysUser/edit');
    //管理员列表
    Route::get('page','sysUser/page');
    //启用、禁用
    Route::post('updateStatus','sysUser/updateStatus');
    //删除
    Route::get('delete','sysUser/delete');
    //修改个人密码
    Route::post('updatePwd','sysUser/updatePwd');
    //登录用户信息
    Route::get('loginUserInfo','sysUser/loginUserInfo');

    //新增角色
    Route::post('role/add','sysRole/add');
    //编辑角色
    Route::post('role/edit','sysRole/edit');
    //角色列表
    Route::get('role/page','sysRole/page');
    //角色列表
    Route::get('role/list','sysRole/list');
    //删除角色
    Route::get('role/delete','sysRole/delete');
    //授权
    Route::get('role/hasAuth','sysRole/hasAuth');

    //新增菜单
    Route::post('menu/add','sysMenu/add');
    //编辑菜单
    Route::post('menu/edit','sysMenu/edit');
    //菜单列表
    Route::get('menu/list','sysMenu/list');
    //删除菜单
    Route::get('menu/delete','sysMenu/delete');
})->middleware(app\admin\middleware\Auth::class);

这样我们之前开发的三个模块的接口都要登录后,携带token才能访问了。

3.4.5用户信息封装

前面讲jwt的时候提到过,把用户信息加密生成一串token,用户登录后台成功后,访问后台接口需要带上这一串token,中间件会解析这个token,如果能被解析则说明这窜token有效,并且解析出来的内容就是当初加密的用户信息。

接下来的问题就是,怎么保存这个用户信息,使其在任何地方都可以被调用?

这里我们打开上一节实现的中间件Auth.php,需要改动一下

$userInfo = JWT::decode($token,new Key(config('app.jwt_code_admin'),'HS256'));          
$request->user = ['id'=>$userInfo->id,'account'=>$userInfo->account];

这里使用$request这个全局对象,设置sysuser变量保存登录信息,这里只保存了id和account,一般情况下id是用到最多的,比如说创建者、更新者保存的都是id,再比如我们修改个人信息的时候,也需要根据id去更新。

为了方便我们获取到id或者是用户信息,最好在写两个全局函数,打开common.php

/**
 * 获取登录用户ID
 */
function getUserId(){
    return isset(request()->user['id']) ? request()->user['id'] : 0;
}

/**
 * 获取登录用户
 */
function getUserInfo(){
    return isset(request()->user) ? request()->user : [];
}

这样的话我们就可以在任何地方很方便的使用它。

还记得前面我们说到的创建者和更新者自动填充么?现在我们就可以完善那段代码

//插入前
public static function onBeforeInsert($data){
    return $data['creator'] = getUserId();
}

//更新前
public static function onBeforeUpdate($data){
    return $data['updator'] = getUserId();
}

3.4.6修改个人密码

我们都知道登录用户是可以修改自己的密码的,本商城项目也提供了一个这样的接口去修改自己的密码

打开SysUser.php,新增以下内容

//修改个人密码
public function updatePwd(){
    $data = $this->request->post();
    //验证规则
    $validate = [
        'password'  => 'require|regex:/^[a-zA-Z0-9_]{8,20}$/',
        'repassword'  => 'require'
    ];
    //提示信息
    $message = [
        'password.require' => '密码不能为空!',
        'password.regex' => '密码长度8~20位,包含字母数字下划线!',
        'repassword.require' => '确认密码不能为空!',
    ];

    $this->validate($data, $validate, $message);


    //两次输入的密码不一致
    if($data['password'] !== $data['repassword']){
        return failure(config('error.er6')['code'],config('error.er6')['msg']);
    }
    $data['id'] = getUserId();
    return SysUserService::update($data);
}

前端只需传两个参数过来即可:密码和确认密码,然后调用我们之前封装好的update方法即可

3.5权限控制

3.5.1左侧菜单

当用户登录后台后,能看到哪些菜单栏目完全是由用户归属的角色所决定的,我们在角色管理模块,新增角色时会给这个角色绑定一些菜单。

我们需要写一个接口,当用户登录成功后,前端调用这个接口获取该用户能访问的菜单。接下来我们先看看代码,打开SysUserService.php,新增如下内容

/**
 * 详情
 * @param  string  $id   用户ID
*/
public static function info($id){
    $user = SysUserModel::withoutField('password')->find($id)->toArray();
    $user['menu'] = [];
    if($user['root'] == 1){
        //如果是超级管理员,直接返回所有菜单--list方法前面已经实现
        $user['menu'] = SysMenuService::list();
    }else{
        if($user['roles']){
            //根据角色ID获取角色拥有的菜单ID
            $authIds = SysRoleService::getAuths($user['roles']);
            //根据id获取菜单详细信息,例如名称  listById方法前面已经实现
            $user['menu'] = SysMenuService::listById($authIds);
        }
    }

    return success($user);
}

这个函数主要是获取用户详细信息,其中包含了用户可以访问到菜单栏目。这里面有个判断就是如果root=1表示是超级管理员,否则就是获取该账号下角色拥有的权限

我们看看SysRoleService.php里面的getAuths()方法

/**
 * 返回角色的权限ID
 * @param  string  $ids  角色ID
 * @return array
*/
public static function getAuths($ids){
    $result = SysRoleModel::where('id','in',$ids)->select()->toArray();
    if(count($result) == 0){
        return [];
    }

    $atuhStr = '';
    foreach ($result as $k => $v) {
        if($v['auth_ids']){
            $atuhStr = $atuhStr.$v['auth_ids'].',';
        }
    }

    if(empty($atuhStr)){
        return [];
    }

    return array_unique(explode(',',substr($atuhStr,0,-1)));
}

每个角色保存的权限id是用逗号分隔,如果多个角色,就把所有auth_ids串联起来,然后再分割、去重,最后得到当前用户的权限ID,最后根据ID去获取列表信息

接下来控制器SysUser.php比较简单

//登录用户信息
public function loginUserInfo(){
    return SysUserService::info(getUserId());
}

最后在sysuser路由分组中新增路由

//登录用户信息
Route::get('loginUserInfo','sysUser/loginUserInfo');

3.5.2授权判断

上面一节我们实现了界面的权限控制,但细想一下,假如说系统设置下还有一个会员注册设置的菜单,虽然说它不会显示在页面左侧,但如果直接访问的话,它还是可以被访问到,因为到目前为止,我们只要求用户登录了就能访问后台,并没有对其拥有的权限进行授权设置。

那怎么实现权限校验呢?下面是大概思路

  1. 新增菜单的时候,我们需要添加一个权限标识,也就是保存到菜单表的perms字段的值,这个值一般就是访问当前菜单的控制器名+方法名
  2. 获取当前登录用户拥有的菜单权限
  3. 获取当前访问的控制器名+方法名,看看这个组合是否存在于当前用户拥有的菜单权限中

有了思路之后呢,我们打开登录校验的中间件Auth.php,在方法里面增加如下的代码

public function handle($request, \Closure $next)
{
    try{
        $token = $request->header('token');
        $device = $request->header('device');
        if($device == 'admin' && $token){
            //解析token得到用户信息
            $userInfo = JWT::decode($token,new Key(config('app.jwt_code_admin'),'HS256'));
            //把部分用户信息保存到全局reqeust对象中
            $request->user = ['id'=>$userInfo->id,'account'=>$userInfo->account];

            //判断是否有权限访问
            if(!$this->checkPermission($request,$userInfo)){
                return failure(config('error.er19')['code'],config('error.er19')['msg']);
            }
        }else{
            return failure(config('error.er14')['code'],config('error.er14')['msg']);
        }
    }catch (\Exception $e) {
        return failure(config('error.er14')['code'],config('error.er14')['msg']);
    }

    return $next($request);
}

private function checkPermission($request,$userInfo){
    if($userInfo->root == 1){
        //超管直接返回true
        return true;
    }

    if(!$userInfo->roles){
        return false;
    }

    if(cache(ADMIN_PERMS_.$userInfo->id)){
        $perms = cache(ADMIN_PERMS_.$userInfo->id);
    }else{
        //根据角色获取菜单ID
        $authIds = SysRoleService::getAuths($userInfo->roles);
        //根据菜单ID获取权限标识,返回的是一个一维数组
        $perms = SysMenuService::getPerms($authIds);
        //保存到缓存中,避免每次查询数据库,注意这里的键值使用了用户ID进行区分
        cache(ADMIN_PERMS_.$userInfo->id,$perms);
    }

    if(count($perms) == 0) return false;

    //通过request对象获取当前访问的控制器名和方法名,也就是权限标识
    $current = $request->controller().'/'.$request->action();
    //判断标识是否存在于当前用户中
    if(!in_array($current,$perms)){
        return false;
    }

    return true;
}

注:

建议此功能放到最后开发,或者是开发好注释掉,这样方便开发

4.后台会员模块

这个模块主要讲三个知识点:

第一、直接导出,导出excelcsv

第二、文件上传(会员头像),上传到云上(oss)

第三、查询优化

4.1会员管理

4.1.1设计表

会员的来源一般是用户在商城展示端注册的,后台需要一个模块对这些会员进行管理,管理员可以清楚的知道哪些会员是新注册的,哪些是优质会员,会员的积分情况等等,有了这些数据,这样有助于运营人员对商城营销推广。

我们先看看后台的界面:

大概的我们设计一个会员表

字段 类型 备注
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) 删除时间,默认为空

这个是相对普通的一个会员表了,后面会因为新增的一些需求而对这个表新增一些字段,目前我们先设计一个基本的会员表。

4.1.2导出组件

上面的界面中,我们可以看到有个导出数据的功能,对于一般的商城网站导出excel或csv,这是很常见的一个功能。但是对于一些对数据保密要求严格以及数据量大的公司来说,一般都不允许直接在界面导出,比如说你要导出十几万的数据,一般是找数据库管理员给你导出,而不是通过程序去导出。

不过像我上面说的毕竟是少数情况,一般商城网站都会有导出功能,接下来我们实现一下导出功能。

这里导出我推荐使用phpspreadsheet ,官方文档https://phpspreadsheet.readthedocs.io/en/latest/

这是一个英文文档,很多时候我们都不喜欢看英文文档,最大的原因就是看不懂了,不过看不懂也要看,作为一个开发人员,你必须强制自己去看英文文档,因为文档就是最好的教程。

对于一些英文很烂的同学来说,如何看文档,这里我给出两个建议:

  • 先看文档的概述、快速开始部分
  • 看官方给的示例

好,现在回到组件上,如图

我们看到该组件支持导入多种格式的文件,也支持导出像excelcsv等重要文件,接下我们将实现excel的导出,至于csv导出有兴趣的同学可以自行实现。

4.1.3导出方法的封装

我们先安装一下这个插件

composer require phpoffice/phpspreadsheet

安装完之后,我们就要思考怎么去实现这个功能,下面我说说我个人的做法:一般情况下我都会把它封装成一个方法,使其在整个项目中都能被方便调用。

对于第三方插件进行二次封装的代码,我们一般放在common整个目录下,新建lib->phpspreadsheet->Excel.php,我们看看这个导出的代码

<?php
// +----------------------------------------------------------------------
// | excel
// +----------------------------------------------------------------------
namespace app\common\lib\phpspreadsheet;

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

class Excel
{	
    /**
     * 浏览器直接导出excel
     *
     * @param array $data 导出的数据
     * @param string $header 标题
     * @param string $fields 字段名
     */
    public static function export($data, $header, $fields){
        $spreadsheet = new Spreadsheet();
        $sheet = $spreadsheet->setActiveSheetIndex(0); // Ⅰ 
        
        // Ⅱ
        $cell = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
        if (!empty($header)) {
            $h = explode(',', $header);
            for ($i=0; $i < count($h); $i++) {
                $sheet->setCellValue($cell[$i].'1', $h[$i]);
            }
        }
        // 如果看不懂,可以这样看
        //$sheet->setCellValue('A1', "编号");
        //$sheet->setCellValue('B1', "账号");
        
        // Ⅲ
        $fields = explode(',', $fields);
        $j = 2;
        foreach ($data as $key => $value) {
            for ($i=0; $i < count($fields); $i++) {
                $sheet->setCellValue($cell[$i].$j, $value[$fields[$i]]);
            }
            $j++;
        }
		
        // Ⅳ
        // Redirect output to a client’s web browser (Xlsx)
        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        header('Content-Disposition: attachment;filename="'.date("Y-m-d", time()).'.xlsx"');
        header('Cache-Control: max-age=0');
        // If you're serving to IE 9, then the following may be needed
        header('Cache-Control: max-age=1');

        // If you're serving to IE over SSL, then the following may be needed
        header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); // Date in the past
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); // always modified
        header('Cache-Control: cache, must-revalidate'); // HTTP/1.1
        header('Pragma: public'); // HTTP/1.0

        $writer = new Xlsx($spreadsheet);
        $writer->save('php://output');
        exit;
    }
}

这个函数有三个参数:

$data 导出的数据,这个很好理解。

$header 标题,这个就是表头,比如说下面的账号、昵称。表头作为参数相信大家也很容易理解,因为不同模块导出的数据表头肯定不一样的,比如商品模块,表头可能就算商品名称、价格等

$fields 字段名,不同的数据,字段是不一样的,我们需要指出对应的字段

接下来我们解释一下上面的代码:

1.选当前的sheet

2.设置标题

3.输出数据

4.设置header参数,使文件可以通过浏览器下载,这里的文件名的话使用时间格式输出,你也可以传入一个文件名

header('Content-Disposition: attachment;filename="'.date("Y-m-d", time()).'.xlsx"');

这段代码封装的比较简单,并没有对文件的一些属性进行设置,有兴趣的同学可以根据文档示例进行丰富

4.1.4一对一连表

新建app->admin->controller->User.php

<?php
// +----------------------------------------------------------------------
// | 用户模块
// +----------------------------------------------------------------------
// | Author: myh
// +----------------------------------------------------------------------
namespace app\admin\controller;

use app\common\service\UserService;
use app\admin\service\AuserService;

class User extends AdminController
{
    //导出
    public function export(){
        AuserService::export($this->request->get());
    }
}

新建app->admin->service->AuserService.php

<?php
// +----------------------------------------------------------------------
// | 会员模块
// +----------------------------------------------------------------------
namespace app\admin\service;

use app\common\model\UserModel;
use app\common\lib\phpspreadsheet\Excel;

class AuserService{
    /**
     * 导出
     */
    public static function export($param){
        $keyArr = array_keys($param);
        $list = UserModel::with('userLevel')->withSearch($keyArr, $param)->withoutField('password')->select()->toArray();
        $head = '编号,账号,昵称,手机,等级,性别,积分,成长值,注册时间';
        $fields = 'user_sn,account,nickname,mobile,level_name,sex,user_integral,user_growth,create_time';
        Excel::export($list, $head, $fields);
    }
}

首先我们先查出会员数据$list,这里有个地方要注意的,我们查出会员等级时,需要关联会员等级表,我们要获取会员等级表level_name的值。因此我们这里使用模型中的一对一关联。

打开app->common->model->userModel.php,新增如下内容

public function userLevel()
{
    return $this->hasOne(UserLevelModel::class, 'id')->bind(['level_name']);
}

接下来就是调用我们自己封装的export函数,新建app->common->lib->phpspreadsheet->Excel.php,这里要提一下,对于第三方组件的二次封装,我们都放在common->lib目录下

4.3头像上传

一般商城网站都会把文件上传到第三方云,例如阿里云(oss),腾讯云(cos),当然如果公司有足够的实力,可以自己部署一台文件服务器,用于文件的保存。

头像上传一般是用户在用户中心上传的,后台管理员可以编辑用户资料,其中就有头像上传,因此这个模块就先讲讲文件上传这个知识点。

4.3.1阿里云OSS

首先打开官方文档https://help.aliyun.com/zh/oss/product-overview/?spm=a2c4g.11186623.0.0.625b6f1bjn1FsU。还是按照我们之前阅读文档的重点:概述、快速开始、提供的例子

第一、购买服务

https://help.aliyun.com/zh/oss/getting-started/console-quick-start?spm=a2c4g.11186623.0.0.4d425d6bcx5NWi根据文档这一章节的步骤来

打开https://www.aliyun.com/product/oss?spm=a2c4g.11186623.J_4VYgf18xNlTAyFFbOuOQe.32.5e134425TurhoY,注册/登录阿里云账号

购买成功后我们创建一个bucket,

4.3.2上传实现

首先第一步就是安装

composer require aliyuncs/oss-sdk-php

接下来就把它提供的代码示例抄下来修改即可:https://help.aliyun.com/zh/oss/developer-reference/simple-upload?spm=a2c4g.11186623.0.0.6c745d6bfD4WM5

app->common->lib目录下新建oss->OSs.php

<?php
// +----------------------------------------------------------------------
// | OSS存储
// +----------------------------------------------------------------------
namespace app\common\lib\oss;

use OSS\OssClient;
use OSS\Core\OssException;
use think\facade\Log;

class Oss{

    //初始化oss客户端
    private static function createOssClient(){
        $accessKeyId = env('oss.access_key_id');
        $accessKeySecret = env('oss.access_key_secret');
        $endpoint = env('oss.endpoint');
        try {
            return new OssClient($accessKeyId, $accessKeySecret, $endpoint);
        } catch (OssException $e) {
            Log::error("初始化oss客户端异常:".$e->getMessage());
            serviceException();
        }
    }
    
    /**
     * 上传文件
     * @param  string     $object     目标文件
     * @param  string     $filePath    源文件
     */
    public static function uploadFile($object,$filePath){
        $bucket = env('oss.bucket');
        try {
            $ossClient = self::createOssClient();
            $result = $ossClient->uploadFile($bucket, $object, $filePath);
            return $result['info'];
        }catch (OssException $e) {
            Log::error("OSS上传文件失败:".$e->getMessage());
            serviceException();
        }
    }

    /**
     * 上传文件
     * @param  string     $object     目标文件
     * @param  string     $content    字符串
     */
    public static function putFile($object,$content){
        $bucket = env('oss.bucket');
        try {
            $ossClient = self::createOssClient();
            $result = $ossClient->putObject($bucket, $object, $content);
            return $result['info'];
        }catch (OssException $e) {
            Log::error("OSS上传文件失败:".$e->getMessage());
            serviceException();
        }
    }
}

这里主要封装了两个上传方法,一个是上传文件,一个是把字符串的内容写到文件里并上传,这两种方式是很常用的,因此我这里就一并封装了。

我们上传头像是上传图片,所有等下调用的是uploadFile()方法。

这里需要注意的是

$accessKeyId = env('oss.access_key_id');
$accessKeySecret = env('oss.access_key_secret');
$endpoint = env('oss.endpoint');
// 这个我们之前创建的bucket
$bucket = env('oss.bucket');

这些配置在.env的值是怎么来的

登录控制台,这里可以获取$accessKeyId$accessKeySecret

还是在控制台找到菜单Bucket 列表,找到刚才我们创建的bucket,然后点击它进去,之后访问概览

这里要注意一下,如果你们上线的代码是部署在阿里云的话,可以选择内网访问。

在控制器app->admin->controller->User.php,新增如下方法

//上传头像
public function uploadAvatar(){
    $file = $this->request->file('file');
    $pathName = $file->getPathname();
    $originalName = $file->getOriginalExtension();

    $object = "avatar/".$file->hash('md5').".".$originalName;
    return success(Oss::uploadFile($object,$pathName));
}

然后添加路由:app->admin->route->app.php

//路由分组
Route::group('user', function(){
    //导出
    Route::get('export','user/export');
    //上传头像
    Route::post('uploadAvatar','user/uploadAvatar');
})->middleware(app\admin\middleware\Auth::class);

最后我们测试一下接口

4.3.3客户端直传

客户端直传指的是前端直接把文件上传到oss,就不通过后端服务了,客户端直传避免了业务服务器中转文件,提高了上传速度,节省了服务器资源 ,因此我们一般也是要求前端直传。

文档:https://help.aliyun.com/zh/oss/use-cases/client-direct-transmission-overview?spm=a2c4g.11186623.0.0.40c97b93j8YYOR

客户端直传,我们服务端唯一要做的是要提供一个临时密钥,因为$accessKeyId$accessKeySecret不能直接在前端配置,这东西绝对不能配置,因此我们在app->common->lib->oss->OSs.php后面添加一个获取临时密钥的方法

//获取临时密钥
public static function getTemKey(){
    $id = env('oss.access_key_id');
    $key = env('oss.access_key_secret');
    // $host的格式为 bucketname.endpoint,请替换为您的真实信息。
    $host = 'http://'.env('oss.bucket').env('oss.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;
}

然后我们就可以在控制器中调用这个方法即可。

4.4查询优化

你知道查询的重要性吗?或许你还没意识到,但我告诉你,整个系统有可能因为你写的一条sql语句而导致崩溃,本章节会给出一些建议,避免你以后踩坑。

打开app->common->service->UserService.php,新增

/**
 * 用户列表
 * @param  array  $param  请求参数
 * @return array
*/
public static function page($param){
    $data = UserModel::page($param,['withoutField'=>'password']);// 一

    foreach ($data['list']  as $k => $v) {
        $data['list'][$k]['status_text'] = UserModel::$status[$v['status']];
        $data['list'][$k]['level_name'] = UserLevelService::getLevelNameById($v['level']); // 二
    }

    return success($data);
}

这是查询会员列表的核心业务代码

一、调用之前封装的分页方法

二、因为会员表存储的是等级的ID,因此这里要查询会员等级表去获取等级的名称,用于前端页面显示

这段代码看似简单,但是延展出来的知识点却很丰富,接下来我逐一讲解:

1.既然需要查询等级名称,我们可以连表查询吗?

当然可以,因此会员表会员等级表的数据量不大,即使会员表数据量大也没关系。但如果说我们的表数据量都很大,且需要连超过3张以上,同时还有复杂的搜索条件,那这个就不能连表了。即使你表加了索引,但也有可能索引失效,一旦无法命中索引,那必然会导致数据库卡死。

对于我们这个项目而言,我这里没有使用连表,那是因此我们之前封装了一个单表的分页方法,同时我们搜索条件又不涉及到会员等级表,因此先把会员数据查出来,然后在通过会员表的level值去查对于的名称。

2.在for循环里面嵌入查询语句,这样做有问题吗?

这个不管数据量大小,我们都不建议在for循环里面查询一些数据,比如说一个for循环,每次取100条数据,甚至更多,例如数据导出,如果说for循环里面有3条查询语句,这意味着就有300次查询,这无疑增加了数据库的压力。

因此我们可以改成以下代码:

public static function page($param){
    $data = UserModel::page($param,['withoutField'=>'password']);
    
    // 根据会员表的level,查出等级数据
    $levelIds = array_column($data['list'],'level');
    $levelList = Db::table('user_level')->where('id', $levelIds)->column('name', 'id');

    foreach ($data['list']  as $k => $v) {
        $data['list'][$k]['status_text'] = UserModel::$status[$v['status']];
        $data['list'][$k]['level_name'] = $levelList[$v['level']]
    }
    return success($data);
}

这是我们最普遍的做法,这样我们只是产生2次查询而已。

但项目中我并没有采用这种方法,我们先看看UserLevelService::getLevelNameById($v['level'])

新建app->common->service->UserLevelService.php,新增如下内容

/**
 * 会员等级列表
 * @return array
*/
public static function list(){
    if(cache(config('redisKey.USER_LEVEL_LIST'))){
        return cache(config('redisKey.USER_LEVEL_LIST'));
    }

    $list = UserLevelModel::order('growth_value','asc')->select();
    if(!empty($list)){
        //永久缓存
        cache(config('redisKey.USER_LEVEL_LIST'),$list,0);
    }

    return $list;
}

/**
 * 根据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;
}

因为我这里使用了缓存,理论上在循环里只查询一次数据库,甚至下个请求都不用查数据库。这种做法只是怎对这种数据量小,又数据不常改变的表,这样我们就可以在任何地方调用getLevelNameById($id)这个方法,从而获取等级名称,后面获取会员详细数据的时候就是调用它。

4.5会员管理

前面几节主要讲了会员模块的几个重要知识点,接下来我们就整理一下会员管理这个模块的需要提供的接口。

打开路由app->admin->route->app.php

//路由分组
Route::group('user', function(){
    //用户列表
    Route::get('page','user/page');
    //用户详情
    Route::get('info','user/info');
     //用户编辑
    Route::get('edit','user/edit');
    //导出
    Route::get('export','user/export');
    //上传头像
    Route::post('uploadAvatar','user/uploadAvatar');
})->middleware(app\admin\middleware\Auth::class);

从这个路由我们看到,目前后台对会员的管理提供了5个接口,下面给出其实现过程

打开app->admin->controller->User.php

<?php
// +----------------------------------------------------------------------
// | 用户模块
// +----------------------------------------------------------------------
namespace app\admin\controller;

use app\common\service\UserService;
use app\admin\service\AuserService;
use app\common\lib\oss\Oss;

class User extends AdminController
{
    //分页列表
    public function page(){
        return UserService::page($this->request->get());
    }

    //详情
    public function info(){
        $id = $this->request->get("id");
        if (empty($id)) {
            return success();
        }
        return UserService::info($id);
    }

    //编辑
    public function edit(){
        $data = $this->request->post();
        //验证规则
        $validate = [
            'id' => 'require',
        ];
        //提示信息
        $message = [
            'id.require' => 'ID不能为空!',
        ];

        if(!empty($data['password'])){
            //如果密码不为空时,说明是修改密码,因此要进行密码校验
            $validate['password'] = 'require|regex:/^[a-zA-Z0-9_]{8,20}$/';
            $message['password.require'] = '密码不能为空!';
            $message['password.regex'] = '密码长度8~20位,包含字母数字下划线!';
        }else{
            //销毁变量
            unset($data['password']);
        }

        $this->validate($data, $validate, $message);
        return UserService::update($data);
    }

    //导出
    public function export(){
        AuserService::export($this->request->get());
    }

    //上传头像
    public function uploadAvatar(){
        $file = $this->request->file('file');
        $pathName = $file->getPathname();
        $originalName = $file->getOriginalExtension();

        $object = "avatar/".$file->hash('md5').".".$originalName;
        return success(Oss::uploadFile($object,$pathName));
    }
}

打开app->common->service->UserService.php

<?php
// +----------------------------------------------------------------------
// | 用户模块业务逻辑
// +----------------------------------------------------------------------
namespace app\common\service;

use app\common\model\UserModel;
class UserService
{   
    /**
     * 更新数据
     * @param  array  $data  更新数据
     * @return bool
     */
    public static function update($data){
        $user = UserModel::find($data['id']);
        if(!$user){
            return failure(config('error.er15')['code'],config('error.er15')['msg']);
        }

        //如果提交过来的账号跟数据库记录的账号不一样,说明当前提交过来的账号是修改过后的账号,因此这里需要验证码修改过的账号是否在数据库里面已经存在
        if(isset($data['account']) && $data['account'] !== $user->account){
            if(UserModel::getByAccount($data['account'])){
                //如果存在
                return failure(config('error.er13')['code'],config('error.er13')['msg']);
            }
        }
        if (isset($data['password'])) {
            $data['password'] = createPassword($data['password']);
        }

        return success($user->save($data));
    }

    /**
     * 用户列表
     * @param  array  $param  请求参数
     * @return array
     */
    public static function page($param){
        $data = UserModel::page($param,['withoutField'=>'password']);

        foreach ($data['list']  as $k => $v) {
            $data['list'][$k]['status_text'] = UserModel::$status[$v['status']];
            $data['list'][$k]['level_name'] = UserLevelService::getLevelNameById($v['level']);
        }

        return success($data);
    }

    /**
     * 用户列表
     * @param  int  $id  用户ID
     * @return array
     */
    public static function info($id){
        $user = UserModel::find($id);
        $user['level_name'] = UserLevelService::getLevelNameById($user['level']);
        return success($user);
    }
}

打开app->common->model->UserModel.php

<?php
// +----------------------------------------------------------------------
// | 用户模型
// +----------------------------------------------------------------------
namespace app\common\model;

class UserModel extends BaseModel
{   
    protected $table = 'ds_user';

    public static $status = [0=>'禁用',1=>'正常'];

    public static $sex = [0=>'未知',1=>'男',2=>'女'];

    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);
    }

    public function searchLevelAttr($query, $value, $data)
    {
        $query->where('level', $value);
    }

    public function userLevel()
    {
        return $this->hasOne(UserLevelModel::class, 'id')->bind(['level_name','growth_value']);
    }
}

4.6等级管理

我们先看看这个模块需要提供哪些接口

路由app->admin->route->app.php,在user分组里面新增如下内容

//路由分组
Route::group('user', function(){
    //用户等级列表
    Route::get('level/list','userlevel/list');
    //用户等级新增
    Route::post('level/add','userlevel/add');
    //用户等级更新
    Route::post('level/edit','userlevel/edit');
    //用户等级删除
    Route::get('level/delete','userlevel/delete');
    //上传等级预览图
    Route::post('level/uploadAvatar','userlevel/uploadAvatar');
})->middleware(app\admin\middleware\Auth::class);

从路由可以看出提供了增删改查和上传图片5个接口,实现起来比较简单,下面是其实现过程

打开app->admin->controller->UserLevel.php

<?php
// +----------------------------------------------------------------------
// | 用户等级模块
// +----------------------------------------------------------------------
namespace app\admin\controller;

use app\common\lib\oss\Oss;
use app\common\service\UserLevelService;

class UserLevel extends AdminController
{
    //列表
    public function list(){
        return UserLevelService::list($this->request->get());
    }

    //新增
    public function add(){
        $data = $this->request->post();
        //验证规则
        $validate = [
            'level_name'  => 'require',
            'growth_value'  => 'require',
        ];
        //提示信息
        $message = [
            'level_name.require' => '等级名称不能为空!',
            'growth_value.require' => '成长值不能为空!',
        ];

        $this->validate($data, $validate, $message);
        return UserLevelService::save($data);
    }

    //更新
    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' => '成长值不能为空!',
        ];

        $this->validate($data, $validate, $message);

        return UserLevelService::update($data);
    }

    //删除
    public function delete(){
        $ids = $this->request->get("ids");
        if(empty($ids)){
            return failure(config('error.er5')['code'],"请选择要删除的数据");
        }

        UserLevelService::destroy($ids);

        return success();
    }
    
    //上传头像
    public function uploadAvatar(){
        $file = $this->request->file('file');
        $pathName = $file->getPathname();
        $originalName = $file->getOriginalExtension();

        $object = "user/".$file->hash('md5').".".$originalName;
        return success(Oss::uploadFile($object,$pathName));
    }
}

打开app->common->service->UserLevelService.php

<?php
// +----------------------------------------------------------------------
// | 用户等级模块业务逻辑
// +----------------------------------------------------------------------
namespace app\common\service;

use app\common\model\UserLevelModel;

class UserLevelService
{
    public static $cache_user_level_list_key = 'user_level_list';

    /**
     * 新增
     * @param  array  $data  新增的数据
     * @return int
     */
    public static function save($data){
        $model = new UserLevelModel;
        $model->save($data);
        //清空缓存
        cache(self::$cache_user_level_list_key, null);
        return success($model->id);
    }

    /**
     * 更新等级数据
     * @param  array  $data 更新的数据
     * @return int
     */
    public static function update($data){
        $model = UserLevelModel::find($data['id']);
        if(!$model){
            return failure(config('error.er15')['code'],config('error.er15')['msg']);
        }
        //清空缓存
        cache(self::$cache_user_level_list_key, null);
        return success($model->save($data));
    }

    /**
     * 删除
     * @param  string  $ids  需要删除数据的id
     */
    public static function destroy($ids){
        if(!empty($ids)){
            //清空缓存
            cache(self::$cache_user_level_list_key, null);
            UserLevelModel::destroy(explode(',',$ids));
        }
    }

    /**
     * 列表
     * @return array
     */
    public static function list(){
        if(cache(self::$cache_user_level_list_key)){
            return success(cache(self::$cache_user_level_list_key));
        }

        $list = UserLevelModel::order('growth_value','asc')->select();
        if(!empty($list)){
            //永久缓存
            cache(self::$cache_user_level_list_key, $list, 0);
        }
        
        return success($list);
    }

    /**
     * 根据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;
    }
}

等级管理这个模块一般来说很少变更,因此这个模块加了缓存,其逻辑也是比较简单

打开app\common\model->UserLevelModel.php

<?php
// +----------------------------------------------------------------------
// | 用户等级模型
// +----------------------------------------------------------------------
namespace app\common\model;

class UserLevelModel extends BaseModel
{   
    protected $table = 'ds_user_level';
}

4.7权益管理

我们先看看这个模块需要提供哪些接口

路由app->admin->route->app.php,在user分组里面新增如下内容

//路由分组
Route::group('user', function(){
    //省略代码
    
    //用户权益列表
    Route::get('privilege/list','userPrivilege/list');
    //用户权益新增
    Route::post('privilege/add','userPrivilege/add');
    //用户权益更新
    Route::post('privilege/edit','userPrivilege/edit');
    //用户权益删除
    Route::get('privilege/delete','userPrivilege/delete');
    //上传权益预览图
    Route::post('privilege/uploadAvatar','userPrivilege/uploadAvatar');
})->middleware(app\admin\middleware\Auth::class);

其实这个模块的实现跟会员等级管理的逻辑是一模一样的

打开app->admin->controller->UserPrivilege.php

<?php
// +----------------------------------------------------------------------
// | 用户权益模块
// +----------------------------------------------------------------------
namespace app\admin\controller;

use app\common\lib\oss\Oss;
use app\common\service\UsePrivilegeService;

class UserPrivilege extends AdminController
{
    //列表
    public function list(){
        return UsePrivilegeService::list($this->request->get());
    }

    //新增
    public function add(){
        $data = $this->request->post();
        //验证规则
        $validate = [
            'privilege_name'  => 'require',
        ];
        //提示信息
        $message = [
            'privilege_name.require' => '权益名称不能为空!',
        ];

        $this->validate($data, $validate, $message);

        return UsePrivilegeService::save($data);
    }

    //更新
    public function edit(){
        $data = $this->request->post();
        //验证规则
        $validate = [
            'id' => 'require',
            'privilege_name'  => 'require',
        ];
        //提示信息
        $message = [
            'id.require' => '请选择要更新的数据!',
            'privilege_name.require' => '权益名称不能为空!',
        ];

        $this->validate($data, $validate, $message);

        return UsePrivilegeService::update($data);
    }

    //删除
    public function delete(){
        $ids = $this->request->get("ids");
        if(empty($ids)){
            return failure(config('error.er5')['code'],"请选择要删除的数据");
        }

        UsePrivilegeService::destroy($ids);

        return success();
    }

    //上传头像
    public function uploadAvatar(){
        $file = $this->request->file('file');
        $pathName = $file->getPathname();
        $originalName = $file->getOriginalExtension();

        $object = "user/".$file->hash('md5').".".$originalName;
        return success(Oss::uploadFile($object,$pathName));
    }

}

打开app->common->service->UsePrivilegeService.php

<?php
// +----------------------------------------------------------------------
// | 用户权益模块业务逻辑
// +----------------------------------------------------------------------
namespace app\common\service;

use app\common\model\UserPrivilegeModel;

class UsePrivilegeService{

    public static $cache_user_privilege_list_key = 'user_privilege_list';

    /**
     * 新增
     * @param  array  $data  新增的数据
     * @return int
     */
    public static function save($data){
        $model = new UserPrivilegeModel;
        $model->save($data);
        //清空缓存
        cache(self::$cache_user_privilege_list_key, null);
        return success($model->id);
    }

    /**
     * 更新
     * @param  array  $data 更新的数据
     * @return int
     */
    public static function update($data){
        $model = UserPrivilegeModel::find($data['id']);
        if(!$model){
            return failure(config('error.er15')['code'],config('error.er15')['msg']);
        }
        //清空缓存
        cache(self::$cache_user_privilege_list_key, null);
        return success($model->save($data));
    }

    /**
     * 删除
     * @param  string  $ids  需要删除数据的id
     */
    public static function destroy($ids){
        if(!empty($ids)){
            //清空缓存
            cache(self::$cache_user_privilege_list_key, null);
            UserPrivilegeModel::destroy(explode(',',$ids));
        }
    }

    /**
     * 列表
     * @return array
     */
    public static function list(){
        if(cache(self::$cache_user_privilege_list_key)){
            return success(cache(self::$cache_user_privilege_list_key));
        }

        $list = UserPrivilegeModel::order('growth_value','asc')->select();
        if(!empty($list)){
            //永久缓存
            cache(self::$cache_user_privilege_list_key, $list, 0);
        }
        return success($list);
    }

    /**
     * 根据ID获取名称
     * @param  string  $id ID
     * @return string
     */
    public static function getPrivilegeNameById($id){
        $list = self::list();
        $privilegeName = '';
        foreach($list as $v){
            if($v['id'] == $id){
                $privilegeName = $v['privilege_name'];
                break;
            }
        }
        return $privilegeName;
    }
}

打开app\common\model->UserPrivilegeModel.php

<?php
// +----------------------------------------------------------------------
// | 用户权益模型
// +----------------------------------------------------------------------
namespace app\common\model;

class UserPrivilegeModel extends BaseModel
{   
    protected $table = 'ds_user_privilege';
}

5.后台商品模块

商品模块是商城项目最核心的模块,也是最复杂的。如果说你能掌握这个模块,那对你来说绝对是获益匪浅,这模块会涉及到以下知识点:

  1. 聊聊电商领域中的SPUSKU
  2. 表的设计,告诉你为什么要这么设计
  3. 复杂的查询,例如属性筛选

5.1SPU和SKU

在电商领域,SPU(Standard Product Unit)和SKU(Stock Keeping Unit)是两个常用的概念,用于对商品进行分类和管理。

SPU的定义:指标准化的产品单元,通常指一组具有相似属性、功能和用途的商品。SPU可以看作是一类商品的集合,具有相同的基本特征。

如图,这是京东商城商品列表

相信买东西的时候大家都打开过这样的一个列表页,可能很多时候你把列表中的商品理解为一个具体的产品。实际上它是一个spu,它包含了一些通用的属性,例如cpu型号、内存、屏幕尺寸、颜色等,并且还有一组通用的图片和描述。

SKU定义:它就是具有独特属性的单个具体产品

如图

点击某个spu,进入具体的spu详情,里面展示的是每个具体的sku,比如图片中的每种颜色和版本的组合就是一个具体sku,每个sku都有自己的属性及库存。

5.2表设计

商品模块相关表的设计很复杂,涉及到的表非常多,下面我会一个个的讲,以后大家项目中遇到可以适当的参考一下。

5.2.1品牌表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
cate_id int(11) 一级分类ID
brand_name varchar(30) 品牌名称
brand_image varchar(255) 品牌图片
sort int(11) 排序
remark varchar(255) 品牌介绍
is_show tinyint(1) 是否显示:1-是.0-否
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

如果说商品涉及到品牌,那么就需要有相应的品牌管理功能,一般来说涉及到名字、图片、排序等字段,至于展示端展示的是名称还是图片,这就看具体需求。

5.2.2分类表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
parent_id int(11) 父级id
category_name varchar(30) 分类名称
image varchar(255) 分类图片
remark varchar(255) 分类描述
level tinyint(1) 等级
sort int(11) 排序
is_show tinyint(1) 是否显示:1-是;0-否
is_recommend tinyint(1) 是否首页推荐:1-是;0-否
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

商品分类表是非常重要的,我们买东西的时候一般会选择分类,例如手机、衣服、笔记本、食品等分类。这里的pidlevel字段是跟分类层级有观的,因为分类有一级分类、二级分类、三级分类,甚至更多,另外还有个”是否首页推荐“字段,适用于首页展示某些商品,也是常用需求之一。

5.2.3规格表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
cate_id int(11) 最后一级商品分类ID
spec_name varchar(30) 规格名称
sort tinyint(4) 排序
is_advanced tinyint(1) 高级选项:0-否, 1-是
is_show tinyint(1) 显示:0-否,1-是
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

商品规格或者说属性,这里的属性是跟分类绑定的,因为不同的分类有不同的属性,例如手机、衣服属性是不一样的,另外鉴于页面的展示的原因,一般需要把属性划分为高级选项和普通选项,这个可参考京东。如果说你属性没有那么多,其实没必要设置高级选项。

5.2.4规格属性值表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
spec_id int(11) 规格ID
spec_value varchar(30) 规格属性值
sort tinyint(4) 排序
is_show tinyint(1) 是否显示:1-是,0-否
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

这个也很好理解,每个属性都有多个值,例如手机的属性-机身内容它有1TB、512GB、256GB等值

5.2.5商品表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
goods_name varchar(150) 商品名称
goods_spu bigint(20) 商品SPU
one_cate_id int(11) 一级分类
two_cate_id int(11) 二级分类
three_cate_id int(11) 三级分类
four_cate_id int(11) 四级分类
brand_id int(11) 品牌
supplier_id int(11) 供应商
image varchar(255) 商品主图
remark varchar(255) 商品简介
status tinyint(1) 商品状态:-1-回收站;0-下架;1-上架
sort int(11) 排序
sales_sum int(11) 商品销量
comment_sum int(11) 评论数量
click_sum int(11) 点击数量
price decimal(10,2) 商品价格
discount_factor decimal(10,2) 折扣系数
freight_type tinyint(1) 运费类型:1-包邮;2-统一运费;3-运费模板
freight_price decimal(10,2) 统一运费金额
freight_template_id int(11) 运费模板
is_new tinyint(1) 新品推荐:1-是;0-否
is_hot tinyint(1) 热点产品:1-是;0-否
is_team tinyint(1) 开启拼团:1-是;0-否
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

商品表存储什么字段,都是根据实际业务来设计的,下面我分下面几点来解释

  1. 像商品名、图片、价格这些用于展示的,都是必须的。
  2. 商品SPU,这个字段是商品的唯一标识,像京东商品之间的关联就是使用SPU,而不是自增ID,不过我们使用自增ID关联即可,一般来说使用php开发都是一些小商城了,没有那么规范,这里提出来主要是让大家知道有这么一个设计原则。
  3. 四级分类,这里限定了四级分类,如果说还不够,那么加字段,弄到五级即可。
  4. 排序,一般用户会看销量、评论数量,因此记录这两个数就很有必要了。
  5. 销售手段,比如说新品推荐、热点产品、拼图等等
  6. 跟运费有关的些字段,比如免运费、统一运费等

5.2.6商品详细描述表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
goods_id int(11) 商品ID
spec_content text 规格参数
show_content text 商品展示
service_content text 售后服务
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

用于展示商品的规格、介绍、售后服务,单独一个表出来

5.2.7评论表

字段 类型 备注
id int(11) 主键(PRIMARY)
goods_id int(11) 商品ID
goods_item_id int(11) 某种规格商品ID
order_goods_id int(11) 订单商品表id
user_id int(11) 用户ID
goods_star tinyint(1) 商品评论星级:1-5星
service_star tinyint(1) 服务评论星级:1-5星
express_star tinyint(1) 物流评论星级:1-5星
comment varchar(255) 商品评论
reply varchar(255) 商家回复
status tinyint(1) 显示状态 0-隐藏 1-显示
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

商品评论需要记录商品ID,具体的某款产品(SKU)ID,评论的用户ID,评论内容,另外还设置了商品的评价、售后服务的评价、无论的评价。

5.2.8评论的图片/视频表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
type tinyint(1) 文件类型:1-图片,2-视频
goods_comment_id int(11) 商品评价id
url varchar(255) 文件链接l
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

用户上传商品的图片和视频

5.2.9商品拥有的规格属性表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
goods_id int(11) 商品ID
spec_id int(11) 规格ID
spec_value_id int(11) 规格属性ID
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

这里存储了商品ID、规格ID、规格属性ID,这个表主要用于属性筛选

5.2.10商品SKU表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
goods_id int(11) 商品ID
title varchar(100) 标题
sku bigint(20) 商品SKU
spec_ids varchar(200) 多个规格id,隔开
spec_value_ids varchar(200) 多个规格属性值ID,隔开
market_price decimal(10,2) 市场价
price decimal(10,2) 价格
cost_price decimal(10,2) 成本价
stock int(10) 库存
bar_code varchar(200) 条码
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

这个表主要是存储了用于商品详情展示的信息,比如说规格、价格、库存等,注意这里存储规格和规格属性值,后面我们可以看看如何进行筛选。

另外这里我们也新增了商品SKU字段,其意义跟SPU类似。

5.2.11 商品SKU主图

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
type tinyint(1) 文件类型:1-图片,2-视频
sku bigint(20) 商品评价id
url varchar(255) 文件链接
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

详情里面的轮播图

5.2.12商品点击表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
user_id int(11) 用户ID
goods_id int(11) 商品ID
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

为什么要这个表?对于一些体量比较大的商城来说,收集用户的行为是非常重要的,这个不单单说后台用于页面的统计,更多的是用于推荐系统,他们需要足够大的数据去训练推荐模型。

5.2.13 商品收藏表

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
user_id int(11) 用户ID
goods_id int(11) 商品ID
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

这个也是收集用户行为相关的表

5.2品牌管理

商品品牌界面:

根据界面,我们设计相应的数据库字段

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
cate_id int(11) 一级分类ID
brand_name varchar(30) 品牌名称
brand_image varchar(255) 品牌图片
sort int(11) 排序
remark varchar(255) 品牌介绍
is_show tinyint(1) 是否显示:1-是.0-否
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

这里需要注意的是品牌是绑定一级分类的,你也可以把一级分类理解为商品类型,比如说手机、电脑、衣服都是不同的商品类型,都有不同的品牌。

接下来就开发接口了,品牌管理主要涉及到增删改查及上传图片接口,如下app->admin->route->app.php

Route::group('goods', function(){
    //品牌列表
    Route::get('brand/page','GoodsBrand/page');
    //品牌新增
    Route::post('brand/add','GoodsBrand/add');
    //品牌更新
    Route::post('brand/edit','GoodsBrand/edit');
    //品牌删除
    Route::get('brand/delete','GoodsBrand/delete');
    //上传品牌LOGO
    Route::post('brand/upload','GoodsBrand/uploadLogo');
})->middleware(app\admin\middleware\Auth::class);

单表的增删改查,前面已经讲了许多,后面的模块中,对于这类型的模块,我就不把代码贴出来了,大家可以看源码。

5.3分类管理

商品分类界面:

根据界面,我们设计相应的数据库字段

字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
parent_id int(11) 父级id
category_name varchar(30) 分类名称
image varchar(255) 分类图片
remark varchar(255) 分类描述
level tinyint(1) 等级
sort int(11) 排序
is_show tinyint(1) 是否显示:1-是;0-否
is_recommend tinyint(1) 是否首页推荐:1-是;0-否
creator int(11) 创建者
updator int(11) 更新者
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

商品分类是多层级的,例如上面的家用电器-》空调-》中央空调,因此我们需要设计一个parent_id字段,用于保存当前分类的上级分类。另外就是分类的名称及是否显示与推荐,这些都是前端页面显示的。

分类管理涉及到以下5个接口,增删改查、上传图片

//分类列表
Route::get('category/list','GoodsCategory/list');
//分类新增
Route::post('category/add','GoodsCategory/add');
//分类更新
Route::post('category/edit','GoodsCategory/edit');
//分类删除
Route::get('category/delete','GoodsCategory/delete');
//上传分类图片
Route::post('category/upload','GoodsCategory/upload');

这里分类列表的输出是树状结构,跟权限管理的菜单模块是一样的逻辑,当我们查出数据后,只需调用一下我们写好的获取树状结构的函数即可

public static function listTree(){
    $list = GoodsCategoryModel::order('sort desc')->select();
    return success(treeData($list));
}

查出所有分类,然后调用递归函数treeData()

5.4商品规格及属性值

还记得前面提到过的商品SPU的概念吗?其中有一句话是这样的:SPU可以看作是一类商品的集合,具有相同的基本特征。

比如说我们选择:女装/男装/内衣 》女士热销 》 连衣裙 这个分类,会出现下面的属性筛选

尺码、版型、颜色等规格,然后每个规格有每个规格特定属性,根据这个需要我们开始设计我们的表

5.4.1设计表

我们先看看相应的后台界面

附录

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;

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) NULL 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 删除时间

1.4管理员表

CREATE TABLE `ds_sys_user`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `root` tinyint(1) NULL DEFAULT 0 COMMENT '是否是超级管理:0-否 1-是',
  `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 '密码',
  `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '备注',
  `role_id` int(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色ID',
  `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(1) NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
  `creator` int(11) NULL DEFAULT 0 COMMENT '创建者',
  `updator` int(11) NULL DEFAULT NULL COMMENT '更新者',
  `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
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '管理员表' ROW_FORMAT = Dynamic;

1.5管理员角色表

CREATE TABLE `ds_sys_role`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `role_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '角色名称',
  ` remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '描述',
  `auth_ids` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '权限',
  `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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '管理员角色表' ROW_FORMAT = Dynamic;

1.6后台菜单表

CREATE TABLE `ds_sys_menu`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `title` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '菜单名称',
  `parent_id` int(11) NULL DEFAULT 0 COMMENT '上一级ID',
  `type` tinyint(1) NULL DEFAULT 1 COMMENT '菜单类型:1-菜单,2-按钮',
  `name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '路由中的name值',
  `icon` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '图标',
  `is_hide` tinyint(1) NULL DEFAULT 0 COMMENT '是否隐藏:0-否,1-是',
  `sort` int(11) NULL DEFAULT 0 COMMENT '排序',
  `path` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '路由中的path值',
  `component` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '组件路径',
  `redirect` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '重定向',
  `btn_power` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '菜单类型为按钮时,权限标识',
  `perms` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '权限标识',
  `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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '菜单表' ROW_FORMAT = Dynamic;

1.7会员权益表

CREATE TABLE `ds_user_privilege`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `privilege_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '权益名称',
  `privilege_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '权益图标',
  `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '权益说明',
  `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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '会员权益表' ROW_FORMAT = Dynamic;
字段 类型 备注
id int(10) unsigned 主键(PRIMARY)
privilege_name varchar(30) 权益名称
privilege_image varchar(255) 权益图标
remark varchar(255) 权益说明
creator int(11) 创建人
updator int(11) 更新人
create_time datetime 创建时间
update_time datetime 更新时间
delete_time datetime 删除时间

1.8商品品牌表

CREATE TABLE `ds_goods_brand` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `cate_id` int(11) DEFAULT '0' COMMENT '一级分类ID',
  `brand_name` varchar(30) DEFAULT '' COMMENT '品牌名称',
  `brand_image` varchar(255) DEFAULT '' COMMENT '品牌图片',
  `sort` int(11) DEFAULT '0' COMMENT '排序',
  `remark` varchar(255) DEFAULT '' COMMENT '品牌介绍',
  `is_show` tinyint(1) DEFAULT '1' COMMENT '是否显示:1-是.0-否',
  `creator` int(11) DEFAULT '0' COMMENT '创建者',
  `updator` int(11) DEFAULT '0' COMMENT '更新者',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `delete_time` datetime DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='品牌表';

1.9 商品分类表

CREATE TABLE `ds_goods_category`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `parent_id` int(11) NULL DEFAULT 0 COMMENT '父级id',
  `category_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '分类名称',
  `image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '分类图片',
  `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '分类描述',
  `level` tinyint(1) NULL DEFAULT 1 COMMENT '等级',
  `sort` int(11) NULL DEFAULT 0 COMMENT '排序',
  `is_show` tinyint(1) NULL DEFAULT 1 COMMENT '是否显示:1-是;0-否',
  `is_recommend` tinyint(1) NULL DEFAULT 0 COMMENT '是否首页推荐:1-是;0-否',
  `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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品分类表' ROW_FORMAT = Dynamic;

1.10 商品规格表

CREATE TABLE `ds_goods_spec`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `cate_id` int(11) NULL DEFAULT 0 COMMENT '最后一级商品分类ID',
  `spec_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '规格名称',
  `sort` tinyint(4) NULL DEFAULT 0 COMMENT '排序',
  `is_advanced` tinyint(1) NULL DEFAULT 0 COMMENT '高级选项:0-否, 1-是',
  `is_show` tinyint(1) NULL DEFAULT 1 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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_cate_id`(`cate_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品规格表' ROW_FORMAT = Dynamic;

1.11 商品规格属性值表

CREATE TABLE `ds_goods_spec_value`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `spec_id` int(11) NULL DEFAULT 0 COMMENT '规格ID',
  `spec_value` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '规格属性值',
  `sort` tinyint(4) NULL DEFAULT 0 COMMENT '排序',
  `is_show` tinyint(1) NULL DEFAULT 1 COMMENT '是否显示:1-是,0-否',
  `creator` int(11) NULL DEFAULT 0 COMMENT '创建者',
  `updator` int(11) NULL DEFAULT NULL COMMENT '更新者',
  `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_spec_id`(`spec_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品规格属性值表' ROW_FORMAT = Dynamic;

1.12 商品表

CREATE TABLE `ds_goods` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `goods_name` varchar(150) DEFAULT '' COMMENT '商品名称',
  `goods_spu` bigint(20) DEFAULT NULL COMMENT '商品SPU',
  `one_cate_id` int(11) DEFAULT '0' COMMENT '一级分类',
  `two_cate_id` int(11) DEFAULT '0' COMMENT '二级分类',
  `three_cate_id` int(11) DEFAULT NULL COMMENT '三级分类',
  `four_cate_id` int(11) DEFAULT '0' COMMENT '四级分类',
  `brand_id` int(11) DEFAULT '0' COMMENT '品牌',
  `supplier_id` int(11) DEFAULT NULL COMMENT '供应商',
  `image` varchar(255) DEFAULT '' COMMENT '商品主图',
  `remark` varchar(255) DEFAULT '' COMMENT '商品简介',
  `status` tinyint(1) DEFAULT '0' COMMENT '商品状态:-1-回收站;0-下架;1-上架',
  `sort` int(11) DEFAULT '0' COMMENT '排序',
  `sales_sum` int(11) DEFAULT '0' COMMENT '商品销量',
  `comment_sum` int(11) DEFAULT '0' COMMENT '评论数量',
  `click_sum` int(11) DEFAULT '0' COMMENT '点击数量',
  `price` decimal(10,2) DEFAULT NULL COMMENT '商品价格',
  `discount_factor` decimal(10,2) DEFAULT NULL COMMENT '折扣系数',
  `freight_type` tinyint(1) DEFAULT NULL COMMENT '运费类型:1-包邮;2-统一运费;3-运费模板',
  `freight_price` decimal(10,2) DEFAULT NULL COMMENT '统一运费金额',
  `freight_template_id` int(11) DEFAULT NULL COMMENT '运费模板',
  `is_new` tinyint(1) DEFAULT '0' COMMENT '新品推荐:1-是;0-否',
  `is_hot` tinyint(1) DEFAULT '0' COMMENT '热点产品:1-是;0-否',
  `is_team` tinyint(1) DEFAULT '0' COMMENT '开启拼团:1-是;0-否',
  `creator` int(11) DEFAULT '0' COMMENT '创建者',
  `updator` int(11) DEFAULT '0' COMMENT '更新者',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `delete_time` datetime DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='商品表';

1.13 商品点击表

CREATE TABLE `ds_goods_click`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `user_id` int(11) NULL DEFAULT 0 COMMENT '用户ID',
  `goods_id` int(11) NULL DEFAULT 0 COMMENT '商品ID',
  `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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品点击表' ROW_FORMAT = Dynamic;

1.14 商品收藏表

CREATE TABLE `ds_goods_collect`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `user_id` int(11) NULL DEFAULT 0 COMMENT '用户ID',
  `goods_id` int(11) 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
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品收藏表' ROW_FORMAT = Dynamic;

1.15 商品评论表

CREATE TABLE `ds_goods_comment`  (
  `id` int(11) NOT NULL COMMENT '自增ID',
  `goods_id` int(11) NULL DEFAULT 0 COMMENT '商品ID',
  `goods_item_id` int(11) NULL DEFAULT NULL COMMENT '某种规格商品ID',
  `order_goods_id` int(11) NULL DEFAULT 0 COMMENT '订单商品表id',
  `user_id` int(11) NULL DEFAULT 0 COMMENT '用户ID',
  `goods_star` tinyint(1) NULL DEFAULT 0 COMMENT '商品评论星级:1-5星',
  `service_star` tinyint(1) NULL DEFAULT 0 COMMENT '服务评论星级:1-5星',
  `express_star` tinyint(1) NULL DEFAULT 0 COMMENT '物流评论星级:1-5星',
  `comment` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '商品评论',
  `reply` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '商家回复',
  `status` tinyint(1) NULL DEFAULT 1 COMMENT '显示状态 0-隐藏 1-显示',
  `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_goods_id`(`goods_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品评论表' ROW_FORMAT = Dynamic;

1.16 商品评论图片/视频表

CREATE TABLE `ds_goods_comment_file`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `type` tinyint(1) NULL DEFAULT 1 COMMENT '文件类型:1-图片,2-视频',
  `goods_comment_id` int(11) NULL DEFAULT 0 COMMENT '商品评价id',
  `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '文件链接l',
  `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_goods_comment_id`(`goods_comment_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品评论图片/视频表' ROW_FORMAT = Dynamic;

1.17 商品拥有的规格属性表

CREATE TABLE `ds_goods_has_spec_value`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `goods_id` int(11) NULL DEFAULT 0 COMMENT '商品ID',
  `spec_id` int(11) NULL DEFAULT 0 COMMENT '规格ID',
  `spec_value_id` int(11) NULL DEFAULT NULL 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_goods_id`(`goods_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品拥有的规格属性表' ROW_FORMAT = Dynamic;

1.18 商品的SKU表

CREATE TABLE `ds_goods_sku`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `goods_id` int(11) NULL DEFAULT 0 COMMENT '商品ID',
  `title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '标题',
  `sku` bigint(20) NULL DEFAULT NULL COMMENT '商品SKU',
  `spec_ids` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '多个规格id,隔开',
  `spec_value_ids` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '多个规格名称,隔开',
  `market_price` decimal(10, 2) NULL DEFAULT NULL COMMENT '市场价',
  `price` decimal(10, 2) NULL DEFAULT NULL COMMENT '价格',
  `cost_price` decimal(10, 2) NULL DEFAULT NULL COMMENT '成本价',
  `stock` int(10) NULL DEFAULT NULL COMMENT '库存',
  `bar_code` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '条码',
  `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(0) NULL DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_goods_id`(`goods_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品的SKU表' ROW_FORMAT = Dynamic;

1.19 商品sku主图

DROP TABLE IF EXISTS `ds_goods_sku_file`;
CREATE TABLE `ds_goods_sku_file`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `type` tinyint(1) NULL DEFAULT 1 COMMENT '文件类型:1-图片,2-视频',
  `sku` bigint(20) NULL DEFAULT 0 COMMENT '商品评价id',
  `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '文件链接l',
  `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_sku`(`sku`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商品sku主图' ROW_FORMAT = Dynamic;

1.20商品详细描述表

CREATE TABLE `ds_goods_content` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `goods_id` int(11) DEFAULT '0' COMMENT '商品ID',
  `spec_content` text COMMENT '规格参数',
  `show_content` text COMMENT '商品展示',
  `service_content` text COMMENT '售后服务',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `delete_time` datetime DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`),
  KEY `idx_goods_id` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品详细描述表';
ThinkPHP遵循Apache2开源协议发布,并提供免费使用。 版权所有Copyright © 2006-2016 by ThinkPHP (http://thinkphp.cn) All rights reserved。 ThinkPHP® 商标和著作权所有者为上海顶想信息科技有限公司。 Apache Licence是著名的非盈利开源组织Apache采用的协议。 该协议和BSD类似,鼓励代码共享和尊重原作者的著作权, 允许代码修改,再作为开源或商业软件发布。需要满足 的条件: 1. 需要给代码的用户一份Apache Licence ; 2. 如果你修改了代码,需要在被修改的文件中说明; 3. 在延伸的代码中(修改和有源代码衍生的代码中)需要 带有原来代码中的协议,商标,专利声明和其他原来作者规 定需要包含的说明; 4. 如果再发布的产品中包含一个Notice文件,则在Notice文 件中需要带有本协议内容。你可以在Notice中增加自己的 许可,但不可以表现为对Apache Licence构成更改。 具体的协议参考:http://www.apache.org/licenses/LICENSE-2.0 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

简介

基于thinkphp6.1开发的商城,,有详细的开发教程,可供学习 展开 收起
PHP 等 2 种语言
Apache-2.0
取消

发行版

暂无发行版

贡献者 (3)

全部

近期动态

5个月前创建了任务 #IBAFHD 优化点
11个月前创建了任务 #IA6EQU
11个月前创建了任务 #I9SJPD vue教程啥时候来
12个月前创建了任务 #I9S6TO 写的好看的很,简单易懂,思路清晰,大赞
12个月前评论了任务 #I8MARQ 大佬,前端模板目前没有吗
加载更多
不能加载更多了
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
PHP
1
https://gitee.com/extraordinary-x/demo-shop.git
git@gitee.com:extraordinary-x/demo-shop.git
extraordinary-x
demo-shop
demo-shop
master

搜索帮助