# demo-shop **Repository Path**: extraordinary-x/demo-shop ## Basic Information - **Project Name**: demo-shop - **Description**: 基于thinkphp6.1开发的商城,,有详细的开发教程,可供学习 - **Primary Language**: PHP - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 48 - **Forks**: 10 - **Created**: 2023-05-20 - **Last Updated**: 2025-05-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 0.开篇 你好!很高兴你能点开这个教程,相信你对这个教程有了那么一点点兴趣,接下来占用你一点点时间,邀你浏览一下本章内容,希望能够让你更加有兴趣去学完这个教程。 作者我是一名九零后程序员,搬砖了好几年,主要使用`PHP`和`Java`作为开发语言,另外也懂些前端,毕竟刚工作那会写了不少`html`、`css`、`js`,到后面也学习了`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](https://lyt-top.gitee.io/vue-next-admin-doc-preview) ## 1.2后端框架 到目前为止,国内使用最多的`PHP`框架当属`Thinkphp`和`Laravel`,这两款框架很相似,文档也是比较齐全。作为一个`PHP`程序员至少要掌握其中一个,当然最好都掌握。 本教程的项目使用了`Thinkphp6.1`的版本,是当前最新版本。 官方文档链接:https://www.kancloud.cn/manual/thinkphp6_0/1037479 **安装** ```shell composer create-project topthink/think demo-shop ``` 默认是单应用模式的,这里我们开发的项目是多应用,因此还需执行以下命令 ```shell 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 命令行入口文件 ``` ![](demo-shop-img/1.1.jpg) 稍微留意一下框起来的部分,后面会用到 **URL重写** 重写url主要是为了隐藏入口`index.php`,我们可以发现很多网站的地址并没有带上入口文件,比如说tp的官网 https://www.kancloud.cn/manual/thinkphp6_0/1037479 而不是 https://www.kancloud.cn/index.php/manual/thinkphp6_0/1037479 官网文档里面也提到了,它也给出了重写的规则,但是最后会发现有点问题,因此做了一下调整 ``` 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] ``` 注意上面是基于Apache的规则,我们在根目录(入口文件所在的目录),新建一个` .htaccess `文件,把规则写进里面去即可 # 2.完善架构 我们下载下来的代码结构是非常简单的,对于实际项目,我们需求对框架的某些功能进行一些封装以及开启框架的某些功能。 ## 2.1接口的结构 本教程是`php+vue`前后分离的一个项目,因此接下来都会讲接口的实现。 ```php AdminController.php`,内容如下 ```php 200, 'msg'=>'操作成功','data'=>$data]); json(['code'=>500, 'msg'=>'服务器异常~']); json(['code'=>1000, 'msg'=>'请输入用户名!']); ``` 不知道你们有没有发现,假如说查询列表数据,每次返回成功,你都要写`'code'=>200, 'msg'=>'操作成功'`,这样很繁琐,因此这里我们简单封装一下。 打开`app`目录下的全局函数文件`common.php`,这里面新增的函数可以全局调用 ```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'=>'服务器异常~']); } ``` 我们就可以这样调用: ```php //返回列表数据 return success($data); //新增一条数据成功 return success(); //新增一条数据失败 return failure(); //新增一条数据,请输入用户名 return failure(1000,'请输入用户名!'); //异常只返回500 return error(); ``` 这里的话我们还需求做个小小优化,当然很多人在开发过程中并没有这样做,这也是情有可原,毕竟时间紧迫的话就会忽略掉。 一般来说我们定义一些错误码及对应信息的常量,例如本项目会在`app->config`下新建一个`error.php` ```php return [ 'er1' =>['code'=>'1000','msg'=>'请输入用户名!'], ]; ``` 最后我们这样调用 ```php return failure(config('error.er1')['code'],config('error.er1')['msg']); ``` ## 2.4表单验证器 前面我们在基础控制器里面提到了验证器,实际上文档中有专门的栏目介绍它https://www.kancloud.cn/manual/thinkphp6_0/1037624 它的主要用途就是新增或修改数据对数据进行合法性验证,一般来说前端会传一些数据给后端接口,比如新增一条管理员数据,那此时就必须验证账号和密码这两个字段,这两个字段不能为空,同时密码还需要满足一定的条件,此时框架提供的验证器就派上用场了。 打开底层控制器`BaseController.php`,我们会发现框架已经给我们创建了一个验证码方法,非常的好用 ```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){ //内容省略 } ``` 接下来看看调用示例 ```php $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); ``` 如果输入的账号为空,运行这段代码后你会发现抛出以下异常 ![](demo-shop-img/2.4-01.jpg) ## 2.5异常接管 上一节讲验证器的时候,抛出了一个账号不能为空的异常,很显然这种提示方式对于前端很不友好,前端无法获取到相应的信息,因此我们要想办法捕抓到这个异常,将提示信息`json`的格式返回给前端 我们打开文档https://www.kancloud.cn/manual/thinkphp6_0/1037615,文档有讲解相关异常的处理。 框架也为我们生成了`app/ExceptionHandle.php`,打开看看里面的内容,其中有这样的一个函数 `render($request, Throwable $e)`,我们在里面新加参数验证码错误的代码 ```php public function render($request, Throwable $e): Response { // 参数验证错误 if ($e instanceof ValidateException) { return failure(1004,$e->getError()); } return parent::render($request, $e); } ``` 这时如果参数验证码错误的话,就会被捕抓到,同时返回`json`格式的数据给前端,如下 ```json { "code": 1004, "msg": "账号不能为空!" } ``` 同样的我们需要接管其它的异常 ```php // 参数验证错误 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(); } ``` 我们统一返回这样的格式给前端 ```json { "code": 500, "msg": "服务器异常~" } ``` **为什么要这样返回?** 因为在生产环境,我们一般不会把系统产生的异常信息返回给用户的,因为这是一种很危险的信号。 **那有些人可能会问,如果都这样提示,那不是不知道哪里出问题了吗?** 别慌,在`app/ExceptionHandle.php`中,开头有这样的一段代码 ```php /** * 不需要记录信息(日志)的异常类列表 * @var array */ protected $ignoreReport = [ // HttpException::class, // HttpResponseException::class, // ModelNotFoundException::class, // DataNotFoundException::class, // ValidateException::class, // RouteNotFoundException::class, // Exception::class, ]; ``` 我们注释掉它,这样错误信息会被日志记录下来 日志一般记录在runtime下面的log目录里的`xxx.log`文件 ```verilog [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 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, ], // 其它日志通道配置 ], ]; ``` 使用示例: ```php // info一般是记录参数、返回值 Log::info("测试!!!"); // error一般是try catch中使用,记录可能出现的异常信息 Log::error("未知错误!"); ``` 最后日志存放的路径默认是`runtime->应用名->log->日期目录->日.log`,例如:`runtime->admin->log->202305->27.log` ```verilog [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`,新增以下内容 ```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()`,看看它的使用方式 ```php //设置缓存,且缓存时间是永久,因为配置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,开启强制路由 ```php // 是否强制使用路由 'url_route_must' => true ``` 另外我们在`admin`应用下新建`route/app.php`,每个应用可设置单独的路由,例如 ```php 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设计表 菜单界面如下: ![](demo-shop-img/3.1.1-01.jpg) ![](demo-shop-img/3.1.1-02.jpg) 菜单里面包含哪些字段,取决于你用了什么前端框架,一般来说前端的框架都给你设计好了,只要你接口输出对应的数据就行。 我们根据页面的要求,去设计我们的数据表。 | 字段 | 类型 | 备注 | | ----------- | ---------------- | -------------------------- | | 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 SysMenuService.php` ```php 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 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 $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 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 你回头看看,整个过程下来,逻辑结构及代码是不是很清晰~ **问题:新增和更新的时候不用判断请求是post请求吗?我看有些是这么写的`if(!$this->request->isPost()){//抛出异常}`?** > 如果说每个方法都这么写,非常的繁琐,因此为了解决这个问题,我们开启了强制路由,路由配置那章有提到。同时我们也对路由异常进行捕抓,异常接管那章有提到。一旦我们请求方式不对,或者写错了,都会抛出异常 ## 3.2角色管理 ### 3.2.1设计表 角色管理界面如下: ![](demo-shop-img/3.2.1-01.jpg) ![](demo-shop-img/3.2.1-02.jpg) 表的设计比较简单 | 字段 | 类型 | 备注 | | ----------- | ---------------- | ------------------------ | | 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.php`和`SysRoleService.php`,模型层、业务层的开发,类似于上面的菜单管理,就是那四个方法**增、删、查、改**,其中的**增、删、改**大家按照上面的步骤开发即可,下面重点讲一下分页查询中涉及到一个重要的知识点:**搜索器** 从上面的界面中我们可以看到有根据角色名称进行搜索数据的功能,一般来说我们在业务层中会这样写: ```php if($request->get('role_name')){ $where = $query->where('role_name', $value); } ``` 当然这样写并没有问题,但你会发现如果条件很多,这个函数体的代码量就会很多,再说了这些搜索条件我们需要在任何地方都可以复用,为了解决这个问题,`thinkphp`的模型给我们提供了很便利的方法:**搜索器** 我们只需要在`app->common->SysUserModel.php`新增如下方法 ```php where('role_name', $value); } } } ``` 这些搜索只针对当前的模型,简单的讲就是根据表的字段去查询数据,你可以随便定义搜索条件,无论哪个应用都可以共用这些搜索条件 我们调用的时候可以这样调用 ```php SysRoleModel::withSearch(array_keys($param),$param)->select(); ``` `withSearch()`第一个参数是搜索的字段,是一个一维数组,例如['role_name'] 第二个参数是搜索字段对应的条件,也是一个一维数组,例如['role_name'=>'超级管理员'] ### 3.2.3分页的封装 正常情况下,在`app->admin->SysRoleService.php`会这样写分页的代码 ```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`,加入这段代码 ```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**:用于设置排序、设置排除字段、设置查询的字段 这个分页方法后续还会完善 完成这步之后呢,我们只需改一下业务层的代码 ```php /** * 分页 * @param array $param 搜索条件 * @return array */ public static function page($param){ return success(SysRoleModel::page($param)); } ``` 我们直接这样调用就行 > 分页的封装仅限于单表,因此我们尽量不要连表查询,不过这就得看我们怎么设计表了 ### 3.2.4控制器及路由 控制器的代码跟菜单权限的差不多,大家可以看源码,这里的话就贴出路由的代码 ```php //新增角色 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设计表 管理员界面如下: ![](demo-shop-img/3.3.1-01.jpg) ![](demo-shop-img/3.3.1-02.jpg) 根据界面我们设计出如下表格: | 字段 | 类型 | 备注 | | ----------- | ---------------- | ------------------------- | | 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`,新增两个函数 ```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加密,这样密码的安全性大大提高。 ```php /** * 密码比较方法,所有涉及密码比较的地方都用这个方法 * @param string $password 要比较的密码 * @param string $passwordInDb 数据库保存的已经加密过的密码 * @return boolean 密码相同,返回true */ function comparePassword($password, $passwordInDb) { return createPassword($password) == $passwordInDb; } ``` 密码验证其实很简单,只要把输入的密码进行加密,然后跟数据库保存的密码进行比对,如果一样,说明输入的密码正常,否则就是错误密码。 ### 3.3.3新增管理员 模型的新建这里就不说了,我们在新增管理员的时候,我们需要选择角色,因此这里需要提供一个获取角色列表的接口,之前我们讲角色管理的时候,只开发了分页的接口,因此我们需要开发一个获取所有正常角色的接口。 打开`SysRoleService.php`,新增如下方法 ```php /** * 列表 * @return array */ public static function list(){ return success(SysRoleModel::where('status',1)->select()->toArray()); } ``` 打开`SysRole.php` ```php //列表 public function list(){ return SysRoleService::list(); } ``` 相应的新增路由 ```php Route::get('role/list','sysRole/list'); ``` 接下来就是新增管理员接口的开发了,新建`SysUserService.php`,内容如下 ```php id); } } ``` 这个代码逻辑很简单 一、先判断新增的账号是否存在 二、保存数据 ### 3.3.4编辑管理员 打开`SysUserService.php`,新增 ```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`,新增 ```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去获取角色数据,后面我们都会使用这种方式去获取数据,避免连表查询。 **问题:这里有没有更简单的方式去获取角色的名称,假如说别的地方要获取名称,那是不是也要这样再写一次代码?** 这里我提供一下思路,因为一个系统的角色并不会太多,因此我们可以在获取角色列表的时候,一次性获取所有,然后保存到缓存里面,然后写个函数,返回名称 ```php function getRoleById($id){ // 这里获取缓存数据 $role = ['1'=>'普通管理员','2'=>'测试管理员']; return $role[$id]; } ``` 那我们调用就可以这样了 ```php foreach ($data['list'] as $k => $v) { $data['list'][$k]['role_name'] = getRoleById($v['id']); } ``` 这样就可以复用代码 ### 3.3.6控制器及路由 这里只贴出路由的代码 ```php //新增管理员 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扩展验证码 先看看登录界面 ![](demo-shop-img/3.1-01.jpg) 这一章我们实现登录验证码 `thinkphp`提供的验证码插件是基于`session`,使用起来很简单 ```html
{:captcha_img()}
``` 但是我们的项目前端使用的是`vue`,它跟后端一般是部署在不同服务器上的,因此无法通过 `Session` 保持会话这状态,这时我们得另想办法了。 网上也有一些插件,你可以去下载插件,但本教程会教你一个更巧妙的方法去实现验证码功能。 首先我们安装一下官网提供的验证码插件 ```shell composer require topthink/think-captcha ``` **生成验证码** 既然不能用session,那我们只能借助缓存来实现我们的验证码,下面看看这段生成验证码的代码,打开`SysUserService.php`,新增内容如下: ```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其实就是那串经过加密的验证码 **Ⅳ**、把第三步得到的那串验证码保存到缓存中 **Ⅴ**、把唯一码和图片数据返回给前端 返回的格式如下: ```json { "code": 200, "msg": "操作成功", "data": { "uniqid": "52461655736fe1ee19", "image": "data:image/png;base64,……………………………………………………………………" } } ``` **判断输入的验证码是否正确** 判断输入验证码是否正确,是在登录那会才实现的,这里我先解释一下这个验证的代码 ```php $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`?而不是像下面这样: ```php if($key == $data['code']) ``` 这是因为我们存在缓存中的验证码是经过加密的。 我们可以看看生成验证码的源码,打开`Captcha.php`,里面有个`generate()`方法,最后有这样的一段代码 ```php $hash = password_hash($key, PASSWORD_BCRYPT, ['cost' => 10]); $this->session->set('captcha', [ 'key' => $hash, ]); ``` 我们可以看到验证码是经过`password_hash`加密的,然后保存在`captcha.key`中。这也是为什么前面我们获取key的时候是这样写的: ```php $key = session('captcha.key'); ``` 我们继续看源码,其中有`check()`这个方法,这个方法文档中也提到,是判断验证码是否正确,我们看看其中的内容 ```php 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`,我们配置验证码的位数、字符集、是否使用中文或者是算术 ```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 **安装** ```shell composer require firebase/php-jwt ``` **示例** ```php //用户信息 $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有效` ```php //解析jwt JWT::decode(jwt,new Key(config('app.jwt_code_pc'),'HS256')); ``` 最后得到用户信息 ```php $user = ['id'=>1,'account'=>'admin']; ``` **注:** > 无论是生成`jwt`还是解析`jwt`,其中有个非常重要的参数:加密的密钥,这个密钥一旦泄露,那么别人就看可以轻易的解析和伪造`jwt`进行登录 ### 3.4.3管理员登录 在登录之前我们先创建一个账号,根据前面的新增管理接口,我们创建一个账号密码是admin/a_123456的超级管理员,如果说你是接口创建的,那么需要打开数据表,把root字段改为1,表示开发账号。 或者是你使用下面一条sql,直接新增一个账号 ```mysql 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`新增如下方法 ```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`,新增如下方法 ```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` ```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 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解析不了就会抛出异常,否则会抛出异常 ```json { "code": "2000", "msg": "token非法!" } ``` 注意这里一定要带上`app.php`文件中配置的加密窜 ```php //jwt加密字符串 'jwt_code_admin' => 'admin-demo-shop' ``` 用户认证中间件已经写好了,接下来是把它注册到路由中,打开`admin/route/app.php`,在后面添加如下 ```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`,需要改动一下 ```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` ```php /** * 获取登录用户ID */ function getUserId(){ return isset(request()->user['id']) ? request()->user['id'] : 0; } /** * 获取登录用户 */ function getUserInfo(){ return isset(request()->user) ? request()->user : []; } ``` 这样的话我们就可以在任何地方很方便的使用它。 还记得前面我们说到的创建者和更新者自动填充么?现在我们就可以完善那段代码 ```php //插入前 public static function onBeforeInsert($data){ return $data['creator'] = getUserId(); } //更新前 public static function onBeforeUpdate($data){ return $data['updator'] = getUserId(); } ``` ### 3.4.6修改个人密码 我们都知道登录用户是可以修改自己的密码的,本商城项目也提供了一个这样的接口去修改自己的密码 打开`SysUser.php`,新增以下内容 ```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`,新增如下内容 ```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()`方法 ```php /** * 返回角色的权限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`比较简单 ```php //登录用户信息 public function loginUserInfo(){ return SysUserService::info(getUserId()); } ``` 最后在sysuser路由分组中新增路由 ```php //登录用户信息 Route::get('loginUserInfo','sysUser/loginUserInfo'); ``` ### 3.5.2授权判断 上面一节我们实现了界面的权限控制,但细想一下,假如说系统设置下还有一个`会员注册设置`的菜单,虽然说它不会显示在页面左侧,但如果直接访问的话,它还是可以被访问到,因为到目前为止,我们只要求用户登录了就能访问后台,并没有对其拥有的权限进行授权设置。 那怎么实现权限校验呢?下面是大概思路 1. 新增菜单的时候,我们需要添加一个权限标识,也就是保存到菜单表的`perms`字段的值,这个值一般就是访问当前菜单的控制器名+方法名 2. 获取当前登录用户拥有的菜单权限 3. 获取当前访问的控制器名+方法名,看看这个组合是否存在于当前用户拥有的菜单权限中 有了思路之后呢,我们打开登录校验的中间件`Auth.php`,在方法里面增加如下的代码 ```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.后台会员模块 这个模块主要讲三个知识点: **第一**、直接导出,导出`excel`或`csv` **第二**、文件上传(会员头像),上传到云上(`oss`) **第三**、查询优化 ## 4.1会员管理 ### 4.1.1设计表 会员的来源一般是用户在商城展示端注册的,后台需要一个模块对这些会员进行管理,管理员可以清楚的知道哪些会员是新注册的,哪些是优质会员,会员的积分情况等等,有了这些数据,这样有助于运营人员对商城营销推广。 我们先看看后台的界面: ![](demo-shop-img/4.1.1-01.jpg) ![](demo-shop-img/4.1.1-02.jpg) 大概的我们设计一个会员表 | 字段 | 类型 | 备注 | | -------------- | ---------------- | --------------------------------- | | 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/ 这是一个英文文档,很多时候我们都不喜欢看英文文档,最大的原因就是看不懂了,不过看不懂也要看,作为一个开发人员,你必须强制自己去看英文文档,因为文档就是最好的教程。 对于一些英文很烂的同学来说,如何看文档,这里我给出两个建议: - 先看文档的概述、快速开始部分 - 看官方给的示例 好,现在回到组件上,如图 ![](demo-shop-img/4.1.2-01.jpg) 我们看到该组件支持导入多种格式的文件,也支持导出像`excel`、`csv`等重要文件,接下我们将实现excel的导出,至于`csv`导出有兴趣的同学可以自行实现。 ### 4.1.3导出方法的封装 我们先安装一下这个插件 ``` composer require phpoffice/phpspreadsheet ``` 安装完之后,我们就要思考怎么去实现这个功能,下面我说说我个人的做法:**一般情况下我都会把它封装成一个方法,使其在整个项目中都能被方便调用。** 对于第三方插件进行二次封装的代码,我们一般放在`common`整个目录下,新建`lib->phpspreadsheet->Excel.php`,我们看看这个导出的代码 ```php 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** ![](demo-shop-img/4.1.3-01.jpg) **2.设置标题** ![](demo-shop-img/4.1.3-02.jpg) **3.输出数据** **4.设置header参数**,使文件可以通过浏览器下载,这里的文件名的话使用时间格式输出,你也可以传入一个文件名 ```php header('Content-Disposition: attachment;filename="'.date("Y-m-d", time()).'.xlsx"'); ``` > 这段代码封装的比较简单,并没有对文件的一些属性进行设置,有兴趣的同学可以根据文档示例进行丰富 > ### 4.1.4一对一连表 新建`app->admin->controller->User.php` ```php request->get()); } } ``` 新建`app->admin->service->AuserService.php` ```php 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`,新增如下内容 ```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,注册/登录阿里云账号 ![](demo-shop-img/4.3-01.jpg) ![](demo-shop-img/4.3-02.jpg) 购买成功后我们创建一个bucket, ![](demo-shop-img/4.3-03.jpg) ### 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 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()`方法。 这里需要注意的是 ```php $accessKeyId = env('oss.access_key_id'); $accessKeySecret = env('oss.access_key_secret'); $endpoint = env('oss.endpoint'); // 这个我们之前创建的bucket $bucket = env('oss.bucket'); ``` 这些配置在`.env`的值是怎么来的 登录控制台,这里可以获取`$accessKeyId`和`$accessKeySecret` ![](demo-shop-img/4.3-04.jpg) 还是在控制台找到菜单`Bucket 列表`,找到刚才我们创建的bucket,然后点击它进去,之后访问`概览` ![](demo-shop-img/4.3-05.jpg) 这里要注意一下,如果你们上线的代码是部署在阿里云的话,可以选择内网访问。 在控制器`app->admin->controller->User.php`,新增如下方法 ```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` ```php //路由分组 Route::group('user', function(){ //导出 Route::get('export','user/export'); //上传头像 Route::post('uploadAvatar','user/uploadAvatar'); })->middleware(app\admin\middleware\Auth::class); ``` 最后我们测试一下接口 ![](demo-shop-img/4.3-06.jpg) ### 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`后面添加一个获取临时密钥的方法 ```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`,新增 ```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次查询,这无疑增加了数据库的压力。 因此我们可以改成以下代码: ```php 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`,新增如下内容 ```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` ```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 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 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 '禁用',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分组里面新增如下内容 ```php //路由分组 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 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 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 admin->route->app.php`,在user分组里面新增如下内容 ```php //路由分组 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 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 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 admin->route->app.php`: ```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分类管理 商品分类界面: ![](demo-shop-img/5.3-01.jpg) ![](demo-shop-img/5.3-02.jpg) 根据界面,我们设计相应的数据库字段 | 字段 | 类型 | 备注 | | ------------- | ---------------- | ------------------------ | | 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个接口,增删改查、上传图片 ```php //分类列表 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'); ``` 这里分类列表的输出是树状结构,跟权限管理的菜单模块是一样的逻辑,当我们查出数据后,只需调用一下我们写好的获取树状结构的函数即可 ```php public static function listTree(){ $list = GoodsCategoryModel::order('sort desc')->select(); return success(treeData($list)); } ``` 查出所有分类,然后调用递归函数`treeData()` ## 5.4商品规格及属性值 还记得前面提到过的商品SPU的概念吗?其中有一句话是这样的:**SPU可以看作是一类商品的集合,具有相同的基本特征。** 比如说我们选择:**女装/男装/内衣 》女士热销 》 连衣裙** 这个分类,会出现下面的属性筛选 ![](demo-shop-img/5.4-01.jpg) 尺码、版型、颜色等规格,然后每个规格有每个规格特定属性,根据这个需要我们开始设计我们的表 ### 5.4.1设计表 我们先看看相应的后台界面 ![](demo-shop-img/5.4.1-01.jpg) ![](demo-shop-img/5.4.1-02.jpg) ![](demo-shop-img/5.4.1-03.jpg) # 附录 ## 1.建表语句 ### **1.1会员表** ```sql 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会员积分明细表 ```mysql 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会员等级表 ```mysql 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管理员表 ```mysql 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管理员角色表 ```mysql 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后台菜单表 ```mysql 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会员权益表 ```mysql 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商品品牌表 ```mysql 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 **商品分类表** ```mysql 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 **商品规格表** ```mysql 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 **商品规格属性值表** ```mysql 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 **商品表** ```mysql 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 **商品点击表** ```mysql 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 **商品收藏表** ```mysql 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 **商品评论表** ```mysql 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 **商品评论图片/视频表** ```mysql 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 **商品拥有的规格属性表** ```mysql 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表** ```mysql 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主图** ```mysql 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商品详细描述表 ```mysql 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='商品详细描述表'; ```